LLM의 JSON 응답이 잘렸을 때, 단순히 max_tokens를 높이지 마세요
요약
LLM의 JSON 응답이 토큰 제한으로 인해 잘렸을 때, 단순히 max_tokens를 높여 재시도하는 대신 이미 생성된 유효한 데이터를 복구하는 방법을 제안합니다. json.JSONDecoder().raw_decode를 사용하여 미완성된 객체 앞의 완전한 레코드들을 구조(salvage)하는 것이 비용 효율적입니다.
핵심 포인트
- JSONDecodeError 발생 시 전체 데이터를 버리는 것은 비용 낭비임
- API의 stop_reason 또는 finish_reason을 통해 잘림 여부를 확인 가능
- raw_decode를 사용하여 잘린 부분 앞의 유효한 객체들을 추출할 수 있음
- 복구된 마지막 데이터 이후의 부분만 다시 요청하여 비용을 절감함
당신의 에이전트가 모델에게 레코드들의 JSON 배열을 요청했습니다. 모델은 작성을 시작했지만, 토큰 제한(token cap)에 걸려 객체(object) 중간에 멈춰버렸습니다. 당신의 코드는 전체 응답에 대해 json.loads를 호출했고, JSONDecodeError를 포착한 뒤 빈 리스트를 반환했습니다. 모델이 이미 작성을 완료했고 당신이 이미 비용을 지불한 모든 레코드가, 단 하나의 미완성 레코드 때문에 사라져 버렸습니다.
반사적으로 max_tokens를 높이고 호출을 다시 실행하고 싶을 것입니다. 그것은 비용이 많이 드는 잘못된 선택이며, 파서를 열기도 전에 API는 이미 당신에게 그 사실을 알려주었습니다.
다음은 제한에 걸려 잘려 나간 응답의 형태입니다. 이는 절벽(cliff)을 보여주기 위해 직접 만든 합성 데이터입니다:
[{"id":1,"name":"Acme Corp","city":"Reno"},
{"id":2,"name":"Globex","city":"Ogdenville"},
{"id":3,"name":"Initech","city":"Austin"},
...
세 개의 완전한 레코드가 있습니다. 그 다음 {"id":4,"na가 나오고, 아무것도 없습니다. 토큰 제한이 떨어진 지점이 바로 여기입니다. 네 번째 객체는 값이 없는 절반의 키(key) 상태이며, 닫는 ]는 영원히 오지 않았습니다. 이 문자열에 json.loads를 실행하면 세 개의 레코드와 경고를 받는 것이 아닙니다. 예외(exception)가 발생하며, 대부분의 코드는 예외에 대해 return []로 응답합니다. 네 번째 레코드가 그루터기(stump)처럼 남아있기 때문에, 이미 완료된 세 개의 레코드도 모두 사라지게 됩니다.
요약 (TL;DR)
- 잘린(truncated) JSON 응답은 그냥 삼켜버릴 파싱 실패(parse failure)가 아닙니다. 잘린 부분 앞의 완료된 레코드들은 유효하고 완전하며 이미 비용이 청구되었습니다.
- 가장 비용이 많이 드는 코드 한 줄은
except json.JSONDecodeError: return []입니다. 단 하나의 미완성된 꼬리 객체(tail object) 때문에 그 앞의 모든 온전한 레코드를 버리게 됩니다. - 해결책은 "
max_tokens를 높이고 재시도하기"가 아닙니다. 그것은 전체 배치를 다시 생성하는 비용을 지불하는 것이며, 단지 절벽을 더 큰 페이로드(payload) 쪽으로 옮길 뿐입니다. API는 이미 잘렸음을 신호로 보냅니다 (Anthropic의stop_reason="max_tokens", OpenAI의finish_reason="length"). - 표준 라이브러리인
json.JSONDecoder().raw_decode를 사용하여 완전한 객체들을 구조(salvage)하세요. 그런 다음 마지막으로 유효했던 id 이후의 꼬리 부분만 다시 요청하세요. 아래의 합성 응답에서 단순한 읽기 방식은 0개를 복구하지만, 구조(salvage) 방식은 3개를 복구하고 재개 커서(resume cursor)를 전달합니다.
당신이 무시하고 있는 신호
모델이 공간(room)이 부족하여 멈췄을 때, API는 이를 숨기지 않습니다. 응답 안에 바로 명시되어 있으며, 처음부터 계속 그곳에 있었습니다.
Anthropic의 Messages API는 stop_reason을 설정합니다. 이 글을 쓰기 전, 중단 이유(stop reasons)를 처리하는 방식에 대해 그들의 최신 문서를 확인했습니다. max_tokens 값에 대한 설명은 정확합니다: "응답이 귀하의 max_tokens 제한에 도달했습니다." 그리고 그들의 표현을 빌린 권장 조치는 "max_tokens를 높이거나 응답을 계속하십시오(Raise max_tokens or continue the response)." 두 번째 절을 다시 읽어보세요. 공식 문서에서는 "응답을 계속하라"는 문구를 "제한을 높이라"는 문구 바로 옆에, 각주가 아닌 대등한 옵션으로 배치하고 있습니다. 플랫폼 자체가 숫자를 높이는 것은 하나의 옵션일 뿐, 유일한 옵션이 아니라고 말하고 있는 것입니다.
OpenAI의 Chat Completions는 다른 필드를 통해 동일한 사실을 드러냅니다. 각 선택지(choice)는 finish_reason을 포함하며, 토큰 제한으로 인해 출력이 잘렸을 때 받는 값은 length입니다. 동일한 신호, 동일한 의미입니다: 모델이 스스로 끝났다고 결정한 것이 아니라, 예산(budget)이 대신 결정한 것입니다.
따라서 max_tokens를 건드리기 전에, 당신은 이미 두 가지 사실을 공짜로 알게 됩니다. API가 이를 표시했기 때문에 응답이 잘렸다는(truncated) 사실을 알게 됩니다. 그리고 부분적인 텍스트를 손에 쥐고 있으므로 대략 어디서 잘렸는지도 알게 됩니다. 이는 처음부터 다시 시작하는 것보다 훨씬 저렴한 조치를 취하기에 충분한 정보입니다.
왜 "그냥 제한을 높이는 것"이 두 번 패배하는가
저 역시 처음에는 제한 조절 노브(limit knob)에 손을 뻗었습니다. 그것은 가장 명백한 레버입니다. 출력이 잘렸으니, 더 많은 공간을 주면 된다는 생각이죠. 저는 max_tokens를 높이고 호출을 다시 실행했으며, 약 하루 동안은 스스로가 영리하다고 느꼈습니다.
하지만 배치(batch) 규모가 커지자, 더 아래쪽에서 다시 잘려 나갔습니다. 당연한 결과였습니다. 제한(cap)은 결코 질병이 아니었습니다. 그것은 하나의 호출이 하나의 예산에 담길 수 있는 것보다 더 많은 레코드(records)를 생성하도록 요청했을 때 나타나는 증상이었습니다. 고정된 크기의 요청에 대해 천장을 높이는 것은, 다음번 약간 더 큰 요청이 새로운 천장에 부딪히게 될 뿐임을 의미합니다. 페이로드(payload)가 계속 커질 수 있는 한, 당신은 결코 잡을 수 없는 선을 쫓고 있는 것입니다.
그리고 그 추격에는 두 배의 비용이 듭니다. 전체 호출을 다시 실행할 때, 당신은 이미 한 번 수신하고 파싱했던 출력 토큰(output tokens)에 대해 다시 비용을 지불하게 됩니다. 예시의 완료된 세 개의 레코드(records)는 이미 생성되었고, 이미 비용이 청구되었습니다. 그것들을 버리고 전체 배열(array)을 두 번째로 요청하는 것은, 마침내 네 번째 레코드에 도달하기 위해 첫 번째부터 세 번째 레코드를 다시 구매하는 것을 의미합니다. 실제 추출 배치(extraction batch) 작업에서, 당신이 계속해서 재구매하게 되는 부분이 바로 가장 비용이 많이 드는 부분입니다.
이 지점에서 비용 측면과 신뢰성 측면은 동일한 측면이 됩니다. 신뢰할 수 있는 조치와 저렴한 조치는 같은 방향을 가리킵니다. 즉, 온전한 것은 유지하고, 누락된 것만 요청하는 것입니다.
직접 실행해 볼 수 있는 수치적 계산
여기 작은 스크립트가 있습니다. 표준 라이브러리에서 json 하나만 임포트(import)합니다. 네트워크, 무작위성(randomness), 시계(clock)를 사용하지 않으므로, 실행할 때마다 결과가 동일합니다. 이 스크립트는 이 포스트 상단에 있는 잘린 응답을 가져와 두 가지 방식으로 읽습니다. 테스트 데이터(fixture)는 특정 작업에서 캡처한 것이 아니라, 메커니즘을 격리하기 위해 수동으로 제작된 합성 데이터(synthetic)입니다. 왜 그 라벨(label)이 중요한지는 나중에 다시 다루겠습니다.
"""잘린 LLM JSON 출력에서 완료된 레코드를 구조(salvage)합니다.
결정론적(Deterministic), 표준 라이브러리 전용(json), 네트워크 / RNG / 시계 / 서브프로세스 / 환경 변수 미사용.
...
python3 -I salvage_truncated_json.py로 실행하면 다음과 같은 결과를 얻습니다:
INPUT: 1 LLM 응답, 토큰 제한에서 잘림 (4번째 객체 미완성)
NAIVE json.loads(whole) -> 복구된 레코드 0개
FIX salvage(raw_decode)-> 복구된 레코드 3개, truncated=True
...
입력된 문자열은 동일합니다. 한 방식으로는 레코드가 0개이지만, 다른 방식으로는 3개가 나옵니다. 유일하게 바뀐 점은 리더(reader)가 첫 번째 예외(exception)에서 포기하느냐, 아니면 이미 디코딩(decoded)한 객체들을 유지하느냐의 차이뿐입니다.
salvage가 실제로 읽는 방법
단순한 함수는 거의 모든 사람이 사용하는 방식입니다. 전체 데이터 덩어리를 파싱하려고 시도하고, 오류가 발생하면 빈 리스트를 반환합니다. 이 방법은 한 가지에 대해서만 정직하고 다른 모든 것에서는 틀립니다. 즉, 응답이 유효한 JSON이 아니라는 것을 올바르게 감지하지만, 그 결과로 응답 전체를 쓸모없다고 결론 내립니다. 잘린 배열은 유효한 JSON이 아닙니다. 하지만 그 안에 있는 세 개의 레코드는 여전히 완벽하게 좋습니다.
'salvage' 함수는 json.JSONDecoder().raw_decode를 사용하여 배열을 최상위 객체 단위로 하나씩 탐색합니다. 이 메서드가 여기서 조용한 영웅입니다. 전체가 잘 구성된 문서를 소비하는 것을 고집하는 json.loads와 달리, raw_decode는 지정된 위치에서 하나의 JSON 값을 파싱하고 값과 멈춘 인덱스를 모두 반환합니다. 당신은 커서를 그 인덱스로 이동시키고, 쉼표와 공백을 건너뛴 다음, 다음 값을 디코딩합니다. raw_decode가 결국 {"id":4,"na에서 막히면 예외를 발생시키는데, 이것이 중단 신호입니다. 당신은 예외가 발생하기 전에 디코딩한 모든 것을 유지할 수 있습니다.
겉보기보다 더 중요한 세부 사항이 하나 있습니다: raw_decode는 문자열에 안전합니다(string-safe). 단순하게 중괄호 개수를 세는 방식으로는 `
저는 프로그램 자체의 출력물에 제한을 두었습니다. 왜냐하면 과장된 해결책은 그저 더 화려한 형태의 버그일 뿐이기 때문입니다. 스크립트는 세 가지 규칙을 출력하며, 저는 이 세 가지 모두를 의미합니다.
첫째, salvage는 완전한 객체(object)만을 유지합니다. 절반만 작성된 네 번째 레코드인 {"id":4,"na는 버려지며, 마땅히 그래야 합니다. 필드의 절반은 데이터가 아닙니다. 따라서 이것은 피해 통제(damage control)이지, 완벽한 치료법이 아닙니다. 완료된 데이터만 복구하고, 중단된 객체는 다시 요청하기 전까지는 사라진 것으로 간주해야 합니다. 만약 잘린 객체의 나머지 부분을 "추측"하려고 시도한다면, 즉시 멈추십시오. 그것은 데이터를 조작(invent)하는 행위입니다.
둘째, raw_decode는 잘린 지점까지의 접두사(prefix)가 그 외에는 잘 형성되어 있다고 가정합니다. 이는 첫 번째부터 세 번째 레코드가 유효한 JSON이라고 신뢰하는 것인데, 잘림(truncation) 현상이 발생했을 때는 거의 항상 그렇기 때문입니다. 즉, 모델은 예산(budget)이 소진될 때까지 깨끗한 객체들을 생성하고 있었을 것입니다. 하지만 만약 모델이 이전 객체 내부에서 구조적 실수(예: 두 번째 레코드의 따옴표 누락 또는 따옴표가 없는 키)를 저질렀다면, salvage는 해당 오류에서 멈추고 더 앞선 커서(cursor)를 보고할 것입니다. 이것이 올바른 동작이며, 동시에 경고이기도 합니다. 이것은 범용 JSON 복구 도구가 아닙니다. 잘못 형성된 객체를 수정하는 것이 아니라, 읽을 수 없는 첫 번째 객체에서 깔끔하게 멈출 뿐입니다.
셋째, 이 장치는 합성된 것이며, 진짜 해결책은 상류(upstream)에 존재합니다. 0 대 3이라는 결과는 수동으로 만든 문자열에 대한 산술적 계산일 뿐, 특정 작업에서 측정된 복구율이 아닙니다. 지속 가능한 해결책은 지루한 방식입니다. API가 이미 제공하는 stop_reason 또는 finish_reason을 읽고, 중단되었다는 신호가 오면 재개 커서(resume cursor) 이후의 뒷부분만 요청하십시오. 무분별하게 max_tokens를 높이는 것은 단지 절벽의 위치를 옮길 뿐이며, 배치(batch)를 다시 생성하는 비용을 지불하게 만듭니다. 여러분의 실제 차단 지점은 추출 페이로드(extraction payloads)가 예산을 초과하는 지점이며, 그 숫자는 오직 여러분만이 확인할 수 있습니다.
이것은 fetch-tool의 잘림 현상이 아닙니다
이전 글을 읽으신 분이라면, 이 문제가 제가 이전에 다룬 문제와 유사해 보일 수 있습니다. 즉, 에이전트가 200 OK를 신뢰하고 쓰레기 데이터나 잘린 페이지로 작동하는 경우입니다. 이것은 같은 파이프의 정반대 끝에 있는 문제입니다. 이전 글은 입력(input)에 관한 것입니다. 에이전트가 읽는 콘텐츠, 불완전하게 도착한 가져온 페이지를 다루며, 해결책은 모델이 절반 된 페이지를 온전한 것처럼 추론하지 않도록 잘린 부분을 인라인으로 표시하는 것입니다.
이번 글은 출력(output)에 관한 것입니다. 여러분 자신의 모델이 생성하는 구조화된 응답이 생성 예산(generation budget)에 의해 잘리는 경우입니다. 페이지가 잘리는 경우는 외부에서 손상되어 도착하는 것이고, 이 경우는 여러분이 생산하고 설정한 한계에 도달하여 발생하는 것입니다. 원인, 커서, 해결책이 모두 다릅니다. 이것들을 명확히 구분할 가치가 있습니다. 왜냐하면 두 상황 모두의 본능은 부분적인 결과물을 조용히 받아들이는 것이고, 바로 그 본능이 여러분을 괴롭히기 때문입니다.
이는 충돌한 크롤링 과정이 아닙니다
구분할 만한 또 다른 이웃 문제가 있습니다. 긴 스크래퍼가 12,000번째 행에서 죽는 경우에는 체크포인트(checkpoint)를 이용해 몇 시간 동안 진행된 프로세스를 재개합니다. 작업자가 쓰러져서 중단된 지점부터 작업을 다시 시작하는 것입니다. 이것은 프로세스 수준의 충돌 복구이며, 재개 키는 긴 실행 과정 속의 위치입니다.
제가 설명하는 것은 더 작고 날카롭습니다. 단일 호출에서 API 응답이 잘려 돌아온 경우입니다. resume_after_id는 죽은 프로세스의 체크포인트가 아닙니다. 이는 하나의 모델 출력물 내부에 있는 마커(marker)로, 어떤 레코드가 이미 온전한지 알려주어 다음 요청이 전체 배열 대신 나머지 부분만 요청하도록 하는 것입니다. 같은 단어
그리고 세 번째 유사 사례에 대한 논의를 마무리하자면: 이것은 행(row)이 정상적으로 파싱되었음에도 불구하고 여전히 쓰레기 데이터(junk)를 포함하고 있는 "조용히 잘못된 유효한 값(valid value that is quietly wrong)" 버그도 아닙니다. 잘림(Truncation)은 구조적인 문제입니다. 응답이 불완전한 JSON이라는 뜻입니다. 이는 잘못된 필드를 가진 완전한 객체(object)와는 다른 종류의 실패이며, 다른 도구를 필요로 합니다. 여기서 당신은 구조(structure)를 복구하는 것이고, 저기서는 의미(meaning)를 검증하는 것입니다.
내가 실제로 할 방법
이 모든 과정은 세 가지 단계로 요약되며, 그중 중간 단계만이 코드로 구현됩니다.
-
신호를 읽으세요. 모든 구조화된 응답(structured response)을 파싱하기 전에
stop_reason또는finish_reason을 확인하세요. 만약 API가 잘렸다고(truncated) 말한다면, 그 말을 믿고 즉시 복구 단계로 넘어가세요. -
전체 레코드(records)를 구출하세요.
raw_decode를 사용하여 모든 완전한 최상위 객체(top-level object)를 유지하고, 잘린 지점에서 깔끔하게 멈추세요. 단 하나의 미완성된 꼬리 객체(tail object) 때문에return []가 되도록 방치하지 마세요. 절벽 앞의 레코드들은 이미 완료되었고 비용을 지불한 것들입니다. -
꼬리 부분을 재개하세요, 호출을 다시 실행하지 마세요. 마지막으로 확인된 유효한 ID를 가져와서 그 뒤에 오는 것들만 요청하세요. 이렇게 하면 동일한 출력에 대해 두 번 비용을 지불하는 것을 방지할 수 있으며,
max_tokens를 한 번씩 높일 때마다 절벽을 조금씩 뒤로 미는 작업을 멈출 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기