瀏覽代碼

fix(deepseek-v4): use canonical model-card dsv4 encoding over stale bundled copy

The sparkrun-vllm-ds4-gb10:fixed image bundles an OLD revision of
deepseek_v4_encoding.py (vLLM-vendored): single REASONING_EFFORT_MAX wording
with max using the high text, high injecting nothing, and reading the
deprecated 'reasoning' message key. Replacing it with the pristine model-card
reference (deepseek-ai/DeepSeek-V4-Flash-0731 encoding/encoding_dsv4.py) which
has the full low/high/max prompt dict, a correct gate for both high and max,
and uses 'reasoning_content'. The deepseek_v4.py normalization fix (committed
earlier) is retained so low is reachable and unknown values map to None.

Verified on dgx3 via the launcher build: low/None -> no prompt, high -> 527,
max -> 577 tokens; reasoning_content round-trips; imports resolve via the real
vllm module path.
Alexander Korolev 2 月之前
父節點
當前提交
bd292d7508

+ 7 - 4
docker-images/deepseek-v4-flash-dspark/Dockerfile

@@ -17,10 +17,13 @@ RUN pip install --no-cache-dir --no-deps --force-reinstall instanttensor==0.1.9
 # Fix reasoning_effort handling in the vendored deepseek_v4 tokenizer:
 # - high was accepted but silently injected nothing (only max triggered text)
 # - low / unknown values were coerced to high (unreachable low, silent escalation)
-# - only a single REASONING_EFFORT_MAX wording existed (max used the "high" text)
-# Replaces the single constant with a low/high/max dict using the full model-card
-# wording and injects for both high and max. See deepseek_v4_encoding.py gate.
-# (Mirrors the local-inference-lab encoding_dsv4.py reference implementation.)
+# - the bundled deepseek_v4_encoding.py was an OLD revision (single REASONING_EFFORT_MAX
+#   wording, max used the high text, read the deprecated `reasoning` key)
+# deepseek_v4_encoding.py is replaced with the canonical model-card reference
+# (deepseek-ai/DeepSeek-V4-Flash-0731 encoding/encoding_dsv4.py): full low/high/max
+# prompt dict, correct gate for both high and max, and reasoning_content key.
+# deepseek_v4.py carries the normalization fix so low is reachable and unknown
+# values map to None. See deepseek_v4.py / deepseek_v4_encoding.py in patches/.
 COPY patches/deepseek_v4.py \
      patches/deepseek_v4_encoding.py \
      /opt/venv/lib/python3.12/site-packages/vllm/tokenizers/

+ 42 - 48
docker-images/deepseek-v4-flash-dspark/patches/deepseek_v4_encoding.py

@@ -1,8 +1,3 @@
-# SPDX-License-Identifier: Apache-2.0
-# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
-# ruff: noqa
-# fmt: off
-
 """
 DeepSeek-V4 Encoding
 
@@ -13,8 +8,7 @@ with tool calling, thinking mode, and quick instruction task support.
 from typing import Any, Dict, List, Union, Optional, Tuple
 import copy
 import json
-
-import regex as re
+import re
 
 # ============================================================
 # Special Tokens
@@ -50,7 +44,7 @@ user_msg_template: str = "{content}"
 latest_reminder_msg_template: str = "{content}"
 assistant_msg_template: str = "{reasoning}{content}{tool_calls}" + eos_token
 assistant_msg_wo_eos_template: str = "{reasoning}{content}{tool_calls}"
-thinking_template: str = "{reasoning}"
+thinking_template: str = "{reasoning_content}"
 
 response_format_template: str = (
     "## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n{schema}"
@@ -67,7 +61,10 @@ tool_output_template: str = (
     "<tool_result>{content}</tool_result>"
 )
 
-REASONING_EFFORT_PROMPTS = {
+# Reasoning effort levels. In thinking mode, the prompt for the selected level is
+# prepended at the very beginning of the conversation. `low` is the default and
+# adds nothing.
+REASONING_EFFORT_PROMPTS: Dict[str, str] = {
     "low": "",
     "high": (
         "Reasoning Effort: Absolute maximum with no shortcuts permitted.\n"
@@ -80,6 +77,7 @@ REASONING_EFFORT_PROMPTS = {
         "Do not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.\n\n"
     ),
 }
+DEFAULT_REASONING_EFFORT = "low"
 
 TOOLS_TEMPLATE = """## Tools
 
@@ -116,7 +114,7 @@ def to_json(value: Any) -> str:
     """Serialize a value to JSON string."""
     try:
         return json.dumps(value, ensure_ascii=False)
-    except Exception:
+    except:
         return json.dumps(value, ensure_ascii=True)
 
 
