Skip to content

feat(data): add DeepSeek-V4.1 chat templates with the official encoder - #887

Open
bighead-liat wants to merge 2 commits into
sgl-project:mainfrom
bighead-liat:feat/deepseek-v4.1-template
Open

bighead-liat wants to merge 2 commits into
sgl-project:mainfrom
bighead-liat:feat/deepseek-v4.1-template

Conversation

@bighead-liat

Copy link
Copy Markdown

Motivation

DeepSeek-V4.1-Flash (released 2026-09-10) has no Jinja template; like V4 it ships a Python reference encoder, and its prompt format is not V4's:

  • tool calls use spaced DSML tag names (<|DSML| calls> / <|DSML| invoke> / <|DSML| parameter>);
  • thinking mode opens with a numeric Reasoning Effort: N (range 1-100, ...) line behind the <|System|> token, and the system prompt itself now sits behind <|System|>;
  • system messages may appear mid-conversation.

Rendering V4.1 conversations through the existing deepseek-v4 entry therefore produces prompts the checkpoint never sees, and a drafter trained on them learns a prefix that serving never emits. This PR adds the V4.1 data path so drafter training for V4.1 renders exactly what the model's own tooling (and an SGLang server) renders.

Modifications

  • specforge/data/encoders/deepseek_v41.py: encoding/encoding.py from deepseek-ai/DeepSeek-V4.1-Flash, vendored verbatim at revision dba1be0a40aa45a94ad051997016db3960a90277 (MIT, same header as the V4 file).
  • specforge/data/parse.py: DeepSeekV4Parser.apply_chat_template now calls a class-level _encode_messages hook instead of the V4 function directly (no behaviour change); DeepSeekV41Parser subclasses it and points the hook at the V4.1 encoder. Exported in __all__.
  • specforge/data/preprocessing.py: dispatch parser_type="deepseek-v4.1".
  • specforge/data/template.py: register deepseek-v4.1 (enable_thinking=False) and deepseek-v4.1-thinking (enable_thinking=True). Both keep the V4 entry's mask convention (header stops at <|Assistant|>), so any later decision about folding the mode tags into the header can be applied to the V4 family together.
  • tests/test_data/test_deepseek_v41_template.py (CPU, no tokenizer download):
    • pins the vendored file to the release revision by sha256;
    • replays the five encoding cases shipped with the model (encoding/tests/, stored in tests/test_data/test_references/deepseek-v4.1_official-encoding-cases.json) byte-for-byte;
    • covers the format changes (spaced DSML tags, <|System|> prefix, reasoning-effort default and integer budgets, mid-conversation system messages, chat vs thinking generation prompts, dropped/kept reasoning content);
    • checks loss-mask spans for both entries, including around a mid-conversation system message, and that encoder errors do not fall back to generic rendering;
    • TestDeepseekV41ServingParity (skipped unless sglang.srt.entrypoints.openai.encoding_dsv41 is importable) renders the same conversations through this parser and through SGLang's encoder and asserts equality, in chat mode and in thinking mode with an integer budget.

Not in this PR: a V4.1 DSpark draft config and recipe. The configs/ allowlist in tests/test_runtime/test_package_architecture.py is being changed by #870; I will follow up once that lands.

Related Issues

sgl-project/sglang#39909: SGLang's copy of the V4.1 encoder still carries the pre-release named-tier budgets (high → 50 where the release reference renders 75). The vendored file here follows the release; the parity test compares chat mode and integer budgets, which the two agree on, and documents the named-tier difference in its docstring.

Accuracy Test

python -m unittest tests.test_data.test_deepseek_v41_template tests.test_data.test_deepseek_v4_template \
    tests.test_data.test_template_registry tests.test_runtime.test_package_architecture
Ran 45 tests ... OK      # parity test executed against sglang main (923e4a56)
pre-commit run --files <changed files>   # all hooks pass

Benchmark & Profiling

Not applicable; data path only.

Checklist

  • Format your code according to the Code Formatting with Pre-Commit.
  • Add unit tests.
  • Update documentation / docstrings as needed.
  • Provide throughput / latency benchmark results (not applicable).

