复读检测与重试
MiMo 怎样发现并打断模型复读(流内片段、跨步文本、重复动作、思考裁剪),怎样给单步结果分类后续跑或放弃,以及按错误种类分预算的请求重试。
长时间自主运行的 Agent 会以两种方式原地打转。一种是输出本身在重复:同一句话在一次生成里翻来覆去,连续几步交出一模一样的文本,或者思考一字不差、工具参数只是略有漂移。另一种是请求反复失败:限流、服务端过载、连接被重置。上游 OpenCode v1.14.19 对前者只有 doom_loop——同一条 assistant 消息里最近 3 个部件是同名同参的工具调用,就发起一次权限询问(见流处理器);对后者只有一个不设次数上限的重试调度。MiMo 保留了 doom_loop(只给系统派生的后台智能体加了 interactive: false,没人应答时直接失败,packages/opencode/src/session/processor.ts:539),又在两头各补了一整套机制。
本篇只讲单步输出的复读与请求级重试。跨步骤的低收益循环(反复改同一处、反复验证却没有进展)以及随后的交接,见 Try-Best 检测与 Hand-off。
怎么用
除思考复读裁剪外,各道检测默认都开着,阈值用环境变量调(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)。
检测点在循环里的位置
下图是主循环一轮迭代里各道检测出现的位置(简化,省略了压缩、检查点等分支):
一步结束后:分类
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 闸门后退出 |
几个细节:
- 第一条规则是注释里说的“核心保证”:只要还有待回填观察结果的客户端工具部件,一律
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):
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" 处理,见下一篇。
跨步复读:两道检测
文本复读。每一步结束后(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):
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 位十六进制。然后:
- 重放旧区间:从用户消息里读出所有已持久化的裁剪区间(
extractAllCrops,loop-streak.ts:199),每次请求按同样的规则过滤一遍(applyPersistedCrops,loop-streak.ts:218)。区间在整个会话里持续生效,用户发新消息也不清除,只有关掉开关才恢复完整视图——规则不变,请求前缀就稳定。 - 检测新区间:只在最近一条已完成回复晚于最近一条用户消息时进行。
detectStreak(loop-streak.ts:69)要求最后trigger_count条带键回复的键相同,再往前走到键不同为止;超过max_span条时只取最近的max_span条,区间前那一条作为锚点保留。还要求区间终点恰好是最近一条已完成回复:如果最近一步只有正文(键为空),说明复读已经断了,不再新裁。 - 裁剪并持久化:只从请求里拿掉区间内键相同的 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)按顺序决定:
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)。
三处重试
- 请求阶段(
packages/opencode/src/session/llm.ts:934):提供方还没吐出任何内容就出错时(包括以error事件形式到来的错误,llm.ts:880),在llm.ts内部按request预算重发。另有一次性的兜底:后端报“不支持 assistant 预填”时,去掉结尾的 assistant 再发一次(llm.ts:944)。 - 流阶段(
processor.ts:859):流建立之后出错,整步重放,重放前删掉本步已写入的部件(processor.ts:846)。一旦收到tool-call事件,retrySafe就置为 false(processor.ts:499):工具可能已经产生副作用,此后出错不再重放,直接记错误结束本步。 - 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 模式:多候选与裁判——同一步并行生成多个只提议不执行的候选,由裁判挑出一个真正执行。