opencode 怎么决定'该结束了'
三种终止路径 + 早停晚停的 trade-off
一、原理: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.ts、prompt.ts、session.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"))
}
})
)
中断处理做几件事:
- 设置
aborted = true标记 - 取消所有正在跑的 tool(每个 tool 的执行是一个 forked fiber,被一并取消)
- 把未完成的 tool part 状态标为
"interrupted"(带metadata.interrupted: true) - 主循环立刻退出
- session 状态切到
idle - 数据库持久化部分完成的工作
注意几个细节:
细节 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。这个循环的健壮性、可观测性、可中断性决定了产品质量。
围绕主循环,几个核心机制:
- runLoop 状态机(2.1)——所有 AI 行为的核心反应器
- turn 9 阶段生命周期(2.2)——可观测的细粒度
- token 经济学(2.3)——把 token 当资源管
- 三态 compaction(2.4)——context 超限的应对
- 三种 hidden agent(2.5)——用 LLM 之前先问"非 LLM 行不行"
- fork 模式(2.6)——让用户感知到的延迟最小化
- 持久知识 vs 临时数据(2.7)——内容分类的产品判断
- 三种终止路径(2.8)——早停晚停的 trade-off
这些机制不是相互独立的——它们环环相扣。fork 让 hidden agent 不阻塞主循环;hidden agent 优化了 token 经济学;token 经济学触发 compaction;compaction 需要内容分类;compaction 也是一种终止判定。
读完第 2 篇你应该有了 AI Agent 的"运行时模型"——理解它怎么跑起来、怎么管理状态、怎么应对各种异常。
接下来第 3 篇我们看 tool 设计——主循环里"做事"的那一部分。15 个工具的描述、实现、错误处理都是 AI 产品体验的关键。