Skip to content

[Bugfix] Hide stop text on batch derender - #59510

Closed
pOpO1-9 wants to merge 5 commits into
vllm-project:mainfrom
pOpO1-9:fix/derender-batch-stop
Closed

pOpO1-9 wants to merge 5 commits into
vllm-project:mainfrom
pOpO1-9:fix/derender-batch-stop

Conversation

@pOpO1-9

@pOpO1-9 pOpO1-9 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Overview

Batch /derender was decoding the stop token the engine had already left out of the text, so stop and stop_token_ids leaked and stop_reason stayed null. Fixes the batch half of #57052. Streaming /derender now returns 400 when the request carries stop strings, which is the direction on #47161.

Claims

  • A batch chat or completions derender drops the last token only when finish_reason is stop and that id is in the client's stop_token_ids, equals tokenizer.eos_token_id, or is in the model's generation_config eos_token_id list. finish_reason=stop alone does not drop a token. include_stop_str_in_output keeps it.
  • The token check runs first. The stop-string check then runs on the text that remains, using the same check_stop_strings as the engine. A matched stop string wins. The space before the stop word stays.
  • stop_reason is the matched string, or the token id. It stays empty for the primary EOS.
  • Token ids on the generate response are not rewritten.
  • Streaming /derender returns 400 if the chat or completion request carries stop strings.

Validation

pytest tests/entrypoints/scale_out/derender/test_derender_stop.py — 12 passed. The tests use a tiny in-memory tokenizer and do not need a GPU. They cover a stop string, keeping that string when asked, an ordinary last token left in place, a client stop_token_ids drop, a primary EOS drop with an empty stop_reason, a generation_config EOS drop, keeping the stop token when asked, the token check before the string scan, a length finish, and the completions stop string.

The live server tests in test_derender.py were not run. This machine cannot start vllm serve.

pytest tests/entrypoints/scale_out/derender/test_derender_stop.py

Details

The engine truncates output_text and then appends the stop token id back onto token_ids. ServingTokens ships those ids. Derender decoded the full list, so the stop word came back and include_stop_str_in_output did nothing.

Derender does not assume every finish_reason=stop input ends in a stop id. Round-trip fixtures encode real text and do not append one. Dropping that token broke encode(derender(ids)) == ids.

