Deepseek V3.1 本地化部署实践

862 阅读10分钟

前言

DeepseekV3.1 已经发布,这是一个混合模型,同时支持 ThinkingNonthinking 两种推理模式。这对于缺卡的小玩家而言无疑是个重大利好。这意味着原先我们需要2套硬件资源来运行的 V3 + R1 的租户,现在只需要一套硬件就可以运行了。我们只需要在资源不足时给他扩容即可,这无疑带来了更大的灵活性(比如在低峰期间就可以拆出一些卡来做其他测试了,手动狗头)。

DeepseekV3.1 的模型结构和 DeepseekV3 保持一致,所以本地部署沿用 V3 的方案即可拉起模型。

当然这并不意味着可以无缝迁移到 V3.1,我们至少要解决以下几个问题。

Thinking 模式切换

DeepseekV3.1 是一个混合模型,默认工作在 NonThinking 模式下,激活 Thinking 模式需要显示输入参数。

huggingface.cocard 里,他给了这样一个 example:

import transformers

tokenizer = transformers.AutoTokenizer.from_pretrained("deepseek-ai/DeepSeek-V3.1")

messages = [
    {"role": "system", "content": "You are a helpful assistant"},
    {"role": "user", "content": "Who are you?"},
    {"role": "assistant", "content": "<think>Hmm</think>I am DeepSeek"},
    {"role": "user", "content": "1+1=?"}
]

tokenizer.apply_chat_template(messages, tokenize=False, thinking=True, add_generation_prompt=True)
# '<|begin▁of▁sentence|>You are a helpful assistant<|User|>Who are you?<|Assistant|></think>I am DeepSeek<|end▁of▁sentence|><|User|>1+1=?<|Assistant|><think>'

tokenizer.apply_chat_template(messages, tokenize=False, thinking=False, add_generation_prompt=True)
# '<|begin▁of▁sentence|>You are a helpful assistant<|User|>Who are you?<|Assistant|></think>I am DeepSeek<|end▁of▁sentence|><|User|>1+1=?<|Assistant|></think>'

这个意思是在 tokenizer.apply_chat_template 调用中,通过 thinking 参数来控制当前的工作模式。

对于推理框架提供的 OpenAI 兼容接口而言,无论 vllm 还是 sglang,都支持 chat_template_kwargs 参数,这个参数的作用就相当于在tokenizer.apply_chat_template 中传入的指定的参数。

例如在 qwen3 里,他给的 example 是这样的:

text = tokenizer.apply_chat_template(
    messages,
    tokenize=False,
    add_generation_prompt=True,
    enable_thinking=False  # Setting enable_thinking=False disables thinking mode
)

那对于拉起的 qwen3 接口,传入这样的参数即可关闭 qwen3thinking 模式

"chat_template_kwargs": {"enable_thinking": false}

对于 DeepseekV3.1 而言,这里自然传入以下参数来开启 thinking 模式即可

"chat_template_kwargs": {"thinking": true}

模型封装

通过调用推理框架拉起的 OpenAI 兼容接口,并传入 "chat_template_kwargs": {"thinking": true} 参数,我们确实可以实现动态的切换模型的工作模式。然而对于终端用户而言,chat_template_kwargs 的对于用户而言显然太底层和太灵活了,用户可能传入其他预期之外的参数,这可能会带来一些意想不到的问题,也可能存在一些风险。并且不同的模型的 thinking 开关参数不一致,对用户而言也并不方便。

对于生产环境而言,可以参考 百炼 的方式,将开启 thinking 的参数统一封装为一致的参数,比如 enable_thinking,这样用户在调用时,只需要传入 enable_thinking 参数即可,而不需要知道底层的 chat_template_kwargs,也无法传入其他的参数。

也可以参考 Deepseek 的方式,依然将 thinking 模型和 NonThinking 模式拆分为两个模型。这样用户通过调整传入 model 来改变对应的模型,这个方案也考虑了兼容性,可能会更有好一些。ChatECNU 采用的就是这个模式。

