함수 호출·구조화 출력 마스터
Claude API로 깨지지 않는 JSON과 신뢰할 수 있는 툴 루프를 만드는 실전 가이드
LLM을 프로토타입에서 프로덕션으로 옮기는 순간 가장 먼저 깨지는 게 출력의 모양이다. 데모에서는 json.loads(response.text)가 잘 돌아가다가, 트래픽이 늘면 모델이 ```json 코드펜스를 붙이거나, "Here is the JSON:" 같은 서두를 달거나, 필드 하나를 빼먹어서 파서가 터진다. 함수 호출(tool use)과 구조화 출력(structured outputs)은 이 문제를 모델 측에서 제약 디코딩(constrained decoding) 으로 풀어준다. 즉 모델이 스키마를 벗어난 토큰을 애초에 생성할 수 없게 만드는 것이다.
이 가이드는 두 축을 다룬다. 첫째는 스키마 강제 — output_config.format(JSON 스키마 응답)과 strict: true(툴 파라미터 검증)로 출력 형태를 보장하는 법. 둘째는 툴 오케스트레이션 — 모델이 여러 툴을 호출하며 작업을 진행하는 agentic loop를 SDK 툴 러너로 자동화하거나, 승인 게이트·로깅이 필요할 때 수동 루프로 직접 제어하는 법. 둘은 같은 POST /v1/messages 엔드포인트의 기능이지 별개 API가 아니다.
전제: 모든 예시는 Claude API(Anthropic 공식 SDK)를 기준으로 한다. 모델은 기본값 claude-opus-4-8을 쓰고, 고볼륨 추출 파이프라인 같은 경우 claude-haiku-4-5로 비용을 낮춘다. 구조화 출력은 Claude Opus 4.8 / Sonnet 4.6 / Haiku 4.5에서 지원한다(Opus 4.5·4.1 등 레거시도 지원). 핵심 원칙 하나만 기억하자: 모델이 스키마를 강제하더라도, 받는 쪽에서 항상 파싱·검증한다. stop_reason이 refusal이거나 max_tokens면 스키마가 보장되지 않기 때문이다.
| 기능 | 파라미터 | 보장하는 것 |
|---|---|---|
| JSON 구조화 출력 | output_config: {format: {...}} | 응답 본문이 지정한 JSON 스키마를 따름 |
| 엄격 툴 호출 | 툴 정의에 strict: true | 툴 input이 스키마를 정확히 만족 |
| 툴 선택 강제 | tool_choice | 모델이 툴을 쓸지/어떤 툴을 쓸지 제어 |
| 자동 툴 루프 | SDK 툴 러너 (beta) | API 호출→툴 실행→결과 피드백 반복 |
1. 왜 프롬프트로 JSON을 강제하면 안 되는가
초기 LLM 통합의 90%는 이렇게 시작한다.
prompt = "다음 후기를 분석해 JSON으로만 답해. 다른 말 붙이지 마: {감정, 점수}"
resp = client.messages.create(model="claude-opus-4-8", max_tokens=1024,
messages=[{"role": "user", "content": prompt + review}])
data = json.loads(resp.content[0].text) # 💥 언젠가 터진다
이게 깨지는 이유는 프롬프트가 사후 제약이기 때문이다. 모델은 자유롭게 토큰을 생성한 뒤 "JSON처럼 보이는" 결과를 내놓을 뿐, 토큰 단위로 스키마가 강제되지 않는다. 실패 양상은 다음과 같다.
- 코드펜스: 응답이
```json\n{...}\n```로 감싸져json.loads가 첫 글자에서 실패 - 서두:
"Here's the analysis:\n\n{...}"— 설명이 앞에 붙음 - 트레일링 코멘트/콤마:
{"score": 5, // 만점\n}같은 비표준 JSON - 필드 누락/타입 흔들림:
score가 어떤 때는5, 어떤 때는"5", 어떤 때는"5점" - enum 위반:
감정필드에"긍정"대신"매우 긍정적"
프롬프트 엔지니어링으로 실패율을 99%→99.9%까지 줄일 수는 있지만 0으로는 못 만든다. 하루 10만 건이면 0.1%도 100건의 장애다.
해법은 제약 디코딩이다. output_config.format에 JSON 스키마를 넘기면, API가 디코딩 단계에서 스키마에 맞지 않는 토큰을 마스킹한다. 모델은 물리적으로 스키마를 벗어난 출력을 생성할 수 없다. 코드펜스도, 서두도, 타입 흔들림도 원천 차단된다.
베스트 프랙티스: 출력 구조가 고정돼 있고 그걸로 프로그램이 분기/저장한다면, 프롬프트로 "JSON으로 답해"라고 부탁하지 말고 스키마를 강제하라. 프롬프트의 포맷 지시문은 삭제해도 된다(스키마가 이미 강제하므로). 단 의미에 대한 지시("감정은 후기의 전반적 톤 기준")는 프롬프트에 남긴다.
2. JSON 구조화 출력 — output_config.format
스키마 강제의 정석 경로는 messages.create()에 output_config.format을 넘기는 것이다. 타입은 json_schema, 스키마는 표준 JSON Schema다.
import anthropic, json
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{
"role": "user",
"content": "추출: John Smith (john@example.com) 가 Enterprise 플랜을 원함."
}],
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string", "format": "email"},
"plan": {"type": "string", "enum": ["Free", "Pro", "Enterprise"]},
"demo_requested": {"type": "boolean"}
},
"required": ["name", "email", "plan", "demo_requested"],
"additionalProperties": False
}
}
}
)
# format을 쓰면 첫 text 블록이 유효한 JSON임이 보장된다
text = next(b.text for b in response.content if b.type == "text")
data = json.loads(text)
print(data["plan"]) # "Enterprise" — enum 밖 값은 나올 수 없음
구조적으로 짚을 포인트:
additionalProperties: false는 거의 필수다. 모든 object에 붙여야 모델이 임의 필드를 끼워넣지 못한다.required에 넣은 필드는 100% 채워진다. 옵셔널 필드는required에서 빼되, 그 필드도properties에는 선언한다.enum은 분류 작업의 핵심 무기다. 라벨 집합을 enum으로 고정하면 "긍정적"/"긍정"/"좋음" 같은 표기 흔들림이 사라진다.- 응답은 여전히 content 블록 배열이다.
output_config.format을 쓰면 첫text블록이 유효 JSON이지만, thinking이 켜져 있으면 그 앞에thinking블록이 올 수 있다.response.content[0].text로 인덱싱하지 말고b.type == "text"로 필터링하라.
deprecated 주의: 예전 코드의 최상위 output_format 파라미터는 폐기됐다. 캐논은 output_config: {format: {...}}다. 또한 구 모델용 패턴인 assistant prefill(메시지 배열을 {"role": "assistant", "content": "{"}로 끝내 JSON 시작을 강제하던 트릭)은 Opus 4.6/4.7/4.8·Sonnet 4.6·Fable 5에서 400 에러다. prefill로 JSON을 강제하던 코드는 전부 output_config.format으로 옮겨야 한다.
3. Pydantic / Zod로 타입까지 한 번에 — messages.parse()
JSON 스키마를 손으로 쓰는 건 지루하고 오타가 나기 쉽다. SDK는 타입 정의에서 스키마를 자동 생성하고 응답을 검증된 객체로 돌려주는 parse() 헬퍼를 제공한다. 이게 권장 경로다.
Python (Pydantic):
from pydantic import BaseModel
from typing import Literal
import anthropic
class Ticket(BaseModel):
category: Literal["bug", "billing", "feature_request", "other"]
priority: Literal["low", "medium", "high", "urgent"]
summary: str
needs_human: bool
client = anthropic.Anthropic()
response = client.messages.parse(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user",
"content": "고객 메시지: '결제했는데 이중청구됐어요. 환불 급해요!'"}],
output_format=Ticket, # Pydantic 모델을 그대로 넘긴다
)
ticket = response.parsed_output # 검증된 Ticket 인스턴스
print(ticket.priority) # "urgent"
print(ticket.category) # "billing"
if ticket.needs_human:
escalate(ticket)
Literal은 enum으로 변환되고, 필드 타입은 JSON Schema 타입으로 매핑된다. response.parsed_output은 그냥 dict가 아니라 타입이 붙은 Pydantic 인스턴스라서 IDE 자동완성과 타입 체커가 다 작동한다.
TypeScript (Zod):
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
const Ticket = z.object({
category: z.enum(["bug", "billing", "feature_request", "other"]),
priority: z.enum(["low", "medium", "high", "urgent"]),
summary: z.string(),
needs_human: z.boolean(),
});
const client = new Anthropic();
const response = await client.messages.parse({
model: "claude-opus-4-8",
max_tokens: 1024,
messages: [{ role: "user", content: "고객 메시지: ..." }],
output_config: { format: zodOutputFormat(Ticket) },
});
// parsed_output은 파싱 실패 시 null — 단언하거나 가드한다
const ticket = response.parsed_output!;
console.log(ticket.priority);
JSON Schema 제약 — 지원/미지원 목록 (중요한 함정):
| 지원됨 | 미지원됨 |
|---|---|
| object, array, string, integer, number, boolean, null | 재귀 스키마(self-reference) |
enum, const, anyOf, allOf, $ref/$def | 숫자 제약 (minimum, maximum, multipleOf) |
문자열 포맷 (date-time, date, email, uri, uuid, ipv4 등) | 문자열 길이 제약 (minLength, maxLength) |
additionalProperties: false | 복잡한 배열 제약, additionalProperties: <비-false> |
Python/TS SDK는 미지원 제약(예: Pydantic의 Field(min_length=3))을 자동으로 스키마에서 제거하고 클라이언트 측에서 검증한다. 즉 min_length 같은 건 모델 디코딩이 아니라 SDK 검증으로 잡힌다 — 이 차이를 알아야 "왜 모델이 짧은 문자열을 냈지?"를 디버깅하지 않는다.
기타 주의:
- 첫 요청 레이턴시: 새 스키마는 일회성 컴파일 비용이 든다. 이후 24시간은 캐시된다. 동일 스키마를 반복 쓰면 첫 호출만 느리다.
- 인용(citations)과 동시 사용 불가 → 400 에러.
- 스트리밍·배치·토큰 카운팅·extended thinking과는 함께 쓸 수 있다.
4. 함수 호출의 핵심 — 툴 정의와 스키마
구조화 출력이 "응답의 모양"을 강제한다면, 함수 호출(tool use)은 "모델이 외부 함수를 호출하는 방식"을 강제한다. 툴은 이름·설명·입력 JSON Schema 세 가지로 정의한다.
{
"name": "search_orders",
"description": "고객의 주문 내역을 조회한다. 사용자가 주문 상태·배송·환불을 물을 때 호출하라.",
"input_schema": {
"type": "object",
"properties": {
"customer_id": {"type": "string", "description": "고객 ID (예: cus_8a3f)"},
"status": {
"type": "string",
"enum": ["pending", "shipped", "delivered", "refunded"],
"description": "필터링할 주문 상태"
},
"limit": {"type": "integer", "description": "최대 반환 개수"}
},
"required": ["customer_id"]
}
}
툴 정의 베스트 프랙티스 (모델의 툴 선택 정확도를 좌우한다):
- 설명은 "무엇을"이 아니라 "언제"를 명시하라. 최근 Opus 모델(4.8 등)은 툴을 더 보수적으로 호출하므로,
description에 호출 조건을 박으면 should-call 비율이 눈에 띄게 오른다."주문을 조회한다"보다"사용자가 주문 상태·배송·환불을 물을 때 호출하라"가 낫다. - 이름은 구체적으로.
weather보다get_current_weather. - 모든 프로퍼티에
description을 달아라. 모델이 인자를 어떻게 채울지의 근거가 된다. - 고정 값 집합은
enum으로. 위status처럼. - 진짜 필수 인자만
required에. 나머지는 옵셔널로 두고 핸들러에서 기본값 처리. - 툴 개수는 절제하라. 툴이 너무 많으면 모델이 헷갈린다. 수십 개 이상이면 tool search(동적 디스커버리)를 검토.
tool_choice로 호출 제어:
| 값 | 동작 |
|---|---|
{"type": "auto"} | 모델이 툴 사용 여부를 결정 (기본값) |
{"type": "any"} | 반드시 하나 이상의 툴을 사용 |
{"type": "tool", "name": "search_orders"} | 특정 툴을 강제 |
{"type": "none"} | 툴 사용 금지 |
어느 값이든 "disable_parallel_tool_use": true를 추가하면 응답당 최대 1개 툴로 제한된다. 기본적으로 모델은 한 응답에 여러 툴 호출을 요청할 수 있다.
5. 엄격 툴 호출 — strict: true로 인자 스키마까지 강제
기본 툴 호출은 모델이 스키마를 대체로 지키지만 100% 보장은 아니다. 인자 하나가 빠지거나 타입이 어긋날 수 있다. strict: true를 붙이면 툴 input이 정확히 스키마를 만족함이 보장된다 — 구조화 출력의 제약 디코딩이 툴 인자에도 적용되는 것이다.
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user",
"content": "3월 15일 도쿄행 항공권 2명 예약해줘"}],
tools=[{
"name": "book_flight",
"description": "목적지로 가는 항공권을 예약한다",
"strict": True, # ← 인자 스키마 강제
"input_schema": {
"type": "object",
"properties": {
"destination": {"type": "string"},
"date": {"type": "string", "format": "date"},
"passengers": {"type": "integer", "enum": [1, 2, 3, 4, 5, 6, 7, 8]}
},
"required": ["destination", "date", "passengers"],
"additionalProperties": False
}
}]
)
strict: true를 쓰면 book_flight가 호출될 때 passengers가 반드시 1~8 정수이고, date가 date 포맷이며, 세 필드가 모두 채워짐이 보장된다. 외부 결제·예약 API에 그대로 넘길 인자라면 이 보장이 결정적이다 — 잘못된 인자가 다운스트림 API에 도달해 부분 실패를 내는 사고를 막는다.
JSON 출력 + strict 툴을 동시에:
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=2048,
messages=[{"role": "user", "content": "다음 달 파리 여행 계획 세워줘"}],
output_config={ # 최종 응답 모양 강제
"format": {"type": "json_schema", "schema": {
"type": "object",
"properties": {
"summary": {"type": "string"},
"next_steps": {"type": "array", "items": {"type": "string"}}
},
"required": ["summary", "next_steps"],
"additionalProperties": False
}}
},
tools=[{ # 중간 툴 호출 인자 강제
"name": "search_flights", "strict": True,
"description": "항공편을 검색한다",
"input_schema": {
"type": "object",
"properties": {
"destination": {"type": "string"},
"date": {"type": "string", "format": "date"}
},
"required": ["destination", "date"],
"additionalProperties": False
}
}]
)
함정 — JSON 이스케이프 파싱. Opus 4.6/4.7/4.8·Sonnet 4.6·Fable 5는 툴 호출 input 필드의 JSON 문자열 이스케이프(유니코드, 슬래시 등)가 모델마다 다를 수 있다. 절대 직렬화된 input을 원시 문자열로 매칭하지 말고 항상 json.loads() / JSON.parse()로 파싱하라. 대부분의 SDK는 block.input을 이미 파싱된 객체로 노출하니 그걸 쓰면 된다.
6. 자동 툴 루프 — SDK 툴 러너 (권장)
툴 호출은 본질적으로 루프다. 모델이 툴을 요청하면 → 실행하고 → 결과를 돌려주고 → 모델이 다음 툴을 요청하거나 끝낸다. 이 agentic loop를 손으로 짜면 boilerplate가 많고 실수가 나기 쉽다. SDK 툴 러너(beta)가 이 루프를 자동으로 돌린다.
Python — @beta_tool 데코레이터:
import anthropic
from anthropic import beta_tool
client = anthropic.Anthropic()
@beta_tool
def get_weather(location: str, unit: str = "celsius") -> str:
"""지정한 위치의 현재 날씨를 조회한다.
Args:
location: 도시와 주, 예: San Francisco, CA.
unit: 온도 단위, "celsius" 또는 "fahrenheit".
"""
# 실제 구현 — 외부 API 호출 등
return f"{location}는 맑고 22도"
# 툴 러너가 루프를 자동으로 돌린다
runner = client.beta.messages.tool_runner(
model="claude-opus-4-8",
max_tokens=4096,
tools=[get_weather],
messages=[{"role": "user", "content": "파리랑 도쿄 날씨 비교해줘"}],
)
# 각 이터레이션이 BetaMessage를 yield, 모델이 끝나면 종료
for message in runner:
for block in message.content:
if block.type == "text":
print(block.text)
데코레이터가 함수 시그니처와 docstring에서 자동으로 입력 스키마를 생성한다. 타입 힌트가 JSON Schema 타입이 되고, docstring의 Args가 프로퍼티 설명이 된다. async 함수는 @beta_async_tool을 쓴다.
TypeScript — betaZodTool + Zod:
import Anthropic from "@anthropic-ai/sdk";
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
import { z } from "zod";
const client = new Anthropic();
const getWeather = betaZodTool({
name: "get_weather",
description: "지정한 위치의 현재 날씨를 조회한다",
inputSchema: z.object({
location: z.string().describe("도시와 주, 예: San Francisco, CA"),
unit: z.enum(["celsius", "fahrenheit"]).optional(),
}),
run: async ({ location }) => `${location}는 맑고 22도`,
});
// 툴 러너가 루프를 돌리고 최종 메시지를 반환
const finalMessage = await client.beta.messages.toolRunner({
model: "claude-opus-4-8",
max_tokens: 4096,
tools: [getWeather],
messages: [{ role: "user", content: "파리 날씨?" }],
});
console.log(finalMessage.content);
툴 러너의 이점:
- 루프를 직접 안 짜도 됨 — SDK가 툴 실행과 결과 피드백을 처리
- 함수 시그니처(Python)·Zod 스키마(TS)에서 입력 스키마 자동 생성, 타입 안전
- 모델이 더 이상 툴을 호출하지 않으면 자동 종료
보안 경고: 툴 러너는 모델이 요청하면 자동으로 함수를 실행한다. 이메일 발송, DB 수정, 결제처럼 부작용이 있는 툴은 함수 내부에서 입력을 검증하라. 호출 전 사람 승인이 필요하면 다음 섹션의 수동 루프를 써라. 부작용 툴을 무조건 자동 실행하는 건 사고의 지름길이다.
7. 수동 agentic loop — 승인 게이트·로깅·HITL이 필요할 때
툴 러너는 편하지만 루프를 가로채지 못한다. 다음이 필요하면 수동 루프를 짠다: 호출 전 사람 승인(human-in-the-loop), 커스텀 로깅, 조건부 실행, 감사 추적.
import anthropic
client = anthropic.Anthropic()
tools = [...] # 툴 정의 배열
messages = [{"role": "user", "content": user_input}]
while True:
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=4096,
tools=tools,
messages=messages,
)
# 모델이 끝냈으면(더 이상 툴 호출 없음) 종료
if response.stop_reason == "end_turn":
break
# 서버사이드 툴이 이터레이션 한도에 걸림 → 재전송하면 서버가 이어서 진행
if response.stop_reason == "pause_turn":
messages.append({"role": "assistant", "content": response.content})
continue
# assistant 응답(tool_use 블록 포함)을 히스토리에 먼저 추가
messages.append({"role": "assistant", "content": response.content})
# tool_use 블록을 모아 실행
tool_results = []
for block in response.content:
if block.type == "tool_use":
# ── 여기가 가로채기 지점 ──
log_tool_call(block.name, block.input) # 감사 로깅
if is_destructive(block.name):
if not await ask_human_approval(block): # HITL 게이트
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": "사용자가 작업을 거부함",
"is_error": True,
})
continue
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id, # ← tool_use의 id와 반드시 일치
"content": result,
})
# 툴 결과를 user 메시지로 추가
messages.append({"role": "user", "content": tool_results})
final_text = next(b.text for b in response.content if b.type == "text")
수동 루프에서 반드시 지킬 규칙:
- API는 stateless다. 매 호출마다 전체 메시지 히스토리를 보낸다. 누적된
messages를 통째로 넘겨야 한다. - assistant의 전체
content를 보존하라.tool_use블록을 빼먹고 텍스트만 추가하면 다음 호출에서 짝이 안 맞아 깨진다. - 모든
tool_use에는 짝이 되는tool_result가 필요하다.tool_use_id가 정확히 일치해야 한다. 하나라도 빠지면 API가 후속 요청을 거부한다. - 에러는
is_error: true로. 툴 실행이 실패하면 결과에"is_error": True와 설명을 담는다. 모델이 인지하고 다른 접근을 시도하거나 사용자에게 되묻는다. - 여러 툴 결과는 한 user 메시지에 모아서. 모델이 한 응답에 툴 3개를 호출했으면 결과 3개를 한 번에 돌려준다.
- 무한 루프 방지.
pause_turn재개에max_continuations같은 상한을 두라(예: 5회). 안 그러면 서버사이드 툴이 계속 돌 수 있다.
8. stop_reason 처리 — 스키마 보장이 깨지는 경계
구조화 출력을 켜도 모든 경우에 유효 JSON이 나오는 건 아니다. stop_reason이 정상 종료가 아니면 스키마 보장이 깨진다. 이걸 체크하지 않고 response.content[0].text를 파싱하면 프로덕션에서 간헐적으로 터진다.
stop_reason | 의미 | 구조화 출력에서의 함의 |
|---|---|---|
end_turn | 정상 종료 | JSON 유효 — 안전하게 파싱 |
tool_use | 툴 호출 요청 | 툴 실행 후 루프 계속 |
max_tokens | 출력 토큰 한도 도달 | JSON 잘림 — max_tokens를 늘려 재시도 |
pause_turn | 서버사이드 툴 일시정지 | 재전송해 재개 |
refusal | 안전상 거부 | JSON 보장 안 됨 — stop_details 확인 |
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=2048,
messages=[...],
output_config={"format": {"type": "json_schema", "schema": SCHEMA}},
)
# 파싱 전에 반드시 stop_reason 확인
if response.stop_reason == "refusal":
# content가 비었거나 부분 출력 — 스키마 안 지켜짐
if response.stop_details:
log.warning("refused: %s", response.stop_details.category)
handle_refusal()
elif response.stop_reason == "max_tokens":
# JSON이 중간에 잘림 — 더 큰 max_tokens로 재시도
retry_with_higher_limit()
else:
text = next(b.text for b in response.content if b.type == "text")
data = json.loads(text) # 이제 안전
핵심 패턴: "성공 경로"에서만 파싱하라. stop_reason을 분기 조건으로 두고, refusal·max_tokens는 별도 처리한다.
max_tokens방어: 구조화 출력은 스키마가 클수록(중첩 객체, 긴 배열) 더 많은 토큰을 쓴다.max_tokens를 넉넉히(많은 추출 작업은 16000+) 잡고, 잘림이 잦으면 키운다.refusal방어: 안전 분류기가 거부하면stop_reason: "refusal"(HTTP 200)이 온다.stop_details.category로 사유를 알 수 있지만None일 수도 있으니stop_reason으로 분기하고stop_details는 부가 정보로만 쓴다.messages.parse()사용 시:parsed_output이 파싱 실패 시None(Python)/null(TS)일 수 있다. 단언하기 전에 가드하거나, refusal/max_tokens를 먼저 거른다.
9. 추출 파이프라인 실전 예제 — 분류 + 추출 + 배치
구조화 출력의 가장 흔한 프로덕션 용도는 대량 추출/분류 파이프라인이다. 비정형 텍스트(이메일, 후기, 지원 티켓)를 구조화된 레코드로 변환해 DB/Airtable/Notion에 적재한다.
from pydantic import BaseModel
from typing import Literal
import anthropic
class SupportRecord(BaseModel):
intent: Literal["refund", "complaint", "question", "praise", "spam"]
sentiment: Literal["positive", "neutral", "negative"]
urgency: int # 1~5 (스키마 제약 불가 → 프롬프트로 안내)
product_mentioned: str | None # 옵셔널: 언급 제품 없으면 None
summary: str
client = anthropic.Anthropic()
def extract(text: str) -> SupportRecord:
resp = client.messages.parse(
model="claude-haiku-4-5", # 고볼륨이라 저비용 모델
max_tokens=512,
system=("고객 지원 메시지를 구조화 레코드로 변환한다. "
"urgency는 1(낮음)~5(긴급) 정수. "
"product_mentioned는 메시지에 구체적 제품명이 있을 때만 채우고 없으면 null."),
messages=[{"role": "user", "content": text}],
output_format=SupportRecord,
)
if resp.stop_reason != "end_turn" or resp.parsed_output is None:
raise ValueError(f"추출 실패: stop_reason={resp.stop_reason}")
return resp.parsed_output
모델·비용 선택: 단순 추출/분류는 claude-haiku-4-5($1/$5 per 1M)로 충분하다. 미묘한 의미 판단이 필요하면 claude-sonnet-4-6, 최고 정확도가 필요하면 claude-opus-4-8. 구조화 출력은 모든 모델에서 출력 모양을 강제하므로, 모델을 낮춰도 포맷은 안전하다(다만 판단 품질은 모델에 따라 달라진다).
배치 API로 50% 비용 절감 — 실시간성이 필요 없는 대량 작업은 배치로:
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request
SCHEMA = SupportRecord.model_json_schema() # 스키마 1회 생성
batch = client.messages.batches.create(
requests=[
Request(
custom_id=f"ticket-{i}",
params=MessageCreateParamsNonStreaming(
model="claude-haiku-4-5",
max_tokens=512,
messages=[{"role": "user", "content": text}],
output_config={"format": {"type": "json_schema", "schema": SCHEMA}},
)
)
for i, text in enumerate(tickets)
]
)
# 폴링 후 결과 수집 (batch.processing_status == "ended")
배치는 최대 10만 요청/256MB, 보통 1시간 내 완료, 모든 토큰에 50% 할인. 구조화 출력은 배치에서도 그대로 작동한다.
함정 —
urgency같은 숫자 범위: JSON Schema의minimum/maximum은 구조화 출력에서 지원 안 됨.urgency: 1~5를 강제하려면 (a)Literal[1,2,3,4,5]로 enum화하거나, (b) 위처럼 프롬프트로 안내하고 받는 쪽에서 검증한다. 모델이 enum이 아닌 정수를 낼 수 있다는 걸 잊지 말 것.
10. 프롬프트 캐싱과 툴 — 비용·레이턴시 최적화
툴 정의는 매 요청에 반복 전송되는 큰 페이로드다. 툴이 많고 시스템 프롬프트가 크면 프롬프트 캐싱으로 비용을 최대 90%까지 줄일 수 있다. 핵심은 렌더 순서: tools → system → messages. 캐시는 prefix 매칭이라, 앞부분이 1바이트라도 바뀌면 뒤가 전부 무효화된다.
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=2048,
tools=tools, # 위치 0 — 가장 먼저 렌더
system=[{
"type": "text",
"text": LARGE_SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"} # tools + system을 함께 캐시
}],
messages=messages,
)
print(response.usage.cache_read_input_tokens) # >0이면 캐시 적중
시스템 블록 마지막에 cache_control을 걸면 그 앞의 tools까지 함께 캐시된다(tools가 system보다 먼저 렌더되므로).
툴 작업에서 캐시를 깨는 사일런트 함정:
| 안티패턴 | 왜 캐시가 깨지나 |
|---|---|
| 세션 중 툴을 추가/제거/재정렬 | 툴은 위치 0 — 전체 캐시 무효화 |
사용자마다 다른 툴 집합 (build_tools(user)) | 위치 0이 사용자별로 달라 교차 공유 불가 |
시스템 프롬프트에 datetime.now() 보간 | 매 요청 prefix가 바뀜 |
| 툴을 매번 다른 순서로 직렬화 | prefix 바이트가 달라짐 → 툴 이름순 정렬로 고정 |
| 세션 중 모델 변경 | 캐시는 모델 스코프 — 전체 무효화 |
검증: usage.cache_read_input_tokens가 동일 prefix 반복 요청에서 계속 0이면 사일런트 무효화가 일어나는 것이다. 시스템 프롬프트의 타임스탬프, 비결정적 json.dumps(→ sort_keys=True), 변동하는 툴 집합을 의심하라.
무효화 계층(전부 다 깨지는 건 아니다):
| 변경 | tools 캐시 | system 캐시 | messages 캐시 |
|---|---|---|---|
| 툴 정의 변경 | ❌ | ❌ | ❌ |
| 모델 전환 | ❌ | ❌ | ❌ |
| 시스템 프롬프트 내용 | ✅ | ❌ | ❌ |
tool_choice, thinking 토글 | ✅ | ✅ | ❌ |
| 메시지 내용 추가 | ✅ | ✅ | ❌ |
즉 tool_choice는 요청마다 바꿔도 tools+system 캐시가 유지된다. 반면 툴 정의·모델 변경은 전체 재빌드를 강제하므로, 멀티턴 agentic loop에서 툴 집합을 고정하고 모델을 한 개로 유지하는 게 캐시 효율의 핵심이다.
11. thinking·effort와 구조화 출력을 함께 쓰기
복잡한 추출이나 다단 추론이 필요한 작업은 extended thinking(adaptive)과 구조화 출력을 함께 쓴다. 모델이 먼저 추론한 뒤 스키마에 맞는 최종 출력을 낸다.
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=8192,
thinking={"type": "adaptive"}, # 모델이 추론 깊이를 스스로 결정
output_config={
"effort": "high", # low | medium | high | max
"format": {"type": "json_schema", "schema": COMPLEX_SCHEMA}
},
messages=[{"role": "user", "content": "이 계약서에서 모든 조항·날짜·당사자를 추출"}],
)
# thinking 블록이 text(JSON) 앞에 온다 — 인덱싱하지 말고 필터링
for block in response.content:
if block.type == "thinking":
pass # 추론 과정 (디버깅/로깅용)
elif block.type == "text":
data = json.loads(block.text) # 스키마를 만족하는 최종 JSON
Opus 4.8 / Sonnet 4.6 thinking 규칙 (구 모델과 다름):
- adaptive만 지원.
thinking={"type": "adaptive"}를 쓴다. 구 모델의{"type": "enabled", "budget_tokens": N}은 Opus 4.8/4.7에서 400 에러다.budget_tokens는 제거됐다. temperature·top_p·top_k도 제거됨 → 보내면 400. 출력 변동은 프롬프트로 유도한다.effort는output_config안에 둔다 (최상위 아님).low/medium/high/max로 추론 깊이·토큰 지출을 조절. 추출 파이프라인은 보통medium이 비용·품질 균형점.thinking블록이 먼저 온다. 구조화 출력을 켜도 응답 content는[thinking..., text(JSON)]순서일 수 있다.b.type == "text"로 필터링하는 습관을 들여라.
중요한 상호작용: thinking이 켜져 있으면 max_tokens에 thinking 토큰 + 출력 토큰이 모두 포함된다. 복잡한 스키마 + 깊은 추론이면 max_tokens를 넉넉히(8192+) 잡지 않으면 thinking에 토큰을 다 쓰고 JSON이 잘려 stop_reason: max_tokens가 된다. 128K까지 큰 출력이 필요하면 스트리밍(.stream() + .get_final_message())을 써서 HTTP 타임아웃을 피한다.
구조화 출력은 extended thinking·스트리밍·배치·토큰 카운팅과 모두 호환된다. 단 citations와는 동시 사용 불가(400)다.
12. 흔한 함정 총정리 — 디버깅 체크리스트
프로덕션에서 마주치는 구조화 출력·툴 오케스트레이션 함정을 한 곳에 모았다. 증상이 나오면 여기부터 본다.
스키마 강제 관련:
- ❌
response.content[0].text로 인덱싱 → thinking이 켜지면 첫 블록이 thinking. 항상next(b.text for b in response.content if b.type == "text"). - ❌
stop_reason미확인 후 파싱 →refusal/max_tokens면 JSON 안 지켜짐. 파싱 전 분기 필수(섹션 8). - ❌
additionalProperties누락 → 모델이 임의 필드 추가. 모든 object에false. - ❌ 숫자/문자열 길이 제약을 스키마로 강제 시도 →
minimum/maxLength등은 미지원. enum화하거나 클라이언트 검증. - ❌ assistant prefill로 JSON 시작 강제 → Opus 4.6+/Sonnet 4.6/Fable 5에서 400.
output_config.format으로 교체. - ❌ 구
output_format파라미터 사용 → 폐기됨.output_config: {format: {...}}. - ❌
messages.parse()의parsed_output을 무조건 단언 → 파싱 실패 시None/null. 가드하거나 stop_reason 먼저 확인.
툴 오케스트레이션 관련:
- ❌
tool_use블록 없이 텍스트만 히스토리에 추가 → 다음 호출에서 짝 불일치로 400. assistant의 전체content를 보존. - ❌
tool_result의tool_use_id불일치/누락 → 모든 tool_use에 정확히 매칭되는 tool_result 필요. - ❌ 직렬화된 툴 input을 원시 문자열 매칭 → 모델별 JSON 이스케이프 차이. 항상 파싱.
- ❌ 부작용 툴을 툴 러너로 무조건 자동 실행 → 결제·삭제·발송은 수동 루프 + HITL 게이트.
- ❌
pause_turn무한 재개 →max_continuations상한. - ❌ 세션 중 툴 추가/모델 변경 → 캐시 전체 무효화. 툴 집합·모델 고정.
- ❌ 트레일링 newline 등으로 툴 input 정확 매칭 → 4.5+는 트레일링 newline 보존. 받는 쪽에서
.rstrip()정규화.
모델 ID·파라미터 관련:
- ❌ 모델 ID에 날짜 suffix 추가 →
claude-opus-4-8이 완전한 ID.-20251114등 붙이면 404. - ❌
budget_tokens·temperature·top_p를 Opus 4.8에 전송 → 400. adaptive thinking + effort로. - ❌
max_tokens저격 → 구조화 출력은 토큰을 더 쓴다. 추출은 16000+, 큰 출력은 스트리밍.
최종 원칙: 스키마 강제는 "모양"을 보장하지만 "성공"을 보장하지 않는다. stop_reason 확인 → text 블록 필터링 → json.loads/SDK 파싱 → 검증, 이 4단계를 모든 구조화 출력 경로에 일관되게 적용하면 프로덕션에서 간헐 장애가 사라진다.