- Published on
Claude Tool Use 400 invalid_request 해결 9가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Claude의 Tool Use를 붙이다 보면 가장 난감한 에러가 400 invalid_request 입니다. 서버 장애가 아니라 요청 스키마가 조금이라도 어긋나면 즉시 실패하기 때문에, 원인을 감으로 찾다 보면 시간을 크게 씁니다.
이 글은 400 invalid_request를 9가지 유형으로 분해해서, 각각의 증상과 해결책을 코드로 바로 적용할 수 있게 정리합니다. (SDK 버전, 모델명, 툴 스키마, 메시지 구조, content 타입, tool 결과 연결 등)
참고: 에러 대응 패턴(재시도, 폴백, 서킷브레이커) 자체는 Claude뿐 아니라 공통입니다. 운영 관점의 설계는 OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커도 같이 보면 도움이 됩니다.
0) 먼저 확인할 것: 에러 바디를 그대로 로깅
400은 대부분 응답 바디에 정확한 필드 경로가 들어 있습니다. 운영에서 놓치기 쉬운 건 로깅 시 개인정보 마스킹을 하면서도 스키마 관련 필드는 남겨야 한다는 점입니다.
// Node.js 예시 (fetch)
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": process.env.ANTHROPIC_API_KEY!,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text();
console.error("Claude error status=", res.status);
console.error("Claude error body=", text);
throw new Error(`claude request failed: ${res.status}`);
}
이제부터는 “어떤 형태의 payload가 invalid_request를 만드는가”를 9가지로 나눠 봅니다.
1) 모델이 Tool Use를 지원하지 않음
가장 흔한 실수는 모델명 오타 또는 Tool Use 미지원 모델을 넣는 것입니다. 이런 경우도 400 invalid_request로 떨어질 수 있습니다.
해결
- 공식 문서에서 Tool Use 지원 모델을 확인
- 환경별로 모델명을 상수화하고, 런타임에서 허용 목록 검증
const ALLOWED_MODELS = new Set([
"claude-3-5-sonnet-latest",
"claude-3-5-haiku-latest",
]);
function assertModel(model: string) {
if (!ALLOWED_MODELS.has(model)) {
throw new Error(`unsupported model for tool use: ${model}`);
}
}
2) tools 정의(JSON Schema)가 유효하지 않음
Tool Use는 툴 정의에 JSON Schema를 씁니다. 여기서 흔히 깨지는 포인트는 아래입니다.
input_schema가 객체가 아님type누락properties에 정의한 키가required와 불일치- 스키마에 허용되지 않는 키를 섞음
해결
- 툴 스키마를 코드로 생성하지 말고, 최소한의 스키마로 시작해서 확장
- 로컬에서 AJV 같은 validator로 사전 검증
const tools = [
{
name: "get_weather",
description: "Get weather by city name",
input_schema: {
type: "object",
properties: {
city: { type: "string", description: "City name" },
unit: { type: "string", enum: ["c", "f"], default: "c" },
},
required: ["city"],
additionalProperties: false,
},
},
];
3) messages 구조가 API 스펙과 다름
Claude Messages API는 messages의 각 원소가 { role, content } 구조를 갖습니다. 여기서 content는 문자열이 아니라 배열 블록 형태를 쓰는 경우가 많고, 블록 타입이 엄격합니다.
흔한 실수
content에 문자열과 배열을 섞어서 보냄role에assistant대신 다른 값을 넣음- 빈 배열 또는 빈 문자열을 넣음
해결
- 팀 규칙으로
content는 항상 블록 배열로 통일
const messages = [
{
role: "user",
content: [
{ type: "text", text: "서울 날씨 알려줘" },
],
},
];
4) Tool call을 유도했지만 tool_choice 또는 프롬프트가 충돌
Tool Use를 쓰면서 tool_choice를 강제하거나(특정 툴만 호출), 반대로 “툴을 쓰지 마” 같은 제약을 시스템 프롬프트에 넣으면 모델이 요청 자체를 비정상으로 판단하거나, 호출 흐름이 꼬여 invalid_request로 이어지는 케이스가 있습니다.
해결
tool_choice를 강제할 때는 해당 툴이 반드시tools에 포함되어야 함- 시스템 프롬프트에 “툴 사용 금지” 같은 상충 문구 제거
const payload = {
model: "claude-3-5-sonnet-latest",
max_tokens: 512,
tools,
tool_choice: { type: "tool", name: "get_weather" },
messages,
};
5) tool_use 결과를 tool_result로 연결하지 않음
Claude가 툴 호출을 생성하면 응답 content에 tool_use 블록이 들어옵니다. 그 다음 턴에서 개발자가 툴을 실행한 결과를 반드시 tool_result 블록으로 되돌려줘야 하는데, 이 연결이 끊기면 요청이 무효가 됩니다.
핵심 규칙
tool_result는 직전의tool_use.id를tool_use_id로 참조해야 함- 결과는 문자열 또는 구조화된 형태(스펙에 맞게)로 제공
// 1) Claude 응답에서 tool_use 추출
const toolUse = assistantContent.find((b: any) => b.type === "tool_use");
// toolUse.id, toolUse.name, toolUse.input
// 2) 실제 툴 실행
const result = await getWeather(toolUse.input.city);
// 3) 다음 요청에서 tool_result로 연결
const messages2 = [
...messages,
{
role: "assistant",
content: assistantContent,
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: toolUse.id,
content: [{ type: "text", text: JSON.stringify(result) }],
},
],
},
];
여기서 tool_use_id가 빠지거나 다른 값이면 invalid_request가 나기 쉽습니다.
6) 툴 입력값이 스키마와 불일치(타입, enum, required)
모델이 생성한 toolUse.input을 그대로 실행하기 전에, 스키마 검증을 하지 않으면 다음 문제가 생깁니다.
- 모델이
unit에Celsius같은 enum 밖 값을 넣음 - 숫자여야 하는 필드에 문자열이 들어옴
- 필수값 누락
이 자체는 “툴 실행 실패”로 끝나야 정상인데, 구현에 따라 결과를 다시 Claude에 전달할 때 잘못된 형태로 보내면 invalid_request로 이어집니다.
해결
- 툴 실행 전: 입력 스키마 검증
- 툴 실행 후: 실패도 정상적인
tool_result로 반환
import { z } from "zod";
const WeatherInput = z.object({
city: z.string().min(1),
unit: z.enum(["c", "f"]).optional(),
});
function safeParseToolInput(input: unknown) {
const parsed = WeatherInput.safeParse(input);
if (!parsed.success) {
return { ok: false as const, error: parsed.error.message };
}
return { ok: true as const, data: parsed.data };
}
7) tool_result.content 타입을 잘못 보냄
자주 하는 실수는 tool_result의 content를 문자열로 보내거나, 블록 배열이 아닌 임의 객체로 보내는 것입니다. Next.js나 서버에서 직렬화하면서 형태가 바뀌는 경우도 있습니다.
해결
tool_result.content는 블록 배열로 통일- 텍스트는
{ type: "text", text: "..." }로 감싸기
const toolResultBlock = {
type: "tool_result",
tool_use_id: toolUse.id,
content: [
{ type: "text", text: "{\"temp\":12,\"unit\":\"c\"}" },
],
};
8) max_tokens 등 파라미터가 범위 밖이거나 누락
max_tokens를 너무 크게 잡거나, 일부 SDK에서 필수 필드를 누락하면 invalid_request가 납니다.
해결
- 모델별
max_tokens상한을 설정하고 서버에서 clamp - 요청 생성 시 필수 필드 체크
function clampTokens(n: number) {
// 예시 값: 실제 상한은 모델 정책에 맞게 조정
return Math.max(1, Math.min(n, 2048));
}
const payload = {
model: "claude-3-5-sonnet-latest",
max_tokens: clampTokens(userMaxTokens ?? 512),
tools,
messages,
};
9) SDK 버전/런타임(ESM-CJS) 문제로 payload가 깨짐
의외로 invalid_request는 “내가 만든 JSON이 맞다”가 아니라 SDK가 직렬화/전송 과정에서 payload를 다르게 만들어서 발생하기도 합니다.
대표 케이스:
- Node 환경에서 ESM/CJS 충돌로 import가 꼬여 다른 버전의 SDK가 로드됨
- 번들러가
undefined필드를 제거하거나, BigInt 등을 섞어 JSON 직렬화가 깨짐
이 경우 원인 추적이 어려워서, 런타임/모듈 시스템을 먼저 안정화하는 게 빠릅니다.
해결
- SDK 버전 고정 및 lockfile 커밋
- 서버에서 최종 전송 JSON을
JSON.stringify로 찍어 비교 undefined필드 제거를 명시적으로 수행
function stripUndefined(obj: any) {
return JSON.parse(JSON.stringify(obj));
}
const safePayload = stripUndefined(payload);
console.log("final payload=", JSON.stringify(safePayload));
실전 디버깅 체크리스트(10분 컷)
아래 순서로 보면 대부분 빠르게 잡힙니다.
- 응답 바디 원문 로깅: 어떤 필드가 문제인지 경로 확인
- 모델명/버전 확인: Tool Use 지원 모델인지
tools스키마 최소화: 가장 단순한 스키마로 재현되는지messages를 블록 배열로 통일: 문자열 혼용 제거tool_useid와tool_result.tool_use_id연결 확인tool_result.content를 텍스트 블록 배열로 통일- 입력값 검증(zod 등) 후 실행, 실패도
tool_result로 반환 max_tokens범위 clamp- SDK/모듈 시스템 안정화 및 최종 JSON 덤프 비교
운영 팁: invalid_request도 “버그 리그레션”처럼 추적하라
Tool Use는 요청 스키마가 조금만 바뀌어도 즉시 깨지므로, 회귀 추적 자동화가 유효합니다. 예를 들어 특정 커밋 이후 invalid_request가 늘었다면, 간단한 재현 스크립트를 만들어 원인을 좁힐 수 있습니다.
마무리
Claude Tool Use의 400 invalid_request는 대부분 “툴 정의”와 “메시지/블록 타입” 그리고 “tool_use와 tool_result 연결”에서 발생합니다. 특히 tool_use_id 연결과 tool_result.content의 타입은 한 번 규칙을 정해두면 재발을 크게 줄일 수 있습니다.
원하면, 사용 중인 SDK(언어), 모델명, 실제 요청 payload(민감정보 제거)를 기준으로 어떤 항목에서 깨졌는지를 체크리스트 형태로 같이 점검해 드릴 수 있습니다.