@@ -150,12 +148,12 @@ def tool_calls_to_openai_format(tool_calls):
     ]
 
 
-def encode_arguments_to_dsml(tool_call: Dict[str, Any]) -> str:
+def encode_arguments_to_dsml(tool_call: Dict[str, str]) -> str:
     """
     Encode tool call arguments into DSML parameter format.
 
     Args:
-        tool_call: Dict with "name" and "arguments" keys.
+        tool_call: Dict with "name" and "arguments" (JSON string) keys.
 
     Returns:
         DSML-formatted parameter string.
@@ -163,10 +161,10 @@ def encode_arguments_to_dsml(tool_call: Dict[str, Any]) -> str:
     p_dsml_template = '<{dsml_token}parameter name="{key}" string="{is_str}">{value}</{dsml_token}parameter>'
     P_dsml_strs = []
 
-    if isinstance(tool_call["arguments"], str):
+    try:
         arguments = json.loads(tool_call["arguments"])
-    else:
-        arguments = tool_call["arguments"]
+    except Exception as err:
+        arguments = {"arguments": tool_call["arguments"]}
 
     for k, v in arguments.items():
         p_dsml_str = p_dsml_template.format(
@@ -246,7 +244,8 @@ def render_message(index: int, messages: List[Dict[str, Any]], thinking_mode: st
         messages: Full list of messages in the conversation.
         thinking_mode: Either "chat" or "thinking".
         drop_thinking: Whether to drop reasoning content from earlier turns.
-        reasoning_effort: Optional reasoning effort level ("max", "high", or None).
+        reasoning_effort: Reasoning effort level, one of "low", "high", "max".
+            None is treated as "low".
 
     Returns:
         Encoded string for this message.
@@ -263,7 +262,7 @@ def render_message(index: int, messages: List[Dict[str, Any]], thinking_mode: st
     tools = msg.get("tools")
     response_format = msg.get("response_format")
     tool_calls = msg.get("tool_calls")
-    reasoning = msg.get("reasoning")
+    reasoning_content = msg.get("reasoning_content")
     wo_eos = msg.get("wo_eos", False)
 
     if tools:
@@ -272,9 +271,10 @@ def render_message(index: int, messages: List[Dict[str, Any]], thinking_mode: st
         tool_calls = tool_calls_from_openai_format(tool_calls)
 
     # Reasoning effort prefix (only at index 0 in thinking mode; "low" adds nothing)
-    assert reasoning_effort is None or reasoning_effort in REASONING_EFFORT_PROMPTS, \
-        f"Invalid reasoning effort: {reasoning_effort}"
-    if index == 0 and thinking_mode == "thinking" and reasoning_effort in ("high", "max"):
+    reasoning_effort = reasoning_effort or DEFAULT_REASONING_EFFORT
+    assert reasoning_effort in REASONING_EFFORT_PROMPTS, \
+        f"Invalid reasoning effort: {reasoning_effort}, expected one of {list(REASONING_EFFORT_PROMPTS)}"
+    if index == 0 and thinking_mode == "thinking":
         prompt += REASONING_EFFORT_PROMPTS[reasoning_effort]
 
     if role == "system":
@@ -351,14 +351,14 @@ def render_message(index: int, messages: List[Dict[str, Any]], thinking_mode: st
             )
 
         summary_content = content or ""
-        reasoning = reasoning or ""
+        rc = reasoning_content or ""
 
         # Check if previous message has a task - if so, this is a task output (no thinking)
         prev_has_task = index - 1 >= 0 and messages[index - 1].get("task") is not None
 
         if thinking_mode == "thinking" and not prev_has_task:
             if not drop_thinking or index > last_user_idx:
-                thinking_part = thinking_template.format(reasoning=reasoning) + thinking_end_token
+                thinking_part = thinking_template.format(reasoning_content=rc) + thinking_end_token
             else:
                 thinking_part = ""
 
@@ -539,10 +539,11 @@ def encode_messages(
         messages: List of message dicts to encode.
         thinking_mode: Either "chat" or "thinking".
         context: Optional preceding context messages (already encoded prefix).
-        drop_thinking: If True, drop reasoning from earlier assistant turns
+        drop_thinking: If True, drop reasoning_content from earlier assistant turns
                       (only keep reasoning for messages after the last user message).
         add_default_bos_token: Whether to prepend BOS token at conversation start.
-        reasoning_effort: Optional reasoning effort level ("max", "high", or None).
+        reasoning_effort: Reasoning effort level, one of "low", "high", "max".
+            Only takes effect in thinking mode. None is treated as "low".
 
     Returns:
         The encoded prompt string.
@@ -589,12 +590,12 @@ def encode_messages(
 
 def _drop_thinking_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
     """
-    Drop reasoning and non-essential messages before the last user message.
+    Drop reasoning_content and non-essential messages before the last user message.
 
     Behavior:
     - Messages with role in ["user", "system", "tool", "latest_reminder"] are always kept.
     - Messages at or after the last user index are always kept.
-    - Assistant messages before the last user get reasoning removed.
+    - Assistant messages before the last user get reasoning_content removed.
     - Developer messages before the last user are dropped entirely.
     """
     last_user_idx = find_last_user_index(messages)
@@ -607,7 +608,7 @@ def _drop_thinking_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, An
             result.append(msg)
         elif role == "assistant":
             msg = copy.copy(msg)
-            msg.pop("reasoning", None)
+            msg.pop("reasoning_content", None)
             result.append(msg)
         # developer and other roles before last_user_idx are dropped
 
@@ -659,9 +660,9 @@ def parse_tool_calls(index: int, text: str) -> Tuple[int, Optional[str], List[Di
     tool_calls_end_token = f"</{dsml_token}{tool_calls_block_name}>"
 
     while index < len(text):
-        index, content_before, stop_token = _read_until_stop(index, text, [f"<{dsml_token}invoke", tool_calls_end_token])
-        if content_before != ">\n":
-            raise ValueError(f"Tool call format error: expected '>\\n' but got '{content_before}'")
+        index, _, stop_token = _read_until_stop(index, text, [f"<{dsml_token}invoke", tool_calls_end_token])
+        if _ != ">\n":
+            raise ValueError(f"Tool call format error: expected '>\\n' but got '{_}'")
 
         if stop_token == tool_calls_end_token:
             break
@@ -705,7 +706,7 @@ def parse_message_from_completion_text(text: str, thinking_mode: str) -> Dict[st
 
     This function takes the raw text output from the model (a single assistant turn)
     and extracts:
-    - reasoning (thinking block)
+    - reasoning_content (thinking block)
     - content (summary/response)
     - tool_calls (if any)
 
@@ -717,11 +718,10 @@ def parse_message_from_completion_text(text: str, thinking_mode: str) -> Dict[st
         thinking_mode: Either "chat" or "thinking".
 
     Returns:
-        Dict with keys: "role", "content", "reasoning", "tool_calls".
+        Dict with keys: "role", "content", "reasoning_content", "tool_calls".
         tool_calls are in OpenAI format.
     """
-    summary_content, reasoning = "", ""
-    tool_calls: List[Dict[str, str]] = []
+    summary_content, reasoning_content, tool_calls = "", "", []
     index, stop_token = 0, None
     tool_calls_start_token = f"\n\n<{dsml_token}{tool_calls_block_name}"
 
@@ -730,37 +730,31 @@ def parse_message_from_completion_text(text: str, thinking_mode: str) -> Dict[st
 
     if is_thinking:
         index, content_delta, stop_token = _read_until_stop(index, text, [thinking_end_token, tool_calls_start_token])
-        reasoning = content_delta
-        if stop_token != thinking_end_token:
-            raise ValueError("Invalid thinking format: missing </think>")
+        reasoning_content = content_delta
+        assert stop_token == thinking_end_token, "Invalid thinking format: missing </think>"
 
     index, content_delta, stop_token = _read_until_stop(index, text, [eos_token, tool_calls_start_token])
     summary_content = content_delta
     if stop_token == tool_calls_start_token:
         is_tool_calling = True
     else:
-        if stop_token != eos_token:
-            raise ValueError("Invalid format: missing EOS token")
+        assert stop_token == eos_token, "Invalid format: missing EOS token"
 
     if is_tool_calling:
         index, stop_token, tool_calls = parse_tool_calls(index, text)
 
         index, tool_ends_text, stop_token = _read_until_stop(index, text, [eos_token])
-        if tool_ends_text:
-            raise ValueError("Unexpected content after tool calls")
+        assert not tool_ends_text, "Unexpected content after tool calls"
 
-    if len(text) != index or stop_token not in [eos_token, None]:
-        raise ValueError("Unexpected content at end")
+    assert len(text) == index and stop_token in [eos_token, None], "Unexpected content at end"
 
     for sp_token in [bos_token, eos_token, thinking_start_token, thinking_end_token, dsml_token]:
-        if sp_token in summary_content or sp_token in reasoning:
-            raise ValueError(f"Unexpected special token '{sp_token}' in content")
+        assert sp_token not in summary_content and sp_token not in reasoning_content, \
+            f"Unexpected special token '{sp_token}' in content"
 
     return {
         "role": "assistant",
         "content": summary_content,
-        "reasoning": reasoning,
+        "reasoning_content": reasoning_content,
         "tool_calls": tool_calls_to_openai_format(tool_calls)
     }
-
-# fmt: on