Repository navigation
feat(data): add DeepSeek-V4.1 chat templates with the official encoder - #887
bighead-liat wants to merge 2 commits into
Conversation
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.
There was a problem hiding this comment.
🟡 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
DeepSeekV41Parserinherits the V4 message contract, but this encoder has nodeveloperbranch: any mid-conversationdevelopermessage (and any tools attached to a developer message, whichDeepSeekV4Parser.apply_chat_templateexplicitly supports) falls through toNotImplementedErrorat 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 onlyid,type,function.name, andfunction.argumentsand therefore drops the V4.1 encoder's supportednamespacefield. A namespaced OpenAI tool call such as{"namespace": "math", "function": {"name": "add", ...}}will train asaddinstead ofmath::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 firstuserafter the initialsystem. That removes valid V4.1latest_remindermessages (the bundled official cases include exactly this ordering) before_encode_messagessees 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
DeepSeekV41Parserreuses the V4 parser contract, which accepts a mid-conversationdevelopermessage and can attach tools to it, but the V4.1 encoder has nodeveloperbranch and raisesNotImplementedErrorfor that role. A valid conversation containinguser -> developer -> assistanttherefore 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.
| 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 = "" |
There was a problem hiding this comment.
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.
…system message, keep tool-call namespaces
|
Replies to the three suppressed comments in the Copilot review: Developer role. Right that the V4.1 reference encoder has no Namespaced tool calls. Fixed in the follow-up commit: the V4 sanitizer's per-call rebuild is now a Leading |
… 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.
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:
<|DSML| calls>/<|DSML| invoke>/<|DSML| parameter>);Reasoning Effort: N (range 1-100, ...)line behind the<|System|>token, and the system prompt itself now sits behind<|System|>;Rendering V4.1 conversations through the existing
deepseek-v4entry 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.pyfromdeepseek-ai/DeepSeek-V4.1-Flash, vendored verbatim at revisiondba1be0a40aa45a94ad051997016db3960a90277(MIT, same header as the V4 file).specforge/data/parse.py:DeepSeekV4Parser.apply_chat_templatenow calls a class-level_encode_messageshook instead of the V4 function directly (no behaviour change);DeepSeekV41Parsersubclasses it and points the hook at the V4.1 encoder. Exported in__all__.specforge/data/preprocessing.py: dispatchparser_type="deepseek-v4.1".specforge/data/template.py: registerdeepseek-v4.1(enable_thinking=False) anddeepseek-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):encoding/tests/, stored intests/test_data/test_references/deepseek-v4.1_official-encoding-cases.json) byte-for-byte;<|System|>prefix, reasoning-effort default and integer budgets, mid-conversation system messages, chat vs thinking generation prompts, dropped/kept reasoning content);TestDeepseekV41ServingParity(skipped unlesssglang.srt.entrypoints.openai.encoding_dsv41is 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 intests/test_runtime/test_package_architecture.pyis 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
Benchmark & Profiling
Not applicable; data path only.
Checklist