reasoning-parser

Deepseek 作为第一个支持思维链的开源模型,其设计的 reasoning_content 目前已然成为了 thinking 模型的 API 接口标准。但对于模型本身的输出而言,思考部分本质上是和正文部分一起输出的,只是带上了不同的标记而已,具体的格式则与模型的 chat_template 有关。主流推理框架如 vllmsglang 则都提供了 --reasoning-parser 参数,将思考部分解析到 reasoning_content 中。

DeepseekV3.1chat_template 相对 Deepseek-R1Deepseek-V3 都产生了很大的变动,因此如果继续沿用原有的解析器,比如在 sglang 中沿用 --reasoning-parser deepseek-r1,则会产生错误解析,比如在 thinking 模式关闭的时候,会错误的将 NonThinking 的内容也解析到 reasoning_content 中。类似这个 #9457 issue 所提到的现象。

sglang 的支持见这个 #9464 PR。

vllm 的支持见这个 #23437 PR。

tools call parser

reasoningparse 一样, 工具调用的部分也是由 chat_template 中的标记来决定的。DeepseekV3.1 的工具调用也做了相当多的调整,因此这里仍然需要一个新的 parser 来进行处理,才能够正确的实现 tools_call 功能。

sglang 的支持见这个 #9446 PR。

vllm 的支持见这个 #23545 PR。

Deepseek V3/R1 不同的是,DeepseekV3.1 仅在 NonThinking 模式下支持工具调用。

MTP 与性能

完成上述的准备后,我们就可以做本地化的部署了。sglang 最新的 v0.5.1.post3-cu126 已经合入了上述的所有 PR,跑这个版本即可。核心变化是 --reasoning-parser--tool-call-parser 和 --chat-template 部分。

以下是在我们在一台 8卡 H20 上的运行示例:

python -m sglang.launch_server \
  --model-path "/root/.cache/huggingface/DeepSeek-V3.1" \
  --port 8000 \
  --host 0.0.0.0 \
  --tp 8 \
  --context-length 131072 \
  --served-model-name deepseek-v31 \
  --reasoning-parser deepseek-v3 \
  --tool-call-parser deepseekv31 \
  --chat-template "./examples/chat_template/tool_chat_template_deepseekv31.jinja" \
  --mem-fraction-static 0.9 \
  --trust-remote-code

在 8卡 H20上,单并发的token吞吐大概能跑到 80 token/s。在128并发,1k的输入输出下,大概能跑到1730 token/s的吞吐量。

如果需要进一步提升性能,那我们要考虑开启 MTP(Multi-token Prediction),MTP 核心是一种预测解码技术,通过一个草稿模型来同时预测多个 token 的生成,再由主模型验证,从而提升推理的速度,详见 speculative_decoding 。

在 sglang 里针对 deepseek 开启 mtp 可以看这里 multi-token-prediction 。这里核心的参数是这几个:

  • speculative_num_steps: 自回归草稿的深度。增加推测范围,但会增加拒绝瀑布(rejection cascades)的风险。
  • speculative_eagle_topk: 每个步骤的分支因子。提高候选多样性,会导致更高的接受率,但会增加内存/计算消耗。
  • speculative_num_draft_tokens: 最大并行验证容量。允许更深度的树状评估,但会导致更高的GPU内存使用。

在不同的硬件,不同的并发下,最优的参数组合可能是不一样。sglang 提供了一个全自动的脚本 bench_speculative.py 来测试,这个脚本会遍历所有的参数组合,输出对应的性能测试指标。如果我们给定这样一个遍历组合 --batch-size 1 4 --steps 0 1 3 5 --topk 0 1 2 4 --num_draft_tokens 0 2 4 8

最后会得到类似这样的一个结果,以下是 batch_szie=1 和 batch_size=4 的部分:

