--- id: ai-structured-output-zod title: LLM Structured Output — Zod / Function Calling category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [ai, llm, structured, zod, vibe-coding] tech_stack: { language: "TS / Zod / OpenAI / Anthropic", applicable_to: ["Backend"] } applied_in: [] aliases: [structured output, JSON mode, function calling, tool use, response_format] --- # LLM Structured Output > JSON 강제 prompt 만으로는 신뢰 X. **OpenAI `response_format: { type: 'json_schema' }` / Anthropic tool_use** 가 schema 보장. **Zod → JSON Schema** 가 표준 워크플로. ## 📖 핵심 개념 - JSON mode: 어떤 JSON 도 통과 (schema 미보장). - Structured output (OpenAI): JSON Schema 기반 **enforce**. parse 실패 0%. - Tool use (Anthropic): 함수 호출 형태 — input_schema 강제. - Zod → JSON Schema: `zod-to-json-schema` 라이브러리. ## 💻 코드 패턴 ### OpenAI structured output ```ts import OpenAI from 'openai'; import { zodResponseFormat } from 'openai/helpers/zod'; import { z } from 'zod'; const Recipe = z.object({ title: z.string(), servings: z.number().int().positive(), ingredients: z.array(z.object({ name: z.string(), qty: z.number(), unit: z.enum(['g', 'ml', 'cup', 'tsp']), })), steps: z.array(z.string()).max(10), }); const client = new OpenAI(); const r = await client.beta.chat.completions.parse({ model: 'gpt-4o', messages: [{ role: 'user', content: 'Spaghetti carbonara recipe' }], response_format: zodResponseFormat(Recipe, 'recipe'), }); const recipe = r.choices[0].message.parsed!; // 타입 = z.infer ``` ### Anthropic tool use (structured) ```ts import Anthropic from '@anthropic-ai/sdk'; import { zodToJsonSchema } from 'zod-to-json-schema'; const client = new Anthropic(); const r = await client.messages.create({ model: 'claude-opus-4-7', max_tokens: 1024, tools: [{ name: 'extract_recipe', description: 'Extract recipe data', input_schema: zodToJsonSchema(Recipe) as Anthropic.Messages.Tool.InputSchema, }], tool_choice: { type: 'tool', name: 'extract_recipe' }, // 강제 messages: [{ role: 'user', content: 'Spaghetti carbonara recipe' }], }); const block = r.content.find(b => b.type === 'tool_use'); const recipe = Recipe.parse(block!.input); ``` ### 검증 + 재시도 ```ts async function getRecipe(query: string, attempts = 3): Promise { let lastErr: unknown; for (let i = 0; i < attempts; i++) { try { const raw = await callLLM(query); return Recipe.parse(raw); // throws ZodError on fail } catch (e) { lastErr = e; // 재시도 시 에러 메시지를 LLM 에 피드백 } } throw lastErr; } ``` ### 점진적 schema (간단 → 복잡) ```ts // V1: 단순 const SimpleRecipe = z.object({ title: z.string(), steps: z.array(z.string()) }); // 동작 확인 // V2: 더 정밀 const Recipe = SimpleRecipe.extend({ servings: z.number().int().positive(), ingredients: z.array(IngredientSchema), }); ``` ### Discriminated union (여러 종류) ```ts const Action = z.discriminatedUnion('type', [ z.object({ type: z.literal('search'), query: z.string() }), z.object({ type: z.literal('calc'), expr: z.string() }), z.object({ type: z.literal('done'), answer: z.string() }), ]); ``` ### Streaming + structured (OpenAI) ```ts const stream = await client.beta.chat.completions.stream({ model: 'gpt-4o', messages: [...], response_format: zodResponseFormat(Recipe, 'recipe'), }); for await (const ev of stream) { if (ev.event === 'content.delta') console.log(ev.parsed); // 부분 객체 } const final = (await stream.finalChatCompletion()).choices[0].message.parsed; ``` ### Function calling (legacy) ```ts const r = await client.chat.completions.create({ model: 'gpt-4o', messages: [...], tools: [{ type: 'function', function: { name: 'extract', parameters: zodToJsonSchema(Recipe) } }], tool_choice: { type: 'function', function: { name: 'extract' } }, }); ``` ## 🤔 의사결정 기준 | 상황 | 추천 | |---|---| | OpenAI 정확한 schema | Structured output | | Anthropic | Tool use + force tool | | 간단 JSON | JSON mode + Zod parse | | 여러 종류 액션 | Discriminated union | | Streaming partial | OpenAI stream + structured | | Schema 변동 | runtime parse + 재시도 | ## ❌ 안티패턴 - **JSON 그대로 신뢰**: 자유 형식이면 누락 / 추가 키. - **Schema 거대**: enum 100개 / 50 필드 — LLM 도 정확히 못 채움. - **Optional 모두**: 강제 없으면 LLM 이 빠뜨림. - **Description 없음**: schema 만 있으면 LLM 이 의미 모름. - **Re-try infinite**: 3번 후 fallback 또는 사용자에게. - **Tool name 동사 X 명사 O**: tool_use 는 동사 권장 (`extract_recipe`). - **PII strict 검증 없음**: 잘못된 형식 통과. ## 🤖 LLM 활용 힌트 - Zod schema → zodResponseFormat (OpenAI) / zodToJsonSchema (Anthropic). - 강제 tool_choice 또는 response_format 으로 보장. - Description 풍부하게. ## 🔗 관련 문서 - [[AI_Prompt_Engineering_Patterns]] - [[AI_Streaming_LLM_Response]]