Gemini MAX_TOKENS truncation이 글 본문을 4섹션에서 잘라먹은 사고 — response_json=False 경로의 silent publish 추적기

#091

3 min read · 794 words

Blogger 자동 발행 파이프라인이 글 본문을 4섹션에서 잘라먹은 채 LIVE 로 통과시킨 사고를 정리한다. TOC 는 9 섹션을 약속했는데 실제 본문은 시도했지만 실패한 방법 섹션까지만 있고, 그 자리에 다른 hook 의 잔재 fragment 까지 어색하게 남아 있었다. 운영자가 라이브에서 직접 catch 했고, 내가 추적해 보니 LLM provider 의 한 줄짜리 조건문이 truncation 을 silent 로 흘려보내고 있었다.

문제 상황

운영자가 새로 발행된 글 4편을 라이브에서 펼쳐 보다가 본문이 갑자기 끊긴 것을 catch 했다. 글 4편 모두 같은 패턴 — TOC 에는 "문제 상황 / 에러 증상 / 환경 / 시도했지만 실패한 방법 / 최종 해결 / 사용한 코드 / 검증 결과 / 현재 상태 / 같은 문제 겪는 분들에게" 9 개 섹션이 줄지어 박혀 있는데, 실제 본문은 네 번째 섹션 끝에서 그냥 멈춰 있었다. 그러고 나서 신경망 다이어그램 잔재가 어색하게 떠 있었다.

에러 증상

잘린 4편 모두 LIVE 상태로 라이브 가시. TOC 약속 9 → 실제 4 섹션. 잘림 직후에 <div class="layer enc">encoder L1 — self-attention</div> 같은 chart inject hook 의 SVG fragment 가 노출돼 있었다. 사용자 입장에서는 글이 그냥 황당하게 끝나 보였다. 추가로 publish_sanitizer 의 hook chain 은 정상 동작해서 schema_v7 / safety CSS / archive ID #NNN / reading time 까지 모두 inject 됐다 — 즉 publish 자체는 "성공" 으로 통과한 상태였다.

환경

Python 3.12 + google-generativeai REST v1beta + gemini 모델 + max_tokens 8000. 호출 경로는 webapp/routers/writer.py 의 generate_draft → _llm_create → call_llm → _call_gemini. response_json=False (HTML 본문 받는 경로). 4편 모두 AI 파헤치기 카테고리.

시도했지만 실패한 방법

처음에는 publish_sanitizer chain 의 chart_inject hook 이 글을 깨는 것으로 의심해 hook 자체를 read-only audit 했다. 그러나 hook 은 정상 동작 — 잘림 직전에 chart 가 들어간 것뿐, 잘린 본문 자체는 hook 책임이 아니었다.

다음으로 Blogger API posts.update 의 body 크기 한도를 의심했다. content 필드는 1MB 까지 허용되므로 8KB 본문이 cap 에 걸릴 이유는 없었다. 이 가설도 폐기.

마지막으로 LLM 자체 출력을 직접 검사했다. tokens_out 카운트가 모두 max_tokens 한도 근처(7800~7950)였고 finishReason 이 MAX_TOKENS 로 떨어진 케이스가 4건 모두였다.

최종 해결

root cause 는 _call_gemini 안의 한 줄짜리 조건문이었다. MAX_TOKENS truncation 을 caller 에게 surface 하는 분기가 response_json=True 경로에만 박혀 있었다. JSON structured output 경로(tip_auto_publisher) 는 truncation 시 ok=False + truncated=True 로 명시 surface 됐지만, HTML 본문 경로(writer.py) 는 같은 truncation 이 발생해도 그냥 ok=True 와 잘린 text 가 return 되고 있었다. 한 줄짜리 if 의 분기를 풀고 모든 경로에서 항상 MAX_TOKENS 를 surface 하도록 고쳤다. 동시에 4편의 잘린 글은 실제로 사용된 코드를 가지고 9 섹션 템플릿으로 다시 작성해 posts.update 로 덮어썼다.

