DeepSeek Harness PI_AI_ERROR:瞬时中断不重试、限流误判与 400 锁死会话
如果你在 DeepSeek Harness(DSH)里用 pi-ai 类路由(Codex、OpenRouter、opencode-go、自建网关等),很可能会遇到一种「什么都没坏但就是不重试」的失败:一轮对话突然结束,报一个笼统的 PI_AI_ERROR,日志里连一条 llm/retry 都没有。 原因不在网络,而在错误分类器:classifyPiAiError 是一串按顺序匹配的无锚文本正则,首个命中即返回,认不出就落到末尾兜底 PI_AI_ERROR;而重试轮只处理 EMPTY_RESPONSE、RATE_LIMIT、SERVER、TIMEOUT、TRANSPORT 五类。于是本批讨论里反复出现的四类上游瞬时故障全部落进兜底:HTTP/2 流中断(upstream_http2_stream_error)、Codex 服务端过载与缓冲限制(server_error / exceeded request buffer limit)、限流被鉴权正则抢走(同一句里出现 401 与 429 时被判成 AUTH),以及无 body 的 400 被判 INVALID_REQUEST 之后长会话进入不可自愈的吸收态(#9589、#9397、#9502、#9564、#9636、#9352)。
DeepSeek Harness 机制:一个纯文本正则分类器 + 五类白名单
一句话结论:分类靠「文本长什么样」而不是「上游给的码是什么」,所以只要上游措辞不在正则表里,就会被归成兜底码 PI_AI_ERROR,而兜底码没有重试资格——这就是「瞬时故障不重试」的全部原因。 两处实现细节决定了所有症状(#9589):
- 分类器:
classifyPiAiError()是一串无锚(不带^/$)的文本正则,按顺序匹配、首个命中即返回;末尾是 catch-all 兜底PI_AI_ERROR; - 重试轮:只重试
EMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT这五类,PI_AI_ERROR不在其中。
DeepSeek Harness 本批被报告的具体缺口
| 上游返回 | 期望归类 | 实际归类 | 原因 |
|---|---|---|---|
upstream_http2_stream_error: Upstream HTTP/2 stream failed | TRANSPORT | PI_AI_ERROR | 分类器只认旧措辞 HTTP2 request did not get a response(#9564、#9508) |
server_error: Our servers are currently overloaded… | SERVER | PI_AI_ERROR | SERVER 分支只认 \b5\d\d\b,而 Responses 路线生成的是 ${code}: ${message},不带数字(#9502) |
exceeded request buffer limit while retrying upstream | SERVER | PI_AI_ERROR | 同上,纯文本无状态码(#9397) |
| Codex 服务端过载(无状态码文案) | SERVER | PI_AI_ERROR | 同上;另外 \bsocket\b 匹配不到 WebSocket(无词边界)(#9589) |
| 同一句里同时含 401/403 与 429 | RATE_LIMIT | AUTH | 401|403 这一条排在 429 之前,首个匹配胜出(#9636) |
400 status code (no body) | —— | INVALID_REQUEST | \b400\b 分支对「只有状态码、无 body」的字符串断言「请求内容非法」(#9352) |
讨论里给过一个很有说服力的量化:约有一半 pi-ai 可重试措辞对 harness 是不可见的——也就是说「上游认为可重试」与「DSH 认为可重试」两张表在漂移(#9397)。
DeepSeek Harness 判据:怎么确认你踩的是这一族
看两件事就够:turn/end 有没有带 assistant/message,以及有没有 llm/retry 事件。
- 一轮被
PI_AI_ERROR打断,日志里turn/end显示错误结束、没有 assistant 产出; - 全程没有任何
llm/retry事件(说明这一次根本没进重试轮); - 换成
TRANSPORT类故障时,能看到重试 5/5 之类的记录——这个对照最能说明问题不在网络而在分类(#9564)。
DeepSeek Harness 处置一:把缺失的码补进重试白名单(配置层)
一句话结论:retryableCodes 是整体替换语义,所以补的时候必须把默认五类一起写全。 配置写在与当前会话模式一致的 profile patch 文件(cordis.patch.yml)里,不是过时的 settings.yaml(#9694)。
- 先确认要补哪个码。看日志里
turn/end的reason.error.code,确认是PI_AI_ERROR还是INVALID_REQUEST(#9694)。 - 在 profile patch 里写全列表(把默认五类一起列出,避免整体替换把它们挤掉):
# ~/.dsh/profiles/<profile>/cordis.patch.yml
- patch:
id: llm-pi-ai
config:
retryPolicy:
retryableCodes:
- EMPTY_RESPONSE
- RATE_LIMIT
- SERVER
- TIMEOUT
- TRANSPORT
- PI_AI_ERROR # 本次要补的兜底码
- 重启 DSH 让配置生效,然后重跑之前失败的那条请求。
- 验证:日志里这次应当出现
llm/retry事件;若仍没有,说明这个码在更早的地方就被拦下(见下一节的 400 情形)(#9694)。 - 知道代价:
PI_AI_ERROR是兜底码,把它加进白名单等于「重试所有未识别的错误」,包括那些本来就不该重试的。所以这是临时缓解,不是长期方案(#9694)。
一个容易困惑的点:为什么在设置界面里找不到这个选项?因为 pi-ai 适配器的
retryPolicy没有标记为可在设置界面编辑(deepseek 适配器才显示),所以只能手写 patch(#9694)。
DeepSeek Harness 处置二:无 body 的 400 为什么是「吸收态」
一句话结论:INVALID_REQUEST 不在默认可重试集合,而压缩溢出的自动恢复只认 CONTEXT_WINDOW_EXCEEDED,两者对不上——所以这个状态既不会被重试、也不会被自动恢复,长会话就此锁死。 症状是「突然每条请求都报 400 status code (no body)」,此后连 /compact 也救不回来(#9352)。
还有一条相关的量级问题值得一起看:某些网关的错误体只有 31 字节,即「有状态码、没有有效消息」,于是 DSH 只能按「裸状态码」处理;opencode-go 上的真实触发条件是 prompt_tokens + max_tokens > 2^20——请求的 max_tokens 取自目录声明的输出上限(例如 384000),而声明的 1M 窗口大于上游实际的 2^20 限制(#9434)。
DeepSeek Harness 三步处置
- 先把请求体量降下来:给该 provider/model 显式压
maxTokens(讨论中给出的一个可行值是 65536),或手动/compact缩短历史(#9434):
providers:
<provider>:
modelOverrides:
<model>:
maxTokens: 65536
- 把
INVALID_REQUEST加进retryableCodes(写法同上一节,注意整体替换语义),让这类 400 有机会重试成功(#9352)。 - 或使用把无 body 4xx 改判为可重试错误的社区插件(讨论中提到有同类插件把「无 body 4xx」改判为
EMPTY_RESPONSE,从而让默认策略接手)(#9352)。 - 验证:重放同一条请求,预期这次要么成功、要么至少出现一次重试记录;若依旧立刻失败,说明还有别的原因(例如窗口声明与实际不符),回到第 1 步继续压体量。
DeepSeek Harness 处置三:别期待「整体放开 PI_AI_ERROR」能一劳永逸
一句话结论:把整个 PI_AI_ERROR 放宽到重试,会把「本不该重试」的错误一起拉进来无限重试。 讨论里多次强调这条边界(#9397、#9502):
- 会连累的错误:某些状态类错误(例如运行中、待处理一类)本来就不该重试,重试反而放大成本;
- 语义相反的相邻分支:靠近
413的分支与「缓冲超限」语义相反,泛化匹配buffer/too-large会把两者混为一谈(#9397); - 更稳的方向:窄修——把上游的
code前缀整族映射到TRANSPORT或SERVER(例如upstream_http2_*整族、server_error单独一条),并且保持分支顺序、把限流分支提到鉴权分支之前(#9502、#9636); - 上游有现成能力:讨论中提到 pi-ai 自身导出了可重试判定(
isRetryableAssistantError),DSH 侧改成调用它可以避免维护第二份会漂移的措辞表(#9589)。
DSH插件 排查注意事项
这一族的排查口诀是:先看「有没有 llm/retry」,再看「turn/end 的 code 是什么」,最后才看上游原文。 九条要点:
- 没有
llm/retry就说明没进重试轮,不要把力气花在「重试次数不够」上(#9564)。 PI_AI_ERROR的字面含义是「分类失败」,不是一种具体故障(#9589)。- 两张重试表在漂移:上游认为可重试的不等于 DSH 认为可重试的,量级差异可达一半(#9397)。
- 限流被误判成鉴权很常见,判据是「密钥明明有效却报 AUTH」(#9636)。
INVALID_REQUEST是吸收态:不重试 + 不自动恢复,必须人工介入(#9352)。- 错误体被截断(如只剩 31 字节)时,DSH 只能按裸状态码处理,排查很难,先解决「错误体为什么丢了」(#9434)。
retryableCodes是整体替换:写它就必须把默认五类一起写全(#9694)。- 设置界面里找不到这个选项是正常的:pi-ai 适配器的
retryPolicy没有暴露到 UI(#9694)。 - 修复状态如实:分类器缺口的窄修、
INVALID_REQUEST的可重试性、以及摘要与上游code的映射,在讨论所指版本里都未修;本文给出的均是用户侧配置与插件缓解,不是官方修复。
排查这类错误时,先把「插件层」变量排掉往往能省一半时间——毕竟重试策略、视觉附件、计价守卫都是插件形态。如果你想在界面上把当前环境的插件与日志一眼看清,与其在各处手敲命令,不如用 DSH Plugin Hub 这类可视化插件市场:已安装列表、装卸历史与系统日志都在一个面板里。

来源:Discussion #9589、Discussion #9397、Discussion #9502、Discussion #9564、Discussion #9508、Discussion #9636、Discussion #9434、Discussion #9352、Discussion #9694。
常见问题
DeepSeek Harness 报 PI_AI_ERROR 且不重试,是因为它是分类器认不出时的兜底码,而不在默认可重试集合里。pi-ai 适配器用一串文本正则给错误分类,首个匹配胜出;上游措辞一旦不在正则覆盖范围内,就会落到末尾兜底 PI_AI_ERROR,而不重试的重试轮只处理 EMPTY_RESPONSE、RATE_LIMIT、SERVER、TIMEOUT、TRANSPORT 这五类,于是这一轮直接结束。判据是 turn/end 没有 assistant/message 且全程没有 llm/retry 事件。
DeepSeek Harness 不重试这两类,是因为它们的措辞都不带数字状态码。分类器的 SERVER 分支只认 5 开头的三位数字,而 Responses 路线生成的字符串是 code: message 的形状、没有数字,所以匹配不上;HTTP/2 那条也只认旧措辞,新码同样落入兜底。可行的缓解是把这些码显式加进重试白名单,或把上游的 code 前缀整族映射到 TRANSPORT 一类,但要注意别整体放开 PI_AI_ERROR。
DeepSeek Harness 把 401/429 同现判成密钥无效,是正则顺序问题。分类器全是无锚文本正则、首个匹配胜出,而 401|403 这一条排在 429 之前,所以同时出现时会被判成 AUTH 而不是 RATE_LIMIT,AUTH 又不在默认可重试集合里,于是既不重试也给出错误的归因。修法是把限流分支提到鉴权分支之前,或给该路由的 retryableCodes 补上对应码。
DeepSeek Harness 长会话一直报无 body 的 400,是无 body 的 400 被判成 INVALID_REQUEST 造成的吸收态:该码不在默认可重试集合,而压缩溢出的自动恢复只认 CONTEXT_WINDOW_EXCEEDED,两者对不上,所以怎么都不会恢复。可用的处置是把 INVALID_REQUEST 显式加进该 profile 的 retryableCodes,或使用把无 body 4xx 改判为可重试错误的社区插件。
DeepSeek Harness 的 retryableCodes 写在 profile 的 patch 文件里(cordis.patch.yml 那一层),不是过时的 settings.yaml。要注意 retryableCodes 是整体替换而不是追加:如果你只写 PI_AI_ERROR,默认的 EMPTY_RESPONSE、RATE_LIMIT、SERVER、TIMEOUT、TRANSPORT 会全部丢掉,所以必须把想要的码一起列全。另外 pi-ai 适配器的 retryPolicy 没有标记为可在设置界面编辑,因此你在设置页里找不到这个选项。
相关术语
- PI_AI_ERROR
- PI_AI_ERROR 是 DeepSeek Harness 的 pi-ai 适配器在错误分类器无法识别上游错误时使用的兜底错误码,表示「分类失败」而不是某种具体故障。它不在默认可重试集合里,因此一轮遇到它通常会直接结束。— https://github.com/deepseek-ai/deepseek-harness/discussions/9589
- classifyPiAiError
- classifyPiAiError 是 pi-ai 适配器里把上游错误文本映射成内部错误码的函数,实现方式是一串按顺序匹配的文本正则,首个命中即返回。它不解析上游的稳定错误码,因此上游措辞变化会导致分类漂移,最终落入 PI_AI_ERROR 兜底。— https://github.com/deepseek-ai/deepseek-harness/discussions/9636
- retryPolicy.retryableCodes
- retryPolicy.retryableCodes 是重试策略里声明「哪些错误码允许自动重试」的列表,属于插件配置。它是整体替换语义:patch 里写什么就只剩什么,未写出的码会失去重试资格。— https://github.com/deepseek-ai/deepseek-harness/discussions/9694
- upstream_http2_stream_error
- upstream_http2_stream_error 是上游在 HTTP/2 流被中断时返回的错误码,属于瞬时传输故障,语义上应当可重试。它当前没有被分类器归到 TRANSPORT 一类,因此会落入 PI_AI_ERROR 兜底而失去重试。— https://github.com/deepseek-ai/deepseek-harness/discussions/9564
来源
- #9589 — llm-pi-ai 把 Codex 服务端过载归类为 PI_AI_ERROR,跳过自动重试· deepseek-ai(GitHub Discussions)
- #9397 — llm-pi-ai 将上游重试缓冲错误归为 PI_AI_ERROR,跳过自动重试· deepseek-ai(GitHub Discussions)
- #9502 — server_error 仍然绕过重试(只认数字 5xx)· deepseek-ai(GitHub Discussions)
- #9564 — llm-pi-ai 把 upstream_http2_stream_error 归入不可重试的 PI_AI_ERROR· deepseek-ai(GitHub Discussions)
- #9508 — upstream_http2_stream_error still becomes non-retryable PI_AI_ERROR on 0.2.1-alpha.2· deepseek-ai(GitHub Discussions)
- #9636 — classifyPiAiError 中 401|403 正则顺序抢走 429,限流被误判为不可重试的 AUTH· deepseek-ai(GitHub Discussions)
- #9434 — opencode-go/deepseek-v4.1-flash: prompt_tokens + max_tokens 超 2^20 时上游 400,错误体被截断· deepseek-ai(GitHub Discussions)
- #9352 — 未分类的空 body HTTP 400 被判为 INVALID_REQUEST,导致长会话被永久锁死· deepseek-ai(GitHub Discussions)
- #9694 — 如何把 PI_AI_ERROR 加入自动重试规则(retryPolicy 配置位置与替换语义)· deepseek-ai(GitHub Discussions)