From d854a8083e375ec0b8471798776e01580fa419b8 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Fri, 7 Aug 2026 16:17:00 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8F=84=EA=B5=AC=20=EC=95=88=20=EC=93=B0?= =?UTF-8?q?=EB=8A=94=20=EB=AA=A8=EB=8D=B8=EC=9D=B4=EB=A9=B4=20=EC=95=8C?= =?UTF-8?q?=EB=A0=A4=EC=A3=BC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로컬 모델 붙이고 나서 생긴 문제. tool use 못 하는 작은 모델은 도구 정의를 받고도 그냥 말로만 답해서, 작업하기를 눌러도 파일이 하나도 안 바뀐다. 앱이 고장난 줄 알기 딱 좋다. 턴마다 경고하면 시끄럽다 — 도구 필요 없는 질문이 더 많으니까. 그래서 한 번이라도 도구를 부른 적 있으면 그냥 넘어가고, 그런 적 없으면서 연속 3턴 비었을 때만 한 번 알린다. --- ide/src/App.tsx | 16 +++++++ ide/src/ai/toolSupport.test.ts | 79 ++++++++++++++++++++++++++++++++++ ide/src/ai/toolSupport.ts | 59 +++++++++++++++++++++++++ ide/src/i18n/dict/oai.ts | 7 +++ ide/vitest.config.ts | 2 +- 5 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 ide/src/ai/toolSupport.test.ts create mode 100644 ide/src/ai/toolSupport.ts diff --git a/ide/src/App.tsx b/ide/src/App.tsx index c170238..705e1f3 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -14,6 +14,7 @@ import { } from "./ai/claude"; import { PROVIDERS_MAP, testProvider, getManagerId, setManagerId } from "./ai/registry"; import { LOCAL_PROVIDER } from "./ai/openaiCompat"; +import { freshState as freshToolSupport, observe as observeToolSupport, type ToolSupportState } from "./ai/toolSupport"; import { CLAUDE_MODELS, CODEX_MODELS, OPENAI_MODELS, GROK_MODELS, GLM_MODELS, GEMINI_MODELS, ModelOpt } from "./ai/models"; import { Message, ToolCall, ToolDef, NeutralMsg, AgentProvider, getStoredKey, setStoredKey, getOAuth, setOAuth, freshOAuth, getModelOverride, setModelOverride, getEndpoint, setEndpoint } from "./ai/provider"; import { MonacoPane, paneRegistry } from "./editor/MonacoPane"; @@ -3763,6 +3764,10 @@ ${(r.output || "").slice(0, 2000)}`; } finalText = turnText || finalText; + // 이 모델이 도구를 쓸 줄 아는지 관찰한다. 못 쓰는 모델(작은 로컬 모델이 그렇다)은 + // 말만 하고 끝나서 앱이 고장 난 것처럼 보인다 — 그럴 때 한 번 알려 준다. + this.observeToolUse(agentId, !!tools?.length, calls.length); + if (stopReason !== "tool_use" || calls.length === 0) break; transcript.push({ role: "assistant", text: turnText || undefined, calls }); @@ -4977,6 +4982,17 @@ ${(r.output || "").slice(0, 2000)}`; ); } + /** 프로바이더별 도구 사용 관찰. 세션 동안만 들고 있으면 된다 — 모델을 바꾸면 + * 새로 보는 것이 맞다. */ + private _toolSupport = new Map(); + + private observeToolUse(agentId: string, sentTools: boolean, toolCalls: number) { + const prev = this._toolSupport.get(agentId) ?? freshToolSupport(); + const { next, tell } = observeToolSupport(prev, { sentTools, toolCalls }); + this._toolSupport.set(agentId, next); + if (tell) this.toast("info", t("oai.noToolCalls", { agent: this.agDef(agentId).name })); + } + componentDidMount() { window.addEventListener("resize", this._clampChatOnResize); applyTheme(getThemeId()); diff --git a/ide/src/ai/toolSupport.test.ts b/ide/src/ai/toolSupport.test.ts new file mode 100644 index 0000000..d90e802 --- /dev/null +++ b/ide/src/ai/toolSupport.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import { freshState, observe, STREAK_BEFORE_TELLING, type ToolSupportState } from "./toolSupport"; + +/** 턴을 여러 번 흘려 넣고 마지막 상태와 "말한 횟수" 를 돌려준다. */ +function run(turns: { sentTools: boolean; toolCalls: number }[], start: ToolSupportState = freshState()) { + let s = start; + let tells = 0; + for (const t of turns) { + const r = observe(s, t); + s = r.next; + if (r.tell) tells++; + } + return { state: s, tells }; +} + +const empty = { sentTools: true, toolCalls: 0 }; +const used = { sentTools: true, toolCalls: 2 }; +const noTools = { sentTools: false, toolCalls: 0 }; + +describe("observe", () => { + it("도구를 한 번 쓰면 할 줄 아는 것으로 확정한다", () => { + const { state } = run([used]); + expect(state.everCalled).toBe(true); + expect(state.emptyStreak).toBe(0); + }); + + it("할 줄 아는 모델이 도구를 안 쓴 턴은 아무 일도 아니다", () => { + const { state, tells } = run([used, empty, empty, empty, empty, empty]); + expect(tells).toBe(0); + expect(state.everCalled).toBe(true); + }); + + // "이 함수 뭐 하는 거야" 같은 질문이 훨씬 많다 — 한 번 비었다고 경고하면 시끄럽다. + it("한두 번 비었다고 말하지 않는다", () => { + expect(run([empty]).tells).toBe(0); + expect(run([empty, empty]).tells).toBe(0); + }); + + it("연달아 세 번 비면 말한다", () => { + const { tells } = run(Array(STREAK_BEFORE_TELLING).fill(empty)); + expect(tells).toBe(1); + }); + + it("한 프로바이더당 한 번만 말한다", () => { + const { tells, state } = run(Array(12).fill(empty)); + expect(tells).toBe(1); + expect(state.told).toBe(true); + }); + + it("중간에 도구를 쓰면 연속이 끊긴다", () => { + const { tells } = run([empty, empty, used, empty, empty]); + expect(tells).toBe(0); + }); + + it("도구를 안 보낸 턴은 세지 않는다 — 판단 근거가 없다", () => { + const { state, tells } = run([noTools, noTools, noTools, noTools]); + expect(tells).toBe(0); + expect(state.emptyStreak).toBe(0); + }); + + it("도구 없는 턴이 섞여도 연속은 이어진다", () => { + const { tells } = run([empty, noTools, empty, noTools, empty]); + expect(tells).toBe(1); + }); + + it("입력 상태를 고쳐 쓰지 않는다", () => { + const before = freshState(); + const snapshot = { ...before }; + observe(before, empty); + expect(before).toEqual(snapshot); + }); + + it("이미 말한 뒤에도 도구를 쓰기 시작하면 확정된다", () => { + const told: ToolSupportState = { everCalled: false, emptyStreak: 5, told: true }; + const { state } = run([used], told); + expect(state.everCalled).toBe(true); + expect(state.told).toBe(true); + }); +}); diff --git a/ide/src/ai/toolSupport.ts b/ide/src/ai/toolSupport.ts new file mode 100644 index 0000000..d2d47a0 --- /dev/null +++ b/ide/src/ai/toolSupport.ts @@ -0,0 +1,59 @@ +// 이 모델이 도구를 쓸 줄 아는가 — 물어볼 수 없으니 관찰해서 판단한다. +// +// 로컬 모델을 붙일 수 있게 되면서 생긴 문제다. 작은 모델은 tool use 를 아예 지원하지 +// 않거나, 도구 정의를 받고도 그냥 산문으로 답한다. 그러면 에이전트는 "작업하기" 를 +// 눌러도 파일 하나 안 건드리고 말만 하고 끝난다 — 사용자 눈에는 **앱이 고장 난 것** +// 으로 보인다. 실제로는 모델이 못 하는 것이고, 그건 말해 주면 되는 일이다. +// +// 그렇다고 도구를 안 쓴 턴마다 경고하면 안 된다. "이 함수 뭐 하는 거야" 처럼 도구가 +// 필요 없는 질문이 훨씬 많다. 그래서 두 가지를 함께 본다: +// 1. 이 프로바이더가 **한 번이라도** 도구를 부른 적이 있는가 — 있으면 할 줄 아는 것이다. +// 2. 도구를 실어 보냈는데 아무것도 안 부른 턴이 연달아 몇 번인가. +// 둘 다 아니어야 말한다. 그리고 한 프로바이더당 한 번만 말한다. + +export interface ToolSupportState { + /** 이 프로바이더가 도구를 부른 적이 있다 — 그러면 영원히 묻지 않는다. */ + everCalled: boolean; + /** 도구를 줬는데 하나도 안 부른 턴이 연달아 몇 번인지. */ + emptyStreak: number; + /** 이미 알린 적이 있다. */ + told: boolean; +} + +export function freshState(): ToolSupportState { + return { everCalled: false, emptyStreak: 0, told: false }; +} + +/** 몇 번 연속 비어야 말할지. 한 번은 흔하다 — 도구가 필요 없는 질문이 그렇다. */ +export const STREAK_BEFORE_TELLING = 3; + +export interface TurnOutcome { + /** 이 턴에 도구 정의를 실어 보냈는가. 안 보냈으면 판단 근거가 없다. */ + sentTools: boolean; + /** 이 턴에 모델이 도구를 부른 횟수. */ + toolCalls: number; +} + +/** + * 턴 하나를 보고 상태를 갱신하고, 지금 알려야 하는지 답한다. + * + * 입력을 바꾸지 않는다 — 부르는 쪽이 돌려받은 상태를 저장한다. + */ +export function observe(prev: ToolSupportState, turn: TurnOutcome): { next: ToolSupportState; tell: boolean } { + // 도구를 안 보낸 턴은 아무 말도 해 주지 않는다(모델을 판단할 근거가 없다). + if (!turn.sentTools) return { next: prev, tell: false }; + + if (turn.toolCalls > 0) { + // 할 줄 안다는 것이 확인됐다. 다시는 의심하지 않는다. + return { next: { everCalled: true, emptyStreak: 0, told: prev.told }, tell: false }; + } + + if (prev.everCalled) { + // 할 줄 아는 모델이 이번엔 안 쓴 것뿐이다 — 그건 정상이다. + return { next: prev, tell: false }; + } + + const emptyStreak = prev.emptyStreak + 1; + const tell = !prev.told && emptyStreak >= STREAK_BEFORE_TELLING; + return { next: { everCalled: false, emptyStreak, told: prev.told || tell }, tell }; +} diff --git a/ide/src/i18n/dict/oai.ts b/ide/src/i18n/dict/oai.ts index 22c694b..294b200 100644 --- a/ide/src/i18n/dict/oai.ts +++ b/ide/src/i18n/dict/oai.ts @@ -13,6 +13,13 @@ export const dict: Record