改动 / #3207
#3207 [Engine] a reply that ends inside its thinking is continued and reported (#1814)
open · @47Hunter47 · 0 评论 · 去 GitHub 看
Server & APIAMD / HIPNVIDIA / CUDAModels & quants
说明
Fixes #1814. #1178's turn-side case is the same end and is covered by the same flag. This is the work #1840 carried (closed in the tracker reset), rebased onto `main` and extended by one test commit. ## Problem and result A reply that ends inside its thinking with no answer comes back empty, and nothing says why: `finish_reason` is `stop`, `content` is empty, the reasoning stops mid-sentence. I retested the end on a stock `v0.2.0-beta` checkout (`3d1fb480`, engine built from that source, `/props.build_info` reports `Strata 0.1.45.3`), RTX 3090, Qwen3.8-Flash-Next IQ3_XXS, 250K context, `--kv int8`, sampling 1.0/0.95/20, `reasoning_budget_tokens` 16384: | prompt | runs | empty replies | | --- | --- | --- | | #1178's minimal input (asking for the turn-end marker, verbatim) | 4 | 4 of 4 (reasoning 103-250 chars, 44-115 tokens generated of an 8000-token cap) | | #1814's prompt (the closing thinking tag in a code fence) | 4 | 1 of 4 | With `reasoning_close_retry: true` the replies stop being empty (0 of 8) but are stubs: 9, 10, 12 and 80 characters on #1178's input, because the close happens exactly where the closing tag would start. With this change, behind one opt-in flag: 0 of 14 empty over two batteries of the same two prompts. The marker's text goes back into the reply as ordinary text where the model was writing it (2 of 3 trigger-prompt runs contain the exact 10-character marker text, which is what that prompt asks for), the thought continues from there (the code-fence prompt answers in full, 1120 and 1211 characters), and the end is reported: one log line, `totals.ended_inside_thinking`, and `"ended_inside_thinking": true` beside `choices`. ## Related discussion #1814 (the shape this fixes), #1178 (the turn-side case, covered here), #2731 (my retest of this end on 0.2.0-beta, reopened with the numbers above), #1840 (the same work before the tracker reset), #1053 and #123 (the close this falls back to), #931 (the plain spans the write-back uses), #728 (the loop this deliberately does not touch). ## Approach In this pack's tokenizer the token that ends the turn is a type-3 control token (id 248046, matched only with `parse_special`) and the closing thinking tag is a type-4 token (id 248069). The turn ends on the control token itself, so no character of any marker reaches the stream, and a turn that ended there on purpose looks exactly the same - which is also why the parser cannot catch it. Behind `"literal_think_guard": true` in `strata-<model>.json` (or `STRATA_LITERAL_THINK_GUARD=1`), like #1053's `reasoning_close_retry`: 1. The end is reported: one log line per reply, `totals.ended_inside_thinking` exposed as `strata:totals_ended_inside_thinking_total` on both `/metrics/prometheus` and `/metrics` with `Accept: text/plain`, and `"ended_inside_thinking": true` beside `choices` and in the stream's last chunk. 2. The token that ended the turn is given back as text where the model was writing it (#1178), as ordinary text through the tokenizer's `plain` spans (#931's mechanism), in the reply and in the next pass's prompt. With the real pack tokenizer: encoding the marker with `parse_special=True` is the single control token 248046, while the same string marked `plain` is the same six ordinary text tokens as the unparsed string, which is what makes the next pass carry on instead of stopping at once. Capped per reply (`LITERAL_MARKER_WRITES = 2`); a thought that repeats whole passages is #728's loop, not a marker it mentions, and goes to the close. 3. Otherwise the thinking is closed the way #123's wrap-up closes it and the reply is continued, capped per reply (`LITERAL_THINK_RETRIES`). The write-back runs before `reasoning_close_retry`'s close, so a server with both on keeps the thought that was writing the marker. With the flag off a default server is unchanged: no log line, no counter, no response field, and unit checks cover the field and the counter being absent. ## Validation **Passed:** - `python -m unittest serve.test_literal_think_guard serve.test_prometheus` - 27 tests, OK (this head `791e7b0e`, Ubuntu, RTX 3090). - Full suite `python -m unittest discover -s serve -t . -p "test_*.py"` - 902 tests, 14 skipped, the failures listed below. - Both fork workflows on this head: `Portable monitor client` and `Minimal serving runtime`, green. - Two live batteries with the guard on (8000 max_tokens): 0 of 14 empty, the marker's text back as ordinary text where the model was writing it, `ended_inside_thinking: true` on the 7 runs that ended inside. - Two live batteries on the stock `v0.2.0-beta` tag without the change: 5 of 8 empty (the table above), and 0 of 8 empty but 9-80 character stubs with `reasoning_close_retry: true`. **Failed:** - `test_keepawake.KeepAwakeTests.test_service_holds_for_the_whole_request_including_early_close` - every full run on this machine, and the same on a detached `v0.2.0-beta` worktree (881 tests, 1 failure, that test). - `test_responses.NoLeakedSampler.test_server_close_ends_the_sampler` - 2 of 3 full runs here. It passes alone (3 of 3) and right after this PR's own module, and it also fails on older trees of this suite on this machine, so I read it as the thread-racy sampler counting this suite already has, not this patch. I did not fix it here. **Not run:** - Other engines than this box's CUDA build (HIP, SYCL, Metal, CPU-only), other models and packs, and streaming clients other than the plain non-stream calls in the batteries. The unit tests run against mock engines. ## Limits - Capped per reply (two write-backs, two closes). A longer pathology is #728's loop, not this end. - The reporting sits behind the same flag, so a client that did not opt in is told nothing; the counter in `/metrics` is what an operator sees. - The marker's text goes back as the tokenizer's plain spans, so the reply mentions the marker as text. Nothing else about the stream changes. - One of this PR's tests needed an adaptation on this base: the length-cut specimen ends on `max_tokens`, but 0.1.45's answer room caps the thinking at 3/4 of `max_tokens` and wraps it up and answers before the cut is reached. The test turns the answer room off so the turn is the one that was recorded. ## History #1840 carried this work and was closed in the tracker reset. The branch is 8 commits (the 7 of #1840 plus the test adaptation above); the diff is +549/-2 in 6 files, the stat #1840 reported plus that test commit. The first push was based on the `v0.2.0-beta` tag and both fork workflows went red on it: the tag's `tools/minimal_server_smoke.py` summary regex predates `fcca2ccb` and rejects `OK (skipped=N)`, and the monitor client smoke wants the packaging preflight that landed after the tag. Rebasing onto `main` fixed both without touching the diff.
本站相关内容
相关页面的快捷入口。