LLM의 응답을 읽고 다음 동작을 결정하는 시스템에서는 사람이 읽기 좋은 문장보다 프로그램이 안정적으로 처리할 수 있는 구조가 더 중요해요. 이번 작업에서는 실제 실패 기록을 바탕으로 오류가 집중된 경로에만 JSON Schema를 적용했어요.
JSON을 요청했지만 JSON이 오지 않았다
기존에는 프롬프트로 출력 형식을 지정했어요.
JSON만 출력하세요.
설명이나 코드 블록을 추가하지 마세요.
모든 필드를 빠짐없이 작성하세요.
하지만 프롬프트는 계약이 아니라 요청에 불과해요. 모델이 JSON 앞에 인사말을 붙이거나 응답을 Markdown 코드 블록으로 감쌀 수 있고, 필요한 필드를 빠뜨릴 수도 있었어요.
이런 응답을 받으면 JSON.parse가 실패해요. 이를 보완하려고 공통 JSON 추출 유틸리티에서 정규식으로 코드 블록과 앞뒤 문장을 걷어낸 다음, JSON처럼 보이는 구간을 추출했어요.
문제는 이 방식으로 출력의 신뢰성을 높일 수 없다는 점이에요. 잘못된 출력을 받은 뒤 복구하는 구조라서 모델이 새로운 변형을 내놓을 때마다 파서도 그에 맞춰 바꿔야 했어요.
수백 건의 실행 기록에서 우선순위를 찾았다
모든 LLM 호출에 스키마를 적용하기 전에 일정 기간 기록한 실제 실행 원장을 분석했어요. 분석 대상은 수백 건의 기록이었어요.
| 구분 | 규모 |
|---|---|
| 전체 실행 | 수백 건 |
| 실패 | 수십 건 |
| 파싱·형태 오류 | 한 자릿수 건 |
| 쿼터 소진 등 그 외 오류 | 수십 건 |
파싱·형태 오류가 차지하는 비율은 낮았어요. 빈도만 보면 적어 보이지만 같은 유형의 오류가 거듭 발생했어요. 초여름에 JSON 추출 유틸리티를 보강한 뒤에도 몇 건의 오류가 더 발생했어요.
- 여름 중반: 제품 평가 경로
- 여름 후반: 작업 검토 경로
- 여름 후반: 경력 안내 경로
- 여름 후반: 블로그 발행 경로
정규식을 아무리 견고하게 만들어도 문제를 완전히 막을 수는 없었어요. 원인은 파서의 예외 처리가 부족한 데만 있지 않았어요. 자유 형식 텍스트를 생성한 뒤 JSON으로 복원하는 구조 자체에도 문제가 있었어요.
오류가 모든 경로에서 고르게 발생한 것도 아니었어요. 파싱·형태 오류는 대부분 작업 검토 경로에 집중됐고, 이 측정 결과를 근거로 작업 검토 경로와 의도 분류 경로를 우선 적용 대상으로 정했어요.
출력 형식을 프롬프트 밖으로 옮겼다
이번 변경의 핵심은 JSON 형식에 관한 제약을 자연어 프롬프트에서 실행 인터페이스로 옮긴 것이에요.
LLM 실행 CLI에서는 --output-schema 옵션으로 출력이 따라야 할 JSON Schema를 전달할 수 있어요. 모델에게 JSON을 “잘 작성해 달라”고 요청하는 대신, 응답을 생성하는 단계부터 허용할 객체 구조와 필드를 제한해요.
llm-cli exec \
--output-schema response-schema.json \
"주어진 작업을 평가하세요"
개념적으로는 다음과 같은 차이가 있어요.
기존
프롬프트로 JSON 요청
→ 자유 형식 텍스트 생성
→ 정규식으로 JSON 후보 추출
→ JSON 파싱
변경
JSON Schema 전달
→ 스키마에 맞는 응답 생성
→ JSON 파싱
이전에는 인사말과 Markdown 코드 블록을 제거하고 누락된 필드를 처리하는 일까지 모두 파서가 맡아야 했어요. 출력 스키마를 사용하면 이런 형식 요구사항을 프롬프트 문구가 아니라 실행 시점의 구조 제약으로 적용할 수 있어요.
프롬프트는 여전히 “무엇을 판단할 것인가”를 설명하고, JSON Schema는 “그 판단 결과를 어떤 형태로 반환할 것인가”를 맡아요. 내용에 관한 지시와 데이터 계약을 분리한 셈이에요.
모든 경로에 적용하지는 않았다
프로젝트에는 구조화된 응답 서식이 수십 개 있었어요. 모든 서식에 JSON Schema를 한꺼번에 추가할 수도 있었지만, 그렇게 하려면 각 서식에 대응하는 스키마를 새로 작성하고 계속 관리해야 했어요.
출력 필드가 바뀔 때마다 프롬프트와 소비 코드, JSON Schema를 함께 맞춰야 하므로 스키마 자체도 유지보수 대상이 돼요. 실제 오류가 드문 경로까지 적용하면 신뢰성을 높여 얻는 효과보다 관리 비용이 더 커질 수 있었어요.
그래서 이번 변경에서는 실행 기록을 근거로 적용 범위를 제한했어요.
- 오류가 집중된 작업 검토 경로
- 라우팅의 입력이 되는 의도 분류 경로
“structured output이 좋으니 모두 적용한다”가 아니라 “실패가 반복되거나 이후 동작에 직접 영향을 미치는 경로부터 적용한다”는 기준을 따랐어요.
복구 중심에서 예방 중심으로
이번 변경에서는 수십 개의 서식 전체를 수정하지 않고, 실제 실패가 집중된 두 경로만 응답 생성 단계에서 형식을 제한했어요.
가장 크게 달라진 점은 파싱 실패를 처리하는 위치예요. 기존에는 모델이 자유 형식 응답을 반환하면 애플리케이션이 정규식으로 복구했어요. 이제 적용 대상 경로에서는 LLM 실행 CLI에 JSON Schema를 전달해 형식 오류가 downstream 파서까지 도달할 가능성을 낮췄어요.
다만 이 변경만으로 파싱 실패가 완전히 사라졌다고 말할 수는 없어요. 제시된 근거에는 변경 이후의 재발률이나 장기 운영 결과가 아직 포함되지 않았어요. 현재 확인할 수 있는 결과는 다음과 같아요.
- 프롬프트에 담겨 있던 출력 형식 요구사항을 명시적인 JSON Schema 계약으로 옮겼다.
- 실패가 집중된 두 경로에만 적용해 변경 범위를 최소화했다.
- 기존의 수백 건 실행 원장을 근거로 우선순위를 정했다.
- 모든 서식에 스키마를 추가할 때 생길 유지보수 부채를 피했다.
후속 검증에서는 같은 실행 원장을 바탕으로 적용 경로에서 파싱·형태 오류가 다시 발생하는지 추적하면 돼요. 이번 변경으로 그 결과를 비교할 명확한 기준점이 생겼어요.
LLM 출력 형식이 프로그램의 다음 동작을 결정한다면 “JSON만 출력하라”는 프롬프트만으로는 부족해요. 핵심은 structured output을 전면 도입하는 데 있지 않고, 실제 실패를 측정해 오류가 집중된 경로에 데이터 계약을 적용하는 데 있어요. 그래야 신뢰성을 높이면서 변경 범위와 유지보수 비용도 통제할 수 있어요.
근거: 내부 변경 제안