Published on

Claude Tool Use 400 오류 - schema·tool_result 해결

Authors

서론

Claude의 Tool Use(함수 호출/도구 호출)를 붙이면 개발 속도는 빨라지지만, 한 번 400(Bad Request)에 걸리면 디버깅 시간이 급격히 늘어납니다. 특히 아래 두 부류가 가장 흔합니다.

  • schema 관련 400: tool의 input_schema(JSON Schema)와 실제로 모델이 만든 tool input, 혹은 우리가 보낸 tool 정의가 맞지 않을 때
  • tool_result 관련 400: 도구 실행 결과 메시지(tool_result)를 API가 요구하는 형태로 보내지 않았을 때

이 글은 “왜 400이 나는지”를 감으로 때려맞추는 대신, 요청/응답의 구조를 기준으로 원인을 분류하고, 바로 적용 가능한 패턴으로 정리합니다. (유사한 400 디버깅 접근은 이미지 파싱 오류 글에서도 동일하게 유효합니다: OpenAI Responses API 400 image_parse_error 해결 가이드)


Claude Tool Use 요청 구조 빠른 복습

Tool Use는 크게 3단계로 생각하면 편합니다.

  1. 툴 정의 포함한 메시지 요청: tools 배열(이름/설명/input_schema)을 보냄
  2. 모델이 tool_use(=도구 호출 의도) 응답: 어떤 tool을 어떤 input으로 호출할지 내려줌
  3. 클라이언트가 tool 실행 후 tool_result를 다시 전송: tool_use의 id에 매칭되는 결과를 보내고, 모델이 최종 답변을 생성

400 오류는 대부분 1번(툴 정의/스키마) 또는 3번(tool_result 포맷/매핑) 에서 터집니다.


1) schema 관련 400: 가장 흔한 원인 7가지

원인 A. input_schema가 “JSON Schema”가 아님

Claude 도구 스키마는 보통 JSON Schema 형태를 기대합니다. 그런데 종종 아래처럼 실수합니다.

  • type 누락
  • properties가 객체가 아닌 배열
  • required가 문자열로 들어감
  • additionalProperties 정책이 애매

안전한 기본 템플릿(객체 입력, 추가 필드 금지):

{
  "type": "object",
  "properties": {
    "query": { "type": "string", "description": "검색 질의" },
    "limit": { "type": "integer", "minimum": 1, "maximum": 20, "default": 5 }
  },
  "required": ["query"],
  "additionalProperties": false
}

additionalProperties: false는 모델의 “쓸데없는 필드 생성”을 줄여 스키마 불일치 400을 예방하는 데 큰 도움이 됩니다.

원인 B. tool name 규칙 위반(중복/특수문자/길이)

SDK/게이트웨이에 따라 제약이 다르지만, 보통 다음이 문제를 만듭니다.

  • tools 배열에서 name 중복
  • 공백/특수문자 포함
  • 너무 긴 이름

권장: snake_case 또는 kebab-case 대신, 도구 이름은 대개 snake_case가 무난합니다.

원인 C. required에 없는 필드를 모델이 생성 → 검증 실패

모델이 required에 없는 필드를 만들어도 보통은 허용되지만, additionalProperties: false를 켜면 정의되지 않은 필드가 들어오는 순간 바로 실패할 수 있습니다.

이때 전략은 둘 중 하나입니다.

  • 정말 금지해야 하는 필드면: 모델 프롬프트에 “스키마 외 필드 금지”를 명시
  • 유연성이 필요하면: additionalProperties: true 또는 additionalProperties에 타입 제한을 둠

원인 D. 타입 불일치(string vs number vs integer)

특히 integer에 문자열이 들어오는 실수가 잦습니다.

  • "limit": "5" (문자열) → integer 기대

해결: 스키마에 type을 느슨하게 주기보다, 서버에서 강제 캐스팅 또는 모델 지시문 강화가 효과적입니다.

원인 E. enum/const 불일치

enum을 쓰면 모델이 다른 값을 만들어 400이 날 수 있습니다.

{
  "type": "object",
  "properties": {
    "sort": { "type": "string", "enum": ["recent", "popular"] }
  },
  "required": ["sort"],
  "additionalProperties": false
}

: enum은 강력하지만 실패율을 올립니다. 실패가 잦다면 description에 허용 값 예시를 반복하거나, 모델이 선택해야 하는 UI(버튼/리스트)처럼 프롬프트를 구성하세요.

원인 F. 스키마는 맞는데 “tools” 위치/필드명이 틀림

API 버전/SDK별로 tools, tool_choice, messages 구조가 조금씩 다릅니다. 필드명을 잘못 넣으면 schema 오류처럼 보이는 400이 납니다.

대응:

  • SDK가 생성하는 raw request를 로깅
  • 서버에서 실제로 전송된 JSON을 그대로 저장

