Article
Goal 为什么又停了:删掉 64 轮上限后,我重新理解了 Agent runtime
看起来像保护,其实是 bug
这次入口不是崩溃,也不是模型报错。
Goal 正常跑完当前 outer turn,工具有结果,状态还是 active,预算也没花完。按理 runtime 该决定要不要开下一轮 continuation。它没有。TUI 最后显示:
paused(no_progress)
continuation_count: 64
第一眼挺像那么回事:跑了 64 轮了,模型大概绕不出来,停一下算保护。
我把 outer-turn ledger 和 continuation event 拉出来对,发现对不上 NoProgress 的定义——没有连续三次相同的模型可修复 gap,没有重复失败证据,也没有预算、审批、workflow 或用户输入接管。停下的唯一原因,是代码里写死了一个 64。
计数可以拿来观察。用它顶替「该不该停」的语义判断,就会出这种事。
上一轮刚修完工具,这一轮修生命周期
在这之前有过更狠的一次:Goal 跑 5 小时 12 分,3.18 亿 Token,463 次 update_goal 全失败,状态仍是 active。模型看得见工具,worker 线程里没有 owner。那次把 Goal 改成 runtime-owned special dispatch,控制面故障不再伪装成无限可恢复的普通 tool failure。过程在《一个 Goal 为什么跑了 5 小时》。
工具能执行之后,下一层问题立刻冒出来:
- 谁拥有 Goal 状态?
- 谁能开关 outer turn?
- 模型说完成时,谁验收?
- pause / cancel / 重启 / workflow 接管,谁说了算?
- 一轮结束后,谁有权决定下一轮能不能开?
答案如果散落在 TUI、callback、provider loop、JSON 文件和几个计数器里,就算不死循环,也会在别的边界上停错。这次就是。
先说清「一轮」是什么
Agent 状态问题,经常从计数口径就开始混。
一次模型响应不是一轮,一次工具调用也不是。在 Orca 里,一个 Goal outer turn 是 runtime 已经接纳的完整 provider/tool loop:
begin outer turn
-> provider stream
-> zero or more tool calls
-> possibly more model responses
-> collect usage and terminal evidence
-> close outer turn
-> decide whether next continuation is admissible
同一轮里读很多文件、跑测试、改代码、再读结果,仍然是一个 outer turn。只有 runtime 决定自动续跑,下一次 provider/tool loop 才是新 outer turn。
这个定义直接影响「进展」怎么判:工具调用多 ≠ 没推进;输出 token 多 ≠ 有推进;outer turn 总数高 ≠ 已经没推进。只有在一段闭合执行序列里,反复出现同一个已归一化的、模型可修复 gap,才算无进展证据。
所以 continuation_count = 64 最多说明「之前有 64 次自动续跑被接纳」,回答不了「第 65 次为什么不该继续」。
v0.2.49:生命周期从 UI 收回 runtime
我没有一上来就删 64。先把 Goal 收成 runtime operation。
旧链路大概是:
TUI knows active
-> provider decides tool schema
-> worker finds callback
-> TUI decides continuation
-> JSON saves the latest shape
每一段都可能留一点状态。一个环节晚半拍,另一个环节就可能做错决定。
v0.2.49 之后:
TUI command
-> RuntimeHost
-> composite GoalRun
-> GoalActor
-> GoalStore (SQLite)
TUI 仍负责体验:/goal pause、/goal resume、渲染状态、累计时间。它不再决定一轮结束后要不要自动再开一轮。RuntimeHost 持有 operation、generation cancellation 和 continuation admission;每条会话自己的 GoalActor 持有 Goal 状态、run/outer-turn ledger、usage、terminal intent、恢复和持久化。SQLite 是权威状态,不再是「顺手写一下」的 JSON。
看起来像搬家,实际改变的是:状态不用从 UI 猜,run 不用从日志倒推,自动续跑也不用看某个局部 flag。它们落在同一个 owner 的生命周期里。
模型说完成 ≠ Goal 完成
重构里我卡得最紧的一点:把 update_goal 从「直接改状态」改成「提交终态请求」。
模型可以这么调:
{
"status": "complete",
"reason": "focused tests passed",
"evidence": [
{
"kind": "test",
"target": "cargo test --workspace",
"summary": "passed"
}
]
}
这不会立刻把 Goal 写成 complete,而是先变成 typed terminal intent。真正转换要走 turn-end audit:
model update_goal
-> intent accepted
-> outer turn closes
-> deterministic preflight
-> bounded verifier when needed
-> SQLite transition
-> semantic event
麻烦是故意的。runtime 至少还要核对:intent 是否来自当前 run / outer turn / generation;outer turn 是否已闭合、usage 是否记账;evidence 和工具终态是否完整;有没有 active workflow;verifier budget 是否还够;blocked 是不是真外部阻塞,而不是模型其实还能继续修的失败。
错误语义也拆开了。参数写错、证据缺失、blocker 不合法——模型可以改,继续 sampling。session owner 丢失、store 写入失败、generation fence 过期、actor 已关闭——说明 runtime 兑现不了 Goal 协议,应结束当前 outer turn,进可恢复的 infrastructure pause,而不是再让模型连调 400 次。上一篇事故缺的就是这条边界。
SQLite 要接住的是中间态
长任务难的往往不是存「最新状态」,而是这些夹缝:provider 已开始、进程突然死;usage 记了一半、verifier 还没跑;用户点了 pause、worker 还在跑;老 generation 没退干净、用户已经 resume;两个进程都以为自己能恢复同一条 stale run。
所以 v0.2.49 的表不止一个 goal:
goals
goal_runs
goal_outer_turns
goal_intents
goal_usage_events
goal_transitions
outer turn 顺序固定成:run 与 outer turn 进入 in-flight → provider/tool loop → 用量按幂等 event id 写入 → 关闭 outer turn → 有 intent 则 preflight 再 verifier → 一事务提交 Goal state 和 transition → RuntimeHost 读 continuation state。
崩溃也有语义。进程在 outer turn in-flight 时退出,下一个持有 runtime lease 的 owner 会把 stale run 收成 Paused(Recovery),关旧 run,发 goal.recovered,而不是假装没事继续请模型。模型刚交 terminal intent、验证没完,intent 仍是待审计事实,不会提前当完成。usage 重复投递时,幂等 key 不会双扣。
能把状态写磁盘,不等于中断后还能说清发生过什么。后者才是我愿意叫它「可恢复长任务」的原因。
pause / resume / cancel 不是三个按钮
UI 容易给人错觉:暂停改个状态,恢复再发一条 prompt,取消把线程掐了。顺序其实很死。
/goal pause 正确顺序大致是:
persist Paused(User)
-> cancellation becomes visible to generation
-> wait generation cleanup
-> charge observed usage
-> close outer-turn ledger
-> resolve operation terminal
若先 cancel 后持久化,进程正好死在中间,重启可能看到不明原因的 in-flight run,把用户行为误诊成基础设施故障。/goal resume 也不能复用旧执行身份:每次恢复拿新 run id 和 generation fence,否则旧 worker 网络返回后,可能把陈旧结果写进已经开始的新 run。
三层保护各管一类陈旧写入:
| 机制 | 解决的问题 |
|---|---|
process 内 lease 和跨进程 flock | 两个 runtime 同时恢复/写同一份 Goal |
| outer-turn in-flight marker | 同一 run 内不能有两轮并发改状态 |
| generation fence | 老的 provider/tool execution 不能提交给新的 run |
文件锁挡不住旧 HTTP response;fence 也替不了数据库拒并行 outer turn。层不能互相替代。
然后才轮到 64
GoalActor、run ledger、intent、admission 都有明确 owner 之后,固定 64 轮显得很突兀。
它想防的是合理担心:模型一直不完成,总得有道保险。但 outer-turn count 不是好信号。健康任务可能每轮只做一个可验证子目标,64 轮后还在推进;坏任务可能 3 轮内反复读同一批文件、重复同一条失败命令;贵任务可能 10 轮内就撞 token budget;低成本但链路长的任务,不该因为数字到了被叫停。
更糟的是旧路径把 OuterTurnLimit 映射成 Paused(NoProgress)。用户看到「模型没进展」,实际是「系统有个没语义的计数器」。
v0.2.50 没把 64 改成 128,也没做成「高级用户可配置」。做的是:
remove MAX_GOAL_OUTER_TURNS_PER_RUN
remove OuterTurnLimit
remove count-based admission rejection
remove OuterTurnLimit -> Paused(NoProgress)
keep continuation_count for ledger and events
信号本身没有停止语义,配置化之后仍然没有。
删了上限,还受什么约束
没有固定轮数 ≠ 无限制跑。continuation admission 仍要同时过这些检查:
| 条件 | 不通过时发生什么 |
|---|---|
Goal 状态仍是 active | 已 complete、blocked、paused 或 budget-limited 就不继续 |
| 上一 outer turn 成功 | 失败与取消不会伪装成 progress |
| 没有 shutdown/cancellation | 当前 execution 正在收尾 |
| 没有 queued user steer | 新输入会写入 transcript,Goal 暂停 |
| 没有 pending interaction | approval、permission、MCP/user input 先交给对应 owner |
| 没有 active workflow | 进入 Paused(WaitingForWorkflow) |
| 不在 plan mode | 用户明确选择规划而不是执行 |
| generation fence 没有重复 admission | 同一轮不能开两次 continuation |
| Goal/verifier budget 可用 | 资源边界仍然生效 |
| 没有结构化 no-progress pause | 三次同类模型可修复 gap 才会停止自动续跑 |
这是 admission 矩阵,不是一条 if。每次拒绝有 reason code,每次暂停落到对应 Goal state。用户、TUI、ACP 和后续恢复不用从日志猜「大概为什么停了」。
脚本 loop 只要决定下一步执行什么;runtime 还要回答:谁现在拥有下一步、这次停止能不能恢复、旧执行会不会回来覆盖新状态、用户能不能读懂这个停止。
TUI / ACP 只投影,不另起状态机
Goal 状态会出现在很多地方:TUI 面板、累计 active timer、ACP session/update、history、重启恢复界面。每个前端若根据局部回调各拼一份,很快就会「库里是 paused,UI 还 running」。
现在 runtime 发 typed semantic events:
goal.created
goal.run.started
goal.turn.started
goal.intent.requested
goal.intent.acknowledged
goal.turn.finished
goal.verification.completed
goal.transitioned
goal.continuation.admitted
goal.continuation.rejected
goal.paused
goal.recovered
goal.completed
TUI 和 ACP 只投影这些事件。Goal prompt context 也是带 id / kind / origin / token limit 的 internal fragment,由 provider 渲在 canonical instructions 后、conversation history 前;状态变了按 id 替换或移除,不改写 user message,也不搅 tool result。同一个事实只留一个来源。
验证
这种改动不能只补一个 unit test。分层大致是:
goal_tracker/goal_store:transition、usage、ledger、recovery、no-progressGoalActor:lease、in-flight 排他、intent、pause/resume、continuation snapshotRuntimeHost:cancellation、queued steer、pending interaction、workflow、plan mode、generation fencegoal_continuation_preflight_has_no_outer_turn_limit:成功 continuation 的 preflight 不再依赖 outer-turn count,其它拒绝条件保留- TUI Goal tests:状态、累计计时、事件投影
- 真实 DeepSeek harness:completion / rejected completion / genuine blocked / cancellation / resume,对照 live event、session JSONL、SQLite audit
- release gate:workspace tests、format、npm staging、站点 build/SEO、GitHub Release、npm、
npm exec
v0.2.50 Release workflow 跑完四平台构建、Release、npm publish、assets 和 public verifier:
GitHub Release verified: https://github.com/echoVic/blade-deepseek/releases/tag/v0.2.50
npm package verified: @blade-ai/[email protected]
npm exec smoke verified: orca 0.2.50
这三行比「我本地跑过了」实在。
还没假装解决的
这次解决的是本机长任务的 ownership、恢复和语义停止,不是分布式调度全家桶。跨机器多节点、成本预算策略、elapsed-time circuit breaker,以后如果要加,各自要有 typed state 和可见 reason,不能再借 NoProgress 或 turn count 凑合。
另一个边界是任务契约。verifier 能拦「模型轻易宣布完成」,却替用户定义不了「什么叫交付」。目标本身不可验证时,runtime 最多做到不自欺,做不到替用户下结论。
把 64 这种错误边界拿掉之后,预算、成本、时间策略反而有地方可以正确加回来。目前我还没把后几项做完,先记在这里。
收束
删掉 64,删的是「系统说不清为什么该停,就拿计数器顶上」这种偷懒。长任务总会失败、暂停、等待、恢复、被人打断;我现在更在意的是:每次继续或停止时,能不能讲清楚谁拥有下一步、为什么是这个状态、之后怎么恢复。讲不清楚的时候,就先别用一个漂亮数字假装讲清楚了。
Keep Reading