---
url: /wave-agent/specs/core/ai-error-handling.md
description: 输出截断自动续写与连续恢复上限、推理内容保留、429/5xx 重试、流式中断降级非流式、截断工具参数恢复
---

# 功能规格说明：AI 错误处理

**创建日期**：2026-03-03

## 用户场景与测试 *（必填）*

### 用户故事：截断时自动继续（优先级：P1）

作为用户，我希望代理在响应被输出 token 限制截断时自动继续其响应，这样我不必手动提示它完成工作。继续提示应该从 UI 中隐藏以保持对话整洁。

**为什么是这个优先级**：这是请求的核心功能。它通过使代理更加自主和减少手动干预来改善用户体验。

**独立测试**：可以通过模拟 `finish_reason: "length"` 的 AI 响应并验证代理添加隐藏继续消息并递归来测试。

**验收场景**：

1. **假设**AI 响应因长度限制被截断，**当**代理处理响应时，**则**它应该添加隐藏的用户消息（带 `isMeta: true`）："Output token limit hit. Resume directly — no apology, no recap of what you were doing. Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces."并自动发起新的 AI 调用

***

### 用户故事：带工具调用的截断（优先级：P1）

作为用户，我希望代理在响应被输出 token 限制截断时执行工具然后自动继续其响应，即使调用了工具也是如此。

**验收场景**：

1. **假设**AI 响应因长度限制被截断且调用了工具，**当**代理处理响应时，**则**它应该仍然添加隐藏的恢复消息（带 `isMeta: true`）并在工具执行后自动发起新的 AI 调用

***

### 用户故事：连续截断恢复上限（优先级：P1，对齐 Claude Code）

作为用户，我希望代理在响应连续被输出 token 限制截断且没有任何工具产出时停止自动续写，这样无产出的规划循环不会无限持续直到平台超时强制终止。

**为什么是这个优先级**：无上限的自动续写会让模型在高复杂度任务中反复重新规划（纯 Reasoning 段、无工具调用），直到 Harbor 等平台的 Agent timeout 强制杀死任务。对齐 Claude Code 的 `MAX_OUTPUT_TOKENS_RECOVERY_LIMIT` 语义：连续恢复上限 3 次，工具调用重置计数，达到上限后终止回合并透传错误。

**独立测试**：可以连续模拟 `finish_reason: "length"` 的 AI 响应（中间无工具调用），验证前 3 次添加隐藏恢复消息并递归、第 4 次不再添加恢复消息并终止回合；以及模拟"截断 → 工具调用 → 再截断"验证计数重置。

**验收场景**：

1. **假设**AI 响应连续因输出 token 限制被截断且中间没有任何工具调用，**当**连续截断恢复次数达到 3 次（即第 4 次截断发生）时，**则**代理不应再添加隐藏恢复消息，而应停止自动续写、终止回合并显示一条错误消息，说明响应重复超过输出 token 上限
2. **假设**AI 响应因长度限制被截断，恢复后调用了工具，**当**后续轮次再次因长度限制被截断时，**则**连续截断计数应从 1 重新开始（工具调用重置计数），仍然添加隐藏恢复消息并自动续写
3. **假设**AI 响应因长度限制被截断且同时包含工具调用，**当**代理处理响应时，**则**它仍应执行工具并自动继续（现有行为不变，该轮不消耗连续截断计数）

***

### 用户故事：截断恢复时保留推理内容（优先级：P1，对齐 Claude Code）

作为用户，我希望代理在响应被截断后自动续写时能看到自己上一轮的推理内容，这样续写是接着之前的思考继续，而不是从零重新推导。

**为什么是这个优先级**：当前纯 Reasoning 段（无文本、无工具调用）的 assistant 消息在转换为 API 消息时被过滤，模型每轮续写都看不到自己上一轮的推理，只能重新推导出同量级的 reasoning 再次被截断——这是无限续写循环的放大器之一。Claude Code 要求 thinking 块在整个 assistant trajectory 内保留（*"Thinking blocks must be preserved for the duration of an assistant trajectory"*），其续写是接着上一轮思考继续的。

**独立测试**：可以构造"user 任务 → assistant 纯 reasoning（截断）→ user 隐藏恢复消息"的消息序列，验证转换后的 API 消息包含 assistant 消息：推理内容保留在 `reasoning_content` 字段，`content` 为说明思考未完成、由系统自动保留的提示文案。

**验收场景**：

1. **假设**AI 响应因输出 token 限制被截断，且该轮只有 reasoning 内容（无文本、无工具调用），**当**代理发起下一轮续写调用时，**则**转换后的 API 消息应包含该 assistant 消息，推理内容保留在 `reasoning_content` 字段（推理模型原生续接通道），`content` 为一段说明文案——OpenAI 兼容上游拒绝无 `content`/`tool_calls` 的 assistant 消息（仅带 `reasoning_content` 会触发 400 "content or tool_calls must be set"），说明文案同时告知模型思考未完成、由系统自动保留，可续接或忽略——模型仍能看到自己上一轮的全部推理
2. **假设**AI 响应因长度限制被截断但该轮包含文本内容，**当**代理发起下一轮续写调用时，**则**`reasoning_content` 照常随 assistant 消息一起发送（现有行为不变）
3. **假设**某 assistant 轮次既无文本、无工具调用也无 reasoning 内容，**当**转换 API 消息时，**则**该消息继续被过滤，不产生空的 assistant 消息

