拆.
主循环——AI 怎么'思考'一轮对话 · 第 08

opencode 怎么决定'该结束了'

三种终止路径 + 早停晚停的 trade-off

4500读完约 23 分钟craft:B+发布于 2026-06-21

一、原理:AI Agent 的终止判定问题

主循环跑起来了——LLM 在调用、tool 在执行、文件在改、命令在跑。问题是——它什么时候停

这个问题听起来简单:LLM 不再请求 tool call 就停。但实际中要复杂得多。

我们看几个真实场景:

场景 1:LLM 觉得自己完了,其实没完。LLM 改了几个文件,输出"已修复,测试通过"。但实际上它根本没跑测试——只是猜测试会通过。主循环根据 finish_reason 退出,用户拿到一个"声称修好但其实没验证"的结果。

场景 2:LLM 觉得没完,其实已经完了。LLM 做完任务后继续想"还能更好吗"——又改了一个文件、又跑了一遍、又改了一个文件。主循环不停,用户等不及——感觉 AI 在过度发挥。

场景 3:LLM 卡在某个细节。修一个 bug 时 LLM 反复尝试同一种方法——10 轮、20 轮、30 轮——都失败。它不放弃,主循环也不停。token 烧没了,用户也烦了。

场景 4:网络断了。LLM 调用失败、重试、再失败。可能是临时网络问题,也可能是 provider 挂了。要重试到什么时候才放弃?

场景 5:用户改主意。任务跑到一半用户按 Ctrl+C——他想加点东西、想换方向、想停下来思考。主循环必须立刻响应。

每个场景都对应一种"终止情况"。一个生产级的 AI Agent 必须处理所有这些:

  • 自然终止(LLM 觉得完了)——最常见但需要质量保证
  • 强制终止(系统觉得该停了)——超出资源边界
  • 错误终止(出问题了)——区分可恢复和不可恢复
  • 用户终止(用户喊停)——必须立刻响应

每种终止有不同的处理方式——状态怎么留、UI 怎么显示、下次怎么继续。设计这套终止机制是 AI Agent 工程最被低估的部分。

二、案例:opencode 的三种终止路径

opencode 处理 turn 终止的代码分散在 processor.tsprompt.tssession.ts 几个文件。我们把它整理成三条主要路径。

路径 1:自然终止——基于 finish_reason

LLM 每次响应完成时会带一个 finish_reason 字段——告诉调用方"我为什么停了":

  • stop / end_turn——正常完成
  • tool_use / tool_calls——我要调工具,等结果再继续
  • length / max_tokens——我说不完了(超出 output token 限额)
  • content_filter——我不能说(被过滤)
  • refusal——我拒绝回答

opencode 的主循环看 finish_reason 决定下一步:

if (finishReason === "tool_use") {
  执行 tool → 继续循环
} else if (finishReason === "stop" || "end_turn") {
  正常退出循环
} else if (finishReason === "length") {
  警告用户 + 退出循环
} else {
  错误处理 + 退出循环
}

这是自然终止——LLM 觉得说完了,主循环就停。

但这里有个产品风险。LLM 可能"觉得说完了"实际上没完——比如它声称跑了测试其实没跑。opencode 没法在主循环层判断这个——它信任 LLM 的 finish_reason。

要降低这个风险,opencode 用 prompt 层的设计——5 张脸 prompt 里都有"完成前要 verify"的指引。Gemini 的 5 步工作流明确包括 "Verify (Tests)" 和 "Verify (Standards)" 两步。这是把"质量保证"前置到 LLM 行为层而不是放在主循环层。

路径 2:强制终止——max-steps

第 1.9 章我们看过 max-steps 机制——主循环跑了太多轮(默认 25),强制让 LLM 停下来给总结。

if (step >= maxSteps) {
  注入伪造的 assistant message "MAXIMUM STEPS REACHED"
  让 LLM 接着这个生成最终总结
  退出循环
}

这是强制终止——系统觉得该停了。

为什么需要这个?因为有些场景下 LLM 会陷入"无效循环"——同一个 bug 改 10 遍、同一个搜索查 20 次、过度探索不收敛。如果不强制停,资源会烧光、用户会失去耐心。

max-steps 的设计妙处在 1.9 章讲过——用 assistant role 注入做 self-persuasion,让 LLM 主动停下来给完整总结。不是粗暴 abort——是有质量保证的强制终止。

路径 3:用户中断——Ctrl+C / abort

用户按 Ctrl+C 或者点 abort 按钮时,opencode 必须立刻停。

Effect.onInterrupt(() => 
  Effect.gen(function* () {
    aborted = true
    if (!ctx.assistantMessage.error) {
      yield* halt(new DOMException("Aborted", "AbortError"))
    }
  })
)