이런 접근은 네트워크/프록시 레벨 장애를 잡는 방법과 동일합니다. Cloudflare/Nginx 로그로 520/521을 빠르게 좁히는 방식이 여기에도 그대로 통합니다: Cloudflare 520·521, Nginx·ALB 로그로 30분 진단

원인 G. 스키마 검증은 통과했는데 “모델 출력이 tool_use를 안 함”을 에러로 처리

일부 구현에서 tool_use가 오지 않았는데도 “무조건 도구 호출이 올 것”이라고 가정하고 파싱하다가, 다음 요청에서 잘못된 tool_result를 보내 400이 터집니다.

해결: tool_use가 없으면 일반 텍스트 응답으로 처리하고 종료하는 분기 필수.


2) tool_result 관련 400: id 매칭과 메시지 타입이 핵심

Tool Use의 3단계에서 가장 중요한 규칙은 이것입니다.

  • 모델이 준 tool_use의 id를 그대로 받아서
  • tool 실행 결과를 tool_result 메시지로
  • 동일한 대화 컨텍스트에 다시 넣어 보내야 합니다.

400의 대부분은 아래에서 발생합니다.

원인 A. tool_result에 tool_use_id(또는 id 매칭)가 없음/오타

모델이 준 tool_use 블록에는 보통 식별자가 있습니다. 이 값을 tool_result에 정확히 연결해야 합니다.

실수 패턴:

  • toolUseId, tool_use_id, id 등 키 이름을 잘못 씀
  • 이전 턴의 id를 재사용

원인 B. tool_result content 타입이 틀림(문자열 vs 배열 vs 객체)

API가 요구하는 content 형태가 정해져 있는데, 문자열만 던지거나 JSON을 문자열로 이중 인코딩하면 실패합니다.

나쁜 예(이중 인코딩):

const result = JSON.stringify({ ok: true, items: [1,2,3] });
// content에 또 문자열로 넣음 → 다운스트림에서 JSON으로 기대하면 꼬임

좋은 예(명확한 구조로 전달):

{
  "ok": true,
  "items": [1, 2, 3]
}

원인 C. tool_result를 “role: tool” 같은 임의 role로 보냄

Claude 메시지 포맷은 보통 rolecontent 내부 블록 타입이 엄격합니다. 임의 role을 쓰면 400이 나기 쉽습니다.

해결: SDK가 제공하는 tool_result 생성 방식을 따르거나, 문서의 메시지 블록 타입(tool_use, tool_result)을 그대로 사용하세요.

원인 D. 같은 tool_use에 tool_result를 두 번 보냄

재시도 로직이 중복으로 실행되면, 동일 id에 대해 결과가 2번 들어가면서 400 또는 논리 오류가 납니다.

해결:

  • tool_use_id 기준으로 idempotency 처리
  • 결과 캐시(요청 키 기반) 적용

원인 E. tool_result에 에러를 담는 방식이 잘못됨

도구 실패 시에도 tool_result를 보내되, 실패를 구조적으로 표현해야 합니다.

권장 패턴:

{
  "ok": false,
  "error": {
    "code": "UPSTREAM_TIMEOUT",
    "message": "Search API timed out after 3s"
  }
}

모델은 이 구조를 보고 “재시도/대안 제시/사용자에게 안내”를 할 수 있습니다.


3) 재현 가능한 디버깅 체크리스트(로그 중심)

400은 서버가 “요청이 계약을 어겼다”고 말하는 겁니다. 따라서 요청 JSON을 그대로 재현할 수 있어야 합니다.

체크리스트

  1. Raw request/response 저장: 성공/실패 케이스 모두 저장
  2. tools 정의에서 name, description, input_schema만 따로 떼어 단위 테스트
  3. 모델이 준 tool_use 블록을 원문 그대로 저장(특히 id)
  4. tool_result 생성 코드를 한 함수로 모으고, 스키마/타입을 고정
  5. 재시도 시 tool_use_id 중복 전송 여부 확인

특히 “TTFB가 갑자기 증가하면서 타임아웃 → 재시도 → 중복 tool_result” 같은 연쇄 장애도 자주 나옵니다. 앱 라우터/서버 컴포넌트처럼 레이어가 많은 시스템에서는 더 흔합니다. 성능 이슈로 재시도가 꼬이는 케이스는 다음 글의 관점이 도움 됩니다: Next.js 14 RSC 느림? TTFB 급증 7가지 해결


4) Node.js 예제: tool_use → 실행 → tool_result까지 안전하게

아래는 “스키마/결과 타입/재시도 중복”을 최소화한 형태의 예시입니다. (SDK 호출부는 개념적으로 작성했으며, 핵심은 tool_use id 매핑결과 구조 고정입니다.)

import crypto from "node:crypto";

