最近把项目里的 DeepSeek V3 升级到新版本,结果同一套代码开始出现 500 错误。错误信息只有一句冷冰冰的 Internal server error,没有任何有用的字段提示。排查下来,主要集中在两类请求格式问题:① system message 里混入了 Unicode 控制字符;② tools 参数传了空数组 []。下面展开讲怎么排查和修。

说明:本文基于实际排查经验整理,部分涉及模型内部行为的描述(如版本间容错差异)为推测,DeepSeek 官方未公开相关实现细节,仅供参考。

为什么会出现这个问题

先看收到的报错长什么样:

openai.InternalServerError: Error code: 500 - {'error': {'message': 'Internal server error', 'type': 'server_error', 'param': None, 'code': 'internal_error'}}

注意 paramNone——它不会告诉你哪个字段有问题。这是最难受的地方。

500 错误可能来自多种原因:请求格式问题、服务端过载,或其他内部异常。由于错误信息统一返回 internal_error,无法从响应体直接区分,只能通过逐步缩小范围来排查。

graph TD
 A[你的请求] --> B{服务端处理}
 B -->|system msg 含控制字符| C[处理异常 → 500]
 B -->|tools 传空数组| D[处理异常 → 500]
 B -->|服务端过载| E[500 overloaded]
 B -->|请求正常| F[正常响应 200]
 C --> G[错误信息: Internal server error]
 D --> G
 E --> G

三种情况返回的错误信息可能相同,这就是为什么很多人以为是服务端问题一直在重试。

方案一:排查 system message 里的 Unicode 控制字符

这是第一个常见坑。system prompt 如果从 Notion、Word 或网页复制粘贴,肉眼看完全正常,但实际上可能藏有零宽空格(\u200b)、BOM 头(\ufeff)等不可见字符。

验证方法,跑一下这个:

system_msg = your_system_prompt
dirty = [
    c for c in system_msg
    if ord(c) in range(0x00, 0x09)      # 0x00-0x08:不可见控制字符
    or ord(c) in range(0x0e, 0x20)      # 0x0e-0x1f:不可见控制字符
    or ord(c) in (0x200b, 0xfeff, 0x200c, 0x200d)  # 零宽字符、BOM
]
print(f"找到 {len(dirty)} 个可疑字符: {[hex(ord(c)) for c in dirty]}")

注意\n(0x0a)、\t(0x09)、\r(0x0d)是合法的文本字符,上面的检测逻辑已将其排除,不会误报。

如果找到了可疑字符,用下面的正则清洗(清洗范围与检测范围保持一致,同样保留 \n\t\r):

import re
clean_msg = re.sub(r'[\x00-\x08\x0e-\x1f\ufeff\u200b-\u200d]', '', system_msg)

清掉这些字符后,部分原本 500 的请求会恢复正常。

方案二:检查 tools 参数是否传了空数组

第二个常见问题。项目里如果有通用的请求封装函数,大概长这样:

payload = {
    "model": "deepseek-chat",
    "messages": messages,
    "tools": get_available_tools()  # 某些场景返回 []
}

get_available_tools() 返回空列表 [] 时,部分模型或版本可能对此报错(具体行为取决于服务端实现,官方未公开相关 schema 约束细节)。更稳妥的做法是只在有实际工具时才传该字段:

tools = get_available_tools()
payload = {"model": "deepseek-chat", "messages": messages}
if tools:  # 只有非空才传
    payload["tools"] = tools

这也是 OpenAI 兼容接口的通行做法:可选字段有值才传,避免传空值引发不必要的问题。

方案三:在业务层统一做请求预处理

如果项目同时调多个模型,在业务代码里逐个适配比较繁琐。可以封装一个请求预处理函数,在发送前统一做两件事:strip 控制字符 + 删除空值可选字段。

import re