***

### 用户故事：速率限制弹性（优先级：P2）

作为用户，我希望代理能够应对临时 API 速率限制（429 错误），这样我的工作不会被瞬态网络或 API 问题中断。重试策略应尊重服务器指示的等待时间，避免在限流窗口内无效重试。

**验收场景**：

1. **假设**AI API 返回 429 错误且响应包含 `Retry-After` 头，**当**代理发出请求时，**则**它应等待 `Retry-After` 头指定的秒数后再重试，而非使用指数退避延迟
2. **假设**AI API 返回 429 错误且响应不包含 `Retry-After` 头，**当**代理发出请求时，**则**它应该使用指数退避（500ms 基准、2 的幂次、25% 随机抖动、上限 32 秒）自动重试请求（最多 10 次重试）
3. **假设**AI API 返回 5xx 服务器错误，**当**代理发出请求时，**则**它应该使用与 429 相同的重试策略自动重试（最多 10 次重试）

***

### 用户故事：流式响应正文中途断连时降级为非流式重发（优先级：P1，对齐 Claude Code）

作为用户，我希望流式响应在正文传输中途断连时代理能自动改用非流式请求重发这一轮，这样笔记本休眠、网络切换这类瞬态中断不会让我只得到一个错误块和半截回答。

**为什么是这个优先级**：现有重试只覆盖"响应头到达之前"（连接错误 / 429 / 5xx 的指数退避）。响应头一旦到达，正文流由 `processStreamingResponse` 消费、位于重试循环之外，中途断连没有任何兜底——长驻的远程 daemon 会一直停在半截内容加错误块，只能手动重发。对齐 Claude Code：其 `withRetry` 同样只包到响应头（`claude.ts` 的 `for await` 在重试循环之外），流中途失败改走非流式降级（`executeNonStreamingRequest`），并用 tombstone 把失败尝试的部分消息从 UI 与 transcript 中移除。

**独立测试**：模拟一个"先返回 200 响应头、再在正文中途断连"的流式尝试，验证代理丢弃该次尝试已流出的内容、改用非流式重发，并把非流式结果作为这一轮的完整结果；再模拟"用户取消"与"响应头之前失败"两种情形，验证不触发降级。

**验收场景**：

1. **假设**流式请求已收到响应头且已向 UI 流出部分内容，**当**正文流中途失败（非用户取消）时，**则**代理应丢弃该次尝试产生的部分消息，并以相同请求参数改用非流式（`stream: false`）重发一次，把非流式结果作为这一轮的完整结果
2. **假设**流式尝试产生了部分文本/推理/工具块，**当**发生降级重发时，**则**失败尝试的部分消息应按消息 id 从会话记录与各宿主界面中移除（宿主收到定向移除通知），界面不留半截文本或悬挂的工具块；该次尝试解析出的工具调用一律不执行
3. **假设**用户在流式响应期间中止（Esc），**当**正文流因此失败时，**则**代理应尊重中止、不触发降级重发
4. **假设**流式请求在收到响应头之前失败（连接错误 / 429 / 5xx），**当**代理处理该错误时，**则**先沿用既有指数退避重试（不立即降级）；重试耗尽后该错误仍按本故事降级一次非流式重发（与 Claude Code 一致：降级请求自身再走一遍完整重试）
5. **假设**降级后的非流式重发仍然失败，**当**代理处理该错误时，**则**错误照常上抛（错误块），不再降级、不循环
6. **假设**流式尝试尚未流出任何内容（未创建 assistant 消息），**当**正文流中途失败时，**则**直接降级重发，无需移除任何消息
7. **假设**上游在正文流中途停止发送数据、随后把连接正常关闭（连接层没有任何错误，本轮也没有携带完成标记），**当**代理处理该响应时，**则**应按"流被提前截断"处理，走与本故事相同的降级路径：丢弃此次尝试已流出的内容、以非流式重发这一轮，而不是把半截内容当成本轮最终结果
8. **假设**一个健康的流式响应正常结束（携带完成标记），**当**代理处理该响应时，**则**不得触发降级（回归保护）

**边界情况**：