const tools = [
  {
    name: "search_docs",
    description: "내부 문서에서 키워드로 검색합니다.",
    input_schema: {
      type: "object",
      properties: {
        query: { type: "string", description: "검색어" },
        limit: { type: "integer", minimum: 1, maximum: 10, default: 5 }
      },
      required: ["query"],
      additionalProperties: false
    }
  }
];

async function runTool(name, input) {
  if (name !== "search_docs") throw new Error("Unknown tool");

  // 서버에서 타입 방어 (모델이 문자열로 줄 때 대비)
  const limit = Number.isInteger(input.limit) ? input.limit : 5;
  const query = String(input.query ?? "");

  // 가짜 검색 결과
  return {
    ok: true,
    query,
    items: Array.from({ length: Math.min(limit, 3) }).map((_, i) => ({
      id: crypto.randomUUID(),
      title: `Result ${i + 1} for ${query}`,
      url: `https://example.com/docs/${i + 1}`
    }))
  };
}

// tool_result 중복 방지용 저장소(메모리 예시)
const sentToolResults = new Set();

async function chat(client, userText) {
  // 1) tools 포함 요청
  const resp1 = await client.messages.create({
    model: "claude-3-5-sonnet-latest",
    messages: [{ role: "user", content: userText }],
    tools
  });

  // 2) tool_use 탐색
  const toolUse = resp1.content?.find?.(b => b.type === "tool_use");
  if (!toolUse) {
    // tool_use가 없으면 일반 답변으로 종료
    return resp1;
  }

  const { id: tool_use_id, name, input } = toolUse;

  // 3) 실제 도구 실행
  let toolOutput;
  try {
    toolOutput = await runTool(name, input);
  } catch (e) {
    toolOutput = {
      ok: false,
      error: { code: "TOOL_EXEC_ERROR", message: e?.message ?? String(e) }
    };
  }

  // 중복 전송 방지
  if (sentToolResults.has(tool_use_id)) {
    throw new Error(`Duplicate tool_result for tool_use_id=${tool_use_id}`);
  }
  sentToolResults.add(tool_use_id);

  // 4) tool_result를 같은 대화 흐름으로 재전송
  const resp2 = await client.messages.create({
    model: "claude-3-5-sonnet-latest",
    messages: [
      { role: "user", content: userText },
      // 모델의 tool_use 응답을 컨텍스트에 포함해야 하는 SDK/포맷이 있음
      { role: "assistant", content: resp1.content },
      {
        role: "user",
        content: [
          {
            type: "tool_result",
            tool_use_id,
            content: toolOutput
          }
        ]
      }
    ],
    tools
  });

  return resp2;
}

이 예제가 400을 줄이는 포인트

  • additionalProperties: false로 입력을 통제
  • 서버에서 limit 캐스팅 등 타입 방어
  • tool_use_id 기반 중복 tool_result 방지
  • tool 실패도 ok:false 구조로 반환

5) 실무에서 자주 쓰는 “고장 덜 나는” 스키마 설계 패턴

패턴 1. 입력은 작고 단단하게, 출력은 넉넉하게

  • 입력 스키마는 최소 필드만 요구(required 최소화)
  • 출력(tool_result)은 모델이 후처리하기 쉬운 구조(예: ok, error, data)로 통일

패턴 2. enum 대신 “의미 있는 문자열 + 서버 검증”

enum은 강력하지만 모델이 틀리면 바로 실패합니다. 실패 비용이 큰 서비스라면:

  • 스키마는 type: "string"
  • 서버에서 화이트리스트 검증 후, 틀리면 tool_result로 에러를 반환

패턴 3. 날짜/시간은 문자열(ISO 8601)로

숫자 timestamp는 단위(ms/s) 혼동이 많습니다.

{
  "type": "object",
  "properties": {
    "from": { "type": "string", "description": "ISO 8601" },
    "to": { "type": "string", "description": "ISO 8601" }
  },
  "required": ["from", "to"],
  "additionalProperties": false
}

6) 결론: 400을 없애는 핵심은 ‘계약을 고정’하는 것

Claude Tool Use 400은 대개 “모델이 이상하다”가 아니라 우리의 계약(스키마/메시지 타입/ID 매핑)이 흔들려서 발생합니다. 정리하면:

  • schema 400은 input_schemaJSON Schema로 단단히 만들고(additionalProperties 포함), 모델 출력에 대비한 서버 타입 방어로 줄일 수 있습니다.
  • tool_result 400은 tool_use_id 정확 매칭정해진 메시지 블록 타입 준수, 그리고 중복 전송 방지로 대부분 해결됩니다.
  • 마지막으로, raw request/response 로깅을 통해 “실패 요청을 그대로 재현”할 수 있어야 디버깅이 끝납니다.

이 3가지만 체계화해두면, Tool Use는 400 지뢰밭이 아니라 안정적인 제품 기능이 됩니다.