Zod를 이용한 타입 안전(Type-safe) LLM 출력: 모델이 무엇을 반환할지 추측하는 것을 멈추세요
요약
LLM의 비정형 JSON 출력을 Zod를 사용하여 타입 안전하게 검증하고 파싱하는 방법을 다룹니다. Vercel AI SDK 및 Anthropic SDK와 함께 스키마를 정의하고 런타임 에러를 방지하는 실무적인 가이드를 제공합니다.
핵심 포인트
- LLM의 자유 형식 JSON 출력은 런타임 에러의 주요 원인임
- Zod를 사용하여 LLM 출력에 대한 단일 진실 공급원(SSOT) 구축
- 단순 분류기부터 구조화된 추출기까지 다양한 스키마 패턴 적용 가능
- Vercel AI SDK 및 Anthropic SDK와의 통합 방법 제시
Zod를 이용한 타입 안전(Type-safe) LLM 출력: 모델이 무엇을 반환할지 추측하는 것을 멈추세요.
저는 지난 1월에 분류기(classifier)를 프로덕션에 배포했습니다. 프롬프트는 단일 category 필드를 가진 JSON을 요청했습니다. 3주 동안은 잘 작동했습니다. 그러다 모델이 {"category":"bug","explanation":"this looks like a crash"}를 반환하기 시작했고, 소비 측(consumer)에서는 단 하나의 키만 예상했기 때문에 런타임 에러(runtime error)가 발생했습니다. 스키마 변경도, 배포도 없었습니다. 모델이 그저 친절해지기로 결정했을 뿐입니다.
Zod와 파싱(parse) 단계에서의 약간의 규율을 더하면 그 간극을 메울 수 있습니다. 이 튜토리얼에서는 LLM 출력 형태를 위한 스키마(schema)를 정의하고, 이를 Vercel AI SDK 및 순수 Anthropic SDK와 함께 사용하는 방법, 그리고 모델이 여전히 틀리는 경우를 처리하는 재시도 루프(retry loop)를 구축하는 과정을 살펴봅니다.
요약 (TL;DR)
| 단계 | 내용 | 이유 |
|---|---|---|
| Zod 스키마 정의 | 원하는 형태를 기술 | 타입(types)을 위한 단일 진실 공급원 (Single source of truth) |
| ... |
1. 문제점: 자유 형식의 JSON은 아무도 서명하지 않은 계약입니다
대부분의 LLM 튜토리얼은 JSON.parse(response)를 보여주고 상황을 종료합니다. 문제는 모델이 당신의 스키마에 동의한 적이 없다는 것입니다. {"category": "bug"}를 반환하라고 요청하면 모델은 다음과 같이 반환할 수 있습니다:
{"category": "bug"}(정상){"Category": "Bug"}(잘못된 대소문자){"category": "bug", "confidence": 0.9}(추가 필드){"error": "I cannot classify this"}(친절하지만, 당신의 스키마는 아님)- 모델이 예의를 차린다고 느껴 JSON을 감싸는 마크다운 펜스(markdown fence)
형태를 실제로 검증하는 파싱(parse) 단계가 없다면, 이러한 모든 경로가 다운스트림(downstream) 데이터를 조용히 오염시킵니다.
해결책은 세 줄의 Zod 코드와 하나의 .safeParse() 호출입니다. 이 글의 모든 기술은 Vercel AI SDK를 사용하든, 순수 Anthropic SDK를 사용하든, 혹은 둘 다 사용하든 이 패턴을 기반으로 합니다.
import { z } from "zod";
const ClassifyResult = z.object({
...
Zod 4(현재 안정적인 메이저 버전)를 설치하세요:
npm install zod@^4.0.0
핵심 API (z.object, z.string, z.enum, z.discriminatedUnion, z.infer)는 모두 Zod 3에서 그대로 이어집니다. 이미 Zod 3를 사용 중이라면, 이러한 유스케이스(use cases)를 위한 마이그레이션은 대부분 추가적인 작업만 필요합니다.
2. LLM 출력 형태를 위한 스키마(Schema) 정의
LLM 출력은 대개 세 가지 형태를 띱니다: 단순 분류기(flat classifiers), 더 풍부한 추출기(richer extractors), 그리고 모델이 분기를 선택하는 판별된 결과(discriminated results)입니다. Zod는 이 세 가지를 모두 처리할 수 있습니다.
단순 분류기 (Flat classifier)
import { z } from "zod";
export const SentimentSchema = z.object({
...
구조화된 추출기 (Structured extractor)
export const InvoiceSchema = z.object({
vendor: z.string(),
amount_usd: z.number().positive(),
...
다중 의도 라우팅을 위한 판별된 유니온 (Discriminated union for multi-intent routing)
이 형태는 모델이 단순한 열거형(enum) 대신 세 가지 이상의 뚜렷한 출력 경로 중 하나를 선택하도록 하고 싶을 때 사용합니다.
export const RoutingResult = z.discriminatedUnion("intent", [
z.object({
intent: z.literal("search"),
...
판별된 유니온(discriminated union)은 엄격합니다. 만약 intent가 "search"라면, Zod는 query 필드가 올 것을 예상하며, intent: "create"와 함께 query 필드가 포함된 파싱(parse) 시도는 깔끔하게 실패합니다.
3. Vercel AI SDK: Output.object를 이용한 generateText
Vercel AI SDK는 최근 버전에서 스키마 네이티브(schema-native) 구조화된 출력을 추가했습니다. Output.object를 통해 Zod 스키마를 전달하면, SDK가 프롬프트 스캐폴딩(prompt scaffolding)과 파싱 단계를 대신 처리해 줍니다.
import { generateText, Output } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
...
반환 타입(return type)은 별도의 캐스팅(casting) 없이 스키마로부터 흘러나옵니다. 만약 모델이 일치하지 않는 값을 반환하면, SDK는 결과가 코드에 도달하기 전에 예외를 발생시킵니다.
객체가 도착하는 대로 부분적인 객체를 스트리밍(streaming)하려면:
import { streamText, Output } from "ai";
const { partialOutputStream } = streamText({
...
스트림 에러는 예외(exception)로 던져지는 대신 인밴드(in-band) 방식으로 전달되므로, 스트림 에러를 위한 onError 콜백을 추가하세요.
4. Raw Anthropic SDK: 구조화된 출력으로서의 도구 사용 (tool use)
Anthropic SDK를 직접 사용하면서 Vercel SDK 래퍼(wrapper) 없이 스키마가 강제된 출력을 원한다면, 가장 확실한 방법은 tool_choice를 사용자의 스키마 도구로 강제하는 도구 사용 (tool use) 방식입니다.
핵심 아이디어: 원하는 JSON 형태를 input_schema로 설명하는 "도구 (tool)"를 정의합니다. 그 다음 tool_choice: { type: "tool", name: "..." }를 사용하여 모델이 해당 도구를 호출하도록 강제합니다. 그러면 모델은 자유 형식의 텍스트 대신 구조화된 입력을 포함한 tool_use 블록을 반환합니다. 이 입력을 Zod로 파싱하면 됩니다.
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
...
type: "tool"이 설정된 tool_choice 파라미터가 핵심적인 세부 사항입니다. 이것이 없으면 모델은 일반 텍스트로 답변하는 것을 선택할 수 있습니다. 하지만 이 설정이 있으면 응답은 항상 구조화된 tool_use 블록으로 돌아옵니다.
실무적인 참고 사항: 여기서는 스키마를 Zod에서 한 번, JSON Schema에서 한 번, 총 두 번 정의하게 됩니다. 스키마가 작다면 이러한 중복은 허용할 만한 수준입니다. 스키마가 더 크다면, npm의 zod-to-json-schema를 사용하여 Zod 정의로부터 input_schema를 자동으로 생성하는 방법을 찾아보세요.
5. 파싱 실패 처리: 재시도(retry) 및 복구(repair)
도구 사용을 강제하고 스키마 프롬프팅을 사용하더라도, 모델은 가끔 검증(validation)에 실패하는 출력을 반환합니다. 네트워크 불안정, 컨텍스트 윈도우(context window) 압박, 그리고 예외적인 입력값(edge-case inputs) 모두 예상치 못한 형태를 만들어낼 수 있습니다. 재시도 루프(retry loop)는 필요해지기 전에 미리 구축해 두세요.
단순 재시도 (Simple retry)
async function classifyWithRetry(
text: string,
maxAttempts = 3
...
스키마 복구: 에러를 다시 전달하기 (Schema repair: feed the error back)
모델이 정답에 가깝지만 틀린 형태를 반환할 때는, 무작정 재시도하는 것보다 검증 에러를 두 번째 호출에 다시 전달하는 것이 종종 더 효과적입니다. 모델은 자신이 무엇을 잘못했는지 확인하고 이를 수정할 수 있습니다.
async function classifyWithRepair(text: string): Promise<ClassifyResult> {
// 첫 번째 시도
const response = await client.messages.create({
...
복구 패턴은 한 번의 추가 호출 비용이 발생하지만, 수동 개입 없이 대부분의 예외 사례를 복구할 수 있습니다.
6. 복사해서 바로 사용할 수 있는 두 가지 스키마
분류기 (Classifier: 채팅을 위한 의도 라우팅)
import { z } from "zod";
export const IntentSchema = z.object({
...
Extractor (비정형 텍스트에서 구조화된 데이터 추출)
export const ContactExtractSchema = z.object({
name: z.string(),
email: z.string().email().optional(),
...
Anthropic SDK 원본 사용 시:
const extractTool = {
name: "extract_contact",
description: "제공된 텍스트에서 연락처 정보를 추출합니다.",
...
결론 (The bottom line)
검증 없는 JSON.parse는 시한폭탄과 같습니다. 모델은 결국 당신이 예상하지 못한 형태를 반환할 것이며, 이때 발생하는 실패 모드는 즉시 포착할 수 있는 명확한 에러가 아니라 소리 없는 데이터 오염(silent data corruption)입니다.
해결책에는 두 가지가 필요합니다. Zod 스키마(TypeScript 타입을 위해 어차피 작성해야 하는 것)와 원본 JSON.parse 대신 사용하는 .safeParse() 호출입니다. Output.object를 사용하는 Vercel AI SDK는 이 연결 과정을 대신 처리해 줍니다. 특정 도구로 tool_choice를 강제하는 Anthropic SDK 원본을 사용하면, 한 단계의 추가 설정만으로 동일한 보장을 받을 수 있습니다.
재시도(retry) 및 복구(repair) 패턴은 보험과 같습니다. 실제로 잘 정의된 스키마를 사용하고 tool_choice를 강제할 경우, 파싱 실패는 전체 호출의 1% 미만에서 발생합니다. 하지만 하루에 50,000건의 분류 작업을 수행한다면 그 1%도 무시할 수 없습니다.
현재 여러분의 가장 혼란스러운 LLM 출력 형태는 어떤 모습인가요? 댓글로 남겨주세요.
GDS K S · thegdsks.com · X에서 팔로우 @thegdsks
Zod 스키마는 모델이 결코 어길 수 없는 계약입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기