Repository navigation
[#21318][fix] Drop streamed stop-word ids from the final streamed text - #21341
Conversation
…ed text
When the sampler matches a stop word by token id and
include_stop_str_in_output is False, _handle_sequence trims the stop ids
from token_ids. In a streamed request, the ids that arrived in earlier
responses had already been fed to the incremental detokenizer, so the
final .text kept the start of the stop string ("<answer>42</answer"
instead of "<answer>42"). If the decoder was still holding the lead
bytes of a character that the stop word completes, the final flush added
U+FFFD: a Thai digit or tone mark stop such as "๒" left "ข้อ ๑ ถูก ข้อ �".
When the trim reaches ids from earlier responses, decode the kept ids
with tokenizer.decode, as the non-streaming path does, and drop the
incremental state. Then move the token-id watermark to the kept ids, so
that later responses for sibling candidates (n > 1) do not decode the
finished output again.
Signed-off-by: spped2000 <spped2000@gmail.com>
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Decode trimmed streamed output tensorrt_llm/executor/result.py |
When retained token IDs are shorter than the prior watermark, the handler uses full decoding. It clears incremental decoder state and updates the watermark to the retained token count. |
Test streamed stop-word handling tests/unittest/executor/test_stop_word_streaming_text.py, tests/integration/test_lists/test-db/l0_cpu.yml |
CPU tests cover both detokenization backends, stop words, length completion, and multiple sequences. The CPU integration test list includes the test module. |
Estimated code review effort: 2 (Simple) | ~10 minutes
Change: Bug fix · Severity of issue fixed: Medium
Possibly related issues
Suggested reviewers: juney-nvidia, junyixu-nv, brnguyen2
Merge Risk: 
Minimal · up to 92fe5
-
No merge-blocking risk is established for this change.
Pre-merge checks: 
4 · 
1
-
Failed checks: 1 warning Passed checks: 4 passed-
Full details: Linked Issues check
-
Explanation
Issue [
#21318] requires final streamed.textto match non-streamed text after token-ID stop trimming, including multi-token stops and pending Thai byte flushes.DetokenizedGenerationResultBase._handle_responsenow detects when trimming removes previously streamed IDs, decodes the retained IDs withtokenizer.decode, clears_incremental_states, and advances_last_token_ids_len. The new CPU tests cover HF and TRTLLM backends, chunk sizes one and two, Thai replacement-character cases, answer-tag stops, final-response-only stops,include_stop_str_in_output, length finishes, and interleaved n=2 responses. The change does not claim to retract already emitted OpenAI-compatible deltas, which#21318excludes.
Full details: Out of Scope Changes check
-
Explanation
The changes are limited to the linked issue's detokenization fix, focused automated tests, and CPU test-list registration. The test cases validate required behavior and unchanged behavior. No unrelated product behavior or unrelated files appear in the reviewed change summary.
-
Full details: Docstring Coverage
-
Explanation
Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 4 functions across 2 files.
Resolution
Write docstrings for the functions missing them to satisfy the coverage threshold.
-
Finishing Touches
-
🧪 Generate unit tests (beta)
-
- Create a new PR
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Comment @coderabbitai help to get the list of available commands.
brnguyen2
left a comment
There was a problem hiding this comment.
Approving — the comments below are optional touch-ups, not blockers.
The gate is correct as written. _handle_sequence sets _last_token_ids_len before it extends token_ids, and the trim only removes trailing ids. So whenever any stop id came in an earlier response, the final response holds only stop ids and len(token_ids) < _last_token_ids_len fires exactly then. The sibling (n>1) test is a good catch for the watermark update.
Two optional follow-ups:
-
Pin the known delta gap in a test. For
answer_tag, the concatenated streamed deltas still end in</answerwhile the final.textis<answer>42, because the earlier chunks already sent that prefix. The description says this is out of scope, but no test records it. Add an assertion (or anxfailwith a pointer to #21318) on the joined deltas foranswer_tag, so a future holdback change has a baseline and nobody readstest_streamed_deltas_do_not_end_in_replacement_characteras covering multi-char stops in general. -
Make the trim signal explicit (nit).
_handle_responseworks out "a trim reached streamed ids" by comparing two lengths that_handle_sequencehappens to leave in a particular order. If_trim_stop_word_outputs(or its caller) recorded how many already-streamed ids it removed, this path wouldn't depend on the watermark ordering in_handle_sequence. Not blocking.
…n stops Assert the joined text_diff values in the stop-word test. For "</answer>" they still end in "</answer": earlier responses already streamed that prefix and deltas cannot take text back. The assertion gives a later holdback change a baseline to update. Signed-off-by: spped2000 <spped2000@gmail.com>
|
Thanks for the review, @brnguyen2.
|
|
Thanks for the reviews, @brnguyen2 and @zhaoyangwang-nvidia. Could a maintainer trigger |
|
/bot run |
|
Automatically added "ci: full pre-merge approved" because this PR has satisfied the required GitHub review approvals. Unresolved review conversations and other required checks remain independent merge requirements. |
|
PR_Github #82648 [ run ] triggered by Bot. Commit: |
|
PR_Github #82648 [ run ] completed with state |
… top n After the merge with NVIDIA#21341, a candidate that a stop word trims while it is outside the top n has its _last_token_ids_len moved to the kept length by a sibling response, so the trim check missed it when it was selected again and its text kept the cut ids. Also compare against the incremental-detokenization cursor. Signed-off-by: Han-Yin Chang <nick20350@gmail.com>
Dev Engineer Review
DetokenizedGenerationResultBase._handle_responsenow fully decodes retained token IDs when stop-word trimming removes IDs that were already streamed. It clears incremental decoder state and updates the token-ID watermark. This prevents stale stop-word text and replacement characters from appearing in final.text.The change does not retract stop-word prefixes already sent in OpenAI-compatible stream deltas. A holdback mechanism remains out of scope. No public API changes are reported.
QA Engineer Review
Added
tests/unittest/executor/test_stop_word_streaming_text.pyand listed it in the CPU CI filetests/integration/test_lists/test-db/l0_cpu.yml. The tests cover both detokenization backends, chunk sizes one and two, previously streamed stop IDs, final-response stops,include_stop_str_in_output, length finishes, and sibling sequences. The author reports that all 20 CPU-only cases pass; test execution was not independently verified here.Coverage verdict: sufficient for the reported regression and listed edge cases. The tests also assert that joined deltas still contain
</answerfor the answer-tag case; previously emitted deltas cannot be retracted.Per-File QA Perspective
tensorrt_llm/executor/result.py: Verify that final streamed text matches non-streamed text after trimming previously streamed stop IDs, and that ordinary incremental decoding remains unchanged when no prior IDs were trimmed.tests/unittest/executor/test_stop_word_streaming_text.py: Covers stop trimming, incomplete Thai byte sequences, final-response and length cases, and sibling sequences across both backends. Listed in the CPU CI test database.tests/integration/test_lists/test-db/l0_cpu.yml: Adds the regression test to CPU CI selection.Description
Fixes #21318.
When the sampler matches a stop word by token id and
include_stop_str_in_outputis False,_handle_sequencetrims the stop ids fromtoken_ids(_trim_stop_word_outputs). A non-streamed request then decodes the trimmed ids. A streamed request does not: the stop ids that arrived in earlier responses were already passed todecode_incrementally, andtoken_ids_diffis empty after the trim. So the final.textkeeps the start of the stop string, for example'<answer>42</answer'instead of'<answer>42'.Since #20424, the HF backend flushes held bytes on the final response. When the stop word completes a character whose lead bytes the decoder was still holding, that flush appends U+FFFD. This happens for every Thai digit
๐-๙and the marks๊ ํ ๎: on the Qwen3 tokenizer and the Typhoon 2.5 / Typhoon-S tokenizers built on it, each of these 13 characters is token8158plus one continuation-byte token ("๒"is[8158, 240]). Withstop="๒", the streamed text is'ข้อ ๑ ถูก ข้อ �'against'ข้อ ๑ ถูก ข้อ 'non-streamed. The TRTLLM backend shows both symptoms too.Fix. In
DetokenizedGenerationResultBase._handle_response, if the trim reached ids from earlier responses (len(token_ids) < _last_token_ids_len), skip the incremental step. Instead:tokenizer.decode, as the non-streaming and beam-search paths do;_incremental_states, which drops the pending flush;_last_token_ids_lento the kept ids.token_ids_diffis[]before and after this, so consumers see no difference. It makes the gate fire once per trimmed output: with n>1, later responses for sibling candidates still walk every output, and without it the finished candidate would be fully decoded again on each of them.The final streamed
.textis thentokenizer.decodeof the kepttoken_ids, as in the non-streaming path. In every case tested here it equals the non-streamed.text.The gate is false, so the incremental path runs as before, for:
include_stop_str_in_output=True;"Hello there! "case intest_llm.py);With n>1 streaming, an output that a response does not touch still re-feeds its previous
token_ids_diffto the incremental decoder. That is older than this change and is what #9802 (closed) tried to address; this PR does not change it.Effect.
.textis fixed.grpc/openengine/streaming.py:104-130sendsoutput.textup to its full length oncefinish_reasonis set.'�'is gone from the lasttext_diff, which is what the OpenAI-compatible stream handlers send as the last delta.Not covered. The OpenAI-compatible stream handlers send each
text_diffas soon as it is decoded. Deltas that already carried a stop prefix (คำตอบ,</answer) before the stop matched are not taken back. That needs a holdback like the gRPC path's_StopPrefixTrackerand is left for a separate change.Test Coverage
New
tests/unittest/executor/test_stop_word_streaming_text.py(cpu_only, registered inl0_cpu.yml, 20 cases). It drives the realDetokenizedGenerationResultBasewith a small byte-fallback tokenizer, for both detokenization backends and for 1 and 2 tokens per response:</answer>stop (leading tokens already decoded) both give a final streamed.textequal to the non-streamed text.text_diffon each of candidate 1's responses, and is fully decoded exactly once.include_stop_str_in_output=True, and a LENGTH finish that still flushes held bytes.Pre-fix vs post-fix. These runs use the
1.4.0rc2release container on CPU. Itsexecutor/result.pyis byte-identical tomain(7d8d94684) before this change.Existing tests that still pass:
tests/unittest/executor/test_stop_word_side_output_trim.pytests/unittest/llmapi/test_tokenizer_decode.pytests/unittest/executor/test_postprocessor_hook.pytests/unittest/llmapi/test_executor.py::test_result_stream_sequence_alignmentReal tokenizer (CPU). With the Qwen3-0.6B tokenizer, the streamed and non-streamed
.textdiffer in 6 of 6 cases before the change and in 0 of 6 after, on both backends.n=2 decode count (CPU). In an interleaved n=2 probe, the finished candidate is fully decoded 6 times without the watermark move and once with it.
GB10 end to end (DGX Spark,
release:1.4.0rc2with this change'sresult.pycopied in for the after run, Qwen3-0.6B, greedy,LLM.generate_async(streaming=True), stop"๒"and"๓"; the engine matches[8158, 240]/[8158, 241]):.textis' �'against' 'non-streamed.' ', and so are the joined deltas.">\n", so the stop matched only at the text level. Its final.textis''both before and after, as expected. Its joined deltas are'</answer'both before and after. That is a live example of the delta leak described under Not covered.PR Checklist
Please review the following before submitting your PR:
PR description clearly explains what and why. If using CodeRabbit's summary, please make sure it makes sense.
PR Follows TRT-LLM CODING GUIDELINES to the best of your knowledge.
Test cases are provided for new code paths (see test instructions)
If PR introduces API changes, an appropriate PR label is added - either
api-compatibleorapi-breaking. Forapi-breaking, includeBREAKINGin the PR title.Any new dependencies have been scanned for license and vulnerabilities
CODEOWNERS updated if ownership changes
Documentation updated as needed
Update tava architecture diagram if there is a significant design change in PR.
The reviewers assigned automatically/manually are appropriate for the PR.
Please check this after reviewing the above items as appropriate for this PR.
GitHub Bot Help
To see a list of available CI bot commands, please comment
/bot help.AI assistance: this change, its tests and this description were prepared with the help of Claude Code; every test and repro cited here was run locally.
🤖 Generated with Claude Code
https://claude.ai/code/session_01ECBhwNjbBwLHSMWvZEbxTD