# Claude API 툴 호출이 조용히 실패할 때 — input_schema와 tool_choice부터 의심하세요 Claude API 툴이 에러 없이 안 불릴 때 stop_reason, tool_choice, input_schema 순으로 찾는 진단 순서. - source: https://polroute.com/posts/claude-api-tool-schema/ - category: 트러블슈팅 - published: 2026-08-17 --- 에이전트가 툴을 안 쓰기로 결정한 것과 못 쓰는 것은 로그에서 똑같이 보입니다. 예외도 없고 400도 없고, 그냥 텍스트로만 답이 옵니다. 이번 주에 이걸로 반나절을 태웠습니다. 억울한 건 그 반나절 중 대부분을 엉뚱한 데서 썼다는 겁니다. 내 코드를 보고, SDK 버전을 올려보고, 시스템 프롬프트를 세 번 고쳤습니다. 정답은 정반대 방향에 있었고요. 그래서 이 글은 고치는 법보다 찾는 순서 얘기가 더 깁니다. ## 증상: 실패처럼 생기지 않은 실패 툴이 안 불린다는 관측 하나에 실제 원인은 최소 세 가지가 섞여 있습니다. - 모델이 안 쓰기로 결정했다. 스키마는 멀쩡하고 설명이 약한 경우. - 모델은 호출했는데 내 루프가 `tool_use` 블록을 안 집었다. - 요청에 툴이 애초에 안 실렸다. 셋 다 "답이 텍스트로만 온다"로 보입니다. 구분하는 제일 싼 방법은 `stop_reason`이랑 블록 타입을 찍어보는 겁니다. ```ts const res = await client.messages.create({ model: "claude-opus-5", max_tokens: 16000, tools, messages, }); console.log(res.stop_reason, res.content.map((b) => b.type)); // "end_turn" [ "text" ] → 모델이 안 쓰기로 결정 (3~4단계로) // "tool_use" [ "text", "tool_use" ] → 모델은 호출했다. 내 루프 문제 (5단계로) ``` 이 두 줄이면 절반은 여기서 끝납니다. 저는 이걸 안 찍고 두 시간을 썼습니다. ## 1단계: 툴이 요청에 들어가긴 했나 제일 흔하고 제일 민망한 원인입니다. 제 경우엔 구조 분해가 한 겹 어긋나 있었습니다. ```ts const agent = new Agent({ tools }); await agent.run(task); // TypeError: tools is not iterable console.log(typeof tools); // "object" ``` `typeof`가 `"object"`를 찍길래 배열인 줄 알았는데, 실제로는 `{ tools: [...] }`를 통째로 넘기고 있었습니다. `typeof`는 배열과 객체를 구분 못 하니까 이 확인은 애초에 아무 의미가 없었죠. 툴 배열을 여러 모듈에서 모아 조립하는 구조라면 조립 지점에 한 줄만 박아두면 됩니다. ```ts if (!Array.isArray(tools) || tools.length === 0) { throw new Error(`tools must be a non-empty array, got ${JSON.stringify(tools)?.slice(0, 80)}`); } ``` 호출부마다 검사하지 말고 조립 함수 한 곳에 두세요. 툴 목록 만드는 경로는 보통 하나라서 거기 하나면 다 덮입니다. ## 2단계: tool_choice로 이분법 진단하기 전달을 고쳤는데도 호출이 안 됐습니다. 여기서부터는 추측을 멈추고 답을 강제로 받아냅니다. `tool_choice`를 `any`로 두면 모델은 무조건 툴을 하나 이상 써야 합니다. ```ts const res = await client.messages.create({ model: "claude-opus-5", max_tokens: 16000, tools, tool_choice: { type: "any" }, // 진단용. 반드시 툴을 쓰게 강제 messages, }); ``` 결과는 이렇게 읽습니다. - 400이 난다 → 스키마가 API 검증을 통과 못 한 것. 3단계로. - 툴이 호출된다 → 스키마는 유효하다. 모델이 쓸 이유가 없다고 판단했을 뿐. 4단계로. - 여전히 텍스트만 온다 → 툴이 요청에 안 실렸다. 1단계로 돌아가세요. 요청 한 번으로 원인 후보가 3분의 1로 줄어듭니다. 이 글에서 건질 게 하나뿐이라면 이거라고 생각합니다. `tool_choice`에 쓸 수 있는 값은 [공식 문서](https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools#forcing-tool-use) 기준 네 가지입니다. - `{ type: "auto" }` — 모델이 알아서 결정 (기본값) - `{ type: "any" }` — 반드시 툴 하나 이상 사용 - `{ type: "tool", name: "search_docs" }` — 특정 툴 강제 - `{ type: "none" }` — 툴 사용 금지 ![Anthropic 공식 문서의 tool_choice 다이어그램 — auto는 툴 호출과 그냥 답하기 중 선택, any는 반드시 툴 중 하나, tool은 지정한 툴 하나로 고정](/images/claude-api-tool-schema-1.webp) 공식 문서의 다이어그램이 셋의 차이를 한 장으로 보여줍니다. `auto`만 "툴을 안 쓰고 답한다"는 선택지를 갖고 있고, 나머지 둘은 그 칸이 닫혀 있어요. 진단이 되는 이유가 여기 있습니다. ([출처](https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools#forcing-tool-use)) `any`랑 `tool`은 진단용이거나 파이프라인 고정용입니다. 일반 에이전트에 켜둔 채로 두면 그냥 답만 하면 되는 상황에도 억지로 툴을 부르니, 원인 찾았으면 `auto`로 되돌려놓으세요. ## 3단계: input_schema 체크리스트 여기가 진짜 원인이었습니다. 툴 정의는 결국 이 형태입니다. ```json { "name": "search_docs", "description": "사내 문서를 검색한다", "input_schema": { "type": "object", "properties": { "query": { "type": "string", "description": "검색 키워드" }, "scope": { "type": "string", "enum": ["all", "design", "backend"], "description": "검색 범위. 지정하지 않으면 전체" } }, "required": ["query"] } } ``` 확인할 것들. - `input_schema.type`은 반드시 `"object"`. 최상위를 `string`이나 `array`로 두면 모델이 인자를 만들 방법 자체가 없습니다. - `properties`의 각 필드에 `description`을 답니다. 필드 설명이 없으면 모델은 `query`에 뭘 넣을지 추측하고, 추측한 만큼 이상한 인자가 들어옵니다. - 값 후보가 정해져 있으면 `enum`을 씁니다. 자유 문자열로 두면 `"디자인"`, `"design docs"`, `"Design"`을 번갈아 만들어냅니다. 이건 제가 실제로 받아본 세 개입니다. - `required`에는 진짜 필수만 넣습니다. `properties`에 없는 이름을 `required`에 적으면 그 시점에 스키마가 깨집니다. - 선택 인자는 `required`에서 빼고 description에 기본 동작을 적어둡니다. `"지정하지 않으면 전체"` 같은 식으로요. 설명을 아무리 다듬어도 인자 모양이 안 잡히는 툴이 있습니다. 중첩된 객체를 받거나 형식이 까다로운 경우인데, 이럴 때 쓰라고 `input_examples`가 있습니다. 스키마 검증을 통과한 예시를 툴 정의에 같이 실어 보내는 필드입니다. ```json { "name": "get_weather", "input_schema": { "...": "..." }, "input_examples": [ { "location": "San Francisco, CA", "unit": "fahrenheit" }, { "location": "Tokyo, Japan", "unit": "celsius" }, { "location": "New York, NY" } ] } ``` 세 번째 예시처럼 선택 인자를 뺀 형태를 하나 넣어두면 "이건 안 넣어도 된다"가 설명보다 정확하게 전달됩니다. 주의할 점은 두 가지입니다. 예시가 `input_schema`를 위반하면 그 자리에서 400이 나고, 웹 검색 같은 서버 툴에는 못 씁니다. 토큰도 조금 듭니다. 간단한 예시는 20~50 토큰, 중첩 객체는 100~200 토큰 정도예요. 입력이 정확해야 하는 툴이면, 예를 들어 결제나 삭제 같은 거라면 [`strict: true`](https://platform.claude.com/docs/en/agents-and-tools/tool-use/strict-tool-use)를 켜세요. 툴 정의의 최상위 필드이고, `tool_choice`가 아니라 툴 객체에 붙습니다. 저는 이걸 처음에 `tool_choice`에 넣고 왜 안 되나 한참 봤습니다. ```ts const tools: Anthropic.Tool[] = [ { name: "cancel_order", description: "주문을 취소한다. 사용자가 명시적으로 취소를 요청했을 때만 호출한다.", strict: true, input_schema: { type: "object", properties: { order_id: { type: "string", description: "취소할 주문 ID" } }, required: ["order_id"], additionalProperties: false, // strict에는 이 두 개가 필수 }, }, ]; ``` `strict: true`는 `additionalProperties: false`랑 `required`를 요구합니다. 대신 `tool_use.input`이 스키마대로 들어온다는 보장을 받으니 검증 코드 한 뭉치를 지울 수 있습니다. ## 4단계: description은 무엇이 아니라 언제 스키마가 유효한데 모델이 툴을 안 부른다면 원인은 거의 항상 description입니다. 요즘 Opus 계열은 툴을 예전보다 보수적으로 고릅니다. 굳이 안 불러도 답할 수 있으면 그냥 답합니다. 대부분의 툴 설명이 이렇게 생겼습니다. ``` "description": "문서를 검색한다" ``` 기능 설명은 맞는데 호출 조건이 없습니다. 모델 입장에선 지금이 그 상황인지 판단할 근거가 없죠. 이렇게 바꿨습니다. ``` "description": "사내 문서에서 키워드로 검색한다. 사용자가 내부 정책, 배포 절차, 온보딩 문서를 물어보거나 '문서에 뭐라고 나와 있냐'고 물을 때 호출한다. 일반 상식 질문에는 호출하지 않는다." ``` 언제 부르는지, 어떤 표현이 신호인지, 언제 안 부르는지. 부정 조건까지 적으면 과호출도 같이 잡힙니다. 이 한 문단 고친 것만으로 호출률이 눈에 띄게 올라갔습니다. > 프롬프트를 손대기 전에 툴 설명을 먼저 보세요. 시스템 프롬프트에 "필요하면 툴을 써라"를 백 번 써도, 툴이 자기를 언제 써야 하는지 말하지 않으면 소용없습니다. 툴 개수도 변수입니다. 20개를 한 번에 던지면 선택 정확도가 떨어져요. 세트를 작게 유지하거나, 이름이 비슷한 툴을 하나로 합치세요. `search_docs` / `find_document` / `query_files` 같은 게 한 요청에 같이 들어가 있으면 모델도 헷갈립니다. 공식 문서도 같은 얘기를 합니다. `create_pr` / `review_pr` / `merge_pr`처럼 쪼개지 말고 `action` 파라미터를 받는 툴 하나로 묶으라고요. 여러 서비스에 걸쳐 있으면 `github_list_prs`, `slack_send_message`처럼 접두사로 네임스페이스를 주라는 권장도 함께 나옵니다. 툴이 늘어날수록 선택 모호성이 비용이 되니까요. ## 5단계: 루프 쪽 함정 두 개 `stop_reason`이 `"tool_use"`인데 동작이 이상하면 그건 모델이 아니라 제 루프 문제입니다. ### 병렬 tool_use를 나눠서 응답하기 한 번의 응답에 `tool_use` 블록이 여러 개 올 수 있습니다. 이때 결과는 하나의 user 메시지에 전부 담아 돌려줘야 합니다. ```ts const toolUses = res.content.filter( (b): b is Anthropic.ToolUseBlock => b.type === "tool_use", ); messages.push({ role: "assistant", content: res.content }); const results = await Promise.all( toolUses.map(async (t) => ({ type: "tool_result" as const, tool_use_id: t.id, content: await executeTool(t.name, t.input), })), ); messages.push({ role: "user", content: results }); // 한 메시지에 전부 ``` 하나씩 따로 보내도 에러는 안 납니다. 대신 모델이 병렬 호출은 잘 안 먹히는구나를 학습해서 점점 한 번에 하나씩만 부르게 됩니다. 에러 없이 느려지는 부류라 원인 찾기가 고약합니다. ### 실패한 툴 결과를 드롭하기 툴이 예외를 던졌다고 결과를 빼먹으면 `tool_use_id` 짝이 안 맞아서 다음 요청이 거절됩니다. 실패도 결과입니다. ```ts { type: "tool_result", tool_use_id: t.id, content: `검색 실패: ${err.message}`, is_error: true, } ``` `is_error: true`로 돌려주면 모델이 다른 인자로 재시도하거나 사용자한테 상황을 설명합니다. 조용히 빼는 것보단 항상 낫습니다. ## 인자는 항상 파싱해서 쓴다 `tool_use.input`의 JSON 문자열 이스케이프는 모델과 버전에 따라 달라질 수 있습니다. 유니코드나 슬래시가 이스케이프돼서 올 수 있어서, 직렬화된 문자열에 정규식이나 `includes()`를 쓰면 어느 날 조용히 깨집니다. ```ts // 하지 말 것 if (JSON.stringify(t.input).includes("design")) { ... } // 할 것 — input은 이미 파싱된 객체다. 필드로 접근한다 const { query, scope = "all" } = t.input as { query: string; scope?: string }; ``` ## 진단 순서 ``` 1. stop_reason과 content 블록 타입을 찍는다 ← 여기서 절반이 끝난다 2. tools가 진짜 배열인지 조립 지점에서 검증한다 3. tool_choice: { type: "any" }로 이분법 진단 400 → 스키마 문제 호출됨 → description 문제 텍스트만 → 툴이 안 실림 4. input_schema: type=object / 필드 description / enum / required 5. description에 언제 부르는지와 언제 안 부르는지를 쓴다 6. 루프: tool_result는 한 메시지에, 실패는 is_error로 ``` 이 순서를 머리에 두지 말고 저장소의 규칙 파일에 적어두는 편이 낫습니다. 같은 실수를 두 번 하면 한 줄 추가하는 식으로요. 그런 식으로 작업 환경 자체를 설계하는 이야기는 [하네스 엔지니어링](/posts/harness-engineering/)에 따로 썼습니다. ## 결론 우리가 보는 툴은 타입이 붙은 함수지만, 모델이 보는 건 이름 하나랑 설명 한 문단, JSON Schema 하나가 전부입니다. 그 셋이 부실하면 코드가 아무리 멀쩡해도 툴은 안 불립니다. 그리고 그 셋은 [프롬프트 캐시](/posts/claude-prompt-caching/)의 프리픽스 맨 앞이기도 해서, 한 글자만 바뀌어도 그 뒤 캐시가 전부 날아갑니다. 스키마는 한 번에 고치고 고정해 두는 편이 여러모로 이득입니다. 이건 프롬프트를 스펙처럼 쓰는 이야기와 정확히 같은 문제입니다. 사람한테 일을 넘길 때 완성 기준을 안 주면 다른 게 나오는 것처럼, 모델한테 툴을 넘길 때 호출 조건을 안 주면 안 부릅니다. 그쪽 이야기는 [바이브 코딩 프롬프트](/posts/vibe-coding-prompt/)에 정리해 뒀습니다. 그래서 디버깅을 "내 코드 → SDK → 스키마" 순으로 하면 오래 걸립니다. `stop_reason` 찍고, 스키마 보고, 설명 보는 순서로 시작하면 반나절이 10분이 됩니다. 다음엔 저도 그렇게 하려고요.