구조화 출력은 부탁이 아니라 계약이다
언어 모델은 산문을 내놓고, 프로덕션 시스템은 JSON을 먹는다. 그 사이 경계에는 AI라는 걸 들어본 적도 없는 검증기가 서 있다.
3 분 읽기서울과 나트랑에서 씁니다
모델 호출이 우리 파이프라인에 들어왔을 때 가장 먼저 표준화한 게 이 경계였다. 출력이 기계로 흘러 들어가는 호출은 전부 스키마에 맞춰 JSON을 반환하고, 그 스키마는 평범한 검증기가 강제한다. 프롬프트에서 정중히 부탁해서가 아니다. 출력을 거부하고, 검증기가 뱉은 에러를 모델 코앞에 들이밀면서 다시 하게 만들어서다.
루프
async function extract(input, schema, tries = 3) {
let feedback = '';
for (let i = 0; i < tries; i++) {
const raw = await llm({
system: RULES + feedback,
user: input,
response_format: schema, // constrained decoding where the API supports it
temperature: 0,
});
const errors = validate(schema, raw); // plain JSON Schema, same lib as our forms
if (errors.length === 0) return raw;
feedback = ' Previous output failed validation: ' + errors.join('; ');
}
return quarantine(input); // a review queue. never a crash, never a guess.
}
무게의 대부분을 지는 건 세 가지다.
- 자유 텍스트보다 enum이 낫다. 신뢰성이 가장 크게 뛴 건 문자열 필드를 닫힌 enum으로 바꿨을 때였다. 어떤 추출 파이프라인은 카테고리 필드를 enum으로 바꾼 그 주에 거부율이 4.1%에서 0.2%로 떨어졌다. 일곱 개 값 중에서 골라야 하는 모델은 여덟 번째를 지어낼 수 없다.
- 검증기 에러는 토씨 하나 안 바꾸고 그대로 돌려준다. 그냥 "다시 해봐"는 같은 실수를 반복하게 만든다. "items[2].price must be integer" 같은 정확한 메시지를 주면 거의 매번 다음 시도에서 바로 고쳐 온다.
- temperature 0은 필요조건이지 충분조건이 아니다. 그리디 디코딩은 무작위성을 없앨 뿐 틀림을 없애진 못한다. 진실을 말해 주는 건 여전히 검증기다.
격리도 하나의 기능이다
세 번 시도하고도 실패한 항목이야말로 흥미로운 놈이다. 이건 리뷰 큐로 들어가고, 사람이 라벨을 달고, 라벨이 붙은 사례는 회귀 테스트 묶음에 합류한다. 우리 스키마는 지난 2년간 제가 뱉어낸 거부 사례들로 단단해졌다. 여기서 진짜 교훈이 나온다. 스키마는 프롬프트의 절반이다. 우리가 프롬프트 엔지니어링에 쓰는 시간의 대부분은 필드를 지우고, 타입을 조이고, enum을 닫는 데 들어간다. 출력에서 자유도를 하나 걷어낼 때마다, 그만큼 절대 일어날 수 없는 환각이 하나씩 늘어나기 때문이다.