- Published on
Claude Tools 400 invalid_tool_result 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 Claude Tools(툴 호출) 기반 에이전트를 붙이다 보면, 모델이 tool을 호출한 뒤 우리가 tool 결과를 다시 모델에 전달하는 구간에서 **HTTP 400 invalid_tool_result**가 터지는 경우가 있습니다. 이 에러는 대개 “도구는 실행됐는데, 모델이 기대하는 형태로 결과가 돌아오지 않았다”는 의미에 가깝습니다.
이 글에서는 invalid_tool_result가 실제로 어떤 상황에서 발생하는지, 어떤 필드를 어떻게 맞춰야 하는지, 그리고 운영에서 재발을 막기 위한 스키마 검증/로깅/리트라이 전략까지 정리합니다.
1) invalid_tool_result의 본질: 결과 포맷이 아니라 “대화 상관관계” 문제
Claude의 Tools 플로우는 단순히 JSON을 주고받는 수준이 아니라, 다음 3단계를 정확히 이어 붙이는 상관관계(correlation) 가 핵심입니다.
- 모델이
tool_use(도구 호출) 메시지를 생성 - 애플리케이션이 실제 도구 실행
- 애플리케이션이
tool_result를 동일한 tool_use에 대응되도록 모델에 전달
여기서 invalid_tool_result는 보통 아래 중 하나입니다.
tool_result가 어떤tool_use에 대한 응답인지 식별 불가tool_result의 필수 필드 누락/타입 불일치- 도구 결과를 텍스트로 보내야 하는데 객체로 보내거나(혹은 반대)
- 모델이 요구한 tool name과 서버에서 실행한 tool name이 다름
- 여러 tool 호출이 섞였는데 순서/ID 매칭이 깨짐
즉 “JSON이 유효하냐”보다 “이 결과가 방금 그 호출에 대한 답이 맞냐”가 더 중요합니다.
2) 가장 흔한 원인 TOP 7 체크리스트
2.1 tool_use_id(혹은 대응 ID) 누락/오타
모델이 tool을 호출하면 보통 tool_use_id 같은 식별자가 함께 옵니다. 이 값이 tool_result에 그대로 들어가야 합니다.
- 잘못된 예: 서버가 임의로 UUID를 새로 생성
- 잘못된 예: 이전 요청의 tool_use_id를 재사용
2.2 content 타입 불일치
Claude의 tool_result는 구현에 따라 content가 문자열 또는 배열(텍스트 블록) 형태를 요구합니다. SDK/엔드포인트가 요구하는 타입과 다르면 400이 납니다.
- 잘못된 예:
content: {"ok": true}(객체) - 올바른 예:
content: "{\"ok\": true}"(문자열 JSON)
2.3 tool_result를 “assistant role”로 보내는 실수
tool 결과는 보통 role=tool 또는 type=tool_result 같은 별도 타입으로 보내야 합니다. 이를 assistant 메시지로 섞어 보내면 모델이 도구 결과로 인식하지 못합니다.
2.4 tool name mismatch
등록한 tool 이름이 get_weather인데 모델 호출은 getWeather로 나갔다면, 서버가 다른 tool을 실행하거나 결과를 잘못 라우팅할 수 있습니다.
2.5 여러 tool 호출 동시 처리에서 매칭 붕괴
한 턴에서 tool_use가 여러 개 나올 수 있는데, 결과를 합쳐서 한 번에 보내거나 순서를 섞으면 실패할 수 있습니다.
- 원칙: 각 tool_use마다 tool_result를 정확히 1:1로
2.6 도구 실행 실패를 “빈 결과”로 반환
에러가 났다면 빈 문자열을 보내기보다, 명시적으로 실패를 표현해야 합니다(예: is_error: true 또는 에러 메시지 포함). 스펙에 맞지 않으면 invalid_tool_result로 떨어질 수 있습니다.
2.7 너무 큰 결과(간접적으로 실패)
결과가 지나치게 커서 컨텍스트 제한이나 게이트웨이 제한에 걸리면, SDK가 다르게 표면화할 수 있습니다. 이 경우는 context_length_exceeded 계열과 함께 점검하세요.
3) 재현 가능한 최소 예제: “잘못된 tool_result” vs “올바른 tool_result”
아래는 의도적으로 invalid_tool_result를 유발하는 패턴을 보여주는 예시입니다. (SDK/버전에 따라 필드명은 다를 수 있지만, 핵심은 동일합니다: tool_use_id 매칭 + content 타입)
3.1 잘못된 예(대표적인 실패)
// 잘못된 예시: tool_use_id 누락 + content가 객체
const toolResultMessage = {
type: "tool_result",
// tool_use_id: "toolu_01..." // 누락
content: { ok: true, data: { temp: 21 } }, // 객체는 종종 거부됨
};
await client.messages.create({
model: "claude-3-5-sonnet-latest",
messages: [
{ role: "user", content: "서울 날씨 알려줘" },
// ... 모델이 tool_use를 반환했다고 가정
{ role: "assistant", content: [{ type: "tool_use", id: "toolu_01ABC", name: "get_weather", input: { city: "Seoul" } }] },
{ role: "user", content: [toolResultMessage] },
],
});
3.2 올바른 예(안전한 형태)
const toolUseId = "toolu_01ABC"; // 모델이 준 id를 그대로 사용
const toolPayload = { ok: true, data: { temp: 21, unit: "C" } };
const toolResultMessage = {
type: "tool_result",
tool_use_id: toolUseId,
content: JSON.stringify(toolPayload), // 문자열로 안전하게
is_error: false,
};
await client.messages.create({
model: "claude-3-5-sonnet-latest",
messages: [
{ role: "user", content: "서울 날씨 알려줘" },
{ role: "assistant", content: [{ type: "tool_use", id: toolUseId, name: "get_weather", input: { city: "Seoul" } }] },
{ role: "user", content: [toolResultMessage] },
],
});
핵심은 다음 2가지입니다.
- tool_use_id를 모델 응답에서 받은 그대로 사용
content는 “SDK/스펙이 요구하는 타입”으로 맞추되, 모호하면 문자열(JSON stringify) 로 보내는 것이 가장 안전
4) 운영에서 재발 방지: 스키마 검증 + 상관관계 로깅
invalid_tool_result는 개발 환경에서는 금방 잡히지만, 운영에서는 “특정 요청에서만” 터지는 일이 많습니다. 특히 동시성/재시도/큐 처리에서 상관관계가 꼬이기 쉽습니다.
4.1 Zod로 tool_result 스키마를 강제하기 (Node/TS)
import { z } from "zod";
const ToolResultSchema = z.object({
type: z.literal("tool_result"),
tool_use_id: z.string().min(1),
content: z.string().min(1),
is_error: z.boolean().optional(),
});
export function buildToolResult(toolUseId: string, payload: unknown, isError = false) {
const msg = {
type: "tool_result" as const,
tool_use_id: toolUseId,
content: typeof payload === "string" ? payload : JSON.stringify(payload),
is_error: isError,
};
// 여기서 터지면 Claude에 보내기 전에 우리가 잡아냄
return ToolResultSchema.parse(msg);
}
이렇게 하면 tool_use_id 누락 같은 치명적인 실수를 API 호출 전에 차단할 수 있습니다.
4.2 상관관계 로깅: tool_use → tool_result 매칭을 한 줄로 남기기
logger.info({
tool_use_id: toolUseId,
tool_name: toolName,
input: toolInput,
result_bytes: Buffer.byteLength(resultContent, "utf8"),
is_error: false,
}, "claude tool executed");
운영 장애 시에는 “모델이 어떤 tool을 호출했는지”와 “서버가 어떤 결과를 어떤 ID로 보냈는지”가 한 번에 보여야 합니다.
5) 동시성/재시도에서 특히 조심할 것
5.1 tool_use_id를 키로 한 단일 응답 보장
하나의 tool_use_id에 대해 여러 번 tool_result를 보내면(중복 재시도), 일부 SDK/게이트웨이에서 invalid_tool_result로 표면화될 수 있습니다.
- 해결:
tool_use_id를 idempotency key로 저장하고, 이미 처리한 경우 같은 결과를 재전송하거나(정책에 따라) 요청을 무시
5.2 스트리밍 사용 시 “도구 호출 완료” 이벤트 이후에만 실행
스트리밍 중간에 tool_use가 완전히 수신되기 전에 실행하면, id/name이 덜 온 상태에서 잘못된 결과를 만들 수 있습니다.
- 해결: tool_use 이벤트를 완전히 수신한 뒤 실행 큐에 넣기
5.3 도구 실패를 정상 결과처럼 포장하지 말기
도구가 실패했는데 content: ""로 보내면 모델이 후속 추론을 못 하고 에러로 이어질 수 있습니다.
{
"type": "tool_result",
"tool_use_id": "toolu_01ABC",
"content": "{\"ok\":false,\"error\":{\"message\":\"timeout\",\"retryable\":true}}",
"is_error": true
}
6) 실전 디버깅 절차(10분 컷)
6.1 원본 요청/응답 전문(raw)을 확보
- 모델이 반환한
tool_use블록 원문 - 서버가 보낸
tool_result블록 원문
둘을 나란히 놓고 아래를 확인합니다.
tool_use.id↔tool_result.tool_use_id일치?- tool 이름 일치?
content타입/형식이 SDK 요구와 일치?- tool_result가 messages 배열의 올바른 위치에 들어갔나?
6.2 결과 크기 확인
도구 결과가 너무 크면(HTML 전체, 로그 전체 등) 다른 400으로도 튈 수 있습니다. 크기를 줄이거나 요약해서 전달하세요.
6.3 “응답 충돌/취소”가 섞였는지 확인
클라이언트 취소, 타임아웃, 중복 실행이 겹치면 도구 결과가 엉뚱한 세션에 붙을 수 있습니다. 비슷한 유형의 운영 이슈는 아래 글의 접근(충돌/취소 분리)이 도움이 됩니다.
7) 권장 아키텍처: “Tool Router + Validator” 레이어를 분리
Claude Tools를 붙일 때는 아래처럼 레이어를 쪼개면 장애가 줄어듭니다.
- Tool Registry: tool name → handler 매핑
- Tool Executor: 입력 검증, 타임아웃, 리트라이, 회로차단
- Tool Result Builder: content 직렬화 규칙 단일화(JSON stringify)
- Validator: tool_result 스키마 검사
- Correlation Store(선택): tool_use_id 처리 상태 저장(중복 방지)
이 구조는 “한두 개 tool”에서 “수십 개 tool”로 늘어날수록 효과가 커집니다.
8) 마무리: invalid_tool_result는 대부분 ‘ID/타입/순서’ 문제다
정리하면 Claude Tools 400 invalid_tool_result는 도구 로직 자체보다 도구 호출과 결과의 연결이 깨졌을 때 발생합니다. 아래 3가지만 강제해도 재발률이 크게 내려갑니다.
- 모델이 준
tool_use_id를 그대로tool_result에 넣기 content는 모호하면 문자열(JSON stringify) 로 통일- tool_use ↔ tool_result를 1:1로 매칭하고, 동시성에서 중복 전송을 막기
추가로, 결과가 너무 커서 다른 400으로 이어지는 경우도 많으니 컨텍스트/페이로드 크기 관리도 함께 점검하세요.