포스트

프롬프트 엔지니어링 기초 (4) - Structured Output: 출력 형식을 강제하기

프롬프트로 부탁한 JSON이 깨지는 상황을 확인하고 output_config의 json_schema와 tool use로 형식을 강제하는 방법과 스키마 제약을 다룹니다.

프롬프트 엔지니어링 기초 (4) - Structured Output: 출력 형식을 강제하기

프롬프트 엔지니어링 기초 시리즈의 4편입니다. 전체 목차는 0편에 있습니다.

파싱이 깨지는 지점

모델의 답을 사람이 읽는 것이 아니라 프로그램이 이어받는 순간부터 형식이 문제가 됩니다. 3편의 분류 작업에 신뢰도까지 함께 받아본다고 하겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
SYSTEM = """문의를 배송, 환불, 상품문의, 기타 중 하나로 분류한다.
아래 형식의 JSON으로만 답한다. 다른 설명은 붙이지 않는다.
{"label": "라벨", "confidence": 0.0}"""

import json

def classify(text: str) -> dict:
    message = client.messages.create(
        model="claude-opus-5",
        max_tokens=1024,
        system=SYSTEM,
        messages=[{"role": "user", "content": text}],
    )
    return json.loads(text_of(message))

대부분 잘 돌아갑니다. 문제는 대부분이라는 점입니다. 12건을 여러 번 돌리면 아래 같은 응답이 섞여 나옵니다.

1
2
네, 분류 결과는 다음과 같습니다.
{"label": "환불", "confidence": 0.9}
1
2
```json
{"label": "배송", "confidence": 0.85}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
앞뒤에 설명이 붙거나 코드 펜스가 감싸면 `json.loads`가 예외를 던집니다. 정규식으로 중괄호를 찾아 잘라내는 코드를 붙이는 것이 흔한 대응인데, 이제는 그럴 필요가 없습니다.

## 방법 1: JSON 스키마로 강제하기

`output_config`에 스키마를 주면 응답이 그 스키마를 따르도록 제약됩니다. 부탁이 아니라 강제입니다.

```python
SCHEMA = {
    "type": "object",
    "properties": {
        "label": {"type": "string", "enum": ["배송", "환불", "상품문의", "기타"]},
        "confidence": {"type": "number"},
        "reason": {"type": "string"},
    },
    "required": ["label", "confidence", "reason"],
    "additionalProperties": False,
}

def classify(text: str) -> dict:
    message = client.messages.create(
        model="claude-opus-5",
        max_tokens=2048,
        system="문의를 분류한다.",
        output_config={"format": {"type": "json_schema", "schema": SCHEMA}},
        messages=[{"role": "user", "content": text}],
    )
    return json.loads(text_of(message))

print(classify("배송이 늦어서 취소하고 환불받고 싶어요."))
# {'label': '환불', 'confidence': 0.92, 'reason': '지연을 이유로 취소와 환불을 요청함'}

enum을 쓴 것이 중요합니다. 라벨 문자열이 환불 요청이나 [환불]로 흔들리던 3편의 문제가 이 한 줄로 사라집니다. 채점 코드에서 문자열을 정규화하던 부분도 함께 지울 수 있습니다.

Python SDK에는 스키마 검증까지 함께 해주는 client.messages.parse()도 있습니다. 위처럼 messages.createoutput_config를 직접 주는 방식이 API 그대로의 형태입니다.

스키마 제약

쓸 수 있는 JSON Schema에는 제한이 있습니다.

쓸 수 있는 것쓸 수 없는 것
object, array, string, integer, number, boolean, null재귀 스키마
enum, const, anyOf, allOf, $refminimum, maximum, multipleOf
date-time, date, email, uri, uuid 등 문자열 formatminLength, maxLength
additionalProperties: falseadditionalProperties에 false 외의 값

모든 object에 additionalProperties: false가 필요합니다. 0에서 1 사이라는 제약처럼 스키마로 표현할 수 없는 규칙은 프롬프트 문장으로 적고, 값 검증은 받는 쪽 코드에서 합니다.

지원 모델은 Claude Opus 5, Sonnet 5, Opus 4.8, Haiku 4.5입니다. 새 스키마를 처음 쓰는 호출은 컴파일 때문에 조금 느리고, 이후 24시간 동안은 캐시됩니다.

방법 2: tool use로 받기

값을 추출하는 것이 아니라 행동을 시키는 경우에는 tool use를 씁니다. 모델이 어떤 함수를 어떤 인자로 부를지 정하고, 실행은 우리 코드가 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
TOOLS = [{
    "name": "create_ticket",
    "description": "고객 문의를 상담 티켓으로 등록한다. 문의가 분류 가능할 때만 호출한다.",
    "strict": True,
    "input_schema": {
        "type": "object",
        "properties": {
            "category": {"type": "string", "enum": ["배송", "환불", "상품문의", "기타"]},
            "summary": {"type": "string"},
            "urgent": {"type": "boolean"},
        },
        "required": ["category", "summary", "urgent"],
        "additionalProperties": False,
    },
}]

message = client.messages.create(
    model="claude-opus-5",
    max_tokens=2048,
    tools=TOOLS,
    messages=[{"role": "user", "content": "출장 전에 못 받으면 취소할게요. 주문번호 20260721-3312"}],
)

print(message.stop_reason)   # tool_use
for block in message.content:
    if block.type == "tool_use":
        print(block.name, block.input)
# create_ticket {'category': '환불', 'summary': '기한 내 미배송 시 취소 요청', 'urgent': True}

strict: True를 붙이면 input이 스키마를 정확히 따르는 것이 보장됩니다. 이때 스키마에 additionalProperties: falserequired가 반드시 있어야 합니다.

실행 결과를 모델에게 돌려주려면 tool_use_id를 맞춰 tool_result를 보냅니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
tool_block = next(b for b in message.content if b.type == "tool_use")
ticket_id = create_ticket(**tool_block.input)   # 우리 코드가 실제로 실행

follow_up = client.messages.create(
    model="claude-opus-5",
    max_tokens=2048,
    tools=TOOLS,
    messages=[
        {"role": "user", "content": "출장 전에 못 받으면 취소할게요."},
        {"role": "assistant", "content": message.content},
        {"role": "user", "content": [{
            "type": "tool_result",
            "tool_use_id": tool_block.id,
            "content": f"티켓 {ticket_id} 생성됨",
        }]},
    ],
)

assistant 메시지에 message.content를 통째로 넣는 것이 중요합니다. 텍스트만 뽑아 넣으면 tool_use block이 사라져 tool_result와 짝이 맞지 않습니다.

어느 쪽을 쓰나

상황방법
정해진 필드를 뽑아내고 싶다output_config의 json_schema
모델이 함수를 부를지 말지 스스로 정해야 한다tool use
여러 함수 중 하나를 고르게 하고 싶다tool use
답이 자유 서술인데 형식만 정하고 싶다json_schema에 긴 문자열 필드

tool use에서 호출 여부를 강제하려면 tool_choice를 씁니다. {"type": "auto"}가 기본이고, {"type": "any"}는 도구를 반드시 하나 쓰게 하며, {"type": "tool", "name": "create_ticket"}은 특정 도구를 지정합니다.

실습: 파싱 성공률

3편의 데이터 12건을 두 방식으로 돌려 파싱 실패 횟수를 셉니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def parse_rate(fn, name):
    ok = 0
    for row in DATA:
        try:
            result = fn(row["text"])
            assert result["label"] in LABELS
            ok += 1
        except Exception:
            pass
    print(f"{name}: {ok}/{len(DATA)} 파싱 성공")

parse_rate(classify_by_prompt, "프롬프트로 부탁")
parse_rate(classify_by_schema, "스키마로 강제")
# 프롬프트로 부탁: 11/12 파싱 성공
# 스키마로 강제: 12/12 파싱 성공

12건 규모에서는 차이가 한두 건입니다. 호출이 하루 수만 건이면 그 한두 건이 매일 수백 건의 예외가 됩니다. 스키마 쪽은 재시도 로직 자체가 필요 없어집니다.

함정

refusal과 max_tokens는 스키마를 지키지 않습니다. 안전 분류기가 요청을 거절하면 stop_reasonrefusal이 되고 내용이 비어 있을 수 있습니다. 상한에 걸려 잘리면 JSON이 중간에서 끊깁니다. 파싱 전에 stop_reason을 확인합니다.

assistant prefill은 이제 안 됩니다. 예전에는 마지막 메시지를 {"role": "assistant", "content": "{"}로 두어 JSON을 강제했습니다. Claude 4.6 이후 모델에서는 400으로 거절되며, 그 자리를 이 편의 두 방법이 대신합니다.

citations와 함께 쓸 수 없습니다. 문서 인용 기능과 output_config.format은 같이 쓰면 400이 납니다. RAG에서 출처를 다루는 방법은 RAG 기초 5편에서 따로 다룹니다.

다음 글: 프롬프트 엔지니어링 기초 (5) - Reasoning: thinking과 effort로 추론 조절하기

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.