2장. 구조화 출력과 도구 호출
학습 목표
- 지시문, JSON 모드, 스키마 기반 구조화 출력이 각각 보장하는 범위를 설명하고 알맞은 방식을 고른다.
- 업무 개념에서 스키마를 도출하고 벤더별 strict 규칙에 맞게 고친다.
- 구문·스키마·의미의 세 단계로 결과를 검증하는 코드를 설계한다.
- 도구를 정의하고, 도구 호출 루프를 예산이 있는 상태 기계로 구현하며, 결과를 정확한 형식으로 대화에 되돌린다.
- 병렬 호출, 도구 선택 옵션, 실행 권한을 조합해 안전한 도구 루프를 설계한다.
들어가며
어느 세무 앱이 영수증 사진에서 지출 항목을 뽑아 장부에 넣는 기능을 만들었습니다. “JSON으로 답하라”는 지시만으로도 시연에서는 문제가 없었습니다. 운영 첫 주에 장애가 세 번 났습니다. 한 번은 응답 앞에 “다음은 결과입니다”라는 문장이 붙어 파싱이 깨졌고, 한 번은 금액이 "12,000원"이라는 문자열로 와서 합계 계산이 멈췄으며, 마지막 한 번은 형식은 완벽했지만 합계가 품목 금액의 합과 달랐습니다. 첫 번째는 형식을 강제하지 않은 탓이고, 두 번째는 스키마를 업무에 맞게 설계하지 않은 탓이며, 세 번째는 형식이 맞으면 내용도 맞다고 믿은 탓입니다.
이 장은 모델의 답을 프로그램이 믿고 쓸 수 있게 만드는 두 기능을 다룹니다. 구조화 출력은 응답의 모양을 고정하고, 도구 호출은 모델이 애플리케이션의 기능을 요청하게 합니다. 앞부분에서 스키마 설계와 검증을, 뒷부분에서 도구 정의, 호출 루프, 결과 반환, 병렬 호출, 실행 권한을 차례로 봅니다.
핵심 개념
구조화 출력은 거푸집이다
반죽을 손으로 빚으면 모양이 매번 조금씩 다르지만, 거푸집에 부으면 모양이 정해집니다. “JSON으로 답하라”는 지시가 손으로 빚기라면, 구조화 출력은 거푸집입니다. 공급자는 JSON 스키마를 문법(grammar)으로 컴파일해 생성 과정을 그 문법에 맞게 제한합니다. 이런 방식을 흔히 제약 디코딩(constrained decoding)이라고 부릅니다.
| 방식 | 보장하는 것 | 보장하지 않는 것 |
|---|---|---|
| 지시문 | 없음(대체로 따름) | 형식, 필드, 타입 |
| JSON 모드 | 유효한 JSON | 스키마 준수 |
| 구조화 출력(strict) | 지정한 스키마 준수 | 거절·잘림 상황의 형식, 값의 정확성, 업무 규칙 |
OpenAI 문서는 JSON 모드(json_object)가 유효한 JSON만 보장하고 스키마 준수는 보장하지 않는다고 분명히 합니다. JSON 모드를 쓸 때는 메시지 어딘가에 “JSON”이라는 말이 있어야 하며, JSON을 만들라고 지시하지 않으면 모델이 공백만 계속 출력하다 한도에 닿을 수 있다고도 경고합니다.
벤더별로 켜는 법과 고르는 기준
| 벤더 | 응답 자체를 구조화 | 도구 인자를 구조화 |
|---|---|---|
| OpenAI Responses API | text.format에 type: "json_schema", name, schema, strict: true |
함수 정의에 strict: true |
| Anthropic Messages API | output_config.format에 type: "json_schema", schema |
도구 정의에 strict: true |
| Gemini Interactions API | response_format에 type: "text", mime_type: "application/json", schema(generateContent API는 responseMimeType과 responseSchema) |
함수 선언의 parameters 스키마 |
무엇을 쓸지는 결과의 용도로 정합니다. OpenAI 문서는 모델을 시스템의 도구·기능·데이터에 연결한다면 함수 호출을, 사용자에게 돌려줄 응답의 모양을 정하려면 text.format을 쓰라고 안내합니다. Gemini 문서도 구조화 출력은 최종 응답의 형식을, 함수 호출은 대화 중의 행동을 위한 것이라고 구분합니다.
스키마를 처음 쓰는 요청은 문법을 만드느라 지연이 더 깁니다(Anthropic은 컴파일한 문법을 마지막 사용 후 24시간 캐시). OpenAI와 Anthropic SDK는 Python의 Pydantic 모델과 TypeScript의 Zod 스키마로 스키마를 만들고 응답을 파싱해 주므로, 손으로 쓴 스키마와 코드의 타입이 어긋나는 일을 줄여 줍니다.
strict 스키마 규칙과 벤더 차이
strict 모드는 JSON Schema 전체가 아니라 그 부분집합만 받습니다. 공급자마다 부분집합이 다르다는 점이 함정입니다.
| 규칙 | OpenAI | Anthropic |
|---|---|---|
additionalProperties |
모든 객체에 false 필수 |
모든 객체에 false 필수 |
| 선택 필드 | 모든 필드를 required에 넣고 null 허용 타입으로 표현 |
required에 없는 필드 허용(선택 필드 수에 한도) |
| 숫자·문자열 제약 | minimum·maximum·multipleOf, pattern, format 지원(미세 조정 모델은 미지원) |
minimum·maximum 같은 숫자 제약, minLength·maxLength 미지원 |
| 재귀 스키마 | $ref로 지원 |
미지원 |
| 최상위(루트) | 객체여야 하며 루트의 anyOf는 불가(하위 속성은 가능) |
— |
| 출력 키 순서 | 스키마 순서 | 필수 필드 먼저, 그다음 선택 필드 |
| 복잡도 한도 | 속성 5,000개, 중첩 10단계, 열거값 1,000개 등 | strict 도구 20개, 선택 파라미터 24개, 유니언 타입 파라미터 16개 |
OpenAI strict 모드에서 선택 값은 필드를 빼는 대신 null을 허용하는 타입으로 씁니다.
{
"type": "object",
"properties": {
"order_id": { "type": "string" },
"memo": { "type": ["string", "null"] }
},
"required": ["order_id", "memo"],
"additionalProperties": false
}
Anthropic에서 지원하지 않는 기능을 쓰면 400 오류가 납니다. Anthropic SDK 도우미는 지원하지 않는 제약을 스키마에서 빼 필드 설명으로 옮기고, Pydantic 기반 messages.parse()나 TypeScript zodOutputFormat()은 응답을 원래 제약으로 다시 검증합니다. Zod 없이 쓰는 jsonSchemaOutputFormat()이나 SDK 없는 직접 호출에서는 같은 검증을 애플리케이션이 해야 합니다. Gemini도 부분집합만 받으며, 매우 크거나 깊게 중첩된 스키마는 거부될 수 있습니다.
업무 개념에서 스키마를 도출한다
스키마는 응답의 모양이 아니라 애플리케이션과 모델 사이의 계약입니다. 설계할 때 네 가지를 정합니다.
- 값이 없을 때의 의미. “필드 없음”,
null, 빈 문자열, 빈 배열은 서로 다른 뜻이어야 합니다. 영수증에 합계가 인쇄되지 않은 경우와 인쇄됐지만 흐려서 읽을 수 없는 경우를 같은null로 합치면 후속 처리가 갈 길을 잃습니다. 이유를 담는 열거형 필드를 함께 둡니다. - 단위와 표현. 금액은 원 단위 정수, 날짜는 ISO 8601(
2026-10-20), 비율은 0~1 실수처럼 정합니다. 원문 표현이 필요하면 별도 필드(raw_date)로 받습니다. - 모델이 만들 값과 서버가 부여할 값. 주문번호, 생성 시각, 사용자 ID처럼 시스템이 아는 값은 모델에게 만들게 하지 않습니다.
- 버전. 운영 중 필드를 바꾸면 화면, 저장소, 배치 작업이 모두 영향을 받습니다. 삭제와 의미 변경은 물론, 필드 추가도
additionalProperties: false로 검증하는 소비자에게는 호환되지 않습니다.schema_version을 두고, 소비자가 버전별로 처리를 나누게 하며 함께 갱신합니다.
{
"type": "object",
"properties": {
"schema_version": { "type": "string", "enum": ["2"] },
"total_krw": { "type": ["integer", "null"] },
"total_status": { "type": "string", "enum": ["read", "not_printed", "illegible"] },
"purchased_on": { "type": ["string", "null"], "format": "date" },
"category": { "type": "string", "enum": ["meal", "supplies", "transport", "other"] }
},
"required": ["schema_version", "total_krw", "total_status", "purchased_on", "category"],
"additionalProperties": false
}
세 단계로 검증한다
구조화 출력을 써도 검증은 끝나지 않습니다. 검증을 세 단계로 나누면 실패 원인과 대응이 분명해집니다.
| 단계 | 질문 | 실패하면 |
|---|---|---|
| 0. 응답 상태 | 거절이나 잘림이 아닌가 | 파싱하지 않는다. 1장의 중단 사유 처리로 |
| 1. 구문 | JSON으로 파싱되는가 | 구조화 출력에서는 드물다. 잘림·거절을 의심 |
| 2. 스키마 | 필드, 타입, 열거값이 맞는가 | 코드 버그나 스키마 불일치. 기록하고 재시도 1회 |
| 3. 의미 | 업무 규칙에 맞는가 | 사람 검토 대기열, 사용자에게 확인 질문, 또는 보류 |
0단계를 빼먹기 쉽습니다. Anthropic 문서는 거절(refusal)과 출력 상한 도달(max_tokens) 응답이 스키마와 맞지 않을 수 있다고 밝히고, OpenAI도 status: "incomplete"를 먼저 확인하라고 안내합니다. 3단계의 예는 “품목 금액의 합 = 합계”, “환불 금액 ≤ 결제 금액”, “종료일 ≥ 시작일” 같은 관계 검사입니다. Gemini 문서의 표현대로, 출력은 구문상 올바른 JSON이어도 값은 애플리케이션에서 항상 검증해야 합니다.
세부 함정도 있습니다. Anthropic 문서는 구조화 출력이 열거값의 대소문자까지 보장하지 않는다며 대소문자를 구분하지 않고 비교하고, 대소문자만 다른 열거값을 만들지 말라고 권합니다. 또 컴파일된 스키마는 메시지 내용과 별도로 마지막 사용 후 최대 24시간 캐시되므로, 필드 이름·설명·열거값에 고객 이름 같은 민감정보를 넣지 말라고 안내합니다.
도구 정의는 실행 계약이다
도구 호출(함수 호출)은 모델에게 “이런 기능이 있다”고 알려 주고, 필요할 때 모델이 이름과 인자를 제안하게 하는 기능입니다. 모델은 우리가 정의한 도구를 직접 실행하지 않습니다. 실행은 애플리케이션이 합니다(웹 검색처럼 공급자가 대신 실행하는 서버 도구는 예외입니다). 도구 정의는 이름, 설명, 입력 스키마로 이뤄지며, 모델에게는 설명이 사용 설명서의 전부입니다.
Anthropic 문서는 상세한 설명을 도구 성능에 가장 큰 영향을 주는 요소로 꼽고, 도구마다 최소 3~4문장으로 무엇을 하는지, 언제 쓰고 언제 쓰지 말아야 하는지, 각 파라미터의 의미, 반환하지 않는 정보 같은 한계를 쓰라고 권합니다.
| 고치기 전 | 고친 뒤 |
|---|---|
"description": "예약 조회" |
"description": "고객의 기존 수리 예약을 예약번호로 조회한다. 새 예약 가능 시간을 찾을 때는 쓰지 않는다(find_slots 사용). 예약번호는 'R-'로 시작하는 10자리다. 반환값은 상태, 방문 일시, 담당 기사 이름이며 결제 정보는 반환하지 않는다." |
그 밖의 설계 원칙은 다음과 같습니다.
- 형식에 민감한 인자에는 예시를 붙입니다. Anthropic은 스키마 검증을 통과하는 예시를 넣는
input_examples필드를 제공합니다. - 서로 관련된 작업은
action파라미터를 가진 도구 하나로 합치고, 여러 서비스에 걸치면 이름에 접두어(billing_,calendar_)를 붙입니다. - 결과에는 다음 판단에 필요한 정보와 안정적인 식별자만 담고, 이미 아는 값(사용자 ID 등)은 모델에게 채우게 하지 않습니다(OpenAI).
- 도구 수를 적게 유지합니다. OpenAI는 한 턴 시작 시 함수를 20개 미만으로, Gemini는 활성 도구를 10~20개 이하로 권합니다.
도구 호출 루프를 상태 기계로 만든다
도구 호출은 한 번의 요청으로 끝나지 않고 루프를 돕니다.
- 도구 정의와 함께 요청한다.
- 응답에서 도구 호출(이름, 인자, 호출 ID)을 꺼낸다.
- 인자와 권한을 검증하고 애플리케이션에서 실행한다.
- 결과를 호출 ID와 짝지어 대화에 추가하고 다시 요청한다.
- 최종 답이 오면 끝내고, 도구 호출이 또 오면 3단계로 돌아간다.
단순한 while 문으로 시작하면 무한 호출, 잘못된 종료, 비용 폭주가 생깁니다. 상태 기계로 생각하면 빠진 경로가 보입니다. 상태는 “생성 중 → 호출 요청 → 검증 → 실행 → 결과 반환 → 생성 중”을 돌다가 “완료” 또는 “예산 초과”로 끝납니다. 예산은 단계 수, 경과 시간, 토큰 비용 세 가지로 둡니다.
const budget = { maxSteps: 6, deadlineMs: Date.now() + 30_000, maxTokens: 60_000 };
let usedTokens = 0;
for (let step = 0; ; step++) {
if (step >= budget.maxSteps || Date.now() > budget.deadlineMs || usedTokens > budget.maxTokens) {
return handOff("budget_exceeded"); // 사람 상담원 연결, 부분 결과 보존
}
const res = await callModel(messages, tools);
usedTokens += totalTokens(res.usage); // 캐시 토큰까지 합산
const outcome = classify(res.stop_reason); // 1장의 분류기
if (outcome !== "needs_tool") return finish(res, outcome);
const results = await runTools(res); // 검증 → 실행 → 결과 봉투
messages.push(assistantTurn(res), toolResultTurn(results));
}
직접 루프를 쓰지 않아도 됩니다. Anthropic SDK의 Tool Runner처럼 도구 실행과 결과 반환 루프를 자동으로 돌려 주는 도우미도 있습니다. 다만 예산, 권한 검사, 감사 기록은 어떤 경우에도 애플리케이션의 책임입니다.
결과를 대화에 되돌린다
모델의 호출과 결과는 호출 ID로 짝짓습니다. 형식은 벤더마다 다릅니다.
| 벤더 | 모델의 호출 | 결과 반환 |
|---|---|---|
| OpenAI | function_call 항목: call_id, name, JSON 문자열 arguments |
function_call_output 항목: 같은 call_id, output(보통 문자열) |
| Anthropic | tool_use 블록: id, name, 객체 input |
다음 user 메시지의 tool_result 블록: tool_use_id, content, 필요하면 is_error |
| Gemini | 함수 호출 단계: 이름, 인자, 호출 ID | function_result: name, call_id, result |
Anthropic은 배치 규칙이 엄격합니다. tool_result는 해당 tool_use 바로 다음 user 메시지에 와야 하고, 그 사이에 다른 메시지를 넣을 수 없으며, 내용 배열에서 텍스트보다 앞에 있어야 합니다. 결과 바로 뒤에 텍스트를 덧붙이면 빈 응답의 원인이 되기도 합니다. OpenAI의 arguments는 JSON 문자열이라 파싱이 필요하고, 반환값이 없는 함수는 성공 여부를 알리는 문자열을 돌려줍니다.
실패도 결과입니다. Anthropic에서는 is_error: true와 함께 무엇이 잘못됐고 다음에 무엇을 시도할지 적습니다. “failed”보다 “재고 시스템 응답 없음. 60초 뒤 다시 시도하거나 다른 지점을 조회하라”가 낫고, 인자가 잘못된 호출에 오류 결과를 돌려주면 Claude는 보통 2~3회 고쳐서 다시 시도합니다. 도구마다 결과 모양이 다르면 해석이 어려우므로 공통 봉투를 정해 둡니다.
{ "status": "needs_info", "data": null, "error": null, "hint": "방문 주소의 동·호수가 필요하다" }
status는 ok, error, needs_info 정도로 충분합니다. 결과 크기에 상한을 두고, 모델이 알 필요 없는 개인정보는 결과에서 빼고 보냅니다.
병렬 도구 호출
모델은 한 응답에서 여러 도구를 동시에 요청할 수 있습니다. Anthropic 문서는 API가 실행 순서를 정하지 않으며, 독립적인 읽기 작업은 동시에, 부작용이 있거나 상태를 공유하거나 순서가 중요한 작업은 차례로 실행하라고 안내합니다. 실행 방식과 상관없이 지켜야 할 규칙이 있습니다.
- 모든 호출에 결과를 하나씩 돌려줍니다. 실행하지 않기로 한 호출에도
is_error: true와 “앞선 호출이 실패해 실행하지 않았다” 같은 짧은 설명을 담은 결과를 보냅니다. - Anthropic에서는 모든 결과를 하나의 user 메시지에 담습니다. 결과를 메시지마다 나눠 보내면 모델이 병렬 호출을 피하도록 “학습”돼 병렬 사용이 줄어든다고 문서는 경고합니다.
- 병렬 호출을 끌 수 있습니다. Anthropic은
tool_choice안의disable_parallel_tool_use: true(최상위 파라미터가 아님)로,auto일 때 응답마다 도구를 최대 하나만 부르게 합니다. OpenAI는parallel_tool_calls: false로 0개 또는 1개만 부르게 합니다.
Gemini 문서는 독립적인 병렬 호출과, 앞 결과가 뒤 호출의 입력이 되는 합성(compositional) 호출을 구분합니다. 의존 관계가 있으면 애플리케이션이 순서를 강제합니다.
tool_choice와 강제 호출
tool_choice는 모델이 도구를 쓸지, 무엇을 쓸지를 정합니다.
| 의도 | OpenAI | Anthropic | Gemini |
|---|---|---|---|
| 모델이 판단 | auto(기본) |
auto(도구가 있을 때 기본) |
auto(기본) |
| 반드시 도구 사용 | required 또는 특정 함수 |
any 또는 tool |
any |
| 사용 금지 | none |
none |
none |
| 허용 목록 제한 | allowed_tools |
— | allowed_tools |
강제 호출은 모델마다 지원이 다릅니다. Anthropic 문서에 따르면 Claude Opus 5.5, Sonnet 5.5, Fable 5.1, Mythos 5.1은 any와 tool을 400 오류로 거부하므로, auto와 strict 도구를 함께 쓰거나 정해진 JSON 응답이 필요하면 구조화 출력을 씁니다. 지원하는 모델에서도 강제하면 사고 과정과 도구 호출 앞의 설명 문장이 생략됩니다. Anthropic에서 tool_choice를 바꾸면 캐시된 메시지 부분이 무효화되고, OpenAI는 도구 목록은 그대로 두고 호출 범위만 제한하는 allowed_tools가 캐시 절감을 지키는 데 도움이 된다고 설명합니다.
추론 모델과 도구 루프
추론 모델은 도구 호출과 함께 추론 정보를 돌려줍니다. 이 정보는 다음 요청에 받은 그대로 다시 보내야 합니다.
- OpenAI: 도구 호출과 함께 온 추론 항목(reasoning item)을 함수 결과와 함께 다시 보냅니다.
previous_response_id를 쓰거나 이전 출력 항목을 그대로 입력에 넣습니다. 무상태로 쓸 때는 추론 항목의encrypted_content를 넘깁니다. - Anthropic: 도구 결과를 돌려줄 때 최신 assistant 메시지의
thinking과redacted_thinking(안전상 일부가 가려진 사고 블록) 블록을 서명(signature)까지 수정 없이 돌려보냅니다. 이 블록을 편집하거나 순서를 바꾸거나 빼면 400 오류가 나며, 내용이 비어 있는 블록도 예외가 아닙니다. - Gemini: 무상태로 이력을 관리한다면 모델이 만든 단계를 수정 없이 다시 보냅니다. 단계 안에 필요한 서명이 들어 있기 때문입니다. SDK를 쓰면 자동으로 처리됩니다.
문제는 대개 토큰을 아끼려고 블록을 걸러 내거나 이력을 다시 직렬화하는 미들웨어에서 생깁니다. 원칙은 “이력은 덧붙이기만 한다”입니다.
실행 권한은 서버가 쥔다
strict 모드는 인자의 모양만 보장합니다. 그 인자로 실행해도 되는지는 별개의 질문입니다. OWASP Top 10 for LLM Applications 2025는 필요 이상의 기능·권한·자율성 때문에 모델 출력이 해로운 행동으로 이어지는 위험을 과도한 위임(LLM06 Excessive Agency)으로, 모델 출력을 충분히 검증·정제하지 않고 다른 시스템에 넘기는 위험을 부적절한 출력 처리(LLM05 Improper Output Handling)로 꼽습니다.
- 사용자·조직 식별자는 세션에서 서버가 넣는다. 모델이 고객 ID를 인자로 고르게 하지 않는다.
- 실행 직전에 권한과 업무 규칙(상태, 한도, 소유권)을 다시 검사한다. Gemini 문서도 함수 호출을 실행하기 전에 검증하라고 권한다.
- 읽기 도구와 쓰기 도구를 나누고, 쓰기 도구는 필요한 최소 범위만 허용한다.
- 되돌리기 어려운 작업은 사람 승인을 거친다. 승인 대기 상태를 저장하고, 승인 결과를 도구 결과로 돌려줘 대화를 잇는다.
- 도구 결과에 섞인 외부 텍스트는 신뢰하지 않는다(1장 신뢰 경계).
깊이 읽기
도구가 많아질 때
시스템을 하나씩 붙이다 보면 도구가 수십, 수백 개로 늘어납니다. 비용과 정확도가 함께 나빠집니다. 도구 정의는 입력 토큰으로 과금되고(OpenAI, Anthropic 모두 명시), Anthropic은 도구를 쓰면 별도의 도구 사용 시스템 프롬프트도 더합니다. 비슷한 도구가 많으면 모델이 잘못 고르는 일도 늡니다.
| 전략 | 방법 | 대가 |
|---|---|---|
| 통합 | 관련 작업을 action 파라미터로 묶는다 |
도구 하나의 설명이 길어진다 |
| 단계별 부분집합 | 대화 단계마다 필요한 도구만 넣는다 | 도구 목록이 바뀌면 캐시가 깨진다 |
| 허용 목록 | 목록은 그대로 두고 호출 가능 범위만 제한(allowed_tools) |
정의 토큰은 그대로 든다 |
| 도구 검색 | 정의를 지연 로딩(defer_loading)하고 모델이 검색해 필요한 것만 불러온다 |
검색 단계가 추가되고, 지원 모델이 제한된다 |
Anthropic은 수천 개의 도구를 필요할 때 찾아 불러오는 도구 검색 도구를, OpenAI는 최신 모델에서 지연 로딩과 도구 검색을 지원합니다. Anthropic에서는 도구 정의를 바꾸면 전체 캐시가 무효화되므로 단계마다 목록을 다시 짜면 비용 절감이 상쇄될 수 있습니다. 어떤 전략이든 도구 선택 정확도를 평가 세트로 잽니다.
스트리밍 중의 구조화 결과
구조화 출력과 도구 인자도 스트리밍으로 받을 수 있습니다. 화면에는 빨리 보여 줄 수 있지만, 실행은 끝까지 기다려야 합니다.
- Anthropic은 도구 입력을
input_json_delta의 부분 JSON 문자열로 보냅니다. 조각을 모아content_block_stop에서 파싱합니다. 큰 파라미터 값을 더 잘게 받고 싶다면 도구별로eager_input_streaming을 켜는 세분화 스트리밍이 있습니다. - OpenAI는
response.function_call_arguments.delta로 조각을,.done으로 완성된 인자를 보냅니다. - Gemini는 스트리밍된 조각이 이어 붙이면 최종 객체가 되는 부분 JSON 문자열이라고 설명합니다.
규칙은 간단합니다. 미리보기는 부분 결과로 그리되, 도구 실행과 저장은 완성된 인자가 세 단계 검증을 통과한 뒤에만 합니다. 스트림이 중간에 끊기면 Anthropic 문서가 밝히듯 도구 사용 블록은 부분 복구가 되지 않으므로, 받은 조각을 이어 붙여 실행하지 말고 새로 요청합니다(4장).
실무 장면
모래알회계: 영수증 추출 스키마를 다시 짜다
자영업자용 장부 앱 ‘모래알회계’는 영수증 사진의 OCR 텍스트에서 지출 항목을 뽑는다. 지시문으로 JSON을 받아 오다가 파싱 실패와 타입 오류가 하루 수십 건씩 나자, 개발자 성 주임이 스키마를 새로 설계했다.
첫 초안은 합계를 선택 필드로 두고 읽을 수 없으면 빼게 했다. 하지만 OpenAI strict 모드에서는 모든 필드가 required여야 했고, 무엇보다 “인쇄되지 않음”과 “흐려서 읽을 수 없음”을 구분해야 했다. 전자는 품목 합계로 대신하고, 후자는 사용자에게 재촬영을 요청해야 하기 때문이다. 그래서 total_krw는 null 허용 정수로, 상태는 total_status 열거형으로 받았다. 금액은 원 단위 정수, 날짜는 ISO 형식으로 고정하고 원문 날짜 문자열은 raw_date로 따로 받았다. 장부 항목 ID와 등록 시각은 서버가 붙인다.
같은 기능을 Claude로도 시험하자 문제가 하나 더 나왔다. 금액이 0 이상이어야 한다는 minimum: 0 제약이 Anthropic 구조화 출력에서는 지원되지 않았다. 팀은 제약을 필드 설명으로 옮기고, 응답을 받은 뒤 애플리케이션에서 다시 검사했다. 의미 검증으로는 품목 합계와 총액의 차이가 10원을 넘으면 사용자 확인 화면으로 보내는 규칙을 넣었다.
JSON 모드에 예시를 더 넣는 안은 스키마 불일치를 막지 못했고, 읽을 수 없으면 0을 넣는 안은 실제 0원 영수증과 구분되지 않아 기각했다. 배포 한 달 뒤 파싱 실패는 사라졌고, 전체의 1.2%가 합계 불일치로 확인 화면에 빠져 사용자가 직접 고쳤다.
손끝수리센터: 수리 접수 에이전트에 선을 긋다
가전 수리 업체 ‘손끝수리센터’는 채팅으로 수리를 접수하는 에이전트를 만든다. 도구는 보증 조회(lookup_warranty), 방문 가능 시간 조회(find_slots), 접수 생성(create_repair_ticket) 세 개다. 시범 운영에서 세 가지 사고가 났다. 사용자가 다른 사람의 제품 일련번호를 말하자 그 보증 내역이 조회됐고, 모델이 시간 조회와 접수 생성을 한 응답에서 동시에 요청해 빈 시간 확인 전에 접수가 만들어졌으며, 모호한 요청 하나에 루프가 아홉 바퀴를 돌며 비용을 썼다.
리드 엔지니어 도 책임은 프롬프트 수정보다 구조 변경을 먼저 했다.
- 고객 ID는 도구 인자에서 뺐다. 서버가 로그인 세션에서 넣고, 일련번호가 그 고객의 등록 제품인지 실행 전에 확인한다.
create_repair_ticket은find_slots가 돌려준slot_hold_id를 필수 인자로 받게 했다. 홀드에는 유효 시간이 있어, 서버는 홀드가 없거나 만료됐으면 실행하지 않고 다시 조회하라는needs_info결과를 돌려준다. 병렬 요청이 와도 의존 관계를 서버가 강제하므로 순서가 꼬이지 않는다.- 읽기 도구 두 개는 병렬 실행을 허용하고, 같은 응답에 쓰기 도구가 섞이면 읽기를 먼저 실행한 뒤 쓰기를 검증한다. 실행하지 않은 호출에는 이유를 담은 오류 결과를 돌려준다.
- 루프 예산을 여섯 단계, 30초로 정하고, 넘으면 대화 요약과 함께 상담원에게 넘긴다.
“다른 사람 제품은 조회하지 말라”는 지시만 넣는 안은 모델이 따르지 않을 때 막을 장치가 없어 서버 검사와 함께만 쓰기로 했다. 접수 생성을 강제 호출로 고정하는 안은 쓰려던 모델이 지원하지 않았고 정보가 부족할 때 질문할 여지도 없애 기각했다. 이후 접수 오류는 사라졌고, 상담원 이관 비율은 7%에 머물렀다.
자주 틀리는 지점
- JSON 모드면 스키마도 지켜진다. JSON 모드는 문법만 보장합니다. 필드와 타입은 스키마 기반 구조화 출력이 보장합니다.
- 스키마를 통과하면 내용도 맞다. 형식은 맞고 값이 틀린 결과가 가능합니다. 합계, 날짜, 상태 같은 관계는 의미 검증에서 확인합니다.
- 선택 필드는 required에서 빼면 된다. OpenAI strict 모드에서는 모든 필드를
required에 넣고null허용 타입으로 표현합니다. - 스키마에 적은 제약은 공급자가 다 지켜 준다. 지원 범위가 다릅니다. Anthropic은 숫자·문자열 길이 제약을 받지 않으므로 애플리케이션에서 검증합니다.
- 실행하지 않은 호출은 결과를 생략해도 된다. 모든 호출에는 결과가 있어야 합니다. 실행하지 않았다면 그 사실을 오류 결과로 알립니다.
- strict를 켰으니 그 인자로 실행해도 안전하다. strict는 모양만 보장합니다. 권한, 소유권, 업무 규칙은 실행 직전에 서버가 검사합니다.
- 병렬 호출 결과는 메시지를 나눠 보내도 된다. Anthropic에서는 하나의 user 메시지에 모아야 하며, 나눠 보내면 병렬 호출이 줄어듭니다.
한눈에 정리
| 주제 | 기억할 것 |
|---|---|
| 보장 범위 | 지시문 < JSON 모드(문법) < 구조화 출력(스키마). 값의 정확성은 보장 밖 |
| strict 규칙 | additionalProperties: false. OpenAI는 전 필드 required + null. Anthropic은 숫자·길이 제약, 재귀 미지원 |
| 스키마 설계 | 없음의 의미 구분, 단위 고정, 서버가 아는 값은 서버가, 버전 필드 |
| 검증 | 상태 → 구문 → 스키마 → 의미 |
| 도구 정의·루프 | 설명이 전부. 루프는 상태 기계 + 단계·시간·토큰 예산 |
| 결과 반환 | 호출 ID로 짝. 모든 호출에 결과. Anthropic은 한 메시지, 결과 먼저 |
| 강제 호출·추론 항목 | 강제 호출은 지원 모델이 제한적(대안: auto + strict, 구조화 출력). 추론 항목은 받은 그대로 되돌린다 |
| 권한 | 식별자는 서버가 넣고, 실행 직전에 다시 검사 |
공식 문서로 확인하기
- OpenAI — Structured outputs: JSON 모드와의 차이, 지원 스키마와 한도를 읽는다.
- OpenAI — Function calling: 호출 흐름, strict 규칙,
tool_choice옵션을 읽는다. - Anthropic — Structured outputs: 지원·미지원 스키마 기능과 복잡도 한도를 확인한다.
- Anthropic — Define tools: 도구 설명 모범 사례와 강제 호출 지원 표를 읽는다.
- Anthropic — Handle tool calls:
tool_result배치 규칙과is_error작성법을 읽는다. - Google — Gemini API function calling: 호출 모드와 모범 사례를 읽는다.
셀프 체크
2-1. 업무 개념에서 스키마를 도출할 때 정해야 할 네 가지를 들고, ‘값을 읽을 수 없음’과 ‘값이 원래 없음’을 구분해 표현하는 방법을 예로 보이라.
2-2. OpenAI와 Anthropic의 strict 스키마 규칙 차이를 두 가지 들고, 두 공급자를 함께 쓰는 서비스가 스키마를 관리할 때 주의할 점을 쓰라.
2-3. 구문 검증, 스키마 검증, 의미 검증을 각각 한 문장으로 정의하고, 세 단계보다 먼저 확인해야 할 것이 무엇인지 쓰라.
2-4. 도구 결과를 돌려줄 때 쓸 공통 봉투 형식의 필드를 정하고, 실패 결과에 다음에 시도할 일을 적는 이유를 설명하라.
2-5. 도구 호출 루프를 상태 기계로 그릴 때 필요한 상태를 나열하고, 루프에 둘 예산 세 가지와 예산을 넘었을 때의 처리를 쓰라.
2-6. 모델이 인자로 고르게 하지 말고 서버가 직접 넣어야 하는 값의 예를 세 가지 들고, 그 이유를 OWASP Top 10 for LLM Applications의 위험 항목과 연결해 설명하라.
2-7. 사내 회의실 예약 도우미를 설계하라. 도구 목록, 각 도구 설명에 꼭 넣을 내용, 서버가 직접 넣을 값, 실행 직전 검사 항목을 표로 정리하라.
실습 과제
실습
스키마와 세 단계 검증기 (45분)
- 영수증, 주문서, 문의 메일 가운데 하나를 골라 업무 개념에서 스키마를 도출한다. 없음의 의미를 구분하는 열거형, 단위, 서버가 부여할 값,
schema_version을 넣는다. - 쓰는 공급자의 strict 규칙에 맞게 고치고, 지원하지 않는 제약은 설명과 애플리케이션 검사로 옮긴다.
- 응답 상태 → 구문 → 스키마 → 의미 순서로 검사하는 함수를 만들고, 단계별 실패를 다른 경로(재시도, 확인 질문, 보류)로 보낸다.
- 입력 20개를 돌린다. 그중 5개는 값이 빠지거나 서로 모순되는 입력으로 만든다.
완료 기준: 20건 모두 어느 단계에서 통과·실패했는지 표로 나오고, 모순 입력 5건이 의미 검증에서 걸러졌으면 완료다.
실습
예산이 있는 상태 기계 도구 루프 (60분)
- 조회 도구 두 개와 변경 도구 하나를 정의하고 strict를 켠다. 사용자 ID는 서버가 넣는다.
- 변경 도구는 조회 결과에서 받은 식별자가 있어야만 실행되게 하고, 실행 직전에 권한과 상태를 검사한다.
- 루프에 단계 수와 시간 예산을 두고, 넘으면 부분 결과와 함께 사람에게 넘긴다.
- 변경 도구가 세 번에 한 번 실패하도록 장애를 주입하고, 실패를 공통 봉투 형식의 오류 결과로 돌려준다. 실행하지 않은 호출에도 결과를 보낸다.
- 대화 열 번을 돌려 로그를 확인한다.
완료 기준: 모든 도구 호출에 짝이 맞는 결과가 있고, 검사에 걸린 호출은 실행되지 않았으며, 예산을 넘은 대화가 사람에게 넘겨졌으면 완료다.