The batch pass recomputes the cut on the derender side because GenerateResponseChoice still has no stop_reason (#47313). Once that lands, derender can pass the field through instead of scanning.

The parser path scans text with special-token markers kept. The engine scans with skip_special_tokens applied, so a stop string that overlaps a marker can cut differently.

Streaming with no chat_request has no stop list to refuse, so that leak stays until output_mode (#56851).

Streaming is a 400, not a hold-back buffer. The engine-side fix is output_mode (#56851).

Logprobs are not trimmed to the shortened text.


Pull Request Checklist
  • I used vLLM's /pr-checklist skill. (Mandatory for agents, optional for humans).

  • AI assistance was used during the creation of this PR.

  • Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.

  • Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.

  • Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.

  • Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.

The scale-out path kept the stop token ids the engine had already left out of the text. Batch derender now drops that token, cuts the stop string, and fills stop_reason. Streaming derender returns 400 for stop strings.

Signed-off-by: Paul El Khoury <82139421+pOpO1-9@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run for upstream CI or /amd-ci run for AMD CI only whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use the corresponding /ci run, /ci retry, and /ci cancel commands, or their /amd-ci variants. New commits do not start upstream CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

@shimib

shimib commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

Thanks @pOpO1-9, this matches the design on #57052. I ran the branch locally: the 7 new tests pass and the existing derender unit and round-trip suites still pass (109 passed, 1 xfail).

One suggestion on the token rule. The engine skips the last token from text for any token-terminated stop, and it merges the model's generation-config stop tokens into stop_token_ids server-side (SamplingParams.update_from_generation_config), so the client's list is a subset. Checking membership misses EOS / end-of-turn tokens when skip_special_tokens=False. A simpler rule that matches the engine: finish_reason == "stop" and no stop string matched, then drop the last token (unless include_stop_str_in_output). No membership check needed.

Two things worth a sentence in the PR body: the parser path scans marker-preserving text while the engine scans with skip_special_tokens applied, so a stop string overlapping a marker can cut differently; and plain streaming with no chat_request can't be gated, so that leak stays until output_mode.

The live render → generate → derender comparison from #57052 would be the strongest validation once you have a machine with torch.

The client stop list misses EOS and end-of-turn ids the engine adds on the server. A token stop now drops the last id when no stop string matched.

Signed-off-by: Paul El Khoury <82139421+pOpO1-9@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@pOpO1-9

pOpO1-9 commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks Shimi. That rule is cleaner, so I put it in.

If the finish reason is stop and no stop string matched, the last token comes out of the text. It stays when include_stop_str_in_output is set. A stop string still wins, and in that case the last token stays too.

I added your two notes to the description. The parser still reads text with the markers kept, so a stop string on a marker can cut differently from the engine. Streaming with no chat request still has nothing to refuse, so that leak waits on output_mode.

Thanks again for running the tests. I still cannot run the live render, generate, derender pass from this machine.

@shimib

shimib commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

@pOpO1-9 I have to walk part of that back. I re-ran the branch at cf3e5fe: the 7 new tests pass, but 7 existing tests in test_derender.py now fail (test_e2e_plain_roundtrip, test_e2e_token_identity, test_e2e_parsed_reasoning, ...). Example: 'The answer is four' == 'The answer is four.'. Those tests encode text into token ids with no trailing stop token and default finish_reason="stop", so the unconditional drop eats a real token. test_e2e_token_identity is the RL invariant, encode(derender(ids)) == ids, so this is a contract, not just a fixture.

Real engine output does end with the stop/EOS id (the detokenizer re-appends it), so the rule is right on real traffic, but derender shouldn't assume that about its input. The membership check you had was correct; it just needed the full set:

  • drop the last token only if it is in the client's stop_token_ids, equals tokenizer.eos_token_id, or is in the model's generation-config eos_token_id list (model_config.try_get_generation_config()), which the engine merges into stop_token_ids server-side;
  • stop_reason is None when it is the primary EOS (engine does the same in check_stop), otherwise the token id;
  • do the token check before the string scan, on the remaining text, which is the engine's order.

Sorry for the detour.

@pOpO1-9

pOpO1-9 commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @shimib. You were right to walk that back.

The last token is dropped only when it is in the client's stop_token_ids, equals tokenizer.eos_token_id, or is in the generation_config eos list. stop_reason stays empty for the primary EOS, and is the token id otherwise. The token check runs first, then the stop string check on the text that is left.

The new unit tests pass, including the case where finish_reason is stop and the last token is ordinary text. I still cannot run the live server tests from this machine. Thank you for catching the round-trip.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Paul El Khoury <82139421+pOpO1-9@users.noreply.github.com>
Streaming /derender was returning 400 whenever the request carried stop strings. Plain chat and completion streams now keep the possible stop prefix in stream state, cut a matched stop string, and emit nothing after it fires. A parser-configured chat stream still returns 400, because that path replays tokens and cannot hold the suffix back.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Paul El Khoury <82139421+pOpO1-9@users.noreply.github.com>
@pOpO1-9
pOpO1-9 force-pushed the fix/derender-batch-stop branch from f4e9f6d to e8fb528 Compare October 1, 2026 10:53
@pOpO1-9

pOpO1-9 commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Streaming /derender no longer returns 400 just because the request has stop strings.

Plain chat and completion streams hold the possible stop prefix in stream_state (held_text), the same max(len(stop)) - 1 characters the engine holds. A matched stop string is cut, and later chunks emit nothing. A stop token is still dropped only when that id is a known stop.

Parser-configured chat streaming still returns 400. That path replays tokens through parse_delta and cannot hold a suffix back. The inline fix for that case remains output_mode on /inference/v1/generate (#56851, #58588).

The missing Signed-off-by on the previous commit is fixed. pre-run-check still requires a maintainer ready label before pre-commit runs.

@hickeyma hickeyma left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

..

@hickeyma hickeyma left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @pOpO1-9, the batch fix looks good.

For streaming, can we go back to rejecting stop rather than holding text back? On #47161 we agreed (#47161 (comment), #47161 (comment), #47161 (comment)) that streaming /derender returns a 400 for stop strings and points to output_mode=text (#56851) because the engine already does the hold-back. We also agreed to change DerenderStreamState only once, designing the #59590 logprob cursor and any future hold-back together. held_text, stop_fired and matched_stop_reason get ahead of that and once clients echo them back they're part of the wire format.

So could you:

  • drop those three fields and apply_streaming_stop and its tests
  • return the 400 for any streaming request with stop strings, chat or completions, parser or not (right now it's only chat with a parser)
  • add a line to derenderer.md saying streaming rejects stop

Dropping a known stop token on the final chunk can stay if it doesn't need state. If it does, leave it out for now.

I was going to open a PR for the streaming 400 but yours covers it, so I'll leave it here. I'll review the batch part once streaming is updated.

Streaming was holding stop text in stream_state. Those fields get ahead of the one agreed change to DerenderStreamState. Stop strings now return 400 for chat and completions, parser or not, and the error points at output_mode=text. A known stop token on the final chunk is still dropped, and that check stores nothing.

Signed-off-by: Paul El Khoury <82139421+pOpO1-9@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@pOpO1-9

pOpO1-9 commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks Martin. Streaming is a 400 again.

held_text, stop_fired, matched_stop_reason, and apply_streaming_stop are gone, along with their tests.

Chat and completions both return 400 when the request carries stop strings, parser or not. The message points at output_mode=text. derenderer.md says streaming rejects stop.

A known stop token on the final chunk is still dropped. That check looks only at the current chunk, so it adds nothing to stream_state.

The unit tests for this pass. I still cannot run the live server from this machine.

@mergify

mergify Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Documentation preview: https://vllm--59510.org.readthedocs.build/en/59510/

@mergify mergify Bot added the documentation Improvements or additions to documentation label Oct 1, 2026
@mergify

mergify Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @pOpO1-9.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@hickeyma hickeyma left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @pOpO1-9, the streaming changes look good and its going in the right direction.

A few things that you might take a look at:

  • It needs a rebase. #60073 moved parse/assembly out of _derender_chat, so the batch chat part will need some rework.
  • generation_eos_ids() calls try_get_generation_config() on every choice and every stream chunk. That isn't cached and does a GenerationConfig.from_pretrained each time and in the stream path it runs on the event loop. Can you load it once in __init__ like InputProcessor does? You can also use self.model_config directly instead of getattr.
  • The stop string scan runs whatever the finish_reason. Can we only scan on stop? The engine also skips the check for the first min_tokens, so a note on that gap would help.
  • The engine only treats EOS/generation config EOS as a stop when ignore_eos is off. Derender should check that too.
  • The parser path now gets the trimmed ids as model_output_token_ids. The main server passes the full output.token_ids.
  • decode_with_stop repeats the token stop logic in strip_finished_stop_token. Calling the helper keeps batch and streaming on one rule?
  • Can the tests go in test_derender.py instead of a new file? test_streaming_stop_strings_are_rejected_by_the_helper and test_stop_helpers_match_the_coupled_ruledon't add much. The held_text assert seems to be left over and the module docstring still mentions streaming.

@shimib

shimib commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

@pOpO1-9 cc @hickeyma

Once #60251 lands, generate reports the engine's stop_reason directly, so derender can use it instead of re-deriving the match. Suggested rule, which also sidesteps the fact that None from a new generate host and a missing field from an old one look identical after parsing:

  1. If choice.stop_reason is not None, use it as-is and only apply the text/token trimming for that specific match.
  2. If it is None and finish_reason == "stop", run the local scan (stop token ids first, then stop strings, as this PR already does) and set stop_reason only when the scan actually matches.
  3. Otherwise leave it None and don't trim.

Step 2 is a no-op exactly in the cases where a new host would have sent None anyway (EOS, length, abort), because there is nothing for the scan to match. So old and new hosts converge on the same answer, and derender never invents a stop_reason the engine didn't have. Whichever of the two PRs lands second rebases the five choice sites.

@hickeyma hickeyma left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pOpO1-9 Are you able to continue this PR?

@hickeyma hickeyma left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pOpO1-9 I needed to move the PR along because it is blocking some issues and RFCs. I tried to rebase with the latest from main but this proved difficult with the different changes in this area since the last rebase.

I have therefore pushed a new clean PR from main latest (#62719) to supersede your PR. I hope you are ok with this?
Thank you for the work on the PR.

hickeyma added a commit to hickeyma/vllm that referenced this pull request Oct 21, 2026
Generate sends back every token, including the ones the engine cut
from the text which means the stop text was showing up again after
derender.

Batch chat and completions now drop the stop token and cut at the stop
string the same way the engine's detokenizer does. That includes
include_stop_str_in_output, min_tokens and ignore_eos. When generate
doesn't report stop_reason, derender works it out from the request.
Completions streaming drops the stop token too.

Streaming derender now returns 400 for requests with stop strings. Text
that's already been sent can't be taken back, so use output_mode=text
on generate for that case.

Supersedes vllm-project#59510
Fixes vllm-project#57052

Signed-off-by: Martin Hickey <martin.hickey@ie.ibm.com>
Co-authored-by: Paul El Khoury <82139421+pOpO1-9@users.noreply.github.com>
@hickeyma

Copy link
Copy Markdown
Contributor

@DarkLight1337 Do you mind closing this PR as #62719 merged?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation frontend needs-rebase

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants