[Bugfix][DeepSeek V4] Handle trailing system messages in prompt rendering (#51262)
Signed-off-by: jiahaoliang <gzliangjiahao@gmail.com> Signed-off-by: Jiahao Liang <gzliangjiahao@gmail.com> Co-authored-by: Chauncey <chaunceyjiang@gmail.com>
Jiahao Liang committed
Aug 25, 2026 at 15:49 UTC
59d7fc92ea038544959aae020a8b28fc34159545
4 files changed
+150
-3
rust/src/chat/src/renderer/deepseek_v4/encoding.rs
+2
-2
@@ -110,7 +110,7 @@ pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
110
}
111
}
112
113
- if is_user_like_entry(message)
113
+ if (is_user_like_entry(message) || matches!(message, ChatMessage::System { .. }))
114
&& next_rendered_entry_is_assistant_or_end(request.messages.as_slice(), message_index)
115
{
116
write_assistant_transition(
@@ -423,7 +423,7 @@ fn write_tool_result(out: &mut String, content: &ChatContent) -> Result<()> {
423
Ok(())
424
}
425
426
-/// Append the assistant transition token after a user-like turn.
426
+/// Append the assistant transition token after a user-like or system turn.
427
fn write_assistant_transition(
428
out: &mut String,
429
thinking_mode: ThinkingMode,
rust/src/chat/src/renderer/deepseek_v4/tests.rs
+62
@@ -195,6 +195,68 @@ fn reasoning_effort_none_disables_thinking() {
195
.assert_eq(&rendered);
196
}
197
198
+#[test]
199
+fn system_to_assistant_transitions_in_chat_and_thinking_modes() {
200
+ let rendered = [false, true].map(|enable_thinking| {
201
+ let mut request = ChatRequest {
202
+ messages: vec![
203
+ ChatMessage::system("rules"),
204
+ ChatMessage::assistant_blocks(vec![
205
+ AssistantContentBlock::Reasoning {
206
+ text: "reason".to_string(),
207
+ },
208
+ AssistantContentBlock::Text {
209
+ text: "answer".to_string(),
210
+ },
211
+ ]),
212
+ ],
213
+ ..ChatRequest::for_test()
214
+ };
215
+ request
216
+ .chat_options
217
+ .template_kwargs
218
+ .insert("enable_thinking".to_string(), Value::Bool(enable_thinking));
219
+ request.chat_options.reasoning_effort = Some(ReasoningEffort::Low);
220
+ render_request(&request)
221
+ });
222
+
223
+ expect![[r#"
224
+ [
225
+ "<|begin▁of▁sentence|>rules<|Assistant|></think>answer<|end▁of▁sentence|>",
226
+ "<|begin▁of▁sentence|>rules<|Assistant|><think>reason</think>answer<|end▁of▁sentence|>",
227
+ ]
228
+ "#]]
229
+ .assert_debug_eq(&rendered);
230
+}
231
+
232
+#[test]
233
+fn trailing_system_transitions_in_chat_and_thinking_modes() {
234
+ let rendered = [false, true].map(|enable_thinking| {
235
+ let mut request = ChatRequest {
236
+ messages: vec![
237
+ ChatMessage::system("rules"),
238
+ ChatMessage::user("question"),
239
+ ChatMessage::system("final rules"),
240
+ ],
241
+ ..ChatRequest::for_test()
242
+ };
243
+ request
244
+ .chat_options
245
+ .template_kwargs
246
+ .insert("enable_thinking".to_string(), Value::Bool(enable_thinking));
247
+ request.chat_options.reasoning_effort = Some(ReasoningEffort::Low);
248
+ render_request(&request)
249
+ });
250
+
251
+ expect![[r#"
252
+ [
253
+ "<|begin▁of▁sentence|>rules<|User|>questionfinal rules<|Assistant|></think>",
254
+ "<|begin▁of▁sentence|>rules<|User|>questionfinal rules<|Assistant|><think>",
255
+ ]
256
+ "#]]
257
+ .assert_debug_eq(&rendered);
258
+}
259
+
260
#[test]
261
fn reasoning_effort_template_kwarg_is_ignored() {
262
let mut request = ChatRequest {
tests/tokenizers_/test_deepseek_v4.py
+77
@@ -113,6 +113,83 @@ def test_deepseek_v4_explicitly_disables_thinking(kwargs):
113
assert prompt == ("<|begin▁of▁sentence|><|User|>Hello<|Assistant|></think>")
114
115
116
+@pytest.mark.parametrize(
117
+ ("enable_thinking", "thinking_token"),
118
+ [(False, "</think>"), (True, "<think>")],
119
+)
120
+def test_deepseek_v4_appends_assistant_transition_after_trailing_system(
121
+ enable_thinking, thinking_token
122
+):
123
+ prompt = _tokenizer().apply_chat_template(
124
+ [
125
+ {"role": "system", "content": "You are helpful."},
126
+ {"role": "user", "content": "What is 2+2?"},
127
+ {"role": "system", "content": "Be concise."},
128
+ ],
129
+ tokenize=False,
130
+ enable_thinking=enable_thinking,
131
+ reasoning_effort="low",
132
+ )
133
+
134
+ assert prompt == (
135
+ "<|begin▁of▁sentence|>You are helpful."
136
+ f"<|User|>What is 2+2?Be concise.<|Assistant|>{thinking_token}"
137
+ )
138
+
139
+
140
+def test_deepseek_v4_does_not_transition_after_mid_conversation_system():
141
+ prompt = _tokenizer().apply_chat_template(
142
+ [
143
+ {"role": "user", "content": "What is 3+3?"},
144
+ {"role": "assistant", "content": "6"},
145
+ {"role": "system", "content": "Answer in French."},
146
+ {"role": "user", "content": "What is 5+5?"},
147
+ ],
148
+ tokenize=False,
149
+ enable_thinking=False,
150
+ )
151
+
152
+ assert prompt == (
153
+ "<|begin▁of▁sentence|><|User|>What is 3+3?"
154
+ "<|Assistant|></think>6<|end▁of▁sentence|>"
155
+ "Answer in French.<|User|>What is 5+5?"
156
+ "<|Assistant|></think>"
157
+ )
158
+
159
+
160
+@pytest.mark.parametrize(
161
+ ("enable_thinking", "expected"),
162
+ [
163
+ (
164
+ False,
165
+ "<|begin▁of▁sentence|>rules<|Assistant|></think>answer"
166
+ "<|end▁of▁sentence|>",
167
+ ),
168
+ (
169
+ True,
170
+ "<|begin▁of▁sentence|>rules<|Assistant|><think>reason</think>answer"
171
+ "<|end▁of▁sentence|>",
172
+ ),
173
+ ],
174
+)
175
+def test_deepseek_v4_transitions_from_system_to_assistant(enable_thinking, expected):
176
+ prompt = _tokenizer().apply_chat_template(
177
+ [
178
+ {"role": "system", "content": "rules"},
179
+ {
180
+ "role": "assistant",
181
+ "reasoning": "reason",
182
+ "content": "answer",
183
+ },
184
+ ],
185
+ tokenize=False,
186
+ enable_thinking=enable_thinking,
187
+ reasoning_effort="low",
188
+ )
189
+
190
+ assert prompt == expected
191
+
192
+
193
def test_deepseek_v4_unknown_role_raises_value_error():
194
# Invalid roles are client errors: they must surface as ValueError
195
# (mapped to HTTP 400 by the OpenAI serving layer), not
vllm/tokenizers/deepseek_v4_encoding.py
+9
-1
@@ -374,7 +374,15 @@ def render_message(
374
prompt += thinking_end_token if thinking_mode != "thinking" else thinking_start_token
375
prompt += task_sp_token
376
377
- elif messages[index].get("role") in ["user", "developer"]:
377
+ # A trailing system message opens generation, while a system message
378
+ # followed by assistant opens that assistant history turn.
379
+ elif role in ["user", "developer"] or (
380
+ role == "system"
381
+ and (
382
+ index == len(messages) - 1
383
+ or messages[index + 1].get("role") == "assistant"
384
+ )
385
+ ):
386
# Normal generation: append Assistant + thinking token
387
prompt += ASSISTANT_SP_TOKEN
388
if not drop_thinking and thinking_mode == "thinking":