青雲的博客

Article

Goal 为什么又停了:删掉 64 轮上限后,我重新理解了 Agent runtime

· 15 分钟阅读

看起来像保护,其实是 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 interactionapproval、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-progress
  • GoalActor:lease、in-flight 排他、intent、pause/resume、continuation snapshot
  • RuntimeHost:cancellation、queued steer、pending interaction、workflow、plan mode、generation fence
  • goal_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

相关文章

评论