본문
LLM structured output은 JSON 문자열을 받는 순간 끝나지 않는다. json.loads를 통과해도 schema validation에서 깨질 수 있고, 복구 가능한 malformed JSON과 그냥 자연어 텍스트를 같은 실패로 취급하면 런타임은 빈 결과나 예외로 무너진다.
핵심은 실패 처리 순서를 먼저 고정하는 것이다.
- parse: 표준 JSON 파서를 먼저 시도한다. 실패하면 복구 후보로 넘긴다.
- repair: 명백히 malformed인 입력만 best-effort로 고친다. 실패하면 원본을 유지하거나 None으로 둔다.
- validate: 같은 schema model로 의미를 검증한다. 실패하면 retry를 한 번만 허용한다.
- fallback: 입력에서만 만든 안정적인 값으로 대체한다. 빈 객체 대신 유효한 구조를 반환한다.
JSON repair는 성공 보장이 아니다
JSON repair는 correctness guarantee가 아니다. trailing comma, 따옴표 누락, code fence 잔여물처럼 사람이 보기엔 고칠 수 있어 보이는 입력을 살려줄 수는 있지만, 그 결과가 실제 스키마에 맞는지는 별개의 문제다.
그래서 파서와 검증기를 분리해야 한다.
패턴 예시: parse -> repair -> validate
이 코드는 복사해서 붙여넣는 구현 예시가 아니다.
1. 표준 JSON 파서를 먼저 시도한다.
2. 실패하면, 명백한 malformed JSON에 한해서만 best-effort repair를 시도한다.
3. repair된 값도 같은 schema로 다시 검증한다.
4. 검증이 실패하면 fallback으로 넘긴다.
이 구조에서 중요한 점은 repair가 끝이 아니라는 것이다. 복구된 JSON도 다시 schema validation을 통과해야 한다. 검증을 생략하면 “파싱은 됐지만 의미가 틀린 결과”가 그대로 runtime에 흘러간다.
정규화와 복구도 구분해야 한다. valid JSON일 때만 문자열 이스케이프를 사람이 읽기 쉬운 형태로 정리하고, malformed JSON까지 손대지는 않는다. 복구 불가능한 입력은 원본이든 None이든 downstream에서 여전히 실패로 보이게 남겨야 한다.
retry는 parse 실패 뒤가 아니라 parse/validation 실패 뒤에 둔다
LLM을 한 번 더 부른다고 모든 JSON 문제가 해결되지는 않는다. 그래서 retry는 “파싱이 깨졌을 때 무조건 재시도”가 아니라, “파싱 또는 검증이 실패했을 때 한 번만 더 시도” 정도가 적당하다.
패턴 예시: 한 번만 다시 시도
이 코드는 복사 가능한 함수가 아니라 흐름을 보여주는 패턴이다.
1. LLM 호출
2. parse/validate 시도
3. 실패하면 한 번만 재시도
4. 여전히 실패하면 fallback 반환
이 패턴의 장점은 경계가 분명하다는 점이다.
- valid JSON + valid schema: 그대로 반환
- valid JSON + invalid schema: retry 대상
- invalid JSON but repairable: repair 후 다시 validation
- unrecoverable text: retry 후에도 실패하면 fallback
retry를 남발하면 런타임은 겉으로만 resilient해 보이고, 실제로는 실패를 늦게 발견할 뿐이다. 반대로 retry를 validation 뒤에 두면 “정말로 쓸 수 없는 결과”만 fallback으로 넘긴다.
deterministic fallback은 같은 입력 + 고정된 clock에서만 같다
fallback은 모델이 다시 생각한 결과가 아니다. 입력에서 뽑아낸 안정적인 정보로만 만들면 동일 입력에 대해 동일 출력이 나온다. 다만 날짜 라벨처럼 현재 시각에 의존하는 값이 있으면 runtime마다 달라질 수 있으니, deterministic이라고 부르려면 같은 입력에 더해 frozen clock이나 injected clock이 필요하다.
안정적으로 쓸 수 있는 재료는 이런 것이다.
- 고정값 또는 입력 메타데이터에서 온 제목
- frozen/injected clock으로 계산한 날짜 라벨
- 앞뒤 세그먼트로 만든 범위
- 입력에서 추출한 짧은 메모
- 실패를 숨기지 않는 일반 안내 문구
fallback도 마지막에는 같은 schema model로 검증해야 한다. fallback이 대충 만든 dict로 끝나면, 다음 단계에서 다시 같은 예외를 만난다.
패턴 예시: fallback은 입력과 clock에서만 만든다
이 코드는 구현 예시가 아니라 fallback 조립 규칙을 보여준다.
- headline: 고정값 또는 입력 기반 기본값
- window_label: 입력 메타데이터 또는 frozen clock에서 계산
- sections: 입력의 앞/뒤 세그먼트에서 추출
- notes: 짧은 발췌 텍스트
- actions: generic default message
여기서 중요한 건 fallback이 “예쁘게” 보이는 게 아니라 “항상 같은 규칙으로” 나온다는 점이다. LLM이 한 번 더 흔들려도 fallback은 흔들리면 안 된다.
회귀 테스트는 실패 경로를 가장 단순하게 재현한다
이 패턴은 테스트로 고정해야 한다. 가장 좋은 회귀 테스트는 실패를 가장 단순하게 재현하는 것이다.
LLM 응답을 not json으로 고정하고, fallback이 빈 객체가 아니라 유효한 구조를 반환하는지 확인한다.
패턴 예시: 확인해야 할 것
이 테스트는 내부 구현이 아니라 실패 경로를 고정하는 체크리스트다.
- invalid JSON이 빈 객체로 바뀌지 않는가
- repair 실패가 silent swallow로 끝나지 않는가
- retry 후에도 실패하면 fallback으로 가는가
- fallback 결과가 다시 schema validation을 통과하는가
structured output의 신뢰성은 “LLM이 항상 JSON을 준다”는 가정이 아니라, 깨졌을 때 무엇을 허용하고 무엇을 막을지 테스트로 고정하는 데서 나온다.
'머신러닝 & 딥러닝 > Agent' 카테고리의 다른 글
| Agent Safety boundary를 독립 runtime으로 분리하는 기술 패턴 (0) | 2026.05.31 |
|---|---|
| Python async gRPC 서비스와 protobuf contract 운영 (0) | 2026.05.31 |
| 메시지 envelope로 요청 메타데이터를 안정화하는 법 (0) | 2026.05.30 |
| 명시적 context payload로 LLM 출력 포맷 제어하기 (0) | 2026.05.30 |
| Python AI 에이전트 패키지 아키텍처 (0) | 2026.05.30 |