{"batch_size": 1, "steps": 0, "topk": 0, "num_draft_tokens": 0, "acc_length": 1.0, "step_time": 0.01175, "speed": 85.12, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 1, "topk": 1, "num_draft_tokens": 2, "acc_length": 1.81, "step_time": 0.01459, "speed": 123.995, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 1, "topk": 2, "num_draft_tokens": 2, "acc_length": 1.819, "step_time": 0.01609, "speed": 113.066, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 1, "topk": 4, "num_draft_tokens": 2, "acc_length": 1.821, "step_time": 0.01607, "speed": 113.303, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 1, "topk": 4, "num_draft_tokens": 4, "acc_length": 1.931, "step_time": 0.01748, "speed": 110.423, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 3, "topk": 1, "num_draft_tokens": 2, "acc_length": 2.46, "step_time": 0.01683, "speed": 146.226, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 3, "topk": 1, "num_draft_tokens": 4, "acc_length": 2.46, "step_time": 0.01682, "speed": 146.247, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 3, "topk": 2, "num_draft_tokens": 2, "acc_length": 1.819, "step_time": 0.0166, "speed": 109.603, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 3, "topk": 2, "num_draft_tokens": 4, "acc_length": 2.547, "step_time": 0.01811, "speed": 140.639, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 3, "topk": 4, "num_draft_tokens": 2, "acc_length": 1.821, "step_time": 0.0167, "speed": 109.059, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 3, "topk": 4, "num_draft_tokens": 4, "acc_length": 2.594, "step_time": 0.01818, "speed": 142.667, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 3, "topk": 4, "num_draft_tokens": 8, "acc_length": 2.944, "step_time": 0.02172, "speed": 135.549, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 5, "topk": 1, "num_draft_tokens": 2, "acc_length": 2.569, "step_time": 0.01936, "speed": 132.721, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 5, "topk": 1, "num_draft_tokens": 4, "acc_length": 2.569, "step_time": 0.01933, "speed": 132.888, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 5, "topk": 2, "num_draft_tokens": 2, "acc_length": 1.819, "step_time": 0.01779, "speed": 102.25, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 5, "topk": 2, "num_draft_tokens": 4, "acc_length": 2.547, "step_time": 0.01931, "speed": 131.863, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 5, "topk": 2, "num_draft_tokens": 8, "acc_length": 2.878, "step_time": 0.02292, "speed": 125.585, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 5, "topk": 4, "num_draft_tokens": 2, "acc_length": 1.821, "step_time": 0.01797, "speed": 101.347, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 5, "topk": 4, "num_draft_tokens": 4, "acc_length": 2.572, "step_time": 0.01949, "speed": 131.939, "completion_tokens": 512.0}
{"batch_size": 1, "steps": 5, "topk": 4, "num_draft_tokens": 8, "acc_length": 2.97, "step_time": 0.02308, "speed": 128.695, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 0, "topk": 0, "num_draft_tokens": 0, "acc_length": 1.0, "step_time": 0.01476, "speed": 67.73, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 1, "topk": 1, "num_draft_tokens": 2, "acc_length": 1.816, "step_time": 0.02069, "speed": 87.766, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 1, "topk": 2, "num_draft_tokens": 2, "acc_length": 1.812, "step_time": 0.02215, "speed": 81.821, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 1, "topk": 4, "num_draft_tokens": 2, "acc_length": 1.828, "step_time": 0.02213, "speed": 82.586, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 1, "topk": 4, "num_draft_tokens": 4, "acc_length": 1.94, "step_time": 0.02567, "speed": 75.565, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 3, "topk": 1, "num_draft_tokens": 2, "acc_length": 2.452, "step_time": 0.02561, "speed": 95.708, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 3, "topk": 1, "num_draft_tokens": 4, "acc_length": 2.452, "step_time": 0.02566, "speed": 95.547, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 3, "topk": 2, "num_draft_tokens": 2, "acc_length": 1.828, "step_time": 0.02298, "speed": 79.54, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 3, "topk": 2, "num_draft_tokens": 4, "acc_length": 2.502, "step_time": 0.02721, "speed": 91.953, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 3, "topk": 4, "num_draft_tokens": 2, "acc_length": 1.828, "step_time": 0.02317, "speed": 78.88, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 3, "topk": 4, "num_draft_tokens": 4, "acc_length": 2.537, "step_time": 0.02728, "speed": 92.988, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 3, "topk": 4, "num_draft_tokens": 8, "acc_length": 2.854, "step_time": 0.03412, "speed": 83.639, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 5, "topk": 1, "num_draft_tokens": 2, "acc_length": 2.566, "step_time": 0.03057, "speed": 83.933, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 5, "topk": 1, "num_draft_tokens": 4, "acc_length": 2.566, "step_time": 0.03067, "speed": 83.672, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 5, "topk": 2, "num_draft_tokens": 2, "acc_length": 1.828, "step_time": 0.02454, "speed": 74.472, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 5, "topk": 2, "num_draft_tokens": 4, "acc_length": 2.488, "step_time": 0.02879, "speed": 86.415, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 5, "topk": 2, "num_draft_tokens": 8, "acc_length": 2.887, "step_time": 0.03571, "speed": 80.835, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 5, "topk": 4, "num_draft_tokens": 2, "acc_length": 1.828, "step_time": 0.02499, "speed": 73.146, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 5, "topk": 4, "num_draft_tokens": 4, "acc_length": 2.525, "step_time": 0.0292, "speed": 86.49, "completion_tokens": 512.0}
{"batch_size": 4, "steps": 5, "topk": 4, "num_draft_tokens": 8, "acc_length": 2.925, "step_time": 0.03592, "speed": 81.433, "completion_tokens": 512.0}

