欢迎光临
我们一直在努力

DeepSeek Harness 传输截断错误分类:从扁平化消息文本恢复 pi-ai 流式断连的重试路由

  • 人工智能
  • AI Agent
  • Agent 框架
  • DeepSeek

【免费下载链接】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:

  • 流式输出中途的套接字断开,呈现为裸的 terminated(undici 扁平化后的产物)或 Premature close(Node 流层措辞);
  • 在终止事件之前被截断的流,每个 pi-ai 提供方各自抛出不同措辞,包括:
    • 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 协议与适配器契约

    赞

    分享

    • 人工智能
    • AI Agent
    • Agent 框架
    • DeepSeek

    【免费下载链接】deepseek-harness

    DeepSeek Harness: Everything is a Plugin.

    项目地址:
    https://gitcode.com/gh_mirrors/de/deepseek-harness

    点击查看 免费下载

    创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

    赞(0)
    未经允许不得转载:171主机测评 » DeepSeek Harness 传输截断错误分类:从扁平化消息文本恢复 pi-ai 流式断连的重试路由
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址