본문으로 건너뛰기
AIPida

함수 호출·구조화 출력 마스터

Claude API로 깨지지 않는 JSON과 신뢰할 수 있는 툴 루프를 만드는 실전 가이드

실전AI 모델·API·

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_reasonrefusal이거나 max_tokens면 스키마가 보장되지 않기 때문이다.

기능파라미터보장하는 것
JSON 구조화 출력output_config: {format: {...}}응답 본문이 지정한 JSON 스키마를 따름
엄격 툴 호출툴 정의에 strict: trueinput이 스키마를 정확히 만족
툴 선택 강제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 정수이고, datedate 포맷이며, 세 필드가 모두 채워짐이 보장된다. 외부 결제·예약 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")

수동 루프에서 반드시 지킬 규칙:

  1. API는 stateless다. 매 호출마다 전체 메시지 히스토리를 보낸다. 누적된 messages를 통째로 넘겨야 한다.
  2. assistant의 전체 content를 보존하라. tool_use 블록을 빼먹고 텍스트만 추가하면 다음 호출에서 짝이 안 맞아 깨진다.
  3. 모든 tool_use에는 짝이 되는 tool_result가 필요하다. tool_use_id가 정확히 일치해야 한다. 하나라도 빠지면 API가 후속 요청을 거부한다.
  4. 에러는 is_error: true로. 툴 실행이 실패하면 결과에 "is_error": True와 설명을 담는다. 모델이 인지하고 다른 접근을 시도하거나 사용자에게 되묻는다.
  5. 여러 툴 결과는 한 user 메시지에 모아서. 모델이 한 응답에 툴 3개를 호출했으면 결과 3개를 한 번에 돌려준다.
  6. 무한 루프 방지. 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%까지 줄일 수 있다. 핵심은 렌더 순서: toolssystemmessages. 캐시는 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. 출력 변동은 프롬프트로 유도한다.
  • effortoutput_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_resulttool_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단계를 모든 구조화 출력 경로에 일관되게 적용하면 프로덕션에서 간헐 장애가 사라진다.