DeepSeek Harness 自定义中转站推理模型工具调用必现 400:compat 与 reasoning 回放

故障排查发布于 2026-10-03作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSH自定义中转站400reasoning_contentopenai-completionscompat工具调用
接入自定义 OpenAI 兼容中转站后,非推理模型正常,换成带思考的 DeepSeek V4 则工具调用必现 400 INVALID_REQUEST。两条独立原因:compat 里写了个不存在的键,导致整块 compat 被 INVALID_CONFIG 拦下;reasoning 回放用哪个键决定中转站是否拒单。

同一份配置、同一个中转站,gpt-5.6-terra 调用工具毫无问题,一换成 deepseek-v4-flash / deepseek-v4.1-flash,只要进入工具调用轮次就必现 400 INVALID_REQUEST——而且报错 body 只有一句没有字段名的 {"message":"Upstream error: 400","type":"invalid_request_error"}。 这里其实是两条彼此独立的原因叠在同一症状上:第一,你 compat 里写的 suppressReasoningContentReplay 在整个 pi-ai 0.85.1 里根本不存在,而未知 compat 键不是被忽略、是在派发时被 INVALID_CONFIG 直接拦下并点名——也就是说那块 compat 里其余四个开关从未生效过(#7134)。第二,真正让推理模型独有的原因在「回放形状」:思考内容以 reasoning_content 还是结构化 reasoning_details 回放,取决于 thinking 块的 thinkingSignature;一个要求工具调用轮里带非空 reasoning_content 的中转站,会在结构化形态下拒掉每一轮(#7134 回复)。修法很具体:先删掉那个不存在的键重测,再用一条 session log 字段确认回放形态,最后决定是补 reasoningEfforts、改路由命名,还是去要求中转站放宽校验。

先分诊:这个 400 是「配置没生效」还是「配置本身非法」

动手改 YAML 之前先做一次最小判定:那条路由上的请求,究竟有没有离开 DSH。 两条路径的表现高度相似(都是 400 家族),但修法完全不同。

判据A:compat 键非法B:回放形状与中转站要求不匹配
报错码INVALID_CONFIG(在 DSH 侧产生)INVALID_REQUEST(来自中转站上游)
是否到达中转站否,派发前就被拒是,中转站返回 400
影响范围该路由上每一次模型调用只有带思考 + 工具调用的轮次
非推理模型是否受影响受影响(与模型无关)不受影响(没有 thinking 块)
报错文本特征点名那个键,并列出可配开关清单只有 Upstream error: 400,无字段名

第一步,确认你写的那几个 compat 键是不是真的存在。最省事的办法是看报错里有没有点名——DSH 会把未声明的键直接写进错误信息:

text
llm-pi-ai: provider "<route>" route sets compat "suppressReasoningContentReplay", which no wire protocol declares;
the configurable switches are supportsStore, supportsDeveloperRole, supportsReasoningEffort, supportsUsageInStreaming,
supportsFinishReason, maxTokensField, requiresToolResultName, requiresAssistantAfterToolResult, requiresThinkingAsText,
requiresReasoningContentOnAssistantMessages, thinkingFormat, chatTemplateKwargs, chatTemplateArgs,
supportsThinkingTokenBudget, thinkingTokenBudgetField, vllmPriority, supportsStrictMode, cacheControlFormat,
supportsLongCacheRetention, supportsMaxOutputTokens, supportsEagerToolInputStreaming, supportsCacheControlOnTools,
supportsTemperature, forceAdaptiveThinking, allowEmptySignature, supportsStrictTools
code: INVALID_CONFIG

第二步,确认「那一行到底写在哪一层」。这一点决定了前面所有尝试的结论是否要推翻:

  • 校验对 route 级和 model 级都会跑(catalog.ts:879、catalog.ts:883);
  • compat 在 route 上合法(config.ts:329),在每个 model 上也合法(config.ts:311 经 modelFields),model 逐字段覆盖 route(catalog.ts:342-343);
  • settings.yaml 里的段名是 llm-pi-ai(packages/llm/llm-pi-ai/src/index.ts:5-7),下面才是 providers.<你的路由名>。
yaml
llm-pi-ai:
  providers:
    <你的路由名>:
      api: openai-completions
      compat:                       # ← route 级
        supportsDeveloperRole: false
        # ...
      models:
        - id: deepseek-v4-flash
          compat:                   # ← model 级,逐字段覆盖 route
            thinkingFormat: deepseek

第三步,做一次单变量对照:把 compat 整块注释掉,只留 api: openai-completions 和模型列表,再跑一次「中文指令 → 思考 → 调用 Pwsh」。如果这时错误从 INVALID_CONFIG 变成真正的 INVALID_REQUEST(或者干脆能过),说明 A 类问题确实存在;如果行为完全不变,说明那一行根本没进 llm-pi-ai 的 compat,被别的 schema 吃掉了,你前面的结论要重来。

原因一:未知 compat 键不是被忽略,是在派发时被判错

这一节的关键结论:DSH 允许你写下未知 compat 键,然后在派发时拒绝它——所以「配置能加载」不等于「配置生效」,更等于「其他配置也生效」。

1. 这个键在仓库里不存在

suppressReasoningContentReplay 的三处核对全部落空:

  • 不在 schema 里:packages/llm/llm-pi-ai/src/config.ts:254-281;
  • 不在 COMPLETIONS_COMPAT_GATE 里:packages/llm/llm-pi-ai/src/catalog.ts:231-258;
  • 已安装的 pi-ai 0.85.1 里也搜不到。

对照一下你列的另外四个键,它们都存在且对 openai-completions 可配:

key是否存在依据
supportsDeveloperRole是config.ts:256、catalog.ts:233(offer)
requiresReasoningContentOnAssistantMessages是config.ts:264、catalog.ts:241(offer)
thinkingFormat是,deepseek 是合法值config.ts:265、catalog.ts:242;可选值见 catalog.ts:99-114,deepseek 在 catalog.ts:101
supportsStrictMode是config.ts:272、catalog.ts:248(offer)
suppressReasoningContentReplay否全仓库无命中

2. 校验发生在派发阶段,且会点名

catalog.ts:537-557 的 assertOfferedCompatFields 会把「没被任何协议声明过」的键判错,错误信息里还会列出真正可配的开关;值为空的键同样被拒(catalog.ts:566-569)。所以正确的心智模型是:

text
写入配置 → 被接受(不做未知键校验)
   ↓
第一次 dispatch → assertOfferedCompatFields → 未知键 → INVALID_CONFIG
   ↓
该路由上每一次模型调用都在到达中转站之前失败

推论很硬:只要这一行确实落在 compat: 下面,你后续调整那四个开关的任何一次测试都没有意义——请求压根没出去。

3. 为什么这不只是「少了个功能」

这里暴露的是一类常见误判:把「配置能被加载」当成「配置已被校验」。上面的流程恰好相反——接受得越宽,失败得越晚。修法建议(也出现在报告里):把未知 compat 键的校验前移到写配置的时候。一个无法被应用的开关,不应该先被静默接受、再让某一轮请求以 INVALID_CONFIG 收场。

原因二:requiresReasoningContentOnAssistantMessages 在自定义路由上会静默失活

这一节的关键结论:这个开关被 model.reasoning 二次门控,而自定义路由的 model.reasoning 默认是 false。

pi-ai 0.85.1 里的判据是:

js
if (compat.requiresReasoningContentOnAssistantMessages && model.reasoning && assistantMsg.reasoning_content === undefined)
    assistantMsg.reasoning_content = ""

注意三个条件缺一不可。而 dsh 对自定义 provider key 下的模型按 base?.reasoning ?? false 解析——已安装的 catalog 里没有你那条路由的描述,所以 model.reasoning 就是 false,除非你的 model 条目显式声明了 reasoningEfforts。实测两种形态:

compat 开关是否声明 reasoningEfforts出站请求里的字段
true否完全没有 reasoning_content 键
true是"reasoning_content": ""

这正好复现了你「compat 设置毫无效果」的观察。还有一层更根本的错配:这个开关的语义只是补一个空字符串(:1045-1049 写的就是 ""),不是把真实思考内容补回去。所以即使它在自定义路由上生效了,也修不了「中转站要求非空真实思考」这一类问题。

顺带纠一个形状:回放时写哪个 reasoning 字段,取决于 thinking 块携带的 thinkingSignature 属于 reasoning / reasoning_content / reasoning_text 哪一个(pi-ai 0.85.1,dist/api/openai-completions.js:155 与 :1001-1007),并且在存在 preservedReasoningDetails 时会整段跳过(:999)。tool_calls 的映射在 :1019-1041,位置在 reasoning 写入之后、也不参与那个判断。所以「因为带 tool_calls 所以 DSH 丢了 reasoning_content」不是这段代码的行为。

实测:思考内容到底有没有发出去

结论先给:真实思考文本确实上了线,变的只是它挂在哪个键下面。

用本机 OpenAI 兼容 relay + 与你完全同形的路由声明(api: openai-completions、自带 models),经真实的 @deepseek-ai/dsh-llm-pi-ai(0.1.5-rc.2,pi-ai 0.85.1)做两轮回放——第 1 轮 relay 回思考 + 工具调用,第 2 轮就是你说会 400 的那一轮。录到的请求体是:

jsonc
// relay 以 reasoning_content 形式上报思考时,第 2 轮发出:
{"role":"assistant","content":null,"reasoning_content":"I should list the files.","tool_calls":[…]}
jsonc
// relay 以结构化 reasoning_details 上报时,第 2 轮发出:
{ …,"reasoning_details":[{"type":"reasoning.text","index":0,"text":"I should list the files."}] }

第二形态里没有 reasoning_content——因为 details 对 pi-ai 来说是回放用的元数据,它会「按收到的形状回放」。于是两种形态在中转站眼里完全不同:

  • 中转站只要普通的推理字段 → 两种形态都能过(第一形态直接带字段);
  • 中转站要求 reasoning_content 非空 → 第二形态必挂。

这解释了为什么同一个中转站上非推理模型没事(根本没有 thinking 块,压根不触发这类校验),而 DeepSeek V4 系列(带思考)必现。

一条命令定性:看 thinkingSignature

你不需要装代理就能先分一半的类——失败会话的 session log 里已经有答案。

打开失败会话的第一条 assistant 消息,取:

text
source.replayState.blocks[0].thinkingSignature

按值判读:

值含义下一步
以 [{"type":"reasoning 开头结构化 details 形态,回放时不会带 reasoning_content走「要求中转站放宽」或「改路由形态」
恰好等于 "reasoning_content"普通字段形态,字段确实发出去了400 在别处,去看失败那次的响应体与完整请求体

若是第二种形态,报告里还记录了几处与 catalog 路由的差异,值得一并核对:请求里带了 store、用 max_completion_tokens 而不是 max_tokens、以及在没设 thinkingFormat 时没有 thinking 对象。这些都可能成为某些中转站的拒单理由。

修法:从最稳到最彻底,四条路

修法一(必做):删掉不存在的 compat 键

diff
   compat:
     supportsDeveloperRole: false
     requiresReasoningContentOnAssistantMessages: true
     thinkingFormat: deepseek
     supportsStrictMode: true
-    suppressReasoningContentReplay: false     # ← 全仓库不存在,会让整条路由每次调用 INVALID_CONFIG

删完重测。这一步不做,后面所有实验都是无效的——因为其他四个开关从未被真正执行过。

修法二:给 model 声明 reasoningEfforts,让开关有机会生效

yaml
llm-pi-ai:
  providers:
    <你的路由名>:
      api: openai-completions
      compat:
        supportsDeveloperRole: false
        requiresReasoningContentOnAssistantMessages: true
        thinkingFormat: deepseek
        supportsStrictMode: true
      models:
        - id: deepseek-v4-flash
          reasoningEfforts: ["low", "medium", "high"]   # ← 让 model.reasoning 解析为 true

但要清楚它的上限:生效后产出的只是 "reasoning_content": ""(空串)。如果你的中转站要的是非空真实思考,这条路走不通——它修的是「字段缺失」,不是「字段为空」。

修法三:改用 catalog 路由命名,别手写路由

按仓库自己的说明,pi-ai 在没有显式配置时会从 provider id 与 baseURL 推断这些兼容开关;而:

私有网关的 URL 什么也说明不了:对不认识的端点,推断结果等同于它就是 OpenAI 本身,这对大多数 OpenAI 兼容网关都是错的。(catalog.ts:345-350)

手写路由正好落在这个坑里;catalog 路由会带上该厂商需要的开关(同样的取舍写在 catalog.ts:218-221 与 :553-554)。而 baseURL 是可以覆盖的:

ts
// catalog.ts:893
request.baseURL ?? base?.baseUrl ?? providerBaseUrl

所以可行的形状是——把路由名直接命名成 deepseek,再把 baseURL 指向你的中转站:

yaml
llm-pi-ai:
  providers:
    deepseek:                        # ← 用厂商名,让 catalog 带上正确的开关
      api: openai-completions
      baseURL: https://<你的中转站>/v1   # ← 覆盖默认地址
      models:
        - id: deepseek-v4-flash

前置条件要你自己确认:你的中转站是否真的按 DeepSeek 官方协议转发(尤其 reasoning_content 的收发语义)。报告人对这一点标注为未验证,我在本文也照实保留。

修法四:把出站请求体抓下来,别再猜

你手上那条 400 的 body 是中转站的通用包装,没有字段名:

json
{"message":"Upstream error: 400","type":"invalid_request_error"}

DSH 侧只是按文案把它归类(packages/llm/llm-pi-ai/src/stream.ts:49 命中 400 / invalid request),而这个码不在默认可重试集合里(packages/llm/llm/src/retry-policy.ts:18-24),所以「必现」就是终态、不会自愈。能指出是哪个字段被拒的,只有中转站的上游错误原文。做法两条:

  1. 在本机起一个转发代理,打印请求体后原样转发,把路由 baseURL 临时指过去;
  2. 直接找中转站要它的上游请求日志。

(在报告核对范围内没有找到 DSH 自带「打印出站请求体」的开关——查过 DEBUG、DSH_DEBUG、DSH_LOG、logLevel 等均无命中;报告人明确这只是「没找到」,未证明不存在。所以上面两条是当前可执行路径。)

排查注意事项与来源

  1. 先把 INVALID_CONFIG 和 INVALID_REQUEST 分开。前者在 DSH 侧产生、与模型无关、影响该路由上每一次调用;后者来自中转站。混在一起排查会绕远路。
  2. 一个非法 compat 键会污染你的全部实验记录。它让整块 compat 在派发时被拒,其他开关从未执行——删掉它之前的所有结论都不可信。
  3. compat 写 route 级还是 model 级都要过校验(catalog.ts:879、:883),model 逐字段覆盖 route(catalog.ts:342-343)。先确认那一行到底落在哪一层、有没有被别的 schema 吃掉。
  4. requiresReasoningContentOnAssistantMessages 有两个隐藏前置条件:model.reasoning 必须为真(自定义路由默认 false,要显式声明 reasoningEfforts),而且它只写空字符串。
  5. 别用「非推理模型正常」来证明「中转站没问题」。非推理模型不产生 thinking 块,本来就不触发这类校验;这个对照只能证明「问题与思考回放相关」。
  6. 回放形态由 thinkingSignature 决定,不看这个字段就断言「DSH 丢了 reasoning_content」,方向容易错。
  7. 400 不代表会重试。该错误码不在默认重试集合里,所以它是确定的终态,别指望多试几次会好。
  8. 中转站的通用包装会掩盖字段名。没有原始请求体/上游错误,任何结论都只是推断——本文中凡属推断处均已标注「未验证」。

来源

文中源码位置与 wire 实测(两轮回放录到的请求体、开关与 reasoningEfforts 的对照)均来自该讨论贴中的回复;reasoningEfforts 相关结论为报告人实测,「中转站是否按 DeepSeek 官方协议转发」一项未经验证,已如实标注。


自定义中转站、compat 开关、协议差异这类问题,最耗时间的往往不是修复,而是确认你到底改的是哪一层配置、改动有没有真的生效。DSH Plugin Hub 提供插件市场、已安装列表、自定义安装、设置与系统日志五个界面:设置页集中管理更新检查、npm 镜像源与代理,系统日志页按分类与级别记录安装、卸载、设置变更与诊断的执行轨迹,并支持导出全文与定位日志文件——排查配置类问题时,可以先用它把环境、版本与操作历史对齐,再动 YAML。

DSH Plugin Hub 系统日志界面:按分类与级别展示执行轨迹,支持导出全文与定位日志文件

常见问题

同一个中转站,gpt-5.6-terra 调用工具完全正常,换 deepseek-v4-flash 就必现 400,是中转站不支持吗?

更可能是「推理模型的回放形状」和「中转站的校验要求」对不上。非推理模型压根没有 thinking 块,所以 assistant 历史里没有 reasoning 相关内容,回放自然不会被校验;推理模型会把思考内容带回历史,而它用哪个键承载(reasoning_content 还是结构化的 reasoning_details)取决于中转站当初返回的形态。如果中转站返回的是结构化 reasoning_details,回放时就只带 reasoning_details、不带 reasoning_content——一个**要求**工具调用轮里有非空 reasoning_content 的中转站就会拒单。这个解释与你的对照测试完全吻合。

我在 compat 里加了 `suppressReasoningContentReplay: false`,为什么完全没效果?

因为这个键在 pi-ai 0.85.1 里根本不存在,全仓库也没有任何命中。更关键的是,未知 compat 键**不是被静默忽略**的:assertOfferedCompatFields 会在派发时把它判错并点名,报 INVALID_CONFIG 并列出真正可配的开关清单。所以只要那一行确实落在 compat: 下面,你那条路由上的**每一次模型调用**都会在到达中转站之前就失败——这也意味着你后面调的其他四个开关从未真正生效过。第一步应该是删掉这个键再重测。

`requiresReasoningContentOnAssistantMessages: true` 设了还是没效果,是 DSH 的 bug 吗?

在自定义路由上它被额外条件挡住了。pi-ai 内部是这样判的:compat.requiresReasoningContentOnAssistantMessages && model.reasoning && assistantMsg.reasoning_content === undefined。而自定义路由不在已安装的 catalog 描述里,model.reasoning 会按 base?.reasoning ?? false 解析成 false——除非你的 model 条目显式声明了 reasoningEfforts。实测:开关为 true 但不声明 reasoningEfforts,出站请求里**连 reasoning_content 键都没有**;一旦声明 reasoningEfforts,同样的开关才会产出 "reasoning_content": ""。而且它的语义只是**补一个空字符串**,不是把真实思考内容补回去——即使生效也修不了「要求非空」的中转站。

我怀疑 DSH 在带 tool_calls 的 assistant 消息里把 reasoning_content 丢了,怎么验证?

这个形状不对。回放时写哪个 reasoning 字段,取决于 thinking 块携带的 thinkingSignature 落在 reasoning / reasoning_content / reasoning_text 里;tool_calls 的映射发生在 reasoning 写入**之后**,不参与那个判断,所以「因为有 tool_calls 所以不写 reasoning_content」不是该段代码的行为。最快的验证方式是打开失败会话的第一条 assistant 消息,看 source.replayState.blocks[0].thinkingSignature:以 [{"type":"reasoning 开头 = 结构化 details 形态;恰好等于 "reasoning_content" = 普通字段形态,此时字段确实发出去了,400 得从别处找。

怎么看 DSH 实际发出去的请求体?有没有内置开关?

在报告的核对范围内**没有找到** DSH 自带的「打印出站请求体」开关(查了 DEBUG、DSH_DEBUG、DSH_LOG、logLevel 等关键词均无命中,但报告人也说明这只是「没找到」、未能证明不存在)。可行的做法是在本机起一个打印请求体的转发代理,把路由的 baseURL 指向它、再由它原样转发到中转站;或者直接找中转站要它的上游请求日志。原因很实际:你手上那条 400 的 body 是中转站的通用包装,里面**没有字段名**,不看原始请求体就只能猜。

相关术语

compat(兼容开关块)
`llm-pi-ai` 里用来描述「某个端点与标准 OpenAI 协议有哪些差异」的配置块,route 级与 model 级都可写,model 逐字段覆盖 route。它只接受各 wire protocol 声明过的键——未声明的键会在派发时被判 `INVALID_CONFIG` 并点名,空值的键同样被拒。— https://github.com/deepseek-ai/deepseek-harness/discussions/7134
requiresReasoningContentOnAssistantMessages
compat 开关之一,本意是「这个端点要求带工具调用的 assistant 消息必须带 `reasoning_content` 字段」。实现上它只在 `model.reasoning` 为真时生效,且写入的是**空字符串** `""` 而非真实思考内容。在 catalog 未描述的自定义路由上,`model.reasoning` 默认按 `false` 解析,因此该开关会静默失活——除非 model 条目显式声明 `reasoningEfforts`。— https://github.com/deepseek-ai/deepseek-harness/discussions/7134
thinkingSignature(思考块签名)
持久化在 assistant 消息 thinking 块上的标记,用于记录「这段思考在线上是以什么形状传过来的」。回放时据此决定写 `reasoning_content` 还是结构化 `reasoning_details`。当 provider 只以结构化 details 上报思考时,durable 消息里只留一个空的 `reasoning` 块,文本只存在于这个不透明签名里——一旦签名丢失,思考内容既不可见也不可恢复。— https://github.com/deepseek-ai/deepseek-harness/discussions/7134
catalog 路由 vs 手写路由
catalog 路由由内置目录描述,会带上该厂商真正需要的兼容开关;手写路由(自定义 provider id + 自己的 models 列表)没有目录描述,pi-ai 只能从 provider id 与 baseURL 推断开关,而「私有网关的 URL 什么也说明不了」——对不认识的端点,推断结果等同于「它就是 OpenAI 本身」,对大多数 OpenAI 兼容网关都是错的。`baseURL` 可覆盖,所以把路由命名成厂商名(如 `deepseek`)、再把 `baseURL` 指向自己的中转站,是常见变通。— https://github.com/deepseek-ai/deepseek-harness/discussions/7134

来源