Skip to content

fix(llm): strip reasoning before an orphan close tag from prefilled templates (#4926) - #4927

Merged
nicoloboschi merged 2 commits into
vectorize-io:mainfrom
FenjuFu:fix/strip-orphan-think-close-tag
Oct 5, 2026
Merged

nicoloboschi merged 2 commits into
vectorize-io:mainfrom
FenjuFu:fix/strip-orphan-think-close-tag

Conversation

@FenjuFu

@FenjuFu FenjuFu commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #4926

What

_strip_reasoning_tags now also handles a reasoning block whose open tag was part of the prompt, not the completion.

Chat templates that prefill <think> into the generation prompt (Qwen3 thinking models, DeepSeek-R1-0528, Spark-X2.5) make the model's output start mid-thought: reasoning</think>answer. Behind an OpenAI-compatible server that runs without a reasoning parser, that is exactly what lands in message.content. Spark-X2.5's template defaults enable_thinking to true and emits <think> in the generation prompt, and the vLLM command in its README passes no --reasoning-parser, so a self-hosted Spark-X2.5 hits this out of the box.

Neither existing rule matches that shape — the closed-block regex needs the open tag, and the unclosed rule anchors on it too — so:

  • free-form output (mental models, reflect) is stored with the chain of thought and a literal </think> still in it;
  • structured output only parses because _strip_code_fences falls back to the outer {...} span, which breaks as soon as the reasoning drafts a brace.

How

Per tag pair, before the existing closed/unclosed passes: if the first close tag has no matching open tag before it, drop everything up to and including it.

The guard that keeps #3426's intent: this is skipped when the text before the close tag starts with {, [ or a code fence. A response that already opened as JSON is the answer, so a </think> inside it is a quoted literal, not a reasoning boundary. Prefilled reasoning is prose, so it never takes that branch.

All three call sites (call structured + free-form, native Ollama, openai-responses) go through the same helper, so no call-site changes.

Testing

tests/test_strip_reasoning_tags.py — 8 new cases: prose and multi-line mental-model leaks, JSON after reasoning that itself drafted braces (the case the outer-span fallback gets wrong), an empty prefilled block, the </thinking> and |endthink| variants, </think> quoted inside bare and fenced JSON (kept verbatim), and an orphan head followed by a normal closed block. The existing 19 cases pass unchanged (27/27 total). Against the old implementation the six stripping cases fail and the two keep-verbatim cases pass, so the latter pin the #3426 behaviour rather than the new branch.

ruff check and ruff format --check are clean on both files.

Known trade-off

A free-form answer that mentions </think> in prose, without <think> before it and without starting as JSON, would be cut at that mention. That needs a model to talk about the closing tag alone, which seemed a much smaller risk than leaking reasoning into every mental model for this family of templates — happy to tighten it if you'd rather key it on something else.

@github-actions github-actions Bot added the core label Sep 29, 2026
FenjuFu and others added 2 commits October 5, 2026 13:42
…emplates (vectorize-io#4926)

Chat templates that prefill <think> into the generation prompt (Qwen3
thinking models, DeepSeek-R1-0528, Spark-X2.5) make the completion read
"reasoning</think>answer" when the server runs without a reasoning
parser. Neither the closed-block nor the unclosed-block rule matches
that, so free-form output stored the chain of thought verbatim.

Drop everything up to the first close tag when no open tag precedes it,
unless the response already opened as JSON or a code fence, where the
tag is a quoted literal.

Signed-off-by: FenjuFu <[email protected]>
The docstring claimed the guard protects a response that "already opened as
JSON or a code fence", but it only tests the start of the text before the
close tag -- a prose preamble ahead of a fenced body defeats it, so a
`</think>` literal in that answer is still read as a boundary. Widening the
guard to any fence before the tag would be worse: it would stop stripping the
common case where the reasoning itself drafts a fenced block. State the narrow
check and the accepted consequence instead of implying a broader one.
@nicoloboschi
nicoloboschi force-pushed the fix/strip-orphan-think-close-tag branch from 4b8f23f to 213fef0 Compare October 5, 2026 11:45
@nicoloboschi
nicoloboschi merged commit 99eb2c7 into vectorize-io:main Oct 5, 2026
221 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reasoning leaks into memory when the chat template prefills <think> (orphan </think> not stripped)

2 participants