사용한 코드

핵심 1줄 fix — _call_gemini 의 truncation surface 조건문에서 response_json 게이트 제거.

# webapp/seo/llm_provider.py

# 이전 (response_json=False 경로 silent 통과)
if response_json and "MAX_TOKENS" in finish_reasons:
 return {"ok": False, "truncated": True, "text": text, ...}

# 수정 (모든 경로 surface)
if "MAX_TOKENS" in finish_reasons:
 logger.warning(
 "Gemini MAX_TOKENS truncation (response_json=%s, tokens_out=%s)",
 response_json, usage.get('candidatesTokenCount'),
 )
 return {"ok": False, "truncated": True, "text": text, ...}

writer.py 의 _llm_create 가 result.get("ok") 체크 후 RuntimeError 를 raise 하므로 추가 caller 패치는 불필요하다. 다만 retry 정책은 "529 overloaded" 만 자동 재시도하기 때문에, MAX_TOKENS 는 에러로 올라가서 발행 자체가 중단된다 — 이게 우리가 원하는 동작이다.

# webapp/routers/writer.py _llm_create
for attempt in range(3):
 try:
 result = call_llm(...)
 if result.get("ok"):
 return _LLMMessage(result.get("text") or "")
 # ok=False + truncated → RuntimeError
 raise RuntimeError(result.get("error") or "LLM call failed")
 except Exception as e:
 msg = str(e)
 if "529" in msg or "overloaded" in msg.lower():
 wait = 8 * (attempt + 1)
 await asyncio.sleep(wait)
 else:
 raise # truncation 은 즉시 raise → publish 차단

잘린 4편 rewrite 는 9 섹션 템플릿으로 다시 작성한 뒤 inject_schema_into_content force=True + apply_reading_time + sanitize_for_publish 를 거쳐 posts.update 로 덮어썼다.

검증 결과

fix 후 코드 깊이 audit 재실행 — KO depth tier 분포는 "none 4 → 0편", "rich 16 → 20편" 으로 회복됐다. 4편 모두 9 h2 + 실제 코드 블록 2-3 개를 가진 정상 상태. publish_quality_gate 의 schema_v7 marker 89/89 유지, sitemap 89 URL 전수 200 OK, 모바일 post-body width 256px 그대로. 운영 기록 기준 fix 적용 후 추가 silent truncation 사례 0건.

현재 상태

fixed. response_json 무관 모든 경로에서 MAX_TOKENS 는 ok=False 로 surface 된다. writer.py 는 RuntimeError 로 즉시 발행 중단, tip_auto_publisher.py 는 ko_draft_max_retries=2 로 자동 재시도. 두 caller 모두 partial body 가 publish 까지 흘러갈 길이 막혔다. 잘렸던 4편의 라이브 글은 9 섹션 + 실제 코드로 정상화 완료.

같은 문제 겪는 분들에게

LLM provider 래퍼를 쓰는 publish 파이프라인이라면 두 가지를 먼저 확인하라. 첫째, finishReason 처리가 모든 응답 모드 (JSON / plain text) 에서 동일하게 surface 되는지 — 조건문에 response_json 같은 게이트가 박혀 있으면 한쪽만 sliently 통과한다. 둘째, retry 정책이 ok=False 를 무조건 다시 시도하지 않도록 분리하라 — 529 overloaded 같은 transient 에러만 자동 재시도하고, MAX_TOKENS 같은 cap 에러는 사람이 prompt 를 줄이거나 max_tokens 를 올려야 풀린다. partial body 가 publish 까지 도달하는 가장 흔한 경로가 이 조용한 silent return 이다. 자본주의 사회에서 API 토큰은 한도가 있고, 코드는 그 한도를 정직하게 caller 에게 알려야 한다.

ToolSignal Pro Editorial

Claude · GPT · Antigravity · Cursor 실전 오류와 해결을 5개 언어로 정리한 AI debugging archive.

이전 글 다음 글