* **如何判定"这一轮已经完整结束"**：以协议层的完成标记为准，不按"有没有内容"判断——模型完全可能只输出了半截推理就再无数据，按内容判断会把截断当成正常回答
  * *对齐 Claude Code*：CC 用 Anthropic 协议的 `message_stop` / `stop_reason` 判断完整性（`claude.ts` 的 `Stream ended without receiving any events`），我们用 OpenAI 兼容协议的 `finish_reason`。差异在于 CC 只在"一条消息都没产出"时才判失败，半截思考已被收尾成消息的情形它不拦；我们按完成标记判，覆盖更彻底
  * *已核对线上网关*：codechat 网关的正常流必定在最后一条数据块携带 `finish_reason`（`stop`/`tool_calls` 等）随后以 `[DONE]` 结束，含 400+ 行的长推理流同样如此——真机探针三条（普通短答、长推理、工具型）确认，因此"缺完成标记"可以安全地当作截断
* **半截推理内容**：降级时流出的半截推理随消息一起丢弃；若整轮只有推理没有正文，已有机制会把推理保留在原生字段并附一句"上文思考被截断"的说明回传（见「截断时自动继续」的边界说明），不额外处理
* **上游从不发送完成标记的网关**：该判定会让每一轮都被视为截断并降级为一次非流式重发（结果是正确的，只是多一次请求）。接入此类网关前需先用上述探针核对其流的收尾形态

***

### 用户故事：调试 API 错误（优先级：P3）

作为开发者，我希望系统在发生 400 Bad Request 错误时保存调试信息，这样我可以轻松诊断格式错误请求或无效参数的问题。

**验收场景**：

1. **假设**AI API 返回 400 错误，**当**代理发出请求时，**则**它应该将原始消息、模型配置和错误详情保存到临时目录以供调试

***

### 用户故事：处理格式错误的工具参数（优先级：P2）

作为用户，我希望代理处理 AI 为工具参数提供格式错误 JSON 的情况，特别是当响应被截断时，这样我能得到关于出了什么问题的清晰说明。

**验收场景**：

1. **假设**AI 为工具参数提供了格式错误的 JSON 且响应被截断（`finish_reason: "length"`），**当**代理尝试解析参数时，**则**它应该显示包含以下内容的错误消息：`"(output truncated, please reduce your output)"`

***

### 用户故事：恢复截断的工具参数（优先级：P2）

作为用户，我希望代理尝试恢复截断的工具参数（如缺少闭合大括号的 Write/Edit），这样工具仍然可以执行而不是直接失败。

**为什么是这个优先级**：当 `finish_reason` 为 `"length"` 时，截断的工具参数很常见。恢复避免了不必要的失败和重试，提高了 Write/Edit 操作的可靠性。

**独立测试**：可以通过提供截断 JSON（缺少闭合大括号）的工具调用并验证 `recoverTruncatedJson()` 闭合它们、工具执行且警告被追加来测试。

**验收场景**：

1. **假设**AI 提供的工具参数包含截断的 JSON（缺少闭合 `}` 大括号或 `"` 字符串），**当**代理尝试解析参数时，**则**它应该尝试通过 `recoverTruncatedJson()` 恢复，闭合未闭合的字符串和大括号，如果恢复的 JSON 解析成功，则执行工具并在结果中追加截断警告
2. **假设**AI 提供的工具参数包含截断的 JSON 且有未闭合的 `[` 方括号，**当**代理尝试解析参数时，**则**不应尝试恢复（无法猜测数组内容），工具应该失败并显示标准错误消息
3. **假设**恢复的 JSON 在恢复后仍然无法解析，**当**代理尝试解析恢复的参数时，**则**它应该回退到错误消息（如果 `finish_reason === "length"` 则带截断提示）

***

### 边界情况

* **无限递归**：如果 AI 不断被截断会发生什么？
  * *对齐 Claude Code*：连续截断恢复上限为 3 次（中间无工具调用则计数不重置），达到上限后终止回合并显示错误消息，不再无限续写；工具调用（正常进度）会重置计数
* **截断恢复上下文**：纯 reasoning 截断轮（无文本、无工具调用）的推理内容是否应该保留到下一轮？
  * *对齐 Claude Code*：保留。推理内容放在 `reasoning_content` 原生字段（推理模型天然从中续接），`content` 携带说明文案（思考未完成、系统自动保留，可续接或忽略）以满足 OpenAI 兼容上游"assistant 消息必须有 `content` 或 `tool_calls`"的校验（仅带 `reasoning_content` 会被剥离并触发 400 "content or tool_calls must be set"）；有文本的轮次 `reasoning_content` 照常随行、`content` 用真实文本；既无文本、无工具调用也无 reasoning 的空消息仍被过滤
* **中断**：如果用户在截断响应期间中止会话会发生什么？
  * *假设*：代理应该尊重中止信号并停止递归
* **降级次数**：流式中断降级会递归吗？
  * *假设*：每次请求最多降级一次（不递归）；降级后的非流式重发沿用既有重试策略（连接错误 / 429 / 5xx 的指数退避）
* **计费**：降级重发后，失败那次流式尝试已经产生的输出会怎样？
  * *假设*：上游仍会为已产生的部分输出计费（重发是一次新请求）；本地只记录最终成功那次请求的 usage
* **后台工具**：如果工具在截断响应期间被放到后台会发生什么？
  * *假设*：代理应该停止递归，就像正常工具调用一样
