이는 도구 오류가 아니라 메시지 순서 오류입니다. Claude는 assistant 턴의 모든 tool_use 블록이 바로 다음 user 턴의 tool_result 블록으로 응답되기를 요구합니다. 동일한 id여야 하며 그 사이에 다른 내용이 없어야 합니다. 일반적으로 도구에서 예외가 발생했을 때 루프가 하나를 누락합니다.
오류
{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "messages.1: tool_use ids were found without tool_result blocks immediately after: toolu_01A... Each tool_use block must have a corresponding tool_result block in the next message."
}
}원인과 해결 방법 한눈에 보기
| 원인 | 해결 방법 |
|---|---|
| 도구에서 예외가 발생해 아무것도 추가되지 않음 | is_error: true와 오류 텍스트를 포함한 tool_result를 그래도 전송하세요. |
| tool_result가 나중 메시지에 들어감 | 반드시 바로 다음 메시지여야 합니다. 그 사이에 assistant 또는 user 턴이 올 수 없습니다. |
| tool_use_id가 일치하지 않음 | tool_use 블록의 정확한 id를 그대로 반영하세요. 새로 생성하지 마세요. |
| 왕복 중간에 기록이 잘림 | 두 절반으로 이루어진 하나의 왕복 중간이 아니라, 전체 도구 왕복 단위로 잘라내세요. |
불변 조건을 한 번에 정리하면
assistant 턴의 모든 tool_use 블록에는 바로 다음 user 메시지에 정확히 하나의 tool_result 블록이 있어야 하며, 동일한 tool_use_id를 포함해야 합니다. 한 턴에 tool_use 블록이 여러 개라면 바로 다음 메시지 하나에 그 수만큼의 tool_result 블록이 필요합니다. 두 턴 사이에는 아무것도 올 수 없습니다.
도구가 실패해도 항상 응답하세요
모델은 실패한 도구를 문제없이 처리할 수 있지만 누락된 도구는 처리하지 못합니다. 오류를 tool_result로 반환하면 대화가 유효하게 유지되고, 대개 400 대신 합리적인 복구가 이루어집니다.
results = []
for block in (b for b in resp.content if b.type == "tool_use"):
try:
out = run_tool(block.name, block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(out),
})
except Exception as e:
# A failed tool still owes the model an answer.
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": f"Tool failed: {e}",
"is_error": True,
})
messages.append({"role": "assistant", "content": resp.content})
messages.append({"role": "user", "content": results})전송 전에 마지막 두 턴을 검증하세요
십여 줄의 단언으로 네트워크에서 400을 받은 뒤가 아니라 호출 지점에서 문제를 잡을 수 있습니다. assistant 턴의 tool_use id를 순회하면서 다음 user 턴이 모두 응답하는지 확인하세요.
def check_pairs(messages):
for i, m in enumerate(messages):
if m["role"] != "assistant" or not isinstance(m.get("content"), list):
continue
ids = {b.get("id") for b in m["content"]
if isinstance(b, dict) and b.get("type") == "tool_use"}
if not ids:
continue
nxt = messages[i + 1] if i + 1 < len(messages) else None
answered = {b.get("tool_use_id") for b in (nxt or {}).get("content", [])
if isinstance(b, dict) and b.get("type") == "tool_result"}
missing = ids - answered
assert not missing, f"message {i}: unanswered tool_use {missing}"왕복 경계에서 기록을 잘라내세요
메시지 수를 기준으로 컨텍스트 창을 자르면 결국 tool_use와 tool_result 사이가 잘립니다. 버릴 내용을 결정할 때 이 쌍을 나눌 수 없는 하나의 단위로 취급하세요.
Kunavo를 통해 호출하는 경우
이 문제는 페이로드 자체에 있으며 Kunavo가 이를 덮어 주지는 않습니다. 400은 재시도하지 않는 목록에 있으므로 잘못된 도구 왕복은 한 번만 실패하고, 같은 오류를 받기 위해 두 번째 업스트림 왕복에 따른 지연을 추가로 발생시키지 않으며, 거부된 요청의 비용은 0으로 기록됩니다. /v1/messages에서는 Messages 프로토콜을 직접 사용하므로 tools, tool_use 및 tool_result 블록이 변환되지 않고 전달됩니다. 이 경우 400은 Anthropic API와 마찬가지로 invalid_request_error 유형으로 반환되며, 업스트림 메시지 텍스트 뒤에 업스트림 자체 요청 ID가 붙고 request_id 필드는 없습니다. 2026년 9월 24일까지는 유형이 api_error였으므로 로그가 그 이전까지 거슬러 올라간다면 HTTP 상태와 메시지 텍스트를 기준으로 분기하세요.
자주 묻는 질문
tool_use 턴을 그냥 삭제하고 응답하지 않아도 되나요?
예, 전체 assistant 턴을 삭제한다면 가능합니다. 유효하지 않은 것은 tool_use를 남겨 두고 tool_result를 생략하는 것입니다.
OpenAI 호환 엔드포인트에도 같은 규칙이 적용되나요?
동일한 페어링이 필요하지만 표기는 다릅니다. assistant 메시지에 tool_calls가 있고, 각 호출마다 tool_call_id를 포함한 role: "tool" 메시지가 하나씩 뒤따라야 합니다.
거부된 요청에도 비용이 발생하나요?
Kunavo에서는 비용이 들지 않습니다. 실패한 요청은 비용 0으로 기록되며 요금이 청구되는 업스트림 호출에 절대 도달하지 않습니다.
관련 가이드
- model_not_found / 404 — Claude, Gemini 및 게이트웨이의 모델 명명
- Claude API 429 rate_limit_error — 원인과 확실한 해결 방법
오류 의미에 대한 자세한 내용은 오류 참조에서 확인할 수 있습니다. 가입 및 인증 가이드를 통해 1분이면 키를 받을 수 있습니다.