무엇이 돌아오는가
모델에게 JSON을 요청하면 종종 '거의 JSON' 형태를 받게 됩니다. 즉, 마크다운 구분자로 감싸진 값, 후행 쉼표(trailing comma), 또는 null이 와야 할 자리에 파이썬 None 같은 형태입니다. 사람들은 본능적으로 프롬프트를 탓하거나 "JSON 모드"를 활성화하는 방법을 찾습니다. 하지만 이 둘 모두 메커니즘을 놓치고 있습니다. 언어 모델(Language Model)은 다음 토큰(token)을 예측할 뿐, 문서를 구축하는 것이 아니며, 문서가 깨졌다는 사실 자체도 알 수 없습니다. 왜냐하면 그 어떤 것도 그것이 '문서'임을 나타내지 않기 때문입니다. 따라서 유용한 질문은 "어떻게 멈추게 할까?"가 아니라 "업스트림(upstream)에 파서 자체가 없을 때, 파서는 어디로 이동하는가?"여야 합니다.
무엇이 돌아오는가
아래의 열두 가지 원본 문자열들은 실제로 문제를 일으키는 형태들입니다. 그중 여덟 개는 jsonrepair 라이브러리가 이름으로 문서화한 복구(repair) 사례입니다. 저는 이 열두 가지를 네 개의 파서로 실행하여 수락 또는 거부 기록을 남겼습니다. 이들은 실시간 호출에서 캡처된 것이 아니라 리터럴(literal) 형태로 지정되었습니다. 즉, 이 글이 작성된 기계에는 API 키가 없으므로, 제시된 형태는 문서화된 것들이며 여기서 '측정'되는 것은 파서들이 이러한 형태에 어떻게 반응하는지입니다.
| 원본 | 노드 (Node) | 파이썬 (Python) | 루비 (Ruby) | PHP |
|---|
| 마크다운 구분자 | ERR | ERR | ERR | ERR |
| ... | | | | |
네 개의 파서, 열두 개의 문서가 있었고, 이들은 거의 모든 면에서 의견을 같이했습니다. 열두 개 중 아홉 개는 표의 모든 파서에 의해 거부되었는데, 이것이 안심되는 부분입니다. 구분자(fence), 후행 쉼표, 잘못된 따옴표 또는 잘림 현상과 같은 경우, 여러분이 사용하게 될 주류 파서들은 이 텍스트를 JSON으로 수락하지 못할 것입니다. 세 개의 행만이 만장일치에서 벗어났는데, 이는 보이는 것보다 더 중요한 두 가지 방식으로 그렇습니다.
루비는 블록 주석을 허용한다
여기서 측정된 빌드인 Ruby 2.6.10은 번들링된 json 2.1.0을 사용하여 {"a":1 /* note */}를 파싱하고 {"a"=>1}을 반환합니다. 표의 다른 모든 파서는 이를 거부합니다. 따라서 Node나 PHP 서비스에서 발생하는 하드 400 에러와는 달리, 루비에서는 깨끗한 200 응답이 나옵니다. 이것은 모호하며, 그것이 요점입니다. 엄격한 파싱(strict parse)은 같은 파이프라인에 있는 두 서비스 간의 공유된 계약이 될 수 없습니다. 그들이 우연히 동일한 파서를 실행하지 않는 한 말입니다.
Python은 NaN과 Infinity를 허용합니다
JSON의 숫자 문법에는 NaN이나 Infinity가 없습니다. RFC 8259는 모든 숫자를 선택적 마이너스 부호, 자릿수, 선택적 소수점 및 선택적 지수로 구성하도록 구축하며, 해당 토큰들은 JSON에 포함되지 않습니다. Node, Ruby, PHP는 {"x": NaN}을 거부합니다. 하지만 Python의 json.loads는 {'x': nan}을 반환합니다. 이 값을 다시 json.dumps로 전달하면 기본 설정으로는 {"x": NaN}이 나오는데, 이는 유효하지 않은 JSON 문서이며 다른 세 가지 파서들이 즉시 거부하는 형태입니다. 오직 allow_nan=False를 통해서만 조용한 왕복(round trip)이 예외(exception)로 바뀝니다. 이것은 숫자 정밀도에 대한 동료 기사에서 작성 측면(write side)에서 발견된 것과 같은 차이점이며, 여기서는 모델이 방금 생성한 데이터의 읽기 측면(read side)에서 발생합니다.
구조적으로 상위 단계에는 보장이 없습니다
모델들이 이를 잘못하는 이유는 JSON에 능숙하지 않아서가 아닙니다. 그 메커니즘 자체가 그러한 약속을 할 수 없기 때문입니다. 샘플링은 확률 분포로부터 토큰 하나씩을 방출합니다. 루프 내의 어떤 것도
오류 코드: 400 - {'error': {'message': "'messages' must contain the word 'json' in some form, to use 'response_format' of type 'json_object'."}}
이 오류는 이 기능 전체의 솔직한 모습을 보여줍니다. 지침(instruction)이 누락되면 문서에는 (OpenAI를 인용하여) 모델이 "끝없는 공백 스트림을 생성할 수 있으며, 요청은 토큰 제한에 도달할 때까지 계속 실행될 수 있다"고 경고합니다. 이는 사용자 입장에서는 단순히 멈춘 것처럼 보이는 실패입니다.
보장이 커버하지 못하는 구멍
토큰 단위의 유효성은 문서 전체의 유효성이 아니며, 이 둘이 어긋나는 지점이 바로 잘림(truncation)입니다. 응답이 max_tokens보다 길면, 스트림은 구조 중간에서 단순히 멈추고 finish_reason은 length로 반환됩니다. Azure의 가이드라인은 두 경우 모두 명확합니다: "응답을 파싱하기 전에 finish_reason 값이 length인지 확인해야 합니다. 모델이 부분적인 JSON을 생성할 수 있습니다", 그리고 문제 해결 시에는 "부분적인 JSON을 파싱하지 마십시오."라고 합니다.
이것은 보장이 도달할 수 없는 경우이며, 복구 계층(repair layer)이 가장 쉽게 덮어버리려는 경우입니다. 표에서 잘린 객체(truncated object)를 가져와서 복구 패스(repair pass)가 중괄호(brackets)를 닫게 해보겠습니다:
model (finish_reason: "length")
{"a":1,"b":[1,2
...
결과는 깨끗하게 파싱되지만, 그것은 거짓입니다. 텍스트 어디에도 리스트가 두 개의 항목에서 끝날지 아니면 오십 개에서 끝날지 말하는 내용은 없습니다. 복구는 두 개를 선택했습니다. 복구는 구문(syntax)을 복원할 뿐, 데이터를 결코 복원하지 못합니다.
측정된 복구 계층
상위(upstream)에 파서가 존재하지 않기 때문에, 하위(downstream)에 하나가 존재해야 하며, 실제로는 JSON.parse 이전에 실행되는 복구 과정입니다. 저는 그 과정의 가장 작고 정직한 버전을 작성하여 동일한 12개 문서에 적용해 보았습니다. 첫 단계로 단순한 정규 표현식(regex) 치환을 사용했는데—펜스와 바이트 순서 표시(byte-order mark)를 제거하고, 주석과 후행 쉼표(trailing commas)를 삭제하며, 싱글 쿼트를 교체하고, Python 및 JavaScript의 비값(non-values)을 매핑하며, 불균형한 중괄호(brackets)를 닫는—이 과정만으로 12개 중 9개를 복구했습니다. 놓친 세 가지 케이스는 단순히 첫 번째 패스가 포함하지 않은 규칙이었으며, 그중 두 가지는 정규 표현식만으로는 안전하게 처리할 수 없는 경우입니다: 개행 문자(newline)는 오직 문자열 안에 있을 때만 이스케이프되어야 하며, 후행 산문(trailing prose)은 값이 끝난 후에만 잘려나가야 합니다. 경계 추적(boundary-tracking) 패스와 키 따옴표 규칙을 추가하자 12개 중 12개를 처리할 수 있게 되었습니다:
| 원본 패턴 | 복구 후 | 복구가 수행한 작업 |
|---|
| markdown fence | parses | 펜스를 제거함 |
| ... | | |
모두 이후에 파싱됩니다. 잘린 것은 거짓(falsified)으로 처리되어 파싱됩니다. 그리고 그 행들 중 일부는 복원이라기보다는 결정입니다: 주석 행은 모델이 작성하는 수고를 들인 내용을 조용히 삭제했고, NaN 행은 더 심각합니다—대체된 null은 실제 null과 구별할 수 없기 때문에, 복구 과정은 | | |
이 모든 것은 제가 임의로 꾸며낸 것이 아닙니다. jsonrepair 라이브러리는 자신이 무엇을 수정하는지 목록을 공개하며, 위에 제시된 12가지 형태 중 8가지가 이름으로 여기에 포함되어 있습니다: 키 주변에 누락된 따옴표 추가, 누락된 이스케이프 문자 추가, 잘린 JSON 복구, 단일 따옴표를 이중 따옴표로 대체, Python 상수 대체, 후행 쉼표 제거, 주석 및 감싸진 코드 블록 제거. 이것이 다루지 못하는 세 가지 형태가 가장 중요한데, 바로 NaN, Infinity, 그리고 값 뒤에 붙는 산문입니다. 이들은 정확히 복구 과정을 거치면서 조용히 살아남는 경우들입니다: NaN은 Python에서는 파싱되지만 다른 곳에서는 그렇지 않기 때문에, 파이프라인은 이를 복구 단계로 전달하다가 어느 서비스가 동의하지 않는지에서 손실할 수 있습니다. 전용 복구 라이브러리와 모델의 실패 모드가 이토록 많이 겹친다는 것은 문제가 우발적인 것이 아니라 구조적인 문제임을 보여주는 가장 명확한 증거입니다.
대신 해야 할 일
- 가능하다면 보장을 상류(upstream)로 이동하세요. 프롬프트에 정중하게 요청하는 것보다 스키마 제약 모드를 선호하십시오: 문서화된 차이점은 JSON 모드가 구문을 약속하는 반면, 구조적 출력은 모델을 특정 스키마로 제한한다는 것입니다. 여기서는 어느 쪽도 사용되지 않았으므로(API 키 없음), 정확한 의미론을 측정하기보다는 문서화된 대로 취급하십시오.
- 응답을 신뢰할 수 없는 텍스트로 간주하세요. 파이프라인은 '복구 → 구문 분석 → 스키마에 대한 유효성 검사' 순서로 진행되며, 마지막 단계는 선택 사항이 아닙니다. 왜냐하면 문법적으로 완벽하지만 필드가 잘못된 문서도 앞의 두 단계를 통과하기 때문입니다.
- 파싱하기 전에
finish_reason을 확인하고, 잘린 응답을 수용 가능한 것으로 복구하지 마십시오. 대신 더 큰 예산으로 오류를 발생시키고 재시도하십시오. 복구는 잘린 문서를 완료된 것처럼 보이게 만듭니다. 이것이 끝났는지 여부는 오직 당신만이 결정할 수 있습니다.
- **
업스트림 파서가 당신의 것과 일치한다고 신뢰하기보다는 경계(boundary)에서 검증하세요.
5. 코드로 도달하기 전에 출력을 확인하세요. 복구 계층(repair layer)은 당신이 가장 보고 싶어 하는 경우들을 정확히 숨깁니다.
자체 페이로드 확인하기
모델의 응답을 validator에 붙여넣어 눈에 보이지 않게 잘못된 복구 결과 대신 첫 번째 실제 오류의 줄과 열을 얻고, formatter에 붙여넣어 파싱될 때 구조를 확인하세요. 유효한 JSON이지만 형태가 잘못된 출력의 경우, JSON Schema validator가 빠진 테스트입니다: '파싱 가능(parses)'과 '정확함(correct)'은 다른 질문이며, JSON 모드는 첫 번째 질문에만 답합니다. 구문 치트 시트는 NaN과 Infinity가 포함되지 않는 이유를 포함하여 문법이 실제로 허용하는 것을 다룹니다.
출처(Provenance)
파서 테이블과 복구(repair) 테이블은 Node 24.18.0을 V8 15.0.245.15, CPython 3.10.11, Ruby 2.6.10 (번들된 json 2.1.0), PHP 8.3.35에서 구동하여 생성되었습니다. 이 Node 런타임은 일반적인 Node 배포판이 아니라 Electron 호스트에 내장된 것이며 — 그 V8 빌드 문자열에는 -electron 접미사가 붙습니다 — 그리고 어떤 경우든 사용된 파서는 V8 자체의 JSON.parse입니다. 복구 패스는 라이브러리에서 가져온 것이 아니라 이 기사를 위해 작성 및 실행되었으며, Python의 NaN 왕복(round trip)은 기본 json.dumps와 allow_nan=False를 사용하여 측정되었습니다. 12개의 소스 문자열은 실제 호출에서 캡처된 것이 아니라 문서화된 실패 형태입니다: 이 글을 작성한 컴퓨터에는 API 키가 없으므로, 여기에 있는 어떤 것도 모델에 의해 생성되지 않았습니다. JSON 모드(JSON mode), 구조적 출력(structured outputs), finish_reason에 대한 주장은 Microsoft의 Azure OpenAI 문서를 인용한 것이며, 이는 OpenAI의 문서를 반영합니다. 그리고 정확히 그 이유 때문에 측정된 것이 아니라 문서화된 것으로 표시되었습니다; jsonrepair 목록은 해당 라이브러리의 리포지토리 README에서 인용된 것으로, 모델을 출처로 명시하지 않고 복구 사항들을 열거하고 있습니다.
관련 자료