LLM transport: a truncated response (finish_reason=length) is silently repaired into a partial result #293
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found while running the #281 spike. Independent of whether we adopt
json_schema, and probably higher-value.The bug
_structured_llamacppinspectsfinish_reasononly when content is empty (webapp/backend/app/services/llm_service.py:196-212):When the model hits
max_tokensand returnsfinish_reason=lengthwith a non-empty body, that body is a JSON object cut off mid-structure. It is returned as if complete.repair_json_objectthen does exactly what it is designed to do — preserves the longest parsing prefix — and the caller receives a well-formed object containing some of the results, with no way to tell it is partial._structured_openaihas the same gap (llm_service.py:283-286).Reproduced
Same ~33k-token prompt, production-shaped lore-proposal schema,
max_tokensforced to 256:json_objectfinish_reason=length→ invalid JSON → repaired to 3 proposalsjson_schemafinish_reason=length→ invalid JSON → repaired to 2–3 proposalsConstrained decoding gives no protection — a grammar constrains what gets emitted, not whether generation is cut off. Both modes fail identically.
Why it matters
This is the exact hazard
repair_json_object's docstring warns about:Here that hazard is reached through a path nothing detects. The failure is silent by construction: the WARNING says "recovered by structural repair", which reads like a successful recovery, and the count that reaches the DB looks like a legitimate short result. A session producing 12 proposals that gets truncated to 3 is indistinguishable from a session that genuinely had 3.
Production default is
max_tokens=2048. Observed completions on synthetic prompts ran 300–1,200 tokens, so real sessions with richer content plausibly reach the cap — and would do so more often the better the lore pipeline gets.Suggested fix
finish_reason=lengthas a first-class outcome in_structured_llamacppand_structured_openai: at minimum log it at WARNING with the completion-token count; preferably surface it to the caller so a truncated structured result can be rejected or retried rather than persisted.max_tokensfor the proposal-generating paths, or making it caller-specified — 2048 is a guess that predates knowing output sizes.extract_json_object(..., strict=True)already exists; a truncation signal makes it usable at the right call sites.Not in scope
Adopting
json_schema(#281) — orthogonal, and does not fix this.Fixed in
f3264d9onmain.Correction to the issue body first
I overstated the production exposure when I filed this. The body says "Production default is
max_tokens=2048… real sessions with richer content plausibly reach the cap", using the lore-proposal path as the example. That path is not capped at 2048.Auditing every structured call site:
extract_highlightshighlights_max_quotesgenerate_session_titles_propose_lore_relationships_asyncmax_tokens=Nonemerge_lore_entry_bodyjson_mode=False, so proseOnly three structured sites inherit the 2048 default, and all three produce naturally bounded output well under it. The lore pipeline — the one I used to illustrate "12 proposals truncated to 3" — passes
max_tokens=None, which omits the field, so llama.cpp generates until EOS or the context window fills.The bug is real and the code path is exactly as described; its current reach is narrower than I implied. Those uncapped calls can still reach this guard by exhausting the 131k context, and
finish_reason=lengthcovers that case too — so the fix is worth having on its own merits, but I don't want a misleading exposure claim sitting in the tracker.This also means suggested fix #2 (raise
max_tokens) is not needed: nothing is being squeezed by the current cap, so raising it would be tuning a constraint that isn't binding.What landed
Rejects truncated structured output; returns truncated prose. A response cut off at the cap is malformed rather than partial, and the caller cannot detect the loss on its own — whereas shortened prose is degraded but usable.
Covers all four providers, not just the two named here — each signals the condition differently:
finish_reason == "length"stop_reason == "max_tokens"done_reason == "length"Neither prompt nor response is logged — only the token count.
On raising being safe: I checked this against the #287 stuck-state hazard before choosing to raise.
LoreGenerationTask.on_failurefires once after retries are exhausted and refuses to clobber a terminal state a later phase already reached, so a rejected response surfaces asfailedwith a recorded error rather than stranding a session mid-pipeline.Tests — 7 new in
test_llm_transport.py, one per provider plus a negative case (finish_reason=stopmust not trip the guard) and a prose case. The last one pins why the guard exists: it feeds the same truncated body torepair_json_objectand asserts it comes back as a well-formed object holding one proposal — the silent loss this prevents.888 backend tests pass (up from 881), ruff clean.
Closing.