qwen-code 守护进程turn_error消息中的供应商错误详情透出方案解析
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
当由 daemon 托管的一轮 prompt 因模型供应商拒绝请求而失败时,Web Shell 里只会显示干巴巴的Internal error,真正的供应商原因(例如The engine is currently overloaded, please try again later)永远到不了任何用户可见的表面,用户无法区分“供应商临时过载”和“守护进程真出 bug”。本文基于 qwen-code 仓库中的设计文档 docs/design/2026-08-30-turn-error-provider-detail.md,完整还原这一问题的五层错误传播链路、最小改动方案(在消息提取处补一条data.error.message分支)、关键决策与边界,并结合 bridge.ts、pipeline.ts 及测试用例给出源码级佐证。读完本文,你将理解:供应商错误如何从流式 chunk 一路穿透到 SSE 事件,为什么修复只需要改动一个 helper,以及守护进程错误提取工具函数extractErrorMessage的完整行为契约。
问题背景:Internal error掩盖了真实的供应商故障
在 daemon 托管的会话中,一旦模型供应商拒绝请求(如网关过载、限流),Web Shell 的对话记录里只呈现一条无信息的Internal error。设计文档记录了一个真实生产案例:会话eeab9c1c-305d-4259-8a37-2ef8d07ca934连续发生四次 turn 失败,界面全部显示为Internal error,而实际原因全部来自上游的engine_overloaded_error错误体。用户面对这样的错误信息,既无法判断是否应该稍后重试,也无法向维护者反馈有效信息,排障体验严重受损。
核心症结在于:错误详情在传播链的第三层被丢弃了。文档给出了明确的五层链路:
- core(agent 子进程):供应商把错误作为流式 chunk 返回(
finish_reason: "error_finish",JSON 错误体放在delta.content中),管线抛出StreamContentError,其.message就是上游的原始 JSON 字符串。 - ACP SDK(agent 子进程):
#tryCallRequestHandler中的兜底逻辑把非RequestError的异常序列化为RequestError.internalError(JSON.parse(error.message)),即线上形状为{code: -32603, message: 'Internal error', data: <解析后的消息>}。对于 JSON 格式消息,得到data: {error: {message, type}};对于JSON.parse失败的纯文本消息,则得到data: {details: <message>}。 - acp-bridge(daemon):
broadcastTurnError用extractErrorMessage(err)构造turn_errorSSE 事件。而extractJsonRpcErrorDetail只读取字符串data、data.details和data.message,唯独不读JSON 解析形状产生的嵌套data.error.message,于是回退到通用的Internal error。 - sdk-typescript:
turn_error校验仅要求sessionId+message,归一化为DaemonUiErrorEvent,其text即该 message。 - webui:
turn_error停留在 transcript 中(不会路由到 notices),渲染为system_error块,文本即事件 message。
文档特别强调:由于纯文本形状(data.details)已经能通过现有提取器透出,只有 JSON 解析形状丢失了详情——缺口只是某个 helper 里少了一个分支,而不是线上字段缺失。
现状剖析:错误提取器的实现与八个调用点
extractErrorMessage的提取逻辑
在 packages/acp-bridge/src/bridge.ts 中,extractErrorMessage与extractJsonRpcErrorDetail构成了守护进程侧的错误消息提取核心。当前实现(设计文档写作时的状态,即“缺少嵌套分支”)处理三种情况:
- Error 实例:取
err.data,交给extractJsonRpcErrorDetail;取不到详情则回退到err.message。 - 普通对象:先取
obj.data交给提取器,再取obj.message。 - 其他值:
String(err)兜底。
而extractJsonRpcErrorDetail的读取顺序为:字符串data(非空)→data.details(非空字符串)→data.message(非空字符串)→ 无结果返回undefined。可以看到,嵌套的data.error.message(ACP SDK JSON 解析形状的产物)确实不在读取范围内。
八个调用点与它们各自的服务面
设计文档用一张表完整列举了extractErrorMessage的全部八个调用点,全部位于bridge.ts:
| 调用点 | 服务面 |
|---|---|
broadcastTurnError | turn_error事件、entry.turnError摘要、刷新回放 |
超时newSession清理隔离(quarantine) | daemon stderr |
两个model_switch_failed发布点 | SSE 事件消息 |
| approval-mode 恢复 | daemon stderr |
| 超时 session-action 清理隔离 | daemon stderr |
sendPrompt转发失败 | daemon stderr |
| pending-prompt 取消转发失败 | daemon stderr |
从源码搜索可以印证:writeStderrLine的 quarantine 日志(如qwen serve: quarantining ACP channel after timed-out newSession cleanup failed...)、model_switch_failed事件中的error: extractErrorMessage(err)字段、sendPrompt: forward failed日志等均复用了同一个提取器。
需要特别指出两点边界:
transcript-replay.ts中定义了一个同名的本地独立 helper(transcript-replay.ts),它只取Error.message或对象的message字段,不是bridge helper 的调用点,本次改动不涉及它。classifyTurnErrorKind(bridge.ts)对 message 做精确匹配判断是否等于terminated(对应model_stream_interrupted错误种类)。这类错误恰恰通过纯文本data.details形状到达,新增嵌套error分支不会干扰它的判定。
变更方案:在消息提取处补一条嵌套error分支
设计文档给出的生产改动非常克制:只扩展extractJsonRpcErrorDetail一处。在现有data字符串 /data.details/data.message检查之后,追加读取嵌套的data.error——既接受纯字符串,也接受带字符串message的对象,同时保持现有顶层键的优先级不变。同时更新extractErrorMessage的文档注释,点名data.error.message形状正是 ACP SDK 对“JSON 字符串消息解析产物”的落点。
broadcastTurnError随后会把供应商自己的消息作为turn_error.data.message发布出去。于是 Web Shell transcript 的错误块、live-state 的turnError摘要、刷新回放,以及消费该turn_error的 SDK 消费者(包括DaemonHttpError)都能拿到真实原因,无需改动任何线上 schema,也无需修改 SDK 或 webui 代码。独立的 turn-status overlay 归一化路径保持不变。
修复效果直接回应了开篇的生产案例:transcript 中显示The engine is currently overloaded, please try again later,而不是Internal error。
关键决策:为什么这样改
设计文档明确记录了四个决策点,值得逐一理解其权衡:
- 修在消息提取处,而不是新增线上字段。曾考虑过新增
turn_error.data.detail并贯穿DaemonTurnErrorData、UI 归一化器、transcript 块类型和 webui adapter 的完整管线——那能为假设中的字符串匹配器保留通用message,但代价是四个包的连锁改动,只为换取相同的用户可见效果。而八个调用点要么是展示、要么是日志、要么是事件表面;对提取结果唯一的行为检查只认terminated(且已通过data.details到达),因此丰富嵌套形状既安全又最小化。 - 保留既有优先级。字符串
data、顶层details、顶层message继续优先于新增的嵌套error.message兜底。 - 不新增长度上限。现有
data.details路径本就无界,病态的超大供应商 blob 今天就会流经系统;本次改动保持对等,而不是发明第二套策略。渲染侧的控制字符净化(sanitizeDaemonTerminalText)与事件总线帧字节核算已经生效,长度上限可作为独立变更另行讨论。 - 不新增
errorKind。DaemonErrorKind保持封闭;本次只改进人类可读文本。结构化的错误种类(例如 provider-overload)属于重试分类工作的范畴,不应混入文本管线。
测试与验证:从单测到集成回归
单元测试覆盖提取器的完整契约
packages/acp-bridge/src/bridge.test.ts 的extractErrorMessage测试组覆盖了完整行为矩阵,包括文档中列出的新增用例:
- Error 实例、JSON-RPC 错误对象的基础提取;
- 嵌套
error.message:构造new RequestError(-32603, 'Internal error', { error: { message: ... } }),断言能提取出供应商文本; - 字符串
data.error:{ code: -32603, message: 'Internal error', data: { error: 'plain string detail' } }提取出纯字符串详情; - 优先级:
data.details: 'top'与data.error.message: 'nested'同时存在时取顶层;data.message: 'top'与嵌套并存时同样取顶层; - 兜底行为:
data.error无可用字符串(如只有{ type: 'engine_overloaded_error' })、空字符串error、空字符串error.message时均回退到Internal error; - 非 JSON 回退不变:纯文本
data、data.details缺失或为空时回退到message; - 普通字符串、
null、undefined、带message的普通对象、无message对象等边界值的String()转换行为。
集成测试:复现生产失败形状
测试基建与回归用例同样在设计文档的受影响文件清单中:
- integration-tests/fake-openai-server.ts 新增
errorContent选择字段:让 fake OpenAI 服务器在同一个 chunk里同时发出错误体和error_finish终止原因,匹配真实网关的行为; - integration-tests/fake-openai-server.test.ts 用自测钉死这一“同 chunk”不变量:响应仍是 HTTP 200,但流中同时包含
"finish_reason":"error_finish"和overloaded文本,且错误体与终止原因必须落在同一帧上——因为 CLI 管线是从携带finish_reason === 'error_finish'的那个 chunk 上读取delta.content的; - integration-tests/cli/qwen-serve-streaming.test.ts 的回归用例完整复刻生产失败形状:fake OpenAI 服务器发出一个携带 JSON 错误体的
error_finishchunk(The engine is currently overloaded, please try again later),随后断言会话 SSE 的turn_error事件message携带供应商文本。
事件 schema 文档同步更新
docs/developers/daemon/09-event-schema.md 的turn_error行补充了说明:对于 agent 侧的-32603失败,当 agent 提供了自有详情(data.details/data.message/ 嵌套data.error.message)时,message字段携带的是供应商自己的详情,而不是通用 JSON-RPC 文本。
底层原理:error_finishchunk 如何变成StreamContentError
要理解为什么错误形状是“JSON 字符串”,需要回到第一层——core 的内容生成管线。在 packages/core/src/core/openaiContentGenerator/pipeline.ts 中,StreamContentError的文档注释说明:某些 OpenAI 兼容端点不返回 HTTP 错误,而是把限流等错误作为普通 SSE chunk、以finish_reason="error_finish"加delta.content中的错误消息返回。
管线在消费 chunk 时做了显式检测(pipeline.ts):
if ((chunk.choices?.[0]?.finish_reason as string) === 'error_finish') { const errorContent = chunk.choices?.[0]?.delta?.content?.trim() || 'Unknown stream error'; throw new StreamContentError(errorContent); }errorContent是原始字符串(对过载场景而言通常是 JSON 错误体),它成为StreamContentError.message。这正是设计文档所说“.message是上游原始 JSON 字符串”的出处。此后该错误一路向上,经过 ACP SDK 的JSON.parse包装(得到data.error.message嵌套形状),最终抵达守护进程的extractErrorMessage——而修复前,这个嵌套形状恰恰是提取器唯一没读的那一个分支。管线测试(如 pipeline.test.ts 中should throw StreamContentError when stream chunk contains error_finish、should preserve a StreamContentError while a closing tag is pending等用例)验证了error_finishchunk 会以StreamContentError形式抛出且不会被误吞。
变更范围边界
设计文档明确划定了本次改动不触碰的区域,防止方案蔓延:
- 不改重试行为。把
engine_overloaded_error归类为可重试(503 等价)属于独立的后置跟进项。 - 不动
matchTurnEvent、normalizeTurnResultError、prompt ledger 记录形状,也不动 Java SDK。 - TUI(非 daemon)的错误渲染不受影响——它不经过 bridge 提取器。
- 不新增
DaemonErrorKind,不做 Web Shell UI 重构。
遗留问题与后续方向
设计文档保留了两个开放问题,作为后续迭代的锚点:
- turn-status 轮询路径。独立的
normalizeTurnResultError路径(turn-status 轮询使用)是否也应提取供应商详情,或携带供应商type(如engine_overloaded_error)作为结构化错误码?该问题被搁置——本次 PR 只改turn_error的 bridge 提取,终端 overlay 仍可能保留通用Internal error。 - 长度上限的位置。如果供应商 blob 在实践中过于庞大,长度上限应该加在 bridge 提取器还是事件发布端?同样延后到观察到实际数据再决定。
总结
这份设计文档展示了一个教科书式的“最小修复”案例:一条用户可见的Internal error,追溯到源头是错误消息从供应商 JSON 错误体 →error_finishchunk →StreamContentError→ ACP SDKJSON.parse包装 → bridge 提取器的链路中,提取器漏读了data.error.message这一个嵌套分支。最终修复只扩展了一个 helper、更新了文档注释,配合单测矩阵与集成回归,就完整打通了供应商错误详情到所有用户可见表面(transcript、live-state 摘要、刷新回放、SDK 消费者)的透出通路,且不触碰线上 schema 与 UI 结构。对于任何在守护进程架构中处理错误传播的开发者,这都是一份值得对照阅读的参考:错误详情丢失时,先沿传播链路逐层核对形状变换,往往能在最后一公里找到最小、最安全的修复点。
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考