Skip to content

Preserve streaming preamble before DeepSeek DSML tool calls for all marker forms - #33813

Closed
hassellof wants to merge 1 commit into
sgl-project:mainfrom
hassellof:fix/dsv4-streaming-preamble
Closed

Preserve streaming preamble before DeepSeek DSML tool calls for all marker forms#33813
hassellof wants to merge 1 commit into
sgl-project:mainfrom
hassellof:fix/dsv4-streaming-preamble

Conversation

@hassellof

@hassellof hassellof commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Motivation

DeepSeekV32Detector.parse_streaming_increment (inherited by DeepSeekV4Detector) returns normal_text="" on the tool-call branch, so any assistant prose that arrives in the same streaming delta as the DSML tool-call opener is silently dropped. The visible message ends mid-sentence, or is empty when the whole preamble lands in that delta. Non-streaming detect_and_parse already preserves that prefix, so the two paths disagree.

This is the same defect #31786 identifies, and that PR's fix is correct — this change builds on it rather than replacing it. The difference: #31786 anchors the split on self.bot_token (<|DSML|tool_calls>), but DeepSeek-V4 frequently opens a tool call with a bare <|DSML|invoke and no enclosing wrapper. When that happens find(bot_token) returns -1, no split occurs, and the preamble is still lost. If it is easier to fold these hunks into #31786, that works just as well — the two are not in competition.

Speculative decoding makes this common rather than rare: multi-token deltas mean "prose and opening tag in the same delta" is the normal case, not a boundary condition.

Modifications

  • _dsml_section_start(): returns the index where the (possibly partial) DSML section begins, searching every marker form (bot_token, <|DSML|invoke, <|DSML|, |DSML|) and treating a trailing partial tag prefix (<, <|, </, </|) as the boundary so a chunk that ends mid-tag holds back only the prefix.
  • parse_streaming_increment(): on entering the tool-call branch, split off any preceding prose, keep only the tag portion buffered, and return the prose as normal_text. Guarded to the first tool call (current_tool_id == -1) so text between consecutive invokes is not re-emitted.

Tool-call parsing itself is unchanged.

Accuracy Tests

Measured on 4× RTX PRO 6000 Blackwell (sm_120), TP=4 + DP attention, DeepSeek-V4-Flash-0731, DSPARK speculative decoding, tool_call_parser=deepseekv4, thinking enabled. Prompt engineered to force a preamble before the tool call, 8 paired runs, streaming vs non-streaming on the same server:

build streaming non-streaming (control)
unpatched 4/8 cut mid-sentence 8/8 complete
patched 8/8 complete, 0 cut 8/8 complete

Tool-call arguments remained valid JSON in every run and no DSML markup leaked into content. The serving environment those runs were measured in (image build, patch set, flags) is published at https://github.com/ombori/deepseek-v4-flash-0731-sglang-4x-rtx-pro-6000 if anyone wants to reproduce the streaming behaviour on SM120.

New unit test at test/registered/unit/function_call/test_deepseekv32_detector.py, run against both DeepSeekV4Detector and DeepSeekV32Detector:

  • prose + wrapped opener in one delta; prose + bare <|DSML|invoke in one delta;
  • boundary-exhaustive: both opener forms split at every index, plus fixed 1/3/7-char chunkings so the opener spans more than two deltas;
  • a chunk ending in a partial tag prefix — prefix held back, preceding prose emitted immediately;
  • a literal < in prose that is not a tag (must not be swallowed, with and without a following tool call);
  • two consecutive invokes — prose is not duplicated;
  • the load-bearing invariant: concatenated streamed normal_text equals non-streaming detect_and_parse normal_text, and streamed calls equal non-streaming calls (names + parsed arguments), at every split point.

The suite fails on unpatched main (9 of 10 test methods, 1144 subtest failures) and passes patched (20/20 tests, 1146 subtests). It is hermetic — pure Python, no GPU, no model.


CI States

Latest PR Test (Base): ❌ Run #31072114031
Latest PR Test (Extra): ❌ Run #31072113886

…r forms

DeepSeekV32Detector.parse_streaming_increment returns normal_text="" on
the tool-call branch, so assistant prose that arrives in the same
streaming delta as the DSML opener is silently dropped. sgl-project#31786 fixes
this for bot_token, but DeepSeek-V4 frequently opens a tool-call section
with a bare "<|DSML|invoke" (no enclosing tool_calls wrapper), and
with speculative decoding a single delta commonly carries both the tail
of the preamble and a partial tag.

Split the buffered text at the earliest DSML marker of any form (or at a
trailing partial tag prefix) before entering the invoke-parsing loop,
emit the prefix as normal_text, and keep only the DSML section in the
buffer. Guarded to the first tool call, matching sgl-project#31786.

Regression test sweeps every chunk-split point for both the wrapped and
bare-invoke openers, for both DeepSeekV32Detector and DeepSeekV4Detector,
and pins the invariant that total streamed normal_text equals
detect_and_parse's normal_text for the same input.

Measured on DeepSeek-V4-Flash-0731 (TP=4 + DP attention, speculative
decoding, streaming): 4/8 responses with a forced preamble lost it
before this change, 0/8 after; non-streaming control 8/8 intact in both
builds.
@hnyls2002

Copy link
Copy Markdown
Collaborator

Covered by #34458 (5899674). Preamble preservation now handles the bare <|DSML|invoke …> form as well as the wrapped one — the marker search walks back to bot_token only when it is present.

You are credited as a co-author on that commit.

If I misread what this PR does and part of it is still missing, please rebase on main and reopen — happy to take it.

@hnyls2002 hnyls2002 closed this Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants