【免费下载链接】deepseek-harness
DeepSeek Harness: Everything is a Plugin.
项目地址:
https://gitcode.com/gh_mirrors/de/deepseek-harness
点击查看 免费下载
本文深入解析 DeepSeek Harness(@deepseek-ai/dsh-llm-pi-ai 适配器)中一次针对 pi-ai 传输层截断错误分类的修复:当模型流式连接在提供方终止事件之前断开(表现为裸 terminated 或 Anthropic stream ended before message_stop 等措辞)时,如何将其从兜底的 PI_AI_ERROR 重新归类为可重试的 TRANSPORT,从而让 llm-retry 策略默认重试而不是放弃整个回合。读者将掌握 pi-ai 错误扁平化的上游机制、classifyPiAiError 的完整分类规则与文本匹配模式,以及错误分类如何与 llm-retry 的重试策略联动。
背景:一次被吞掉的重试机会
一次 TUI 运行中,模型连接在流式输出中途断开,界面上只浮现出单条 terminated 通知;另一个被截断的 Anthropic 响应则浮现出 Anthropic stream ended before message_stop。从语义上看,这两者都是传输层截断(transport truncation)——连接在提供方的终止 SSE 事件之前就已死亡——然而 dsh-llm-pi-ai 中的 classifyPiAiError 对这两种措辞都不匹配,最终落入兜底的 PI_AI_ERROR。
问题的后果不止于错误码不准确:PI_AI_ERROR 不在 llm-retry 的 DEFAULT_RETRYABLE_CODES 之中,因此一次本可恢复的连接断开被当作永久性失败处理,永远不会被重试,直接导致整个回合失败。这正是本次修复要解决的痛点:可恢复的传输故障必须被识别并路由到重试路径上。
上游根因:pi-ai 在终止事件前就扁平化了错误
修复决策的出发点是一段无法在适配器内绕过的上游事实:细节丢失发生在 pi-ai 内部,且不可恢复。
pi-ai 在推送终止 error 事件之前,把捕获到的错误缩减为 error.message(其 api/anthropic-messages.js 中的逻辑为 errorMessage = error instanceof Error ? error.message : JSON.stringify(error)),丢弃了原始的 Error 对象及其 cause 链。具体链路是:
- undici(Node.js 内置的 HTTP 客户端)把可据以采取行动的 SocketError 放在 cause 上,但只交给 fetch 包装层一个裸的 terminated;
- pi-ai 只保留了这个词,进一步把 cause 抹掉;
- dsh-llm-pi-ai 适配器最终拿到的只是 errorMessage 字符串,没有任何结构化错误可供解析。
同时,pi-ai 的 SimpleStreamOptions 没有暴露任何 fetch/dispatcher/client 钩子,适配器无法在细节被扁平化之前自行捕获 cause。因此,文本匹配是 pi-ai 当前唯一交付给下游的信号,分类只能基于消息文本进行——这也是整个修复方案的核心约束。
修复决策:扩展分类器,识别两类传输措辞
本次修复的核心改动位于 packages/llm/llm-pi-ai/src/stream.ts 中的 classifyPiAiError 函数。该函数接收 pi-ai 终止事件携带的 errorMessage 字符串,返回 Harness 的稳定错误码;mapStopReason 在 stopReason === 'error' 时调用它(见 stream.ts)。
修复新增了对两类传输措辞的识别,均映射到 TRANSPORT:
- Anthropic stream ended before message_stop
- … before a terminal response event
- … ended without a terminal event
- Stream ended without finish_reason
第二类的匹配模式统一为 stream ended (?:before|without)\\b(大小写不敏感),见 stream.ts。第一类则通过扩展原有的网络/连接相关正则覆盖,将 terminated 与 premature close 加入匹配集合(见 stream.ts)。
完整分类规则一览
以下为修复后 classifyPiAiError 的完整匹配顺序(自上而下,先命中者优先),其中 TRANSPORT 相关的两条规则即为本次新增:
| \\b(?:401|403)\\b | AUTH | 认证失败 |
| isQuotaExceededError(message) | QUOTA | 配额/余额耗尽(终止性失败) |
| \\b429\\b 或 rate.?limit | RATE_LIMIT | 请求速率限制(可重试) |
| \\b413\\b、failed to buffer the request body: length limit exceeded、payload too large、request body too large | INVALID_REQUEST | 请求体被拒绝,重发无法成功 |
| \\b400\\b 或 invalid.?request | INVALID_REQUEST | 无效请求 |
| \\b5\\d\\d\\b | SERVER | 服务端错误(可重试) |
| time(?:d)?\\s*out 或 timeout | TIMEOUT | 超时(可重试) |
| stream ended (?:before|without)\\b | TRANSPORT | 新增:终止事件前流被截断 |
| \\b(?:network|connection|socket|fetch)\\b、\\bECONN[A-Z]+\\b、other side closed、HTTP2 request did not get a response、WebSocket closed unexpectedly、\\bterminated\\b、premature close | TRANSPORT | 扩展:网络/连接/套接字断开,含裸 terminated 与 Premature close |
| (以上均不命中) | PI_AI_ERROR | 兜底:未分类失败 |
值得注意的是,INVALID_REQUEST 的分类顺序在 SERVER(5xx)之前,因为 413 属于请求体大小超限——这是网关或提供方的请求体上限,重发相同请求不可能成功,因此被归类为无效而非瞬时故障。
代码中的上游注记
分类器携带一条 XXX(pi-ai upstream) 注记(见 stream.ts),点名扁平化发生的具体位置,并说明期望的修复方式:如果 pi-ai 有朝一日转发原始的 Error,或提供一个让我们捕获 cause 的钩子,就应改为基于 code/cause 分类。在此之前,分类仍是尽力而为的文本匹配,XXX 标记其为一个权宜之计而非最终状态。
被否决的替代方案
修复过程中评估过三个替代方案,各有明确的技术理由被否决,理解它们有助于把握方案边界:
通过 pi-ai 的 fetch/dispatcher/client 钩子捕获 cause:否决。pi-ai 0.81.1 一个都没有暴露。StreamOptions 只提供 onPayload/onResponse,而 onResponse 在响应体流被消费之前触发,无法观察到流式输出中途的断开。Anthropic 路径虽接受一个 client 对象,但为拦截传输错误而为每个请求构造并注入提供方 SDK client,只为一个诊断字符串就越过了适配器的服务边界,得不偿失。
把两种错误都保留为 PI_AI_ERROR,并放宽 llm-retry 的可重试集合:否决。PI_AI_ERROR 是真正未分类失败的兜底,其中包含不可重试的失败(畸形的提供方响应、意料之外的 SDK bug)。让兜底可重试,会重试那些永远不会成功的失败;修复之道是分类出可恢复的那种情况,而不是模糊整个桶。
在适配器里把扁平化后的错误包装成 LlmError('TRANSPORT', { cause }),仿照 DeepSeek 适配器:否决。DeepSeek 适配器包装的是拿到响应之前的 fetch 拒绝,其 cause 仍然完好,因此链式包装保留了真实细节;而在 pi-ai 路径中,终止事件的 errorMessage 已经是一个没有 cause 可链的扁平化字符串,包装只会加一层却恢复不了任何东西——分类出 code 是唯一还能增加的价值。这一对比也解释了为什么 Harness 的两个 LLM 适配器对传输错误的处理路径不同。
与重试策略的联动:TRANSPORT 为什么可重试
分类的最终价值体现在 llm-retry 插件上。@deepseek-ai/dsh-llm 的默认可重试错误码集合定义在 packages/llm/llm/src/retry-policy.ts:
const DEFAULT_RETRYABLE_CODES = Object.freeze([
EMPTY_RESPONSE_CODE, // 'EMPTY_RESPONSE'
'RATE_LIMIT',
'SERVER',
'TIMEOUT',
'TRANSPORT',
])
重试执行器位于 packages/llm/llm-retry/src/index.ts,其判定逻辑清晰:当策略为 normal 模式且 !policy.retryableCodes.includes(failure.code) 时,直接 return next() 放弃重试。换言之,错误码是否落在 retryableCodes 内,直接决定了一次失败是否值得重试。
修复前,传输截断落入 PI_AI_ERROR,不在默认集合内,被当作永久失败;修复后,这些失败携带 TRANSPORT,默认即被重试,无需任何额外配置。默认策略为 normal 模式、最多 5 次重试,初始退避 500ms、最大延迟 10s、抖动比例 0.1(默认值均定义于 retry-policy.ts);组合方也可通过配置文件中的 retryPolicy 覆盖这些参数。
测试验证:文本匹配的完整覆盖
本次修复的测试用例位于 packages/llm/llm-pi-ai/tests/convert.spec.ts,用 it.each 参数化地验证了全部传输措辞都映射为 TRANSPORT(见 convert.spec.ts):
it.each([
'other side closed',
'HTTP2 request did not get a response',
'WebSocket closed unexpectedly',
// undici flattens a mid-stream socket drop to this bare word (its SocketError
// cause is discarded upstream before it reaches us).
'terminated',
'Premature close',
// pi-ai's per-provider throws when the wire closes before the terminal event.
'Anthropic stream ended before message_stop',
'OpenAI Responses stream ended before a terminal response event',
'openrouter stream ended without a terminal event',
'Stream ended without finish_reason',
])('maps pi-ai transport wording %j', (errorMessage) => {
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage })))
.toMatchObject({ kind: 'error', failure: { code: 'TRANSPORT' } })
})
同一测试文件中还验证了既有分类规则未受回归影响:HTTP 401 → AUTH、HTTP 429 → RATE_LIMIT、429 insufficient_quota → QUOTA、HTTP 500 → SERVER、provider timed out → TIMEOUT、ECONNRESET socket closed → TRANSPORT、上下文超限与 INVALID_REQUEST 等(见 convert.spec.ts)。值得留意的是 vector length limit exceeded 仍正确落入 PI_AI_ERROR,说明新的传输匹配规则没有过度捕获包含 "length limit exceeded" 字样的非传输错误。
文档同步:Known-Limitations 条目
与代码改动配套,llm-pi-ai/README.md 在 Known Limitations and Deferred Work 一节新增了对应条目,记录 pi-ai 会扁平化 cause 链、因此 Harness 的错误码是从消息文本分类而来(见 packages/llm/llm-pi-ai/README.md)。该条目与既有的 "Provider HTTP status is unavailable"(pi-ai 错误事件不暴露稳定的 HTTP 状态)共同界定了适配器在错误可观测性上的边界。
修复后的行为与残留风险
改进后的行为:
- 流式输出中途的传输层断开和终止前的流截断现在都携带 TRANSPORT,组合出的 llm-retry 策略会默认重试,而不是让该回合失败;
- 通知文本不变(仍是 terminated / Anthropic stream ended before message_stop):cause 细节在适配器看到之前就已丢失,因此 errorChain(见 packages/llm/llm/src/error.ts,用于渲染完整 cause 链的诊断工具)没有更多内容可渲染,只有被路由的 code 得到了改善。
残留风险:
- 分类仍然依赖字符串匹配,且依赖提供方的措辞。未来某个 pi-ai 版本若改写这些错误的措辞,就会静默回退到 PI_AI_ERROR,直到匹配模式被更新——这正是 XXX(pi-ai upstream) 注记存在的意义,它指向那个持久的修复方向:基于 pi-ai 转发的 code/cause 路由,而非文本;
- 分类是尽力而为的:无法覆盖 pi-ai 未来新增提供方的全部措辞,需要随测试用例持续补充。
延伸阅读
- pi-ai 适配器实现总览 — dsh-llm-pi-ai 的完整配置、登录与故障恢复说明,以及新增的 Known-Limitations 条目
- 重试策略与执行器 — 默认可重试错误码与退避配置的完整定义
- llm-retry 插件 — retryableCodes 判定与重试执行逻辑
- 错误码与错误链 — HarnessError.code、errorChain 等诊断基础设施
- LLM 流式子系统文档 — StreamChunk 协议与适配器契约
赞
【免费下载链接】deepseek-harness
DeepSeek Harness: Everything is a Plugin.
项目地址:
https://gitcode.com/gh_mirrors/de/deepseek-harness
点击查看 免费下载
相关推荐
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考






