Repository navigation
forward-serve: whitespace around a reasoning block is layout, not text — 'one word' came back as "\n\nParis" (Refs #1786) - #2118
Conversation
…t (Refs #1786) 'Answer with one word' came back as "\n\nParis", and a model that thinks nothing sent a reasoning chunk of two newlines. The whitespace leading the reasoning, and the whitespace leading content that follows a block, are dropped: the rule app/think_split.h applies on the in-process route. Two existing arms pinned the newline after the opening tag as part of the reasoning; they are updated. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
ready for review at 8a14ad6 |
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 8a14ad6: READY
Whitespace at a reasoning boundary is layout, not content — and getting that wrong is visible to a user in a way most parser bugs are not: a stray blank line or a swallowed space at the seam between [think] and the answer is the first thing anyone notices in a demo.
The shape is right: emit() takes (is_reasoning, raw) and appends to an ordered vector<pair<bool, string>>, so the emitted segments keep their order and their reasoning/answer tag together rather than being reassembled by position. if (raw.empty()) return in front of the normalisation keeps an empty segment from becoming a whitespace-only one — the failure that would otherwise make "the model said nothing" indistinguishable from "the model said a space".
Lands with tests/npu_answer_parser_test.cpp and the sampling-test wiring, so the seam is pinned rather than eyeballed.
|
VERDICT: READY at The defect is a one-word bug with a visible symptom, and the evidence is the served text itself: The model writes The arms I would have asked for are the ones present: The last one is the subtle half and the reason this is a parser change rather than a One ask: an arm where the answer itself starts with whitespace that is meaningful — a fenced code block whose first line is indented ( Scope of my check: fresh configure (rc 0), built the parser test in a scratch dir, ran it (49 checks). I did not run a serve: the served-text evidence above is the PR's own, from FastFlowLM's Qwen3.5-2B-NPU2 and the #1785 ChatML rows. |
|
VERDICT: READY at Three asks:
And one thing to note in the body: this makes the streaming route match the in-process route, which means a client that has only ever seen one of them will see different output after this lands. That is the intended change, but it is the kind of change that should be one sentence in the release notes rather than a discovery. |
Refs #1786 (the
Qwen3.5-2B-NPU2row; follows #2098, which is merged). Backlog, not a release blocker.What was wrong, on the NPU
FastFlowLM's
Qwen3.5-2B-NPU2served with its own template, thinking on, "What is the capital of France? Answer with one word.", run tofinish=stop, main with #2098:and on the ChatML path, where the model thinks nothing, every answer in the #1785 rows came back as
'\n\n\n\nItem 3 is', preceded by a streamedreasoning_contentchunk that was two newlines (data: {"choices":[{"delta":{"reasoning_content":"\n\n"},…).A model writes
<think>\n…\n</think>\n\nand then its answer. Those newlines are layout. The splitter returned them as text on both sides of the block.The change
AnswerStreamdrops the whitespace that leads the reasoning, and the whitespace that leads the content when that content follows a reasoning block. A block that holds only whitespace produces no reasoning at all. Whitespace inside either text is kept; content with no reasoning before it is returned exactly as written; content that came before a block keeps the space after the block.This is the rule
app/think_split.halready applies on the in-process route (its header: "whitespace around the block is dropped"), so the two routes return the same strings.parse_answer, the streamed path andcontent_maskshare the one splitter, so they change together.After, same request, binary from this branch
Two existing expectations change
Two arms in
tests/npu_sampling_test.cpppinned the newline after the opening tag as part of the reasoning. They assert the new rule now:"<think>\nOkay, the user asked."gave reasoning"\nOkay, the user asked.", now"Okay, the user asked.";"<|channel>thought\nlet me think<channel|>Done."gave"\nlet me think", now"let me think".That is a visible change to
reasoning_contentfor every thinking model on this route: its first character is no longer the newline the template puts after the tag. A client that depended on that newline would see a difference; I know of none, and the in-process route never sent it.Tests
tests/npu_answer_parser_test.cpp, 11 new checks: the newline after the opener and the blank line after the closer are in neither text; an empty block gives no reasoning and the answer starts at its first character; content with no reasoning before it is untouched; content before a block keeps the following space; inner whitespace is kept; an answer that is only layout after the block is empty content; streamed, an empty block and its layout produce no chunk and the first chunk is the answer's first word; the per-token mask gives layout tokens no content entry; a tool call after the block and a newline is still a call; without tools the marker after the block is kept whole.all cases ok,npu_sampling: ok.tools/copyright.py --checkrc 0. Docs:docs/npu-chat-template.md, "Whitespace around the block is layout".Limits
</think>) is left as it is, as on the in-process route."reasoning_format": "none"was not exercised.🤖 Generated with Claude Code