DeepSeek-V4.1-Flash ships a Python encoder instead of a Jinja template,
like V4, but its prompt format differs: spaced DSML tag names, a numeric
reasoning-effort prefix rendered behind <|System|> in thinking mode, and
mid-conversation system messages. Register `deepseek-v4.1` and
`deepseek-v4.1-thinking` with a `DeepSeekV41Parser` that reuses the V4
parser's message handling and swaps in the V4.1 encoder, vendored
verbatim from deepseek-ai/DeepSeek-V4.1-Flash at revision dba1be0a.

Tests pin the vendored file to that revision by hash, replay the five
encoding cases shipped with the model, cover the V4.1 format changes and
the loss-mask spans, and, when an SGLang with the V4.1 encoder is
importable, check that training renders exactly what the server renders.
Copilot AI lite review requested due to automatic review settings September 17, 2026 07:48

Copilot AI 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.

🟡 Changes recommended

Unresolved issues affect prompt rendering and valid message/tool handling.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds DeepSeek-V4.1 chat-template support using the official encoder for training and serving parity.

Changes:

  • Vendors the V4.1 encoder and registers chat/thinking templates.
  • Adds parser and preprocessing integration.
  • Adds official golden, masking, and SGLang parity tests.
File summaries
File Review
tests/test_data/test_references/deepseek-v4.1_official-encoding-cases.json Adds official encoding fixtures.
tests/test_data/test_deepseek_v41_template.py Adds rendering, masking, and parity coverage.
specforge/data/template.py Registers V4.1 chat and thinking templates.
specforge/data/preprocessing.py Dispatches the V4.1 parser.
specforge/data/parse.py Adds parser integration; leading-message handling, developer roles, and namespaced tool calls require correction.
specforge/data/encoders/deepseek_v41.py Adds the official encoder; dropped-thinking closure and developer-role handling require correction.
Review details

Suppressed comments (4)

specforge/data/encoders/deepseek_v41.py:647

  • DeepSeekV41Parser inherits the V4 message contract, but this encoder has no developer branch: any mid-conversation developer message (and any tools attached to a developer message, which DeepSeekV4Parser.apply_chat_template explicitly supports) falls through to NotImplementedError at the final role check. V4 renders developer messages as user turns, so V4.1 should either preserve that handling—including treating developer as the latest user for thinking retention—or reject/normalize the role before invoking this encoder.
    elif role == "user":
        prompt += USER_SP_TOKEN

specforge/data/parse.py:623

  • This subclass still inherits DeepSeekV4Parser._sanitize_message, which rebuilds every assistant tool call with only id, type, function.name, and function.arguments and therefore drops the V4.1 encoder's supported namespace field. A namespaced OpenAI tool call such as {"namespace": "math", "function": {"name": "add", ...}} will train as add instead of math::add, diverging from the official/SGLang rendering; preserve the call/function namespace (or override the sanitizer for V4.1) before selecting this encoder.
    _encode_messages = staticmethod(encode_deepseek_v41_messages)

specforge/data/parse.py:623

  • This subclass inherits GeneralParser.parse, whose leading-message cleanup drops every message before the first user after the initial system. That removes valid V4.1 latest_reminder messages (the bundled official cases include exactly this ordering) before _encode_messages sees them, so preprocessing cannot reproduce the official prompt for those conversations. Preserve V4.1-supported leading roles (or otherwise bypass that cleanup) and add a preprocessing regression test.
class DeepSeekV41Parser(DeepSeekV4Parser):
    """Render DeepSeek-V4.1 conversations with its official Python encoder.

    V4.1 keeps the V4 message contract but changes the prompt format: spaced
    DSML tag names, a numeric reasoning-effort prefix rendered behind the
    ``<|System|>`` token in thinking mode, and mid-conversation system
    messages. The vendored encoder is pinned to the model release, so training
    renders exactly what the checkpoint's own tooling renders.
    """

    _encode_messages = staticmethod(encode_deepseek_v41_messages)