中断处理做几件事:

  1. 设置 aborted = true 标记
  2. 取消所有正在跑的 tool(每个 tool 的执行是一个 forked fiber,被一并取消)
  3. 把未完成的 tool part 状态标为 "interrupted"(带 metadata.interrupted: true
  4. 主循环立刻退出
  5. session 状态切到 idle
  6. 数据库持久化部分完成的工作

注意几个细节:

细节 1:中断不回滚。已经完成的工作(已读的文件、已跑的命令、已改的代码)保留下来。LLM 看到的是"上次做了 3 件事被中断了"——下次可以接着追问继续。

细节 2:中断有 250ms 缓冲cleanup() 函数会给正在跑的 tool 250 毫秒完成机会。如果某个 tool 正好快做完了(比如还差几行输出),让它跑完。超过 250ms 强制 cancel。

细节 3:标记 interrupted 而不是 error。被用户中断的 tool 状态是 interrupted 不是 error——这是个语义区分。LLM 看到 interrupted 知道是用户主动停的,不会以为是自己出问题了。

细节 4:response 仍然完整。中断前 LLM 已经说的内容、调用的 tool、产生的输出都保留。session 不进入"undefined"状态——清晰知道"中断在哪里"。

这种"中断设计"让用户敢按 Ctrl+C——他知道不会丢工作、可以接着追问。如果按 Ctrl+C 等于"全部丢",用户会害怕中断、即使想停也忍着不停。

路径 4(特殊):错误终止——可恢复 vs 不可恢复

第 0.5 章后面会专门讲(在 R6-07 错误重试章节涵盖)。简单说,opencode 区分两类错误:

  • 可重试错误(network、rate limit、5xx)——自动重试
  • 不可重试错误(context overflow、permission denied、bad input)——立刻退出

这种分类决定主循环遇到错误时是继续还是停。具体策略 7.4 章会讲。

三、设计启示:终止判定的"早停 vs 晚停"trade-off

这一章的核心论点:终止判定不是技术问题,是产品判断——早停浪费工作、晚停浪费资源

设计 AI Agent 的终止机制要权衡两端:

早停(aggressive termination)——一有迹象就停:

  • 优点:节省 token、用户等待时间短
  • 缺点:可能停在"差一点点就完成"的地方——前面的工作浪费

晚停(lazy termination)——能跑就让它跑:

  • 优点:任务完成率高
  • 缺点:可能跑很久——用户等不及

opencode 的设计是中间偏晚停——max-steps 默认 25 步给了足够空间,但不无限。用户能接受的等待时间在 30 秒到几分钟之间——25 步通常落在这个范围。

但这个 25 是默认值——用户可以在 config 里覆盖。复杂任务用户可以调到 50 或 100,简单任务可以调到 5 或 10。让用户能控制这个 trade-off 是产品的关键能力。

设计你的 AI 产品时关于终止判定有几条原则。

1. finish_reason 不是绝对的退出信号。LLM 说"完了"可能没真完——你的产品要有别的机制验证(比如要求 LLM 调用 verify 工具、要求用户确认、要求至少跑过测试)。

2. 给一个"硬上限"max-steps。哪怕用户期望任务尽量做完——也要有上限。否则一个 bug 能让你烧掉用户一周的预算。

3. 中断必须立刻响应。不能"等当前 tool 跑完再停"——某些 tool 可能 30 秒不返回。要从 Effect runtime / Stream 层 / tool 实现层都支持 cancellation。

4. 中断不回滚。已完成的工作保留——让用户能接着追问。粗暴回滚让用户害怕中断,结果用户即使想停也忍着——体验更差。

5. 区分"该停的错误"和"该重试的错误"。Context overflow 不重试(重试也是同样结果);rate limit 重试(等一会就好)。

6. 早停时给清晰的"为什么停"。强制停了要告诉用户"达到 max steps 限制了,已完成 X、还剩 Y、建议下一步 Z"。不要静默退出——用户莫名其妙。

7. UI 要显示"在跑还是停了"。session 状态(busy / idle / error)必须实时反映在 UI。不要让用户猜"它还在跑还是已经卡死了"。

8. 重要任务支持"中断后继续"。用户中断后开新 turn,AI 能基于上次中断点继续——不要每次都从头开始。

最后一个观察。终止判定是 AI Agent 工程里最考验产品判断力的部分之一——它涉及"信任 LLM 多少、给用户多少控制、什么时候系统强制干预"这些根本判断。

每个 AI 产品都要做这个判断——但大多数产品做得很粗糙:直接信任 finish_reason、没有 max-steps、中断要么"全保留"要么"全丢"。这种粗糙在 demo 里看不到,但在长期使用里用户能感受到——"这个 AI 总是说做完了实际没做完"或者"按 Ctrl+C 之后乱七八糟"。

把终止判定做精细,是 AI 产品从"能跑"到"好用"的一道关键升级。

第 2 篇小结

第 2 篇我们看了主循环——AI Agent 的"思考"循环——的 8 章。核心论点贯穿全篇:

AI Agent 跟 Chatbot 的本质差别在主循环。Chatbot 是 request/response,Agent 是 agentic loop。这个循环的健壮性、可观测性、可中断性决定了产品质量。

围绕主循环,几个核心机制:

  1. runLoop 状态机(2.1)——所有 AI 行为的核心反应器
  2. turn 9 阶段生命周期(2.2)——可观测的细粒度
  3. token 经济学(2.3)——把 token 当资源管
  4. 三态 compaction(2.4)——context 超限的应对
  5. 三种 hidden agent(2.5)——用 LLM 之前先问"非 LLM 行不行"
  6. fork 模式(2.6)——让用户感知到的延迟最小化
  7. 持久知识 vs 临时数据(2.7)——内容分类的产品判断
  8. 三种终止路径(2.8)——早停晚停的 trade-off

这些机制不是相互独立的——它们环环相扣。fork 让 hidden agent 不阻塞主循环;hidden agent 优化了 token 经济学;token 经济学触发 compaction;compaction 需要内容分类;compaction 也是一种终止判定。

读完第 2 篇你应该有了 AI Agent 的"运行时模型"——理解它怎么跑起来、怎么管理状态、怎么应对各种异常。

接下来第 3 篇我们看 tool 设计——主循环里"做事"的那一部分。15 个工具的描述、实现、错误处理都是 AI 产品体验的关键。