Repository navigation
Count single-token reasoning delimiters only when generated as that token (#48) - #94
Conversation
…oken A model that writes <think> or </think> as ordinary text, for example while explaining a chat template, was treated as emitting a real reasoning delimiter: text moved between reasoning_content and content and the tag disappeared (NVIDIA#48). Structured-output grammars ended the reasoning region on the same text. Derive, once per server, the contract's reasoning open and close that the frontend tokenizer encodes as exactly one round-tripping token. TaggedOutput and ThinkReasoning accept such a delimiter only at the byte offset where its generated token's text starts, holding possible delimiter text until it arrives and releasing it as text at finish. The MiniMax leading-close check follows the same rule. The grammar references the same delimiters by token ID, so reasoning ends only at the token while its bytes remain ordinary answer text. DSML and XTML keep recognizing their own markup. Multi-token delimiters, such as Gemma 4's <|channel>thought open, stay text-matched. No package or metadata change. Validation: on base 82cb15a, Helios lib 1220 passed, 21 ignored, 5 failed; main fails the same five on that host (four engram::reader staging-memory tests, one package-cache test that cannot fail a write as root). All 12 integration targets pass; test_requests builds with server,test-tools. Ignored checkpoint tests pass: delimiter resolution for eight recipe tokenizers, glm_frontend, deepseek_frontend and the Qwen 3.8 grammar envelope. Live Flare on two RTX PRO 6000 GPUs, base 3267c05 against this change: Qwen 3.8 non-streaming, streaming and JSON-schema requests and Gemma 4 E4B requests all split reasoning at the generated delimiter token on this change. One streamed Qwen tool-call response generated a <think> token inside reasoning, which is still removed as a delimiter. Not run: live runs on other recipes, Gemma streaming, /v1/messages, image_admission and instruction_boundary (fixture hash mismatch), Heliopause, Python and CUDA tests. A model that writes its real close in ordinary pieces stays in reasoning, and under a structured-output grammar runs to its token limit; Qwen 3.8 never did so on this change. Design-Signed-Off-By: Mark Yong via Cursor Agent (Auto); design-review=babb
|
Detail behind the validation in the PR description: what the new tests cover, the checkpoint paths, further live observations, and the live harness for the tables. Each harness script sends requests to a running Flare server with What the new tests coverUnit tests:
Checkpoint tests (
Checkpoint paths used:
Further live observationsBoth images were built from the repo Dockerfile with the #76 workaround (copy Qwen 3.8, non-streaming:
Qwen 3.8, streaming:
Gemma 4 E4B:
|
Flag plumbing for the transplanted upstream NVIDIA#94/NVIDIA#95 direct-table geometry: --trellis-prefill-table 0|1|2 (default off) stamps B12X_W4A16_PREFILL_TABLE before any kernel compiles and records the selection in the plan settings, the same contract as --trellis-prefill-shapes (W3 R3). 1 = 48-row blocks, 2 stages, tile 128,128,64,256 for 1536..2560-row launches; 2 = additionally 64-row blocks, 3 stages, tile 64,128,64,128 for >=2560-row launches. Decode / verify / draft launches never see the switch (kernel-side gate). Design-Signed-Off-By: Claude (hermes-agent); design-review=a38f
Fixes #48.
Request
ThinkReasoningfinds reasoning delimiters by matching decoded text. When a model writes<think>or</think>as ordinary text, for example while explaining a chat template, Helios treats the mention as a real delimiter: text moves betweenreasoning_contentandcontent, and the tag disappears (#48). The real delimiter is a single added token (248068 and 248069 on Qwen 3.8); a mention arrives as ordinary pieces such as . The issue reproduces the defect in 5 of 7 live Qwen 3.8 runs and in parser tests for Qwen and GLM.Structured-output grammars have the same blind spot: the reasoning region ends on the delimiter's text, so a spelled
</think>inside reasoning ends it and the rest of the reasoning is forced into the answer grammar.Change
reasoning_delimiter_tokens(helios/src/model/prompt.rs) encodes the contract's reasoningopenandclosewith the frontend tokenizer and keeps each one that encodes to exactly one token and decodes back to the same text.Server::newderives the list once;GrammarCompilerstores it and each request passes it toprepare_output, so the parser and the grammar always use the same list.TaggedOutputandThinkReasoning(helios/src/output/tagged.rs) accept a listed delimiter only where its token was generated.Input::Token(id)arrives before its decoded text, so the parser records the byte offset where a listed token's text will start and holds text that could still become that delimiter until the text arrives. Text at any other offset, including a full spelled-out tag, is ordinary reasoning or answer text. The MiniMax leading-close check (initial_close) follows the same rule.OutputPipeline::gates_reasoning_tokensexcludes DSML and XTML, which recognize their markup in their own state machines, and lists every tool parser kind so a new kind cannot be added without deciding.GrammarCompiler(helios/src/grammar.rs) references each delimiter by token ID (<[id]>), which llguidance matches against the generated token before its bytes. Reasoning therefore ends only at the token, while the token's bytes stay ordinary text, so a structured answer may still contain it and returns it as text. The grammar does not ban the delimiter tokens from structured answers: Qwen 3.8 generates the<think>token inside JSON strings, and a ban pushes the model to substitute<tool_call>or a blank. Referencing the delimiters by ID leaves the answer grammar the same as without this change.docs/api.mdandhelios/model-contract.mdstate the rule and its limitations.vLLM takes the same approach: its parsers declare
token_id_terminalsandTokenIDScannermatches them by token ID. SGLang's structured-output backend ends reasoning onthink_end_idsthroughTokenSequenceMatcher.No Protobuf, metadata or package change. Existing packages pick up the fix with the new Flare build.
Scope across recipes
The rule depends on how each checkpoint's tokenizer encodes its declared delimiters, not on model identity. Delimiters resolved from real checkpoint tokenizers:
qwen38<think>248068,</think>248069glm53<think>154841,</think>154842mimo_v2<think>151667,</think>151668nemotron_h<think>12,</think>13minimax_m3<mm:think>200059,</mm:think>200060step5<think>128798,</think>128799gemma4<channel|>101;<|channel>thoughtis two tokens and stays text-matcheddeepseek4,deepseek41kimi3If a delimiter is not one token, it falls back to text matching, the behavior without this change, so for that recipe #48 is not fixed and nothing regresses.
Behavior changes
reasoning_contentorcontentinstead of switching modes.</mm:think>in output that starts in content is removed only when it is the token. Spelled out, it now stays incontent.</think>in reasoning no longer ends reasoning; only the token does.Validation
All runs on a 32-core x86_64 build host, base
82cb15a0with this change applied,--features server,runtime-packageunless noted. Checkpoint paths, test descriptions and further live observations are in the first comment on this PR, with the live harness scripts.82cb15a0without this change fails the same five on the same host: fourengram::readertests that cannot register staging memory (Cannot allocate memory), andpackage::cache::selection_tests::failed_default_clear_keeps_the_selected_entry, which cannot fail a write as root.test_requests, thetest-toolsbinary with an updatedprepare_outputcall site, builds with--features server,test-tools.output::pipeline::tests::{single_token_reasoning_delimiters_leave_spelled_tags_as_text, finish_releases_text_held_for_a_delimiter_token, channel_thought_gates_only_its_single_token_close},model::prompt::tests::single_token_reasoning_delimiters_gate_generated_output,grammar::tests::single_token_reasoning_delimiters_end_reasoning_only_as_tokens,server::logprobs::tests::spelled_reasoning_delimiter_in_the_answer_receives_output_text_logprobs.--ignored), all passing:tests/reasoning_delimiters.rs8 of 8 (the table above),tests/glm_frontend.rs1 of 1,tests/deepseek_frontend.rs2 of 2,grammar::tests::qwen_output_envelope_uses_checkpoint_token_bytes1 of 1.rustfmt --check(edition 2024) reports no difference this change introduces.Live Qwen 3.8 Flash, non-streaming
One package,
Qwen3.8-Flash-Next(GB202), exported atbce032c0fromnvidia/Qwen3.8-Flash-Next-FP4-Local@b9ce18a6, concurrency 32, MTP draft tokens 5, served by Flare containers built from3267c054and from this branch on two RTX PRO 6000 Max-Q GPUs (SM120) with identical flags. Requests went to/v1/chat/completionswithreturn_token_ids, fixed seeds,max_tokens4096 and the model's default sampling. A response counts as correct whenreasoning_contentandcontentequal the generated token IDs split at the first</think>token (248069). Sampling was not bit-reproducible across runs, so the builds are compared by distribution rather than as paired outputs.3267c054correct</think>token; the model never wrote its real close in pieces.3267c054, 46 of 68 JSON responses returned reasoning that differs from the tokens. In 37 the model never generated</think>: a spelled</think>in reasoning ended the grammar's reasoning region, forcing the rest of the reasoning into the answer grammar.Live Qwen 3.8 Flash, streaming
The same package and images, with
"stream": trueandreturn_token_ids. Reasoning and content were rebuilt from the deltas and compared with the streamed token IDs split at the first</think>token. The images were built before the branch was rebased onto82cb15a0; the commits after3267c054do not touch Helios output or grammar code.3267c054correctget_weathertool call, 8 seeds: reasoning matches the tokens3267c054, 9 of the 12 responses to the first and third prompts switched between reasoning and content deltas more than once. On the branch, every response streamed all reasoning before any content.<think>token inside reasoning, which is removed as a delimiter.3267c054removes it too, along with spelled<think>text.Live Gemma 4 E4B
One package (
unsloth/gemma-4-E4B-it-NVFP4@1c363766, GB202, concurrency 4), exported at3267c054with FlashInfer344e4837, the pin from #85, served by the same images and GPUs, with identical flags and criteria as the Qwen run. Gemma closes reasoning with the single token<channel|>(101), while its<|channel>thoughtopen stays text-matched. Each response was split at the first generated 101 token, with the<|channel>thoughtprefix removed from the reasoning half.3267c054correct<channel|>, thinking on, 8 seeds<channel|>in a string, thinking on, 8 seeds<channel|>token and also spelled<channel|>in the text. On3267c054, reasoning ended at the spelled marker inside the reasoning, and the rest of the reasoning was returned as content.Not run: live runs on any recipe other than Qwen 3.8 and Gemma 4 E4B, streaming on Gemma, and
/v1/messagesrequests;image_admissionandinstruction_boundary, whose reference fixture hash does not match the available Qwen 3.8 snapshots; Heliopause, Python and CUDA tests.Limitations
<|channel>thoughtopen, and DeepSeek's delimiters through the official parser.<think>is removed from reasoning, and a generated</think>ends it. Qwen 3.8 did this once in 32 thinking-on streaming responses.reasoning_content. Under a structured-output grammar, reasoning then cannot end, so the request runs to its token limit without an answer. Qwen 3.8 never wrote its close in pieces in this branch's live runs. On3267c054, 37 of 68 JSON responses never generated it and produced answers only because the grammar ended reasoning at spelled text. vLLM's token-ID terminals carry the same assumption.flush_pendingemits the deferred terminal instead. The difference only affects the final reasoning state, since no answer text follows.Written with AI assistance; reviewed before opening.