# 复读检测与重试

> MiMo 怎样发现并打断模型复读（流内片段、跨步文本、重复动作、思考裁剪），怎样给单步结果分类后续跑或放弃，以及按错误种类分预算的请求重试。

- 作者：David（道雾轩）
- 专栏：MiMo Code 源码解读（https://daiw.org/manual/mimo-code.md）
- 最后更新：2026-09-17
- 原文：https://daiw.org/manual/mimo-code/loop-recovery
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

长时间自主运行的 Agent 会以两种方式原地打转。一种是输出本身在重复：同一句话在一次生成里翻来覆去，连续几步交出一模一样的文本，或者思考一字不差、工具参数只是略有漂移。另一种是请求反复失败：限流、服务端过载、连接被重置。上游 OpenCode v1.14.19 对前者只有 `doom_loop`——同一条 assistant 消息里最近 3 个部件是同名同参的工具调用，就发起一次权限询问（见[流处理器](https://daiw.org/manual/opencode/processor)）；对后者只有一个不设次数上限的重试调度。MiMo 保留了 `doom_loop`（只给系统派生的后台智能体加了 `interactive: false`，没人应答时直接失败，`packages/opencode/src/session/processor.ts:539`），又在两头各补了一整套机制。

本篇只讲单步输出的复读与请求级重试。跨步骤的低收益循环（反复改同一处、反复验证却没有进展）以及随后的交接，见 [Try-Best 检测与 Hand-off](https://daiw.org/manual/mimo-code/try-best-handoff)。

## 怎么用

除思考复读裁剪外，各道检测默认都开着，阈值用环境变量调（`packages/opencode/src/flag/flag.ts:177`、`flag.ts:190`）：

| 环境变量 | 默认 | 含义 |
| --- | --- | --- |
| `MIMOCODE_TEXT_NGRAM_N` | 4 | 流内复读检测的最短片段（token） |
| `MIMOCODE_TEXT_REPEAT_THRESHOLD` | 20 | 片段首尾相接重复几次算复读 |
| `MIMOCODE_TEXT_WINDOW_TOKENS` | 500 | 只看最近多少个 token |
| `MIMOCODE_OUTPUT_LENGTH_CONTINUATION_LIMIT` | 3 | 输出撞到长度上限后自动续写几次 |
| `MIMOCODE_INVALID_OUTPUT_CONTINUATION_LIMIT` | 2 | 只有思考或完全空白时催促几次 |
| `MIMOCODE_TEXT_TOOL_CALL_RETRY_LIMIT` | 2 | 把工具调用写成正文时重试几次 |

配置文件里有三处相关：

- `experimental.loop_streak_recovery`（`packages/opencode/src/config/config.ts:434`）：思考复读裁剪，默认关闭，子键 `enabled`、`trigger_count`（默认 3）、`max_span`（默认 64）。
- 顶层 `retry`（`packages/opencode/src/config/config.ts:211`）与 `provider.<id>.retry`（`packages/opencode/src/config/provider.ts:137`）：八组重试预算，后者按字段覆盖前者，例如 `"retry": { "rateLimit": { "maxRetries": 8 } }`。每组可设 `mode`（`bounded` 或 `persistent`）、`maxRetries`（0 到 100）、`deadlineMs` 或 `noDeadline`（二者互斥，`packages/opencode/src/config/retry.ts:7`）、`initialDelayMs`、`maxDelayMs`、`jitterRatio`。
- `provider.<id>.options.chunkTimeout`（`packages/opencode/src/config/provider.ts:129`）：两个流式分块之间允许的最长静默，默认 480000 毫秒，即 8 分钟（`packages/opencode/src/provider/provider.ts:46`）。

## 检测点在循环里的位置

下图是主循环一轮迭代里各道检测出现的位置（简化，省略了压缩、检查点等分支）：

```mermaid
flowchart TD
  A["迭代开始：分类上一条回复"] -->|filtered 或 failed| X["写错误并退出"]
  A -->|think-only、invalid、text-tool-call、length| N["追加提醒，重新迭代"]
  A -->|final| G["过 Goal 闸门后退出"]
  A -->|continue| B["重复动作提醒"]
  B --> C["思考复读裁剪，实验开关"]
  C --> D["发请求：流内 n-gram 监控，请求与流重试"]
  D -->|text-repeat| R["REMIND、REPLAN、终止"]
  D --> E["步后分类"]
  E --> F["跨步文本复读：MILD、STRONG、终止"]
  R --> A
  F --> A
```

## 一步结束后：分类

`classifyAssistantStep`（`packages/opencode/src/session/classify.ts:32`）是个纯函数，按固定优先级给一条 assistant 回复归类，副作用全部留给主循环。它在三处被调用：循环顶部审视上一条回复（`packages/opencode/src/session/prompt.ts:3726`）、fork 子智能体的步后（`prompt.ts:4374`）、主路径的步后（`prompt.ts:4674`）。

| 分类 | 判定 | 主循环的处理 |
| --- | --- | --- |
| `continue` | 还有未出错的客户端工具部件；或尚无 `finish`；或 `finish` 为 `tool-calls`；或是早于当前用户消息的旧回复 | 继续下一步 |
| `text-tool-call` | `finish` 为 `tool-calls`，却没有结构化工具部件，正文里出现 `<invoke name=`、`<function_calls>` 之类标记 | 给这条回复记上 `TextToolCallError`，追加提醒后重来，最多 2 次 |
| `failed` | 回复已带错误，或 `finish` 为 `error` | 补写 `ModelError`（已有错误则保留），退出 |
| `filtered` | `finish` 为 `content-filter` | 写 `ContentFilterError` 退出，不重试 |
| `think-only` | 没有正文，只有非空思考 | 追加提醒续跑，最多 2 次 |
| `invalid` | 什么都没有，原因记为 `empty output` | 与 `think-only` 共用计数器 |
| `final` | 有非空正文；`finish` 为 `other` 时另标 `degraded` | 过 [Goal](https://daiw.org/manual/mimo-code/goal) 闸门后退出 |

几个细节：

- 第一条规则是注释里说的“核心保证”：只要还有待回填观察结果的客户端工具部件，一律 `continue`，优先级高于一切。但状态已是 `error` 的工具部件不算——SSE 超时或中止后，未完成的工具部件会被标成 error，若仍判 `continue`，循环会卡在一个永远不会返回的权限询问上（`classify.ts:42`）。
- `text-tool-call` 的“丢弃”靠错误标记实现：组装模型消息时，带错误的 assistant 消息整条跳过（中止且已有内容的除外，`packages/opencode/src/session/message-v2.ts:951`），于是这条坏回复既不会让请求以 assistant 结尾，也不会污染后续上下文。
- GPT 推理模型例外：模型 ID 匹配 `/(^|\/)gpt-\d/i` 时，只有思考也算 `final`（`classify.ts:118`）。
- `finish` 为 `length` 不走分类表：循环顶部与步后都先调 `autoContinueOutputLength`（`prompt.ts:3189`），在没有客户端工具调用时追加“从断点继续，别重述”的提醒，最多 3 次，超过则记 `OutputLengthError`。要求结构化输出（`json_schema`）而步后得到 `final`、`think-only` 或 `invalid` 时，改由 `autoRetryStructuredOutput`（`prompt.ts:3486`）催模型调用结构化输出工具，次数取请求里的 `retryCount`，默认 2（`message-v2.ts:111`）。
- `think-only` 与 `invalid` 的提醒按角色分三种措辞（`prompt.ts:3373`）：检查点写手被要求回复 `CHECKPOINT_COMPLETE`，子智能体被要求把结果交还父智能体，主智能体被要求给出最终答复或调用工具；角色映射在 `packages/opencode/src/agent/config.ts:28`。

上面这些提醒都是新建一条 synthetic 用户消息；计数器都是 `runLoop` 的局部变量，注释写明新的用户轮次会让它们归零（`prompt.ts:3047`）。

## 流内复读：n-gram 监控

处理器每次发请求都新建一个 `TextNgramMonitor`（`processor.ts:825`），思考增量与正文增量都喂给它（`processor.ts:443`、`processor.ts:676`）。一旦命中就置位 `textNgramRepeat`，`Stream.takeUntil` 随即停止消费（`processor.ts:830`），`process` 返回 `"text-repeat"`（`processor.ts:907`），已经流出的部分照常落库。

分词很朴素（`packages/opencode/src/session/prompt/text-ngram-detection.ts:5`）：转小写、压缩空白、按空格切，CJK 字符（`U+3000` 至 `U+30FF`、`U+3400` 至 `U+4DBF`、`U+4E00` 至 `U+9FFF`、`U+F900` 至 `U+FAFF`、`U+FF00` 至 `U+FFEF`）每个字单独算一个 token。监控只保留最近 500 个 token，交给 `detectConsecutiveRepeat`（`text-ngram-detection.ts:33`）：

```ts
if (threshold < 2 || tokens.length < minBlockSize * threshold) return false
const maxPeriod = Math.floor(tokens.length / threshold)
for (let p = minBlockSize; p <= maxPeriod; p++) {
  let run = 0
  for (let i = 0; i <= tokens.length - p - 1; i++) {
    if (tokens[i] === tokens[i + p]) {
      run++
      if (run >= p * (threshold - 1)) {
        const blockStart = i - run + 1
        const distinct = new Set(tokens.slice(blockStart, blockStart + p)).size
        if (distinct >= minDistinct) return true
        run = 0
      }
    } else {
      run = 0
    }
  }
}
return false
```

对每个周期 `p`（从 4 到“窗口长度 ÷ 20”，窗口满时即 25），数连续多少个位置满足 `tokens[i] === tokens[i + p]`；连续达到 `p × 19` 个，说明一段长 `p` 的片段首尾相接出现了 20 次，片段里还得至少有 3 个不同的 token。名字叫 n-gram，判的其实是“连续周期重复”；按出现次数统计 n-gram 的 `detectRepeatedNgram` 还留在文件里，测试把它标为 legacy（`packages/opencode/test/session/text-ngram-detection.test.ts:23`）。按这个口径，“我再检查一下这个文件。”（11 个 token）连说 19 遍不触发、20 遍触发；“哈”连写 200 遍不触发（片段里只有 1 个不同 token）；长于 25 个 token 的片段在 500 个 token 的窗口里放不下 20 遍，也不会触发。

命中后由 `handleTextRepeat`（`prompt.ts:3540`）处理：第一次追加 `TEXT_NGRAM_RECOVERY_REMIND`，第二次追加 `TEXT_NGRAM_RECOVERY_REPLAN`，第三次（计数达到 `TEXT_NGRAM_MAX_RECOVERY`，即 2）发布一条“Text repetition detected”错误并退出循环。两段提示的要求逐级加码，REMIND 要求换措辞、换工具或说明卡在哪里（`text-ngram-detection.ts:99`），REPLAN 要求推倒重来（`text-ngram-detection.ts:110`）：

> You MUST completely replan before continuing:

Max 模式的每个候选也各带一个监控，任何一个候选命中，整步都按 `"text-repeat"` 处理，见[下一篇](https://daiw.org/manual/mimo-code/max-mode)。

## 跨步复读：两道检测

**文本复读**。每一步结束后（`prompt.ts:4782`），把本步非 synthetic 的正文拼起来，为空就跳过；不为空时归一化（`packages/opencode/src/session/prompt/text-loop-recovery.ts:5`）：去首尾空白、转小写、压缩空白、去掉开头的 `let me `、`i'll `、`i will `、`let's `，再截前 200 个字符，后面拼上本步各工具调用的名字与参数。结果推进一个长度为 5 的缓冲区，最近 3 条完全相同即判定复读。处理与流内复读同构（`prompt.ts:4804`）：第一次追加 `RECOVERY_PROMPT_MILD`，第二次追加 `RECOVERY_PROMPT_STRONG`，第三次发布“Text loop detected”错误并退出。STRONG 版要求模型放弃计划、说明失败原因、向用户求助，并预告了后果（`text-loop-recovery.ts:39`）：

> If you repeat the same output again, the session will be terminated.

**重复动作**。`stepSignature`（`prompt.ts:223`）只看工具调用：工具名加键排序后的参数 JSON，正文和思考都不算——注释解释说，模型在 ReAct 循环里常常换着说法做同一件事。每轮迭代从最新的已完成回复往回收集签名，遇到不调工具的一步就停；最近 3 步（`REPEATED_STEP_THRESHOLD`，`prompt.ts:193`）签名全同，就在最后一条用户消息上追加一段“你在重复同一动作，换个做法”的提醒（`prompt.ts:3861`）。这段提醒只加在本轮组装请求用的消息列表上，不写数据库，也没有计数和终止。它与上游 `doom_loop` 互补：`doom_loop` 只看同一条消息内的连续工具调用，而主循环每一步都会新建一条 assistant 消息。

## 思考复读裁剪

上面几道检测都只是追加提醒，复读过的历史仍留在上下文里，下一轮照样当作示范。`experimental.loop_streak_recovery` 换了个思路：直接把复读的那几步从请求里拿掉。打开 `enabled` 后，主循环每次发请求前都会处理复读区间（`prompt.ts:4053`）。判断依据是给每条已完成回复算出的键（`packages/opencode/src/session/prompt/loop-streak.ts:44`）：

```ts
export function streakKey(parts: MessageV2.Part[]): string {
  // Thinking is the loop source. When present, key on thinking alone so
  // slightly drifted tools still count as the same streak (MR-3931 shape).
  // Fall back to exact tool signature only for thinking-less steps.
  const reason = reasonHash(parts)
  if (reason) return `reason:${reason}`
  const tools = toolSignature(parts)
  if (!tools) return ""
  return `tool:${tools}`
}
```

思考的归一化与文本复读相同但不截断，整段做 SHA-256 后取前 16 位十六进制。然后：

1. **重放旧区间**：从用户消息里读出所有已持久化的裁剪区间（`extractAllCrops`，`loop-streak.ts:199`），每次请求按同样的规则过滤一遍（`applyPersistedCrops`，`loop-streak.ts:218`）。区间在整个会话里持续生效，用户发新消息也不清除，只有关掉开关才恢复完整视图——规则不变，请求前缀就稳定。
2. **检测新区间**：只在最近一条已完成回复晚于最近一条用户消息时进行。`detectStreak`（`loop-streak.ts:69`）要求最后 `trigger_count` 条带键回复的键相同，再往前走到键不同为止；超过 `max_span` 条时只取最近的 `max_span` 条，区间前那一条作为锚点保留。还要求区间终点恰好是最近一条已完成回复：如果最近一步只有正文（键为空），说明复读已经断了，不再新裁。
3. **裁剪并持久化**：只从请求里拿掉区间内键相同的 assistant 消息，夹在中间的纯正文回复保留（`loop-streak.ts:123`），数据库一条不删。然后在当前那条用户消息上写一个 `ignored`、`synthetic` 的空文本部件，`metadata.origin` 记下 `kind`（值为 `loop_streak_crop`）与 `fromId`、`toId`、`key`、`truncated`。这个部件不发给模型，也不显示成气泡，只供之后的请求重放。

裁剪不插入新的用户消息，也不对模型说任何“你在复读”之类的话。如果裁剪后请求以 assistant 结尾，发送前由 `ensureTrailingUserMessage`（`packages/opencode/src/provider/transform.ts:435`）补一条内容为 `Continue.` 的用户消息。日志会估算被裁掉的内容块数，超过 20 块时标记 `cacheRisk`（`loop-streak.ts:160`），但只记录、不拦截。本轮刚裁剪过时，跨步文本复读不再追加提醒（`loopStreakCropped`，`prompt.ts:4804`），因为被裁掉的步骤已经不在视图里。

对照设计文档 `docs/compose/spec/loop-streak-recovery.md`，有两处与代码不一致：文档的裁剪伪代码按 ID 区间整段剔除（`loop-streak-recovery.md:92`），代码还要求键相同；文档的安全阈值表说恢复说明会写出剩余相似消息数（`loop-streak-recovery.md:112`），配置项说明也写着“inject a recovery note”（`packages/opencode/src/config/config.ts:437`），但代码不注入任何说明，`remainingSimilar` 只进日志，这与文档后文“None in the model-facing stream”（`loop-streak-recovery.md:152`）一致。

## 请求重试：从一个函数到一套预算

上游 v1.14.19 的 `retry.ts` 只有 125 行：`retryable` 判断能否重试（上下文溢出不重试，API 错误看 `isRetryable` 或 5xx，另认几种限流文案），`delay` 算等待（有 `retry-after` 头照办，否则 2 秒起翻倍，无响应头时封顶 30 秒），`policy` 把二者包成 Effect 的 `Schedule`，不设次数和总时长上限。MiMo 的同名文件扩到 489 行，设计来源是 `docs/architecture/retry-coordinator.md`，拆成“决策、选预算、算退避、判停止”四步。

`decide`（`packages/opencode/src/session/retry.ts:288`）把任意错误归一化成 `RetryDecision`：是否可重试，阶段（`request` 或 `stream`），范围（`request`、`live-step`、`max-candidate`、`max-judge`），种类，以及服务端建议的等待时间。种类有六个：

- `terminal`：用户中止、鉴权失败、上下文溢出、免费或订阅额度用尽，HTTP 402、501、505，404（少数适配器例外），API 错误里的 400、401、403、422，以及其余认不出的错误。
- `network`：`ECONNRESET`、`ETIMEDOUT`、`fetch failed`、`socket hang up` 等连接层错误（清单在 `packages/opencode/src/provider/error.ts:6`）。
- `rate_limit`：429、`too_many_requests`、文案里带 rate limit 的。
- `server`：408、425、其余 5xx，以及错误码含 `exhausted` 或 `unavailable` 的。
- `stream`：上游流读取失败（`stream_read_error`、`upstream_error`）。
- `unknown`：提供方自称可重试、却归不进上面几类的 API 错误。

预算的默认值在 `DEFAULT_RETRY_CONFIG`（`retry.ts:59`）：

| 预算 | 次数 | 总时长 | 起始间隔 | 最大间隔 | 抖动 |
| --- | --- | --- | --- | --- | --- |
| `request` | 4 | 30 秒 | 200 毫秒 | 30 秒 | 10% |
| `stream` | 5 | 10 分钟 | 2 秒 | 30 秒 | 10% |
| `maxCandidate`、`maxJudge` | 3 | 3 分钟 | 500 毫秒 | 30 秒 | 10% |
| `network`（持续模式） | 不限 | 不限 | 5 秒 | 60 秒 | 0 |
| `server`、`unknown` | 8 | 15 分钟 | 2 秒 | 30 秒 | 10% |
| `rateLimit` | 5 | 15 分钟 | 2 秒 | 5 分钟 | 10% |

哪个错误用哪组预算，由 `budgetFor`（`retry.ts:161`）按顺序决定：

```ts
export function budgetFor(config: ResolvedRetryConfig, decision: RetryDecision): RetryBudget {
  if (decision.phase === "request") return config.request
  if (decision.kind === "network") return config.network
  if (decision.scope === "max-candidate") return config.maxCandidate
  if (decision.scope === "max-judge") return config.maxJudge
  if (decision.kind === "server") return config.server
  if (decision.kind === "rate_limit") return config.rateLimit
  if (decision.kind === "unknown") return config.unknown
  return config.stream
}
```

等待时间由 `retryDelay`（`retry.ts:379`）计算：服务端给了提示就用提示值——`retry-after-ms`、`retry-after`（秒数或 HTTP 日期），或错误文案里“try again in 30s”一类说法（按严格格式解析，上限 5 分钟，`retry.ts:270`）——并夹在 100 毫秒与预算的最大间隔之间；没有提示时取 `min(maxDelayMs, initialDelayMs × 2^(attempt−1))`，再乘以 `1 ± jitterRatio`。`policy`（`retry.ts:414`）遇到下列情况停止：错误不可重试（先调 `onTerminal`，主会话在决策带 `uiMessage` 时借此显示提示）、`replaySafe` 返回 false、有界预算的次数用完、已耗时或下一次等待会越过总时长。主会话里 OpenAI 的 `server_is_overloaded` 另走静默通道：第一次立即重试且不更新状态，最多 3 次（`GPT_OVERLOAD_RETRIES`，`retry.ts:463`）。

## 三处重试

1. **请求阶段**（`packages/opencode/src/session/llm.ts:934`）：提供方还没吐出任何内容就出错时（包括以 `error` 事件形式到来的错误，`llm.ts:880`），在 `llm.ts` 内部按 `request` 预算重发。另有一次性的兜底：后端报“不支持 assistant 预填”时，去掉结尾的 assistant 再发一次（`llm.ts:944`）。
2. **流阶段**（`processor.ts:859`）：流建立之后出错，整步重放，重放前删掉本步已写入的部件（`processor.ts:846`）。一旦收到 `tool-call` 事件，`retrySafe` 就置为 false（`processor.ts:499`）：工具可能已经产生副作用，此后出错不再重放，直接记错误结束本步。
3. **Max 模式**：候选与裁判只在内存里累积输出，重来不会重复写入，所以可以放心整条流重试，范围分别是 `max-candidate` 与 `max-judge`。

每次真正发生的重试都发布 `session.retry.attempt` 事件（`packages/opencode/src/session/session.ts:283`），带阶段、范围、种类、次数与下次等待时长。会话状态里的 `attempt` 是跨请求与流两个阶段的连续序号，会话回到 idle 才清零（`packages/opencode/src/session/status.ts:83`）。

与设计文档对照，有三处值得注意：

- 文档把 `max-candidate`、`max-judge` 写成“3 次、3 分钟”，但 `budgetFor` 先判网络错误，所以 Max 模式候选遇到连接错误时走的是持续模式的 `network` 预算。
- 文档示例在 `retry` 顶层写了 `jitterRatio`。`resolve` 确实会读它，但它只在调用方不传 `budget` 函数时才用于兜底预算（`retry.ts:455`）；现有两处 `policy` 调用和 `llm.ts` 的请求重试都按预算取值，实际生效的是各组预算自己的 `jitterRatio`。
- 文档里 `rate_limit` 一行没提总时长，代码给的是 15 分钟。

文档与代码一致的是：结构化输出、无效输出、文本式工具调用、长度续写这些“语义重试”不归重试协调器管，走的是上文那套提示词级的有界循环。

下一篇：[Max 模式：多候选与裁判](https://daiw.org/manual/mimo-code/max-mode)——同一步并行生成多个只提议不执行的候选，由裁判挑出一个真正执行。
