---
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<typeof Recipe>
```

### 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<Recipe> {
  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]]
