- Published on
Claude/Bedrock Tool Use 400 ValidationException 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
Amazon Bedrock에서 Claude(특히 Tool Use 지원 모델)를 붙이다 보면, 애매하게 한 번은 되다가도 특정 입력에서 갑자기 400 ValidationException이 터지는 경우가 많습니다. 문제는 이 에러가 “요청이 유효하지 않다”는 사실만 말해줄 뿐, 어디가 잘못됐는지(툴 스키마인지, 메시지 배열인지, tool_result 매칭인지)를 친절하게 알려주지 않는다는 점입니다.
이 글에서는 Bedrock + Claude Tool Use에서 자주 발생하는 400 ValidationException 패턴을 원인별로 분해하고, **정상 동작하는 요청 포맷(코드)**과 함께 재현/검증 방법을 정리합니다. 운영 환경에서 400이 간헐적으로 튀는 상황은 대개 “요청 구조가 특정 케이스에서만 깨지는” 형태라서, 체크리스트 방식으로 잡는 게 가장 빠릅니다.
> 참고로, 비슷하게 원인 파악이 어려운 HTTP 계열 장애를 추적하는 접근은 다른 글에서도 반복됩니다. 예: Kubernetes API 413 Request Entity Too Large 해결, OpenAI Responses API 403 model_not_found 해결 가이드
1) Bedrock Tool Use 요청 구조 한 번에 정리
Bedrock의 Claude Messages API(또는 Converse API)를 사용할 때 Tool Use는 크게 다음 요소가 맞물립니다.
messages[]: 유저/어시스턴트 대화. content는 단순 문자열이 아니라 블록 배열(text/tool_use/tool_result 등)로 들어갈 수 있음tools[]: 모델에게 제공하는 도구 목록. 각 tool은name,description,input_schema(JSON Schema) 등을 가짐- 모델 응답의
tool_use: 모델이 “이 도구를 이 입력으로 호출해라”라고 생성 - 클라이언트가 실제 도구 실행 후
tool_result를 만들어 같은 tool_use의 id에 매칭하여 다시 모델에 전달
ValidationException은 위 중 하나라도 구조/타입/필수값이 어긋나면 발생합니다. 특히 아래 3가지가 빈도가 높습니다.
- tool schema(JSON Schema) 불일치
- messages.content 블록 타입/필드 불일치
- tool_use.id ↔ tool_result.tool_use_id 매칭 실패
2) 가장 흔한 원인 TOP 7
원인 1) tools[].input_schema가 JSON Schema가 아님
Claude Tool Use에서 input_schema는 “그냥 예시 JSON”이 아니라 JSON Schema여야 합니다. 최소한 type: "object"와 properties 구조가 필요합니다.
잘못된 예(예시 JSON을 그대로 넣음):
{
"tools": [
{
"name": "search",
"description": "Search something",
"input_schema": {
"query": "string"
}
}
]
}
올바른 예(JSON Schema):
{
"tools": [
{
"name": "search",
"description": "Search something",
"input_schema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search query" }
},
"required": ["query"],
"additionalProperties": false
}
}
]
}
additionalProperties: false를 켜면 모델이 임의 필드를 뱉어도 바로 잡기 쉬워서 운영 안정성이 올라갑니다(대신 모델이 스키마를 더 잘 따라야 함).
원인 2) tool name 제약 위반(공백/특수문자/중복)
툴 이름은 보통 식별자처럼 써야 합니다.
- 공백/슬래시/한글 등 특수문자 포함 → ValidationException
- 동일 name 중복 → ValidationException
권장: snake_case 또는 kebab-case 대신 영문/숫자/언더스코어 위주로 통일
예: get_weather, lookup_user, fetch_order
원인 3) messages.content를 문자열로 보내면서 tool_use/tool_result를 섞어씀
Claude Messages API에서 content는 단순 문자열로도 되지만, Tool Use를 쓰는 순간부터는 블록 배열을 일관되게 유지하는 편이 안전합니다.
특히 다음처럼 “assistant 메시지에 tool_use 블록을 넣어야 하는데 text만 넣거나”, “tool_result를 잘못된 role에 넣는” 경우가 많습니다.
권장 패턴(요약):
- user: text 블록
- assistant: tool_use 블록(모델이 생성)
- user(또는 tool role 지원 시 tool): tool_result 블록(클라이언트가 생성)
- assistant: 최종 답변
원인 4) tool_result가 tool_use_id를 잘못 참조
모델이 생성한 tool_use에는 보통 id가 있습니다. 클라이언트는 도구 실행 결과를 다시 보낼 때 반드시 동일한 id를 tool_use_id로 지정해야 합니다.
- tool_use.id 누락
- tool_result.tool_use_id에 다른 값 사용
- 여러 tool_use 중 하나만 결과를 보내고 나머지를 누락
이 경우가 “가끔만 400이 나는” 전형적인 패턴입니다. 예를 들어 모델이 어떤 질문에서는 도구를 1개만 쓰지만, 다른 질문에서는 2개를 연속 호출하도록 생성하면, 클라이언트가 1개 결과만 보내고 2번째를 누락하면서 깨집니다.
원인 5) tool_result content 타입 불일치
tool_result의 content가 문자열인지, 블록 배열인지, 혹은 JSON을 문자열로 감싸야 하는지 혼동이 잦습니다.
안전한 방식은 “도구 출력이 JSON이면 문자열로 직렬화해서 text로 전달”입니다.
예:
{
"type": "tool_result",
"tool_use_id": "toolu_abc123",
"content": [
{"type": "text", "text": "{\"ok\":true,\"items\":[1,2,3]}"}
]
}
모델이 도구 결과를 자연어로 요약하길 원하면, tool_result는 최대한 구조적으로 주고(예: JSON 문자열), 다음 assistant 턴에서 요약을 시키는 쪽이 디버깅이 쉽습니다.
원인 6) max_tokens / temperature 등 파라미터 위치 오류
Bedrock SDK/런타임에서 API 버전별로 파라미터 키가 다르거나, 특정 키가 허용되지 않으면 ValidationException이 날 수 있습니다.
max_tokensvsmax_tokens_to_sample혼용anthropic_version누락/오타- Converse API와 Messages API 파라미터를 섞음
해결책은 “사용 중인 API(Converse인지 InvokeModel인지)와 모델 문서에 맞춘 최소 요청부터” 점진적으로 옵션을 늘리는 것입니다.
원인 7) 요청 본문이 너무 커져 내부적으로 거절(간접 원인)
엄밀히는 413이 더 흔하지만, 어떤 래퍼/프록시/미들웨어를 거치면 400 ValidationException 형태로 뭉개져 나오는 경우도 있습니다.
- tool_result에 로그/HTML/대용량 JSON을 그대로 붙임
- 대화 히스토리를 무제한 누적
이 경우는 히스토리/툴 결과를 요약/절단하는 전략이 필요합니다. (K8s에서 큰 payload로 깨지는 케이스는 Kubernetes API 413 Request Entity Too Large 해결처럼 “크기”가 핵심 단서가 됩니다.)
3) 정상 동작하는 최소 예제 (Python, boto3)
아래 예제는 “모델이 tool_use를 생성 → 클라이언트가 도구 실행 → tool_result를 다시 전달”의 왕복을 가장 단순하게 구현한 형태입니다. 실제 서비스에서는 tool_use가 여러 개일 수 있으니 반복 처리로 확장하면 됩니다.
> 주의: Bedrock의 API 스타일(Converse/Messages/InvokeModel)과 모델 ID는 계정/리전에 따라 다릅니다. 아래는 개념적으로 가장 중요한 메시지/툴 블록 구조에 집중한 예시입니다.
import json
import boto3
bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")
MODEL_ID = "anthropic.claude-3-5-sonnet-20240620-v1:0" # 예시
TOOLS = [
{
"name": "search_docs",
"description": "Search internal docs by keyword",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string"}
},
"required": ["query"],
"additionalProperties": False
}
}
]
def call_model(messages):
body = {
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 512,
"temperature": 0.2,
"tools": TOOLS,
"messages": messages,
}
resp = bedrock.invoke_model(
modelId=MODEL_ID,
body=json.dumps(body).encode("utf-8"),
accept="application/json",
contentType="application/json",
)
return json.loads(resp["body"].read())
def fake_search_docs(query: str):
# 실제로는 사내 검색/DB/HTTP 호출 등
return {
"hits": [
{"title": "Tool Use Guide", "url": "https://example.com/tool-use", "score": 0.91},
{"title": "ValidationException FAQ", "url": "https://example.com/faq", "score": 0.84},
],
"query": query,
}
# 1) 사용자 질문
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Bedrock에서 tool use 400 validationexception이 나요. 원인 찾아줘."}
],
}
]
first = call_model(messages)
# 2) 모델이 tool_use를 요청했는지 확인
# Anthropic 계열 응답은 content 블록에 tool_use가 섞여 나올 수 있음
assistant_blocks = first.get("content", [])
tool_uses = [b for b in assistant_blocks if b.get("type") == "tool_use"]
# tool_use가 없다면 그냥 답변 텍스트로 종료
if not tool_uses:
print("".join([b.get("text", "") for b in assistant_blocks if b.get("type") == "text"]))
raise SystemExit
# 3) assistant의 tool_use 블록을 messages에 추가
messages.append({"role": "assistant", "content": assistant_blocks})
# 4) 각 tool_use 실행 후 tool_result를 user 턴으로 전달
tool_result_blocks = []
for tu in tool_uses:
if tu.get("name") != "search_docs":
continue
tool_input = tu.get("input", {})
query = tool_input.get("query", "")
result = fake_search_docs(query)
tool_result_blocks.append(
{
"type": "tool_result",
"tool_use_id": tu["id"],
"content": [
{"type": "text", "text": json.dumps(result, ensure_ascii=False)}
],
}
)
messages.append({"role": "user", "content": tool_result_blocks})
# 5) 최종 답변
final = call_model(messages)
print("".join([b.get("text", "") for b in final.get("content", []) if b.get("type") == "text"]))
이 예제에서 400 ValidationException을 피하기 위한 핵심은 다음입니다.
- tool schema를 JSON Schema로 제공
- assistant의 tool_use 블록을 그대로 messages에 다시 포함
- tool_result에
tool_use_id를 정확히 매칭 - tool_result content는 text 블록으로 직렬화
4) “간헐적” ValidationException을 잡는 디버깅 체크리스트
운영에서 가장 짜증나는 형태는 “특정 질문에서만 400”입니다. 이때는 요청/응답을 그대로 로깅해서 깨지는 순간의 payload를 확보해야 합니다.
4.1 요청 payload를 그대로 남기기(민감정보 마스킹)
- Bedrock 호출 직전의 JSON body를 로그로 남김
- PII/토큰/쿠키는 마스킹
- tool_result가 너무 크면 길이만 남기고 앞/뒤 일부만 샘플링
예: Node.js에서 길이 가드
function safeSnippet(s, max = 2000) {
if (s.length <= max) return s;
return s.slice(0, max) + `...<truncated ${s.length - max} chars>`;
}
console.log("bedrock_request_body=", safeSnippet(JSON.stringify(body)));
4.2 tool_use가 여러 개인 케이스를 강제로 테스트
프롬프트에 “필요하면 2개 이상의 도구 호출을 해도 된다” 같은 문구가 들어가면, 모델이 멀티 tool_use를 생성할 수 있습니다. 이때 클라이언트가 첫 번째 tool_use만 처리하면 400이 날 가능성이 커집니다.
- tool_use 배열을 모두 순회하는지
- 모든 tool_use에 대해 tool_result를 반환하는지
4.3 스키마 엄격 모드에서 모델 출력이 스키마를 위반하는지 확인
additionalProperties: false를 켠 경우, 모델이 가끔 {"query":"...","top_k":5}처럼 쓸데없는 필드를 끼워 넣습니다.
대응 전략:
- 엄격 모드 유지 + 모델에게 “스키마 외 필드 금지”를 시스템 프롬프트로 강하게 지시
- 또는 additionalProperties를 true로 열어두고 서버에서 유효성 검사를 수행
4.4 Bedrock SDK/런타임 버전 차이 확인
같은 코드라도 환경이 다르면(로컬 vs Lambda, 컨테이너 이미지 버전 차이) 요청 직렬화가 달라질 수 있습니다.
- boto3/botocore 버전 고정
- Node AWS SDK v3 패키지 버전 고정
- JSON 직렬화 시
undefined제거(특히 JS)
5) 실전에서 추천하는 “안전한” 설계 패턴
5.1 Tool 실행 레이어를 분리하고, Tool Result를 표준화
Tool Use를 여러 개 붙이면 도구마다 출력 포맷이 제각각이 되며, 그 결과를 그대로 모델에 붙이는 순간 payload가 커지고 예외가 늘어납니다.
권장:
- 모든 tool output을
{ ok: boolean, data: any, error?: {code,message} }로 감싸기 - 모델로 전달할 때는
json.dumps된 짧은 JSON만 전달 - 원본 응답(대용량)은 별도 스토리지(S3/DB)에 저장하고 링크/요약만 전달
5.2 “tool_result는 짧게, 후처리는 모델에게”
예를 들어 검색 결과 50건을 다 보내지 말고 상위 3~5건만 보내고, 모델에게 “이 중에서 답변 구성”을 시키는 편이 안정적입니다. 대용량은 400/413/timeout의 지름길입니다.
5.3 장애 분류를 HTTP처럼 다루기
ValidationException은 사실상 “클라이언트 요청 오류”입니다. 따라서 서버/모델 문제가 아니라 요청 생성 코드의 버그로 분류하고 다음처럼 운영 지표를 나누면 좋습니다.
- 4xx(특히 400): 요청/스키마/직렬화 버그
- 5xx: 런타임/리전/일시 장애
- timeout: 네트워크/도구 실행 지연
이런 방식의 분류는 다른 인프라 장애 트러블슈팅에도 동일하게 적용됩니다. 예를 들어 403 계열을 체계적으로 파고드는 방식은 GitHub Actions OIDC로 AWS 배포 AccessDenied 해결 같은 케이스에서 특히 유효합니다.
6) 결론: 400 ValidationException은 “대부분 포맷/매칭 문제”다
Bedrock에서 Claude Tool Use를 사용할 때 400 ValidationException이 나면, 우선 모델의 품질 문제가 아니라 아래를 의심하는 게 빠릅니다.
tools[].input_schema가 JSON Schema로 정확한가messages[].content가 블록 배열로 일관적인가tool_use.id와tool_result.tool_use_id가 1:1로 매칭되는가- tool_result의 content 타입/크기가 과하지 않은가
위 체크리스트대로 요청 payload를 확보하고, “최소 요청 → 옵션 추가” 방식으로 좁혀가면 대부분 당일 내에 원인을 찾을 수 있습니다.
다음 글에서는 멀티 tool_use(연쇄 호출)에서 상태 관리를 어떻게 단순화하는지, 그리고 tool output을 요약/캐시해 비용과 오류율을 동시에 낮추는 패턴도 다뤄보겠습니다.