specforge/data/parse.py:623

  • DeepSeekV41Parser reuses the V4 parser contract, which accepts a mid-conversation developer message and can attach tools to it, but the V4.1 encoder has no developer branch and raises NotImplementedError for that role. A valid conversation containing user -> developer -> assistant therefore fails only after dispatch; either implement the role in the V4.1 encoder or normalize/reject it explicitly in this parser, with a regression test.
class DeepSeekV41Parser(DeepSeekV4Parser):
    """Render DeepSeek-V4.1 conversations with its official Python encoder.

    V4.1 keeps the V4 message contract but changes the prompt format: spaced
    DSML tag names, a numeric reasoning-effort prefix rendered behind the
    ``<|System|>`` token in thinking mode, and mid-conversation system
    messages. The vendored encoder is pinned to the model release, so training
    renders exactly what the checkpoint's own tooling renders.
    """

    _encode_messages = staticmethod(encode_deepseek_v41_messages)
  • Files reviewed: 6/6 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +705 to +709
if thinking_mode == "thinking" and not prev_has_task:
if not drop_thinking or index > last_user_idx:
thinking_part = thinking_template.format(reasoning_content=rc) + thinking_end_token
else:
thinking_part = ""

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

They do, just not from this branch. assistant_msg_template is {reasoning}{content}{tool_calls}<eos>; the </think> (or <think>) that opens an assistant turn is appended as the generation header by the transition after the preceding user/system message (the index + 1 < len(messages) block further down). With drop_thinking=True an earlier turn renders <|Assistant|></think>First answer<|end▁of▁sentence|>, which is what test_thinking_mode_keeps_only_latest_turn_reasoning pins and what the model's own golden case 2 (thinking, two turns) produces byte-for-byte in test_official_golden_cases. The file is vendored verbatim from the release and pinned by hash, so nothing here is ours to change.

@bighead-liat

Copy link
Copy Markdown
Author

Replies to the three suppressed comments in the Copilot review:

Developer role. Right that the V4.1 reference encoder has no developer branch and would raise. What an SGLang server does with one is render it as a user turn, byte-identical to a user message with the same content (checked against encoding_dsv41 on main in both modes). The follow-up commit normalizes developer to user in DeepSeekV41Parser, both in _normalize_message (ahead of parse()'s role checks, so the assistant turn after it is kept and supervised) and in apply_chat_template, and hosts request tools on a system message only, as the server does with request tools. Covered by test_developer_messages_render_as_user_turns, test_tools_are_hosted_by_a_system_message, and a developer conversation in the SGLang parity test.

Namespaced tool calls. Fixed in the follow-up commit: the V4 sanitizer's per-call rebuild is now a _sanitize_tool_call hook, and the V4.1 parser keeps an OpenAI namespace (on the call or its function) so the encoder renders math::add. test_namespaced_tool_calls_keep_the_namespace pins the rendered invoke and that the V4 parser is unchanged.

Leading latest_reminder messages. Correct, and it is not specific to V4.1: GeneralParser.parse drops every non-user message between the initial system message and the first user turn, and truncates an assistant turn that follows a role other than user/tool, so latest_reminder (and task) rows render differently through parse() than through the encoder for V4 as well. Training corpora do not carry these internal roles, which is why neither parser has hit it. I have left it out of this PR since the fix belongs in the shared V4 base and changes V4 behaviour; happy to send it as a follow-up covering both parsers with the golden cases 3 and 4 as the regression.

yushengsu-thu added a commit to yushengsu-thu/SpecForge that referenced this pull request Nov 4, 2026
… lint

The sgl-project#887 conflict resolution left two GLMParser classes in parse.py: main's
GLMParser(ThinkingParser) header with only its docstring, then an old
GLMParser(GeneralParser) body that shadowed it, so GLM reasoning spans were
no longer split (11 failures in tests/test_data/test_glm_parser.py, 27/27 on
the base). Keep main's class and drop the dangling header. Also apply the
isort/black fixes the lint job asked for in three V4.1 test files.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants