Repository navigation
[Kimi-K3] Return the reply as content when the model skips its think channel - #2910
Conversation
🏷️ CI GuideRuns automatically on every eligible PR before approval:
Heavy model tests:
|
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues were identified for the Kimi-K3 reply-parsing changes.
No open issues 🎉
What changed in this PR
This PR fixes Kimi-K3 reply parsing when the model writes an answer directly into its think channel, ensuring the reply reaches content in both delivery modes.
Changes:
- Declares answer-end markers in reasoning dialects and validates that they are also content framing.
- Updates non-streaming and streaming parsing to recognize skipped think channels, while preserving already-streamed reasoning.
- Adds regression coverage for chunk boundaries, tool calls, normal reasoning, and truncated output.
| File | Description |
|---|---|
tests/entrypoints/test_k3_skipped_think_channel.py |
Tests skipped-channel recovery and preservation of existing behavior. |
atom/entrypoints/openai/reasoning.py |
Replays streamed text as content when an answer closer identifies a skipped channel. |
atom/entrypoints/openai/reasoning_dialects.py |
Defines and validates answer-end markers and updates non-streaming splitting. |
Review effort: Balanced
| out.append((self._field, scan.released)) | ||
| if self.state == 0: | ||
| self._at_offset_zero = False | ||
| elif self.state == 1 and self._answer_end_markers: |
There was a problem hiding this comment.
this holds one str per token for the whole trace, even when no skip happens. coalesce every N chunks?
There was a problem hiding this comment.
Fair point. The replay needs the text itself either way; what the list adds is one str object per chunk. Measured on 30K chunks of ~14 chars, the list holds 1.90 MB against 0.42 MB of text (an io.StringIO would be 0.44 MB). So it is about 1.5 MB extra per long K3 trace, held only during state 1 and released as soon as the channel closes the ordinary way. It only applies to dialects that declare answer_end_markers, which today means K3 alone. I'd rather keep this PR as is and switch to io.StringIO as a follow-up, unless you'd like it here.
| watched |= self._open_markers | self._end_markers | ||
| elif state == 1: | ||
| watched |= self._end_markers | ||
| watched |= self._end_markers | self._answer_end_markers |
There was a problem hiding this comment.
this is a no-op since answer_end_markers are already in framing, drop it or leave a comment?
There was a problem hiding this comment.
Right, it's redundant: watched starts from _framing, and __post_init__ requires answer_end_markers to be a subset of content_framing. I kept it on purpose so that this line spells out everything state 1 watches. If the framing check is ever relaxed, the skipped-channel branch still sees its marker instead of going silent.
|
@valar-cop review the pr |
|
@Jasen2201 already reviewed df236b1: #2910 (review) say |
|
ci triage for b4e72a3:
|
done |
valarLip
left a comment
There was a problem hiding this comment.
Thanks for tracking this to the model's own behaviour; the BEAM replay makes the case clearly. I reviewed df236b1 by reading the code and running the affected tests (1265 passed, 29 skipped). I also ran a CPU probe through the real split and filter.
The blocker is the divergence the description accepts. Streaming and non-streaming must return the same reasoning and content for the same output, at every chunking, with no exceptions. That is the contract ReasoningChannel exists to keep, and the docs promise it. Please rework this so that no case diverges.
Must change
1. The divergence is not limited to streamed reasoning_content. Today the three endpoints disagree with each other.
The probe used the PR's own input: The answer is 42.<|close|>response<|sep|><|close|>message<|sep|>.
- Non-streaming
/v1/chat/completions(the split):(None, "The answer is 42."). - Non-streaming
/v1/messagesand/v1/responses: these read the whole output through the streaming filter (read_whole_blocks,api_server.py:2490/:2829). They return[thinking("The answer is 42."), text("The answer is 42.")], so the reply is delivered twice./v1/responsesalways carries the reasoning item, so a Codex multi-turn conversation feeds the reply back as reasoning and as message. - Streaming:
reasoning_content"The answer is 42." in pieces, thencontent"The answer is 42." again.
About one BEAM reply in five takes this path.
The way out is a single definition of the result. Today there are two: the split (_split_channel) and the filter (ReasoningFilter.process re-sending state 1 as content, reasoning.py:296). As long as both exist, a rule added to one has to be re-derived in the other, and they drift; this PR is that drift. Pick one rule that the stream can actually deliver, then make every path produce exactly that. There are two choices, and each has a cost:
- Hold back. The filter keeps think-channel bytes until the channel is decided, so nothing is ever re-sent. The cost is that the chain of thought is no longer streamed live for this dialect. If you go this way, please measure what it does to time-to-first-reasoning-token.
- The streamed shape is the answer. The reply appears as reasoning and then as content in every path, including the non-streaming chat split. It is duplicated, but identically everywhere, and
contentis still filled for BEAM.
Whichever you pick, the non-streaming split should come from the same code as the stream, so that the two cannot differ by construction.
2. The new shapes have to go through the existing consistency gate, with no exemption. TestTheReasoningSplitAgreesWithItself builds its corpus from each dialect's declared markers and compares split and stream byte for byte. It does not read answer_end_markers, so it never sees this shape, and adding the shape would fail test_the_chain_of_thought_is_the_same_too today. Please do the following:
- make
reasoning_shapes(dialect)generate the answer-end shapes fromdialect.answer_end_markers; - delete
tests/entrypoints/test_k3_skipped_think_channel.py. Itsstreamed()duplicatessplit_reasoning_streaming(test_stream_marker_properties.py:753), and its K3 fixture duplicates the one intest_reasoning.py:269; - move the remaining cases into
test_reasoning.py(TestTheChannelEndsAtWhicheverCloserComesFirst) on the existing fixture.
3. A quoted closer turns the whole chain of thought into content (reasoning_dialects.py:210). If the chain of thought contains the literal text <|close|>response<|sep|>, the split treats the channel as skipped. The probe input closes with <RE> so<|close|>think<|sep|><|open|>response<|sep|>ok<RE> gives (None, "closes with so<|close|>think<|sep|>ok"), so the chain of thought and a raw think marker reach the client as content. Before this PR it gave ("closes with so", "ok"). test_a_quoted_token_does_not_open_a_channel_that_was_closed guards the same class of problem for openers; this needs the same treatment, decided on token ids, not text.
Worth addressing
- Memory (
reasoning.py:277). Every K3 request keeps its entire chain of thought, chunk by chunk, in_released_in_channeluntil think closes. That is O(CoT) resident memory per request, times concurrency andn, for a rare event. It is held to the end on amax_tokens-truncated trace. With the hold-back rule there is nothing to keep. With the other rule, nothing needs to be kept past the current call. - Docs.
docs/serving_benchmarking_guide.md:149("What is held" and the dialect paragraph) needs the answer-end rule and its cost, whichever you pick. - Comments. About 15 lines of code carry about 45 lines of comments. The BEAM / KVV numbers and the SGLang PR reference appear four times: the field comment (
reasoning_dialects.py:103), the_split_channeldocstring, the 14-line comment inprocess()and the test module docstring. They belong in the description. The code needs one constraint line. Also,_split_channel's docstring opens with "One rule" and is immediately followed by "Unless". __post_init__check (reasoning_dialects.py:116). This validates one module constant at import time, plus a dedicated fake-dialect test. The registry invariants are asserted inTestTheDialectsAsRegistered(test_reasoning.py:463), which takes one line here. Better still, if the split drops the answer-end marker itself, as the filter does, the "must also be framing" coupling goes away.reasoning.py:330clears_released_in_channelon the 0→1 transition too, where it is always empty. The twoself._answer_end_markerschecks state what the list's emptiness already says.
|
@valarLip Thanks for the careful review. Reworked in cbc0766. How SGLang handles this (sgl-project/sglang#37743,
So SGLang has both problems you raised: its streaming and non-streaming modes disagree, and a quoted closer flips the channel. The previous head copied that design, divergence included. I think your rule is the more reasonable one, so this head follows it. It takes your second option, the streamed shape is the answer, with every path on one reader:
Worth addressing:
BEAM is unaffected. On the 700 raw outputs, |
…channel On ~1M-token prompts Kimi-K3 sometimes writes its reply straight into the prompt-opened think channel and closes it with the answer's own closer, `<|close|>response<|sep|><|close|>message<|sep|>`, without ever emitting `<|close|>think<|sep|>` or `<|open|>response<|sep|>`. Both delivery modes returned the whole reply as `reasoning_content` with `content` empty. Under the Kimi Vendor Verifier's BEAM (1M) this hit about one reply in five (141 of 700 on ATOM, 144 of 700 on upstream vLLM), and every one scored 0. The answer's closer seen inside the reasoning channel before any think exit proves the channel was skipped, so: - `ReasoningDialect.answer_end_markers` declares it (Kimi-K3: `<|close|>response<|sep|>`), and must also be content framing. - `_split_channel` returns the whole text as content when that marker comes before every end marker. A tools section after it stays in place for the tool parser. - `ReasoningFilter` replays what state 1 had already released as `content` when the marker arrives, then continues as the answer. Content agrees with the split on every chunking; streamed `reasoning_content` keeps the copy it had already sent, since holding state 1 back would stall every ordinary chain of thought. Same reading as sgl-project/sglang#37743. Replaying the 700 raw upstream-vLLM BEAM outputs through the new split: the 141 replies ending in the answer's closer come back as content (stream and split agree at 1, 5 and 64-char chunks); the 3 without it (one cut by max_tokens, two with no marker) stay empty; the 556 answered ones are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on every path Review (valarLip): streaming and non-streaming must return the same reasoning and content for the same output, at every chunking, on every endpoint. - `separate_reasoning` is now `ReasoningFilter` run over the whole output in one chunk. The per-dialect splits (`_split_channel`, `_split_inline_tag`, `_inline_tag_split`), `ReasoningDialect.split`, `SplitResult`, `strip_framing` and `__post_init__` are deleted. The filter is chunk-invariant, so chat, /v1/messages and /v1/responses, streamed or not, agree by construction. - `answer_end_markers` (K3: `<|close|>response<|sep|>`): when the think channel's last words, framing aside, are the answer's closer, followed by the end of the output or the next channel, the channel was the reply. It has already streamed as reasoning, so it is sent again as content. A closer the channel goes on past (a quoted one) changes nothing. - The channel text is one `io.StringIO`, kept only for a dialect with an answer closer and dropped when the channel ends. - Tests: cases moved into test_reasoning.py on the existing K3 fixture, `reasoning_shapes` generates the answer-end shapes for the parity sweep, and test_k3_skipped_think_channel.py is removed. Docs updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cbc0766 to
57d4f62
Compare
valarLip
left a comment
There was a problem hiding this comment.
Re-read at 57d4f62. I ran the affected tests (1364 passed, 29 skipped) and a CPU A/B of ReasoningChannel.split against the base, about 60k marker combinations at chunk sizes 1, 7 and whole output. Thanks for taking the one-reader route. For <think> and <mm:think> the new reader is byte-identical to the base, the quoted-closer case reads ("closes with so", "ok") again, and a 400 KB non-streaming split got faster (K3 0.436 → 0.399 ms, <think> 0.216 → 0.115 ms). I'm not re-raising the channel-text memory, which you've answered.
Three K3 behaviours still differ from what they should be. Two come from the "last words" anchor I pushed for last round, so they're partly on me. After those come the cleanups the single reader makes possible. Each item ends with the change I'm asking for.
A. Correctness
1. A chain of thought that ends with the answer closer, followed by a proper think exit, is replayed into content (reasoning.py:300-313). _close_channel runs on any end marker, including the ordinary think exit <|close|>think<|sep|> and <|open|>response<|sep|>. Take this K3 output (starts_open=True):
CoT ...<|close|>response<|sep|><|close|>think<|sep|><|open|>response<|sep|>Final answer.<|close|>response<|sep|><|close|>message<|sep|>
It gives content CoT ... Final answer. at chunk sizes 1, 7 and whole output; the base gave ("CoT ...", "Final answer."). Concretely, a model that drafts its reply in the think channel, exits normally and writes it again gets the reply twice in content. The quoted-closer test doesn't cover this because it has text between the closer and the exit.
→ Change: replay only when the channel ends at the end of the output, or at a channel that isn't the think exit (e.g. tools). An exit through think_end_marker / <|open|>response<|sep|> means the model did answer in the response channel, so nothing is replayed. Add this shape, with its expected (reasoning, content), to TestTheChannelEndsAtWhicheverCloserComesFirst.
2. One whitespace byte after the closer defeats the anchor (reasoning.py:262-266). Any released byte in state 1 clears _answer_closed. So Reply.<|close|>response<|sep|>\n<|close|>message<|sep|>, or the same with a trailing \n, still gives ("Reply.\n", "") at every chunk size. The 141 BEAM samples happen to have no whitespace there, so the tests can't see it.
→ Change: treat whitespace-only releases like framing when deciding whether the closer was the channel's last word (keep the flag set and keep the whitespace in the channel text), and add both shapes to the tests.
3. With thinking off, a leading tool call loses its opener (reasoning.py:285-292). The offset-0 "declined to think" rule matches every end marker, including the extra <|open|>tools<|sep|>. Since non-streaming now goes through the filter, a thinking-off K3 output that opens with <|open|>tools<|sep|><|open|>call tool="get_weather"...<|close|>tools<|sep|> has its tools opener consumed, leaving <|close|>tools<|sep|> unbalanced. You noted the K3 parser still finds the call by its prefix (confirmed). But where no K3 tool parser is resolved, the client gets raw call tokens without their opener, and the base kept them intact. It accounts for the tools-at-offset-0 and framing-then-tools differences in my A/B (~2.1k cases).
→ Change: let the offset-0 rule recognise think_end_marker only. An opener such as tools is a channel switch, not "declined to think", so it should be released like any other text in state 0.
B. Cleanups the single reader makes possible
4. The "whole output through the filter" loop exists three times (reasoning.py:353-358). It appears in ReasoningChannel.split's literal-answer branch, in separate_reasoning (whose only production caller is split), and in serving_anthropic.py:231 read_whole_blocks. Copilot's quadratic finding on feeding the whole output as one chunk applies to all three.
→ Change: one helper, ReasoningFilter.read_whole(text) (or ReasoningChannel.events(text)), that feeds bounded chunks and flushes once. Have split and read_whole_blocks use it. Delete separate_reasoning, or reduce it to a one-line delegate.
5. The parity test now compares the filter with itself (tests/entrypoints/test_stream_marker_properties.py:855). With one reader, which is what I asked for, TestTheReasoningSplitAgreesWithItself checks chunk invariance only. Its whole-text chunk (10 000) runs exactly the computation split does on every corpus string, and its docstring still says "both readers". test_the_messages_and_responses_bodies_read_it_the_same is filter against filter too. Item 1 passes all of it at every chunk size. The 120k-string sweep in the description isn't committed, so it doesn't gate anything.
→ Change: rename the class to a chunk-invariance test and drop the self-comparing whole-text chunk and the tautological endpoint test. Add pinned expected (reasoning, content) for the K3 answer-end shapes: closer at end, closer + framing, closer + think exit (item 1), closer + whitespace (item 2), and tools at offset 0 (item 3).
6. Redundant state (reasoning.py:265, :311, flush). The channel never reopens (state 2 doesn't return to 1), so self._answer_closed = False in _close_channel is dead. The "released text clears the flag" rule is written in process() and again in flush() (if rest: self._answer_closed = False). The _channel_text is not None checks restate what an empty answer_end_markers already says.
→ Change: decide "did text follow the closer" in one place (at the scanner's release point, which also gives item 2 its one home), and delete the dead reset and the second copy in flush().
7. The answer closer has two sources (reasoning_dialects.py:150). answer_end_markers=(CHANNEL_RESPONSE_END,) uses this module's local string, while the line above uses k3.TOOLS_START. That the closer counts as framing now depends on k3.CHANNEL_FRAMING happening to contain the same string.
→ Change: use k3.RESPONSE_END. While there, delete the local CHANNEL_TOOLS_START / CHANNEL_MESSAGE_END / CHANNEL_END_OF_MSG / CHANNEL_CALL_PREFIX, which have no references.
8. Docs and comments describe the deleted two-reader structure, or a change log (reasoning_dialects.py:6, docs/serving_benchmarking_guide.md:359):
- the module docstring says it "iterates DIALECTS to detect/split";
- the
extra_end_markerscomment says "while the non-streaming split read it correctly"; ReasoningFilter's docstring talks about "keeping the two paths on one answer";separate_reasoning's docstring and the new guide paragraph ("Both are now one reader ... the per-dialect split functions ... are gone") are history.
→ Change: describe what the code does now (one reader; the split is the filter run over the whole output) and move the history to the commit message.
| return [(self._field, rest)] if rest else [] | ||
| out = [(self._field, rest)] if rest else [] | ||
| if self.state == 1: | ||
| if rest: |
There was a problem hiding this comment.
held suffix is a marker prefix, not model text.. so content ends up empty here? keep it out of the replay and only clear _answer_closed when it's not a prefix
There was a problem hiding this comment.
Right: the held suffix is a marker's first bytes, not model text, and it cleared the flag, so Reply.<|close|>response<|sep|><|close|>mes came back with empty content. Fixed in b4e72a3. flush() no longer touches the flag, and the held text never enters the replay. Whether text followed the closer is now decided only in _keep, at the scanner's release point, and whitespace doesn't count either. That case now reads ("Reply.<|close|>mes", "Reply."), pinned as cut-mid-marker.
| the channel and wrote its answer there, which K3 does on ~1M-token prompts. | ||
| That is only known when the channel ends, after the reply has streamed as | ||
| `reasoning_content`, so it is sent again as `content`. Every endpoint and both | ||
| delivery modes return the reply in both fields. The costs are that copy and |
There was a problem hiding this comment.
/v1/messages drops reasoning unless thinking is requested, so content only there.. scope this sentence?
There was a problem hiding this comment.
Scoped in b4e72a3: on /v1/messages the reply is in content alone unless thinking was requested, since that endpoint drops reasoning otherwise.
… non-answer channel; read whole outputs in bounded pieces The answer closer marks a skipped think channel only when the channel ends at the end of the output, or at a channel other than the answer's own. An exit through `<|close|>think<|sep|>` or `<|open|>response<|sep|>` means the model answered in the response channel, so nothing is replayed. Whitespace after the closer does not count as words after it. Neither does a marker cut off at the end of the output; that text is not kept for the replay. Whether text followed the closer is decided in one place (`ReasoningFilter._keep`). With thinking off, only the think exit at offset 0 means "declined to think". A leading `<|open|>tools<|sep|>` is a channel switch and is released as text, so the call keeps its opener on both delivery paths. `ReasoningFilter.read_whole` feeds a complete output in 4 KiB pieces. `ReasoningChannel.split` and `read_whole_blocks` use it, and `separate_reasoning` delegates to `split`. One piece per output made every marker re-scan and copy the rest of the output, so the read was quadratic in the marker count on the event loop: 31.7 s on an 819K-character output with 32,768 markers, now 174 ms. A normal 800K-character output takes 0.5 ms. `answer_end_markers` reads `k3.RESPONSE_END`, and the unused local channel constants are gone. The reasoning parity sweep is now a chunk-invariance test at 1/3/11 characters. The K3 answer-end shapes have pinned `(reasoning, content)` expectations: closer at the end, closer and framing, closer and whitespace, closer cut off mid-marker, closer and the think exit, and tools at offset 0 with thinking off. Equivalence on BEAM: the 700 raw upstream-vLLM BEAM outputs, replayed through the previous head (57d4f62) and this one, give the same `content` and the same `reasoning_content` on all 700. That is 141 replies recovered, 3 still empty and 556 answered replies unchanged. Stream and split agree at 1/5/64 characters on every output. The GPU BEAM result measured for this PR (0.320) carries over. tests/entrypoints: 2651 passed, 93 skipped, 3 xfailed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@valarLip Thanks for the re-review. All eight points hold up; I reproduced each with a CPU probe before changing anything. Fixed in b4e72a3. A. Correctness
B. Cleanups
BEAM is unchanged. I replayed the 700 raw upstream-vLLM BEAM outputs through 57d4f62 and b4e72a3. Both
|
There was a problem hiding this comment.
🟡 Changes recommended
Bounded reads split long reasoning traces into separate blocks and Responses items sharing the same ID, creating client ambiguity that needs coalescing and regression coverage beyond 4 KiB.
1 open issue
1 resolved since last review
Review effort: Balanced
| ) | ||
| out: list = [] | ||
| for field, segment in reader.process(text) + reader.flush(): | ||
| for field, segment in reader.read_whole(text): |
There was a problem hiding this comment.
4 KiB pieces split one reasoning run into many blocks/items with the same id, streaming keeps one. coalesce adjacent reasoning events in read_whole_blocks and add a >4 KiB test shape?
There was a problem hiding this comment.
Fixed in fda4bcf, in two ways. The 4 KiB pieces are gone: MarkerScanner now reads a whole output in one feed in linear time. And read_whole_blocks joins adjacent reasoning segments into one event, which matters independently of the pieces. On main, framing inside a chain of thought (aa<|end_of_msg|>bb) already produced two thinking blocks / reasoning items. A 9,000-character trace and that framing case now each give one, as streaming does. Both shapes are in the tests, including outputs over 4096 characters with a marker across 4095/4096.
| out.extend(self._close_channel(None)) | ||
| return out | ||
|
|
||
| def read_whole(self, text: str) -> list: |
There was a problem hiding this comment.
this comment is stale now, read_whole feeds bounded pieces. update it so it matches how this path reads the output?
There was a problem hiding this comment.
Updated in fda4bcf. It is now read_whole_events, and it reads the whole output in one feed again: the quadratic cost was fixed in MarkerScanner (one buffer and a cursor, no hand-back), so the bounded pieces are gone.
valarLip
left a comment
There was a problem hiding this comment.
Re-read at b4e72a3, by reading the code, running tests/entrypoints (2750 passed; the one failure is test_structured_output needing llguidance, unrelated), and two probes against main, alternated (split cost and per-token streaming cost). Running the streaming filter over the whole output and deleting the per-dialect splits is the right direction, so keep it. The quoted-closer, whitespace and tools-at-offset-0 fixes read right.
Two of the items below come from my own last two rounds (the bounded pieces, and treating whitespace as framing), so they're partly on me. Each item ends with the change I'm asking for.
A. Correctness
1. A trace cut off right after a quoted closer is replayed as content (reasoning.py:298). With starts_open, the input I will end with <|close|>response<|sep|>\n ending at EOF (e.g. max_tokens) returns ('I will end with \n', 'I will end with \n'), while main returns content ''. Since whitespace now counts as framing, which I asked for, a closer followed only by whitespace at EOF looks like a finished reply. So a chain of thought truncated at that point is sent again as content, which contradicts the description's "a max_tokens-truncated trace stays reasoning".
→ Change: at end of output, replay only if the sequence ended normally (stop / EOS), not on a length cut. Pass the finish reason into flush (e.g. flush(stopped: bool)), and add both shapes, stopped and length-cut, to the K3 cases.
B. Performance
2. Fix the quadratic scan in MarkerScanner, not by chunking in one reader (reasoning.py:38, _WHOLE_OUTPUT_PIECE = 4096). I asked for bounded pieces last round, but that was the wrong layer. MarkerScanner.feed runs find for every marker over the buffer and copies rest on each hit, so one large feed is quadratic in the marker count. The 4096 piece works around that in the reasoning reader only, while tool_parser.stream.read_whole still feeds the whole output at once. It also costs on marker-dense outputs: split over 460k chars with 2000 framing markers takes 3.98 ms here against 0.68 ms on main (0.09 vs 0.24 ms with no markers). The pieces also cause the multiple same-id reasoning blocks / items that Copilot and valar-cop flagged.
→ Change: make feed scan by offset without copying the remainder (keep one buffer and a cursor; search only from the cursor). Then delete _WHOLE_OUTPUT_PIECE and feed the whole output in one call, so the block / item split goes away and both read_whole paths benefit.
3. Streaming replay sends the whole channel as one content event (reasoning.py:316). For a 100k-character reply the probe sees one content event of length 100 000. That's one pass through the tool parser on the event loop and one oversized SSE frame.
→ Change: emit the replayed text in bounded pieces (this is where a size bound belongs), yielding to the loop between them.
4. Per-token streaming cost for every K3 request (reasoning.py:206). The think channel is written into a StringIO and every released piece gets an isspace check, for a shape seen near 1M tokens. A quick alternating A/B on the K3 think channel (one run each, noisy): main 727 / 751 ns per token, this head 819 / 809 (about +10%), plus a full copy of the chain of thought held per concurrent request.
→ Change: decide without keeping the text. Track only where the last closer ended and whether anything non-framing followed it. At replay, rebuild the channel text from the output the caller already holds (streaming already has it in the request's accumulated text), and drop the StringIO.
C. Cleanups
5. separate_reasoning has no production caller left (reasoning.py:138). Only tests call it, and test_stream_marker_properties' SEEDED list maintains rules for it.
→ Change: delete it and point those tests at ReasoningChannel.split.
6. Two read_whole with different contracts (reasoning.py:327 vs tool_parser/stream.read_whole). One returns an event list, the other (content, calls), and serving_anthropic uses both.
→ Change: rename the reasoning one read_whole_events, matching tool_parser.read_whole_events.
7. Two sources for the K3 tokens (reasoning_dialects.py:141-143). One line mixes local CHANNEL_RESPONSE_START with k3.TOOLS_START, and the next uses k3.RESPONSE_END. Three local CHANNEL_* constants still duplicate kimi_k3_tokens.
→ Change: use k3.* throughout and delete the remaining local CHANNEL_*.
8. Comments that point at removed text or describe the old read.
reasoning_dialects.py:92points toseparate_reasoningfor a rule whose explanation was removed withSplitResult.api_server.py:2604still says "Both stages over one chunk", thoughserving_anthropic.py's copy of that sentence was updated.
→ Change: point the first at ReasoningChannel.split, and make the second match how the output is now read (after item 2, "the whole output, once").
9. Tests: a duplicated sweep, and no chunk-boundary case.
tests/entrypoints/test_reasoning.py:321_every_wayhand-writes chunked streaming againstsplitagain;test_stream_marker_propertiesalready sweeps chunk invariance, and the answer-end shapes are in its corpus.test_stream_marker_properties.py:877dropped the 10 000 chunk, and every corpus string is far shorter than 4096, so nothing covers a marker straddling a piece boundary.
→ Change: keep test_reasoning.py to asserting split's results. Add one long case (over 4096 chars, with a marker across 4095/4096) to the sweep, which still matters for the streaming feed after item 2.
10. The guide restates the code comments (docs/serving_benchmarking_guide.md:155). The two new paragraphs re-describe _close_channel, _keep and the dialect field almost line by line, including "~1M tokens" and the memory cost, so the rule now lives in three places.
→ Change: keep one sentence: a dialect that declares answer_end_markers replays a skipped channel as content. Leave the details in the code.
…nly on a stopped output
MarkerScanner keeps one buffer and a cursor. A hit leaves the text after it
in the scanner: `feed("")` reads on, and `take_rest()` hands it to a caller
that routes it elsewhere (the tool parser, when a region opens; the
reasoning filter, when its state changes). One compiled alternation of the
markers, longest first, finds the next one from the cursor. This is the same
leftmost-then-longest answer as a `find` per marker, in one C call per hit.
`Scan` loses `rest` and is a NamedTuple.
So one feed of a whole output is linear in its length, whatever its marker
count. That holds for both whole-output readers, the reasoning filter and
`tool_parser.read_whole_events`. The 4 KiB pieces in the reasoning reader are
gone, and the whole output is read in one feed. Before, every hit handed the
remainder back to be fed again and searched it for every marker. On main,
`/v1/messages` took 295 ms on 460K characters with 2000 framing markers and
10.5 s on 819K with 32,768; it now takes 5.8 ms and 86 ms.
Reasoning filter:
- `flush(stopped=...)` and `split(text, stopped=...)`: at the end of the
output, a skipped channel is replayed only if the model stopped itself
(`ended_by_stop`: EOS, a stop token or a stop sequence). A trace cut off at
`max_tokens` right after a quoted closer stays reasoning. Every streaming
flush and non-streaming read passes the engine's finish reason.
- The replay goes out in 4 KiB content events.
- The `isspace` check runs only after a closer. Per token in the K3 think
channel this is now 1280 ns, against 1311-1353 on main.
- `read_whole` is renamed `read_whole_events`. `separate_reasoning` is
deleted, and its tests use `ReasoningChannel.split`.
`read_whole_blocks` joins adjacent reasoning segments into one event, so a
chain of thought split by framing inside it is one thinking block or
Responses item, as streaming sends it.
The K3 dialect reads every token from `kimi_k3_tokens`; the local
`CHANNEL_*` constants are gone. The guide keeps one sentence on the replay.
Tests: K3 cases assert `split` only; stopped and length-cut shapes; replay
pieces; long sweep shapes with a marker across 4095/4096; a scanner test
that one feed neither copies the remainder nor searches the text twice
(fails on the old hand-back).
Equivalence on BEAM: the 700 raw upstream-vLLM BEAM outputs, replayed
through the previous head (b4e72a3) and this one, give the same `content`
and `reasoning_content` on all 700. That is 141 recovered, 3 still empty and
556 answered unchanged, with stream == split at 1/5/64 characters. The BEAM
replies all end by EOS, so `stopped` holds for them. The GPU BEAM result
(0.320) carries over.
tests/entrypoints + test_required_channel: 2712 passed, 93 skipped,
3 xfailed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@valarLip Thanks. Taken in fda4bcf. Each point is below, with what I measured. CPU numbers compare the merge base 825b63b ("main") with this head, as medians. A. Correctness
B. Performance
C. Cleanups
Copilot's and valar-cop's block split is fixed too. Removing the pieces fixes the 4 KiB case, and BEAM is unchanged. The 700 raw upstream-vLLM outputs, replayed through b4e72a3 and fda4bcf, give identical
|
…the model stopped `stopped` is now keyword-only with no default on `ReasoningFilter.flush`, `ReasoningFilter.read_whole_events`, `ReasoningChannel.split` and `read_whole_blocks`. An endpoint added later cannot omit it and so replay a skipped channel on a `max_tokens` cut. The test-only calls pass it explicitly. Tests that fail when the wiring is wrong. With `ended_by_stop` forced to True, all 1668 reasoning and endpoint tests used to pass; now 12 fail: - `test_the_reasoning_reader_is_told_whether_the_model_stopped`: chat, `/v1/messages` and `/v1/responses`, streamed and not, for `eos`, `stop_<id>` and `max_tokens`, through the real routes. The test double requires `stopped` and records it. - `test_the_chat_response_reads_the_finish_reason`, through `_build_chat_choice`. - `ended_by_stop` pinned on the engine's raw reasons. Wording: `split`'s docstring says a replayed channel is in both halves, `_pattern_for` finds where a marker starts, and two test docstrings no longer describe bounded pieces. The `api_server` comment is reflowed. Equivalence on BEAM: the 700 raw upstream-vLLM BEAM outputs (all ended by EOS) give the same `content` and `reasoning_content` on all 700 as b4e72a3/fda4bcf55: 141 recovered, 3 still empty, 556 unchanged. tests/entrypoints + test_required_channel: 2741 passed, 93 skipped, 3 xfailed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@valarLip I ran ATOM's own
Checked and cleared:
BEAM is unchanged: the 700 replayed outputs give identical |
There was a problem hiding this comment.
🟡 Changes recommended
A critical filter-state bug keeps reasoning detection active inside tool arguments when thinking is off, causing literal reasoning markers to divert argument content into reasoning and leave the tool argument empty.
1 open issue
Review effort: Balanced
| _REPLAY_PIECE = 4096 | ||
|
|
||
|
|
||
| def ended_by_stop(finish_reason: str | None) -> bool: |
There was a problem hiding this comment.
why re-spell the stop set here? let's derive it from one list in protocol.py, next to openai_stop_reason
There was a problem hiding this comment.
Agreed, done in a3190e5. ended_by_stop now lives in protocol.py, next to openai_stop_reason, and the stop set is spelled once there: MODEL_STOP_REASONS = ("eos", "stop", "stop_sequence") plus the stop_<token_id> prefix. openai_stop_reason derives its stop branch from the same predicate, and its mapping is unchanged (max_tokens → length, everything else → stop). The callers import it from protocol, and reasoning.py only takes the bool.
…ner starts the answer `ended_by_stop` moves to `protocol.py`, next to `openai_stop_reason`, with the stop set spelled once there (`MODEL_STOP_REASONS` and the `stop_<token_id>` prefix). `openai_stop_reason` derives its "stop" branch from the same predicate. Its mapping is unchanged: `max_tokens` gives `length`, and everything else gives `stop`. The callers import it from `protocol`, and `reasoning.py` only takes the bool. With thinking off, the reasoning filter (state 0) watches every end marker again. The think exit keeps its offset-0 rule: declined to think at offset 0, the model's own words further in. Another end marker, the K3 tools opener, means the answer's channels have begun wherever it falls. It is forwarded unchanged, so the tool parser still sees it, and the filter moves to the answer state. No think channel follows a tools channel, so a think marker inside the call is the argument's own text. Before, `text="<|open|>think<|sep|>payload<|close|>think<|sep|>"` sent `payload` to `reasoning_content` and left the argument empty. With the call at offset 0 that came from this PR's offset-0 change; after other text it was already so on main. Regression test with the call leading and after text. Equivalence on BEAM: the 700 raw upstream-vLLM BEAM outputs give the same `content` and `reasoning_content` on all 700 (141 recovered, 3 empty, 556 unchanged). BEAM requests think, so the thinking-off rule does not apply. tests/entrypoints + test_required_channel: 2744 passed, 93 skipped, 3 xfailed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…0839 / 将 Kimi-K3 ATOM P/D 切换到 rocm/atom-dev:nightly_202611040839 - The image is rocm/atom-dev:nightly_202611040839 (ROCm/ATOM 16ebac2eb), pinned by digest; the AToMesh router pin follows it. - It carries ROCm/ATOM#2902, which chunks Kimi-K3 image prefills, and ROCm/ATOM#2910, which returns the reply as content when Kimi-K3 writes it inside its think channel. They close the two Kimi Vendor Verifier gaps: OCRBench 0.864 -> 0.886, BEAM (1M) 0.248 -> 0.320 with Kimi-K3 as judge. The changelog entry lists them. - 镜像改为 rocm/atom-dev:nightly_202611040839(ROCm/ATOM 16ebac2eb),按 digest 固定;AToMesh 路由的固定版本随之更新。 - 该镜像包含 ROCm/ATOM#2902(Kimi-K3 图像 prefill 分块)和 ROCm/ATOM#2910(Kimi-K3 把回复写进 think 通道时,作为 content 返回),补上了 Kimi Vendor Verifier 的两处差距:OCRBench 0.864 -> 0.886,BEAM (1M) 0.248 -> 0.320(Kimi-K3 作为评分模型)。changelog 条目已列出这两个 PR。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>


Problem
On ~1M-token prompts Kimi-K3 sometimes skips its think channel. The prompt opens
<|open|>think<|sep|>, and the model writes its reply straight into that channel. It closes the reply with the answer's own closer,<|close|>response<|sep|><|close|>message<|sep|>, and never emits<|close|>think<|sep|>or<|open|>response<|sep|>.Both delivery modes returned the whole reply as
reasoning_content, withcontentempty. The Kimi Vendor Verifier's BEAM (1M) benchmark scorescontentonly, so about one reply in five scored 0. Upstream vLLM shows the same thing on the same requests, so this is the model's behaviour, not an ATOM numerics problem:content<|close|>response<|sep|>SGLang reads this shape too (sgl-project/sglang#37743): non-streaming returns it as content, while streaming re-sends what it already streamed as reasoning, so its two modes return different
reasoning_content.Change
A skipped channel is only known when the channel ends, after a streamed reply has already gone out as
reasoning_content. This PR sends the reply again ascontent, and does it the same way on every endpoint and in both delivery modes.One reader. The non-streaming split is
ReasoningFilterrun over the whole output (ReasoningChannel.split→ReasoningFilter.read_whole_events). The per-dialect split functions are gone (_split_channel,_split_inline_tag,_inline_tag_split,ReasoningDialect.split,SplitResult,strip_framing,separate_reasoning). The filter is chunk-invariant, so chat,/v1/messagesand/v1/responsesreturn the same reasoning and content, streamed or not.When the channel was the reply.
ReasoningDialect.answer_end_markersdeclares the answer's closer; for Kimi-K3 that isk3.RESPONSE_END. Inside the think channel, the filter drops the closer and remembers it. The channel's text is replayed ascontentwhen all of these hold:<|close|>think<|sep|>or<|open|>response<|sep|>means the model answered in its own channel, so nothing is replayed;stopped=protocol.ended_by_stop(finish_reason)(eos,stop_sequence,stop_<id>). It is a required keyword, so a trace cut atmax_tokensright after a quoted closer stays reasoning.A closer the channel goes on past (a quoted one) changes nothing. The replay goes out in 4 KiB content events. The channel text is kept as one
io.StringIO, only for a dialect that declares an answer closer, and dropped when the channel ends.Thinking off. At offset 0, only the think exit means "declined to think". A
<|open|>tools<|sep|>is a channel switch: it passes through as text, so the call keeps its opener.MarkerScannerreads a whole output in linear time. This scanner is shared with the tool-call parser. It keeps one buffer and a cursor, and finds the next marker with one compiled alternation, longest first. That is the same leftmost-then-longest answer as afindper marker. A hit leaves the text after it in the scanner:feed("")reads on, andtake_rest()hands it to a caller that routes it elsewhere.Scanno longer hasrest. Handing the remainder back on every hit made one large feed quadratic in its marker count. On the merge base,/v1/messagestook 295 ms on 460K characters with 2000 framing markers and 10.5 s on 819K with 32,768. It now takes 5.8 ms and 86 ms. On ordinary outputs both readers are 2-3x faster than before, and per-token streaming cost is unchanged.Blocks.
read_whole_blocksjoins adjacent reasoning segments. A chain of thought that the filter splits at framing inside it is then onethinkingblock or Responses reasoning item, as streaming sends it.The alternative to replaying was holding the channel back. It would stream no K3 reasoning until the channel closed, on every request, to avoid a copy on a shape seen at ~1M tokens.
A reply ended by EOS with no closer still reads as reasoning. It cannot be told apart from a trace cut off at
max_tokens.With thinking off, a K3 tools opener also moves the filter to the answer state, wherever it falls. It is forwarded unchanged, so a think marker inside the call stays in the argument. Markers inside arguments for the inline
<think>dialects, and framing inside arguments, predate this PR and are tracked in #2974.Tests
test_reasoning.py,TestTheChannelEndsAtWhicheverCloserComesFirst, which pinssplit's(reasoning, content)for the K3 shapes:ended_by_stop(inprotocol.py, one stop set shared withopenai_stop_reason) on the engine's raw reasons;test_api_compatibility.py,test_the_reasoning_reader_is_told_whether_the_model_stopped: chat,/v1/messagesand/v1/responses, streamed and not, through the real routes.test_stream_marker_properties.py,TestTheReasoningReadIsChunkInvariant: the split against 1/3/11-character streaming over every dialect's corpus. The corpus is generated fromanswer_end_markersand includes outputs over 4096 characters with a marker across 4095/4096.test_marker_scanner.py,TestOneLargeFeed: a whole feed reads what one-character feeds read, and neither copies the remainder nor searches the text twice. The old hand-back fails it.tests/entrypoints+test_required_channel.py: 2744 passed, 93 skipped, 3 xfailed. black 26.10.1 and ruff are clean.Validation
Replay. The 700 raw upstream-vLLM BEAM outputs, with their channel tokens kept, run through this PR (stream and split agree at 1/5/64 characters on every output):
content. Each ends in exactly<|close|>response<|sep|><|close|>message<|sep|>, by EOS.max_tokens, two EOS with no marker.content.The GPU result below was measured on the first version of this change. Every later revision gives the same
contentandreasoning_contenton all 700 replayed outputs. KVV's BEAM request is non-streaming and scorescontentonly, so the result carries over.On GPU. Kimi-K3 on 8x MI355X with the #4311 serving config (DSpark 4, FP8 KV, ptpc_fp8). The client sends KVV's own BEAM request:
thinking_effort=max, temperature 1.0, top_p 0.95, non-streaming. Kimi-K3 is the judge, since KVV names none.contentThe 10 left are:
max_tokens;Paired question by question against vLLM's answers, the replies themselves score the same (+0.003 ± 0.022 on the questions both engines answered). So the BEAM gap was this parsing alone.
Tool calls. The KVV
tool_call_json_schemapre-flight passes all 408 cases with this change, the same as without it.🤖 Generated with Claude Code