def sanitize_payload(payload: dict) -> dict:
    """发送前清洗请求体:移除控制字符、删除空值可选字段"""
    # 清洗所有 message content 中的控制字符
    for msg in payload.get("messages", []):
        if isinstance(msg.get("content"), str):
            msg["content"] = re.sub(
                r'[\x00-\x08\x0e-\x1f\ufeff\u200b-\u200d]',
                '',
                msg["content"]
            )
    # 删除空值可选字段
    for key in ("tools", "tool_choice", "stop"):
        if key in payload and not payload[key]:
            del payload[key]
    return payload

关于聚合 API 网关:OpenRouter 等聚合网关确实可以作为过渡方案,但在选择第三方服务时,建议自行验证其 API 端点可用性、服务条款及数据处理方式,再决定是否接入。依赖网关兜底不是长久之计,业务代码里做好预处理更可靠。

完整排查流程

遇到 500 时,按这个顺序来:

第一步:用最简请求验证服务是否可用

# 用最简单的请求测试,排除服务端过载
resp = client.chat.completions.create(
    model="deepseek-chat",
    messages=[{"role": "user", "content": "Hi"}]
)

如果这个请求也 500,大概率是服务端问题,可以稍后重试。如果这个正常、你的复杂请求才 500,说明是请求格式的问题,继续往下排查。

注意:此步骤只能区分"服务端是否可用",无法区分 model ID 是否正确等其他问题,排查时需结合错误信息综合判断。

第二步:如果简单请求正常,检查 system message

把 system prompt 打印成 repr() 看有没有不可见字符,或用方案一的检测代码扫一遍。

第三步:检查请求体里有没有传空数组或空值

重点看 toolstool_choicestop 这几个可选字段,传了空值就删掉。

第四步:加指数退避重试兜底

import time
import openai

for i in range(3):
    try:
        resp = client.chat.completions.create(
            model="deepseek-chat",
            messages=[{"role": "user", "content": "Hello"}]
        )
        break
    except openai.InternalServerError:
        if i < 2:
            time.sleep(2 ** i)

常见问题 FAQ

Q: DeepSeek 的 model 参数填什么?

调用 DeepSeek 官方 API 时,model ID 填 deepseek-chat(对应 DeepSeek-V3 系列)。具体路由到哪个版本取决于后端,建议以 DeepSeek 官方文档 为准。

Q: 怎么区分是服务端过载还是请求格式有问题?

{"role": "user", "content": "Hello"} 单条消息做最简测试。如果简单请求正常、复杂请求才 500,基本可以判断是请求格式问题。服务端过载时,响应体中有时会包含 overloaded 相关提示,但不同情况下返回内容可能有差异,不能完全依赖此判断。

Q: max_tokens 设多少合适?

根据 DeepSeek 官方文档deepseek-chat 最大输出为 8192 tokens。超出限制通常会返回 400 错误(参数校验失败),而非 500。如果遇到 500,一般不是 max_tokens 的问题,但保守起见可以设在 4096 以内。

Q: 我用 Cursor / Cline 调 DeepSeek 也报 500 怎么办?

这些工具底层也是拼 HTTP 请求,检查它们的 system prompt 模板有没有控制字符。如果自定义了 Rules 文件,从其他地方粘贴过来的内容可能带不可见字符,用方案一的检测代码扫一遍。

Q: 重试几次合适?间隔多少?

指数退避是通行做法:建议最多 3 次,间隔 1s → 2s → 4s。如果 3 次都 500,大概率不是过载问题,继续排查请求格式。具体重试策略可参考 DeepSeek 官方文档的建议。

小结

这类问题最难受的不是修起来难,而是定位花时间——错误信息什么都不说,只能一个字段一个字段排除。在业务代码里加一个统一的请求预处理步骤(strip 控制字符 + 删除空值可选字段),可以避免大部分此类问题。希望 DeepSeek 后续能把 validation error 的具体原因写进 response body 里,别再统一返回一个 internal_error 了。

更多推荐