Issue 01중국 AI
AC POST
중국 AI 목록
掘金2026년 9월 18일 21:05중국어 → 한국어

LLM 구조화 출력의 계약화 엔지니어링: JSON 모드와 스키마 제약

운영 환경에서 수집한 LLM 구조화 출력 실패 유형을 빈도순으로 정리하고 JSON 모드, 스키마 제약, 재시도와 폴백을 다루는 계약화 접근을 설명한다.

중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.

대규모 모델은 산문을 출력하는 데 가장 능숙하지만, 엔지니어링 시스템은 구조만 받아들인다. 당신이 LLM에게 "JSON을 출력하라"고 시켰을 때 현실 세계에서는 이런 일이 벌어진다: "좋습니다, 다음은"이라는 군더더기 한 마디; ````json` 코드 블록 울타리; 필드명 대소문자 표류; 숫자가 문자열로 변환; schema에 존재하지 않는 타임스탬프를 환각으로 생성. 이 글은 LLM 구조화 출력의 엔지니어링 방안을 체계적으로 해부한다——디코딩 계층 제약, 프롬프트 계층 제약에서 파싱 계층 방어, 재시도와 강등까지, "현학적 샘플링"을 "SLA가 있는 인터페이스"로 바꾸는 방법을 다룬다.

1. 실패 모드 목록: 먼저 어떻게 망가지는지 알아야 한다

프로덕션 환경에서 수집된 LLM 구조화 출력 실패 모드를 발생 빈도순으로 정렬하면:

- 울타리와 서문: ````json ... ``` ` 로 감싸거나, 서두에 "Sure! Here's the JSON:" 한 마디.

- 잘림: max_tokens가 부족해 JSON이 중간에 끊김—— {"segments":[{"id":"a1" 그리고 끝.

- 필드 표류: start를 startTime으로, duration을 length로 씀.

- 타입 표류: "duration": "4.2" (문자열), "subtitleIds": "sub-1,sub-2" (이어붙인 문자열).

- 제약 위반: duration: -3, start > sourceOut, 존재하지 않는 소재 ID 참조.

- 실체 환각: assetId: "asset-999"를 지어내거나, 존재하지 않는 타임스탬프를 부여.

- 형식 함정: 작은따옴표, 꼬리 쉼표, 주석 //, NaN/Infinity 리터럴.

- 언어 혼입: 중국어 텍스트를 요구했는데 반환에 영어 문장부호나 번역체가 섞임.

각 유형마다 서로 다른 방어선이 필요하다——만능 한 방은 없다.

2. 디코딩 계층 제약: 모델이 "물리적으로" 불법 JSON을 출력할 수 없게 만들기

가장 신뢰할 수 있는 방안은 검증을 디코딩 과정으로 앞당기는 것이다:

Structured Output / JSON Schema 제약 디코딩

주류 추론 서비스는 JSON Schema 전달을 지원하며, 추론 엔진(vLLM/outlines/guided decoding)은 매 단계 token 샘플링 시 출력이 schema를 위반하게 만들 token을 차단한다:

const response = await client. chat . completions . create ({ model : '...' , messages : [...], response_format : { type : 'json_schema' , json_schema : { name : 'video_segments' , strict : true , schema : zodToJsonSchema (aiSegmentsResponseSchema), }, }, })

효과: 필드명, 타입, 필수 여부, 열거값이 디코딩 계층에서 잠기고, 울타리/서문/꼬리 쉼표가 물리적으로 사라진다. 한계:

- strict: true 모드에서 대부분 구현은 additionalProperties를 지원하지 않고, 복잡한 정규식 pattern 지원도 제한적이다.

- 의미 제약은 여전히 통제 못 함: schema는 duration: number를 잠글 수 있지만 duration >= 0은 잠그지 못한다; ID가 문자열임은 잠글 수 있지만 ID가 실제로 존재함은 잠그지 못한다. 참조 무결성은 반드시 애플리케이션 계층에 남겨야 한다.

- schema가 너무 복잡하면(깊은 중첩 + 대량의 anyOf) 제약 오토마타가 퇴화해 출력 품질이 떨어진다. 프로덕션 경험: LLM에 주는 schema는 내부 schema보다 "한 층 낮게"——평평하고, 필드가 적고, 열거값이 제한적이어야 한다.

Function Calling의 계약 본질

Function calling / tool use는 본질적으로 위와 같다: 도구의 parameters가 곧 schema이고, 모델은 시그니처에 맞는 호출을 강제로 산출한다. 차이는 의미 계층에 있다——모델은 "이것이 도구를 호출하는 것"임을 알고 있으므로, Few-shot과 설명 품질이 필드 의미 정확도에 더 큰 영향을 미친다.

3. 프롬프트 계층 제약: Prompt 엔지니어링의 네 가지 철칙

디코딩 제약이 있더라도 프롬프트는 여전히 내용 품질을 결정한다. 구조화 출력의 prompt에는 네 가지 철칙이 있다:

1. Schema가 곧 프롬프트

목표 JSON의 골격을 prompt에 그대로 붙이고, 필드마다 의미와 값 범위를 주석으로 단다:

출력은 엄격하게 다음 구조의 JSON을 따르라(다른 어떤 텍스트도 없이): { "segments": [ { "id": "seg-1", // 고유 ID, seg-N 증가 "scriptText": "...", // 이 구간의 내레이션 원고, 중국어, 15~60자 "startSeconds": 0.0, // 원본 소재에서 권장 시작점, 음수 불가 "durationSeconds": 4.0, // 길이, 0.5 ~ 15 "keywords": ["..."] // 2~5개의 내용 키워드 } ] }

2. One-shot 또는 Few-shot 예시로 형식 고정

완전하고 경계가 명확한 예시 하나가 열 줄의 설명보다 낫다. 예시는 "틀리기 쉬운" 상황을 포괄해야 한다——예를 들어 길이는 반드시 소수점 한 자리를 유지해야 하고, 키워드는 반드시 배열이어야 하며 가운뎃점으로 구분한 문자열이어서는 안 된다.

3. 부정 목록을 명시적으로 선언

금지: markdown 코드 블록, JSON 앞뒤의 설명 텍스트, 주석, 꼬리 쉼표, 작은따옴표, NaN, 음수 길이, 제공되지 않은 소재 ID 참조.

복창하는 것처럼 들리지만, 디코딩 제약이 없는 모델에서는 부정 목록이 형식 오류율을 절반으로 줄일 수 있다.

4. 모델이 "발명하지 말고 보완만 하게" 만들기

컨텍스트에 소재 목록(ID + 간략 설명)을 제시하고 명확히 한다: "assetId는 다음 목록에서만 선택할 수 있으며, 새 ID를 발명하는 것은 금지한다". 이는 실체 환각 문제를 직접 상쇄한다——모델은 (확률적으로) 본 적 없는 것을 참조할 수 없다.

4. 파싱 계층 방어: "텍스트"를 "데이터"로 바꾸는 안전 통로

업스트림이 아무리 신뢰할 만해도, 파싱 계층은 입력이 적대적이라고 가정해야 한다. 프로덕션급 파서는 네 단계로 나뉜다:

function parseLlmJson<T>( raw : string , schema : z. ZodType <T>): T { // 1. 울타리와 서문 제거: 첫 { 또는 [ 부터 마지막 } 또는 ] 까지 취함 const start = raw. search ( /[{[]/ ) const end = Math . max (raw. lastIndexOf ( '}' ), raw. lastIndexOf ( ']' )) if (start < 0 || end <= start) throw new LlmFormatError ( 'no json found' ) const body = raw. slice (start, end + 1 ) // 2. 관대한 복구: 꼬리 쉼표, 스마트 따옴표, NaN const cleaned = body . replace ( /,\s*([}\]])/g , '$1' ) // 꼬리 쉼표 . replace ( /[\u201c\u201d]/g , '"' ) // 스마트 따옴표 . replace ( /\bNaN\b/g , 'null' ) // 3. JSON.parse let data : unknown try { data = JSON . parse (cleaned) } catch (e) { throw new LlmFormatError ( 'json parse failed' , { cause : e }) } // 4. Zod 검증 + 흔한 표류의 필드 별칭 정규화 return schema. parse ( normalizeAliases (data)) } function normalizeAliases ( data: any ): any { // startTime → start, length → duration…… 배포 전 top 표류 별칭을 집계해 매핑 테이블 구축 if ( Array . isArray (data)) return data. map (normalizeAliases) if (data && typeof data === 'object' ) { const out : any = {} for ( const [k, v] of Object . entries (data)) { out[ ALIAS_MAP [k] ?? k] = normalizeAliases (v) } return out } return data }

safeParse 실패 시, Zod issues를 직렬화해 prompt에 되먹여 모델에 다시 준다——이것이 다음 절의 재시도 메커니즘이다.

5. 재시도와 자가 수리: 오류 피드백 폐루프

첫 출력이 실패했다고 곧바로 사용자에게 오류를 보고해서는 안 된다. 엔지니어링에는 효율적인 자가 수리 루프가 있다:

async function generateWithRepair<T>( basePrompt : string , schema : z. ZodType <T>, maxAttempts = 3 , ): Promise <T> { let messages : ChatMessage [] = [{ role : 'user' , content : basePrompt }] for ( let attempt = 1 ; attempt <= maxAttempts; attempt++) { const raw = await llm. chat (messages) try { return parseLlmJson (raw, schema) } catch (e) { if (e instanceof ZodError ) { // 검증 실패: issue를 정확히 되먹임 messages = [ { role : 'user' , content : basePrompt }, { role : 'assistant' , content : raw }, { role : 'user' , content : `너의 이전 라운드 출력이 검증을 통과하지 못했다, 오류는 다음과 같다:\n` + e. issues . map ( i => `- 경로 ${i.path.join( '.' )} : ${i.message} ` ). join ( '\n' ) + `\n수정한 뒤 전체 JSON을 다시 출력하라, 여전히 어떤 추가 텍스트도 없어야 한다.` , }, ] } else if (attempt < maxAttempts) { continue // 파싱 실패: 단순 재시도 (temperature를 0.2 낮출 수 있음) } else { throw e } } } throw new LlmFormatError ( 'exceeded max attempts' ) }

핵심 세부사항:

- 오류는 구체적이어야 함: path와 message를 그대로 모델에 주는 것이 두루뭉술한 "형식이 틀렸다"보다 수리 성공률이 한 자릿수 배 높다. 실측으로 두 라운드 내 수리율은 보통 90%+.

- 온도 전략: 첫 라운드 temperature: 0.7로 창의성을 유지하고, 수리 라운드는 0.2로 낮춰 형식을 유지한다.

- 잘림은 별도로 처리해야 함: finish_reason이 length임을 감지하면 같은 파라미터로 재시도하지 말 것——max_tokens를 키우거나, 分批 생성(한 번에 N개 세그먼트만 생성하고 여러 번 반복)으로 전환한다. 후자는 초장문 출력의 품질 저하 문제도 덤으로 해결한다.

6. 강등 사슬: LLM을 못 쓸 때의 우아한 퇴화

프로덕션 시스템은 LLM이 타임아웃, 속도 제한, 지속적 출력 부적합을 겪는다고 가정해야 한다. 강등 사슬 예시:

Tier 1: 주 모델 + 디코딩 제약 + 자가 수리 루프 ↓ 실패 Tier 2: 예비 모델(다른 벤더) + 동일 schema ↓ 실패 Tier 3: 규칙 엔진 폴백——고정 길이로 균등 분할하고, ASR 문장부호로 문장을 끊어 scriptText 생성 ↓ 항상 사용 가능 Tier 4: 빈 초안 + 안내 문구("AI 분석을 일시적으로 사용할 수 없어, 빈 프로젝트를 생성했습니다")

규칙 엔진 폴백의 가치는 흔히 과소평가된다: 문장부호 단절 + 균등 분할은 "충분한" 시나리오의 60%를 커버할 수 있으며, 사용자는 AI가 끊겼다는 것조차 인지하지 못할 수 있다. 강등은 실패가 아니라 제품 설계의 일부다.

핵심 제약: 어느 계층이 산출하든 최종적으로는 동일한 Zod schema 검증을 통과해야 한다——Tier 3의 규칙 출력과 Tier 1의 LLM 출력은 데이터 계층에서는 아무런 차이가 없다. 이것이 바로 "계약"의 의미다: 상류는 교체 가능하지만, 경계는 변하지 않는다.

7. 비용과 지연의 구조적 최적화

구조화 출력은 자연히 두 가지 최적화에 적합하다:

- 배치 파이프라인 : 10분짜리 영상 하나를 30개 구간으로 자른다고 할 때, 한 번에 30개 구간을 생성하는 것(출력 4000+ token, 절단 위험 높음, 지연 30s+)보다 분 단위로 창을 나누어 생성하며 창마다 3개 구간을 만드는 편이 낫다. 창 사이에는 "연결 컨텍스트"(이전 창 마지막 구간의 키워드)만 전달하므로 token 비용은 70% 감소하고, 첫 구간 지연은 80% 감소한다.

- 캐시와 멱등성 : hash(영상 지문 + prompt 버전 + schema 버전)을 캐시 키로 삼아, 같은 소재를 반복 분석하면 바로 적중한다. prompt가 한 번 변경되면 schema 버전 번호가 연동해 올라가고, 기존 캐시는 자연히 무효화된다——"prompt는 바꿨는데 온라인에서는 여전히 옛 로직이 도는" 더러운 캐시를 피한다.

8. 소결

방어선 | 차단하는 실패 모드 | 잔여 위험 디코딩 제약(JSON Schema) | 울타리, 서두, 타입 오류, 필드 누락 | 의미적 제약, 참조 무결성 프롬프트 엔지니어링(schema + few-shot + 부정 목록) | 필드 표류, 포맷 함정, 부분적 환각 | 롱테일 표류 파싱 방어(울타리 제거 + 복구 + Zod) | 끝 쉼표, 스마트 따옴표, 별칭 표류 | 심층 의미 오류 자기 수정 루프(issue 되먹임) | 첫 라운드의 각종 오류 | 여러 라운드에도 실패하는 소수 저하 사슬(대체 모델 → 규칙 엔진) | 서비스 불가, 지속적 실패 | 품질 저하(감지 가능하고 통제 가능)

핵심 사상을 한 문장으로: LLM을 "대체로 신뢰할 만하지만 간헐적으로 경계를 넘는" 외부 서비스로 취급하고, 전통적인 분산 시스템의 수단(계약, 검증, 재시도, 저하, 캐시)으로 그것을 다스린다 . 모델은 세대가 바뀌고 prompt는 조정되겠지만, 이 방어 심층은 변하지 않는다.