Claude가 14%의 확률로 JSON 블록을 반환했습니다. 제가 더 일찍 알았더라면 좋았을 Rust 크레이트(crate)를 소개합니다.
요약
Claude와 같은 LLM을 사용할 때 JSON 형식이 완벽하게 반환되지 않는 문제를 해결하기 위해 개발된 Rust 크레이트 `llm-json-repair`를 소개합니다. 이 도구는 코드 펜스 제거, 불필요한 산문 삭제, 중첩 구조 추출 등 3단계의 로컬 패스를 통해 추가적인 API 호출 비용 없이 JSON 데이터를 정제합니다.
핵심 포인트
- Claude Sonnet 4.5/4.7 사용 시 약 14%의 확률로 JSON 파싱 오류가 발생함
- 주요 오류 원인은 코드 펜스(json wrapper), 앞뒤 산문, trailing comma, 스마트 따옴표 등임
- llm-json-repair는 LLM 재호출 없이 로컬에서 O(n) 복잡도로 데이터를 정제함
- 3단계 패스(펜스 제거, 균형 잡힌 추출 등)를 통해 구조화된 데이터를 안정적으로 확보함
저는 다음과 같이 끝나는 시스템 프롬프트(system prompt)를 사용했습니다: "JSON 객체로만 응답하세요. 코드 펜스(code fences)를 포함하지 마세요. 설명을 포함하지 마세요." 이 정도면 충분할 것 같았습니다. 하지만 그렇지 않았습니다. 일주일 동안 구조화된 출력(structured-output) 호출(대부분 Claude Sonnet 4.5 및 4.7을 사용한 12,400건)을 진행하며, 첫 번째 serde_json::from_str 시도에서 응답이 실제로 JSON으로 파싱(parseable) 가능한 빈도를 기록했습니다. 결과는 86.0%였습니다. 나머지 14%는 다음과 같은 문제 중 적어도 하나를 가지고 있었습니다:
json ...래퍼(wrappers) (가장 흔한 경우로, 전체의 약 9.3% 차지)- 앞뒤에 붙는 산문(prose) ("요청하신 JSON입니다:")
- 마지막 배열 또는 객체 요소의 trailing comma (마지막 쉼표)
- 학습 데이터에서 유입된 스마트 따옴표(Smart quotes)
그래서 저는 llm-json-repair를 작성했습니다. 세 번의 패스(pass)가 순서대로 적용되며, 각 패스는 비용이 저렴하고 LLM을 다시 호출하지 않습니다. 만약 다운스트림(downstream) 모델의 수정이 필요하다면, 그것은 사용자가 직접 연결(glue on)해야 할 문제입니다. 이 크레이트(crate)는 또 다른 API 호출을 소비하기 전에 수행하는 로컬 정리 작업입니다. 세 번의 패스는 다음과 같이 작동합니다.
llm_json_repair::repair;
let raw = r#"``` json { "intent": "book_flight", "slots": { "origin": "DAL", "destination": "JFK", }, } ```"#;
let cleaned = repair(raw)?;
let parsed: serde_json::Value = serde_json::from_str(&cleaned)?;
repair가 실제로 수행하는 작업은 순서대로 다음과 같습니다:
Pass 1: 펜스 제거 (strip fences)
첫 번째 와 마지막 를 찾습니다. 둘 다 존재하면 그 사이의 내용을 가져옵니다. 선택적인 json 언어 태그는 시작 펜스와 함께 제거됩니다. 닫는 펜스 뒤에 텍스트가 있으면 버려집니다. 이 패스는 개념적으로 한 줄로 끝나는 작업이지만, 저를 괴롭혔던 예외 케이스는 리터럴 하위 문자열 `를 포함한 JSON 문자열 값(네, 한 번은 고객 지원 상담 스크립트가 입력되었습니다)이었습니다. 따라서 매처(matcher)는 가장 바깥쪽 수준에서만 펜스를 제거하며, 펜스가 단독 줄에 있거나 시작 부분에 있는 경우에만 작동합니다.
Pass 2: 균형 잡힌 추출 (balanced extraction)
문자를 하나씩 확인합니다. 첫 번째 { 또는 [를 찾습니다. 중첩(nesting) 횟수를 계산합니다. 중첩이 0이 되면 멈춥니다. 해당 하위 문자열을 반환합니다. 이 방식은 모델을 사용할 필요 없이 앞부분의 산문 ("Here is the JSON:")과 뒷부분의 산문 ("Let me know if you need anything else.")을 제거합니다.
또한 모델이 연속으로 두 개의 JSON 객체를 출력하는 경우(드물지만 실제로 발생함)도 처리합니다. 이 경우 첫 번째로 완성된 객체를 가져옵니다. rust let raw = "Sure thing! Here is the JSON:\n{\"intent\": \"refund\", \"reason\": \"late\"}\nLet me know if you have questions."; let cleaned = repair(raw)?; assert_eq!(cleaned, r#"{\"intent\": \"refund\", \"reason\": \"late\"}"#); 이 과정의 비용은 응답에 대해 O(n)입니다. 4 KB 응답의 경우 제 노트북에서 약 30마이크로초(microseconds) 내에 실행됩니다.
3단계: trailing comma(후행 쉼표) 수정
후보 JSON을 순회합니다. 공백 뒤에 바로 } 또는 ]가 오는 각 ,에 대해 쉼표를 삭제합니다. 문자열 리터럴(string literal) 내부에 있는 경우는 건너뜁니다(이스케이프 처리를 포함하여 따옴표 상태를 추적합니다). 이는 Claude가 긴 객체를 생성할 때 제가 목격하는 가장 흔한 구문 오류(syntactic failure)입니다. 마지막 필드 뒤에 trailing comma를 붙이는 것입니다. 작은 문제처럼 보이지만 serde_json은 이를 거부합니다. 이를 로컬에서 수정하면 추가적인 API 호출(round trip)을 아낄 수 있습니다.
제 로그에서 발견된 실제 오류 사례
Raw response: json json { "summary": "User wants to cancel order #4419", "actions": [ {"name": "cancel_order", "args": {"id": 4419}}, {"name": "notify_user", "args": {"channel": "email",}}, ], } json
세 가지 문제: 펜스(fences), 두 개의 trailing comma, 그 외에는 유효한 JSON.
수정 후: json { "summary": "User wants to cancel order #4419", "actions": [ {"name": "cancel_order", "args": {"id": 4419}}, {"name": "notify_user", "args": {"channel": "email"}} ] }
깔끔하게 파싱됩니다. 두 번째 LLM 호출이 필요 없습니다.
포기해야 할 때 (When to bail out)
세 단계가 모두 실행되었음에도 결과가 여전히 파싱되지 않으면, repair는 에러를 반환합니다. 이를 조용히 삼켜서는(swallow) 안 됩니다. 이 크레이트(crate)는 Result<String, RepairError>를 반환하고 에러 변체(error variant)를 통해 실패한 단계를 알려주는 try_repair를 제공합니다.
rust match try_repair(raw) { Ok(s) => use_json(s), Err(RepairError::Pass3(_)) => { // 대괄호 구조는 괜찮지만 내용이 잘못됨 // 에러를 첨부하여 모델 측에 한 번 더 재시도(retry)해 볼 가치가 있음 } Err(e) => log::warn!("could not repair: {e}"), }
이것이 해결하지 못하는 것
몇 가지 솔직한 한계점들입니다.
이것은 LLM을 호출하지 않습니다. 만약 모델이 환각 (hallucination)된 필드 이름을 출력했다면, 이 크레이트 (crate)는 이를 수정할 수 없습니다. 검증 및 재시도 (validate-and-retry) 루프가 필요하다면 agentcast-rs를 사용하세요. 스키마 (schema)를 강제하지 않습니다. 오직 파싱 가능성 (parseability)만을 복구합니다. 여전히 그 위에 serde derive나 jsonschema를 사용해야 합니다. 모든 JSON5 확장을 처리하지는 않습니다. 작은따옴표 문자열 (single-quoted strings), 따옴표가 없는 키 (unquoted keys), 그리고 주석 (comments)은 지원되지 않습니다. Claude가 이를 출력하는 것을 본 적이 없기에, 해당 패스 (passes)들을 추가하지 않았습니다. NDJSON (newline-delimited JSON)은 처리하지 않습니다. 하나의 블롭 (blob)이 들어가면, 하나의 블롭이 나옵니다. 크레이트 전체는 async가 없는 약 350줄의 안전한 Rust (safe Rust)로 구성되어 있습니다. 동기 (sync) 함수로 작동하거나 모든 런타임 (runtime) 내부에서 작동합니다.
Repo: https://github.com/MukundaKatta/llm-json-repair
crates.io: llm-json-repair = "0.1"
파싱 (parsing), 비용 (cost), 재시도 (retry), 예산 (budget), 복구 (repair)와 같은 화려하지 않은 LLM 배관 작업 (plumbing)을 위해 제가 발행하는 작은 Rust 크레이트 세트의 일부입니다.
형제 크레이트 (Sibling crates): claude-cost, llm-retry, agentcast-rs.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기