我们实际测试了 batch_size 1,4,8,16,32,64,128 的情况(得跑巨久),找到了一个比较适合 8卡H20的组合 :speculative-num-steps=3, speculative-eagle-topk=1, speculative-num-draft-tokens=4

在这个参数组合下开启 mtp 后,在输入输出 1K 的参数下,单并发吞吐可以跑到 150 token/s,1.8倍提升,128并发下吞吐可以跑到 2250 token/s,大概1.3倍提升,整体是符合预期的。

Anthropic 兼容 API

kimiglmdeepseek 都开始支持 Anthropic 标准的 API,Claude Code 的影响力恐怖如斯。

我们华东师范大学也支持 Anthropic 兼容 API 了,采取的是 proxy 方案。我们修改了claude-code-proxy 这个项目,让他支持对每个用户自己的 API KEY 来进行鉴权,详见 #37 这个 PR。

不过好消息是 vllmsglang 都有支持 anthorpic api 的 PR 了,例如 #21313#8149,将来可能切换到原生支持是更好的选择。

极你太美

万万没想到的是,DeekseekV3.1 引起的最大热度竟然是极你太美。具体可以看知乎这个提问下的相关回答:如何评价 DeepSeek V3.1 模型输出 Token 会被随机替换为「极」?

我们针对这个事情也做了一些测试,结论大概是这样:

  1. 在数据构造的场景里,持续的规律性长文本输出确实会让 DS 懵逼,并开始输出 极。
  2. 出现 极 的情况和输出的长度相关,在比较低的输出长度下不会出现这个问题。所以在数据构造的场景里,分批来构造就可以规避掉 极。
  3. 调整 temperature, top_p 等参数恐怕作用不大。在 logprobs 里很多场景 极 直接出现在了第一位。降低 temperature 和 top_p 搞不好是反而出来的更多了。
  4. 通过提示词可以一定程度缓解,但无法完全避免。
  5. 写作,代码,提问等场景里,只要不涉及持续的规律性的文本构造,即便是超长文本输出也基本上不会出现这个问题。

详见这里 聊聊Deepseek V3.1的极你太美

参考资料

以上