Featured image of post 第十一话|Agent 为什么会停下来:从模型信号到 Runtime 状态迁移

第十一话|Agent 为什么会停下来:从模型信号到 Runtime 状态迁移

模型输出了「请确认」,程序为什么真的会停?从一段 JSON 输出跟到状态迁移、调度退出和调用计数:模型只产生候选决定,Runtime 才拥有状态迁移权;ReAct、计划步、Workflow 节点只是等待信号的三种来源。

系列 GYA(Get Your Agent) 第 11 / 13 篇
主题 AgentLLMRuntime状态机HITL教程

给一个 Agent 下达这样的任务:根据给定主题和本地资料,整理一份有依据的技术文章创作纲领,然后停下来等用户确认;确认之前,不生成正文草稿。

在 Codex、WorkBuddy 这类产品里,这个任务的表现可以直接观察到:它检索、读资料、写出一份纲领,然后停下,把纲领和一句「是否继续」摆在你面前;你回复之后,它接着往下做。

停这件事看起来理所当然,其实需要解释。模型的输出是一段文本或一段 JSON。它在里面写「请确认」四个字,不会让 Python 的 while 循环条件变成 False,也不会让 Java 的线程进入等待。语言表达能力,和程序控制流,是两码事。

上一话把问题停在「下一步到底由谁拍板」。这个问题的答案里有一类决定和工具动作不同:工具动作有对应的执行路径,而「停下来等用户」没有对应动作可执行——它要求的是一次状态变化。模型已经说了「请确认」,程序为什么不像往常那样继续循环?

整篇只回答这一件事:模型只产生候选信号,Runtime 才拥有状态迁移权。 前面所有的机制、对照、证据,最后都汇到这句话上。


一、「请确认」不是指令

先回答第一个问题:模型说出「请确认」,为什么本身不能停掉任何东西?

先看两个最短的版本。它们跑同一个模型、同一个任务,唯一的区别是循环里有没有一处处理等待信号。

缺陷版:

while True:
    decision = model.decide()
    print(decision)
    # 没有处理 REQUEST_USER_INPUT:它只是一行被打印出来的输出

正确版:

while state.status is RunStatus.RUNNING:
    decision = controller.decide(state)
    transition = runtime.handle(decision, state)
    if transition.status is RunStatus.WAITING_FOR_USER:
        return transition

差别只有一个分支。缺陷版把决定当数据打印出来,控制流继续;正确版把决定交给 Runtime,由 Runtime 迁移状态、再由调度循环按状态退出。

把这两个版本放回真实 demo 上,用同一批离线夹具各跑三种控制器,输出是这样的(数字取自本仓库 traces/offline-python-normal.log 与 traces/offline-python-naive.log,Java 侧逐字段一致)。为了不在一开始就淹没在代号里,先只看两列最要紧的:最终状态,和「草稿到底写没写出来」。

模式最终状态草稿写出
缺陷版 react还在跑(RUNNING)否
缺陷版 plan_and_execute报完成(COMPLETED)否
缺陷版 workflow报完成(COMPLETED)否
正确版 react等用户(WAITING_FOR_USER)否
正确版 plan_and_execute等用户(WAITING_FOR_USER)否
正确版 workflow等用户(WAITING_FOR_USER)否

一张表就能看懂差别:正确版三种模式都停在「等用户」,缺陷版没有一行停对——要么还在跑,要么更糟,报了个「完成」却没写出任何东西。

缺陷版那两种走法,各自说不上停:

  • react 把预算耗光了(一路反复读资料读到 8 步用尽),状态停在 RUNNING,但日志里却有一个「说过要等」的标记。
  • plan_and_execute 越过了等待点,一路走到 write_draft(写草稿),被准入检查拦下,然后以 COMPLETED 收场——一份没有被写出来的草稿配一个完成状态。

第二种走法值得单独看一眼:它既没有写任何东西,也没有停下来,最后还报了个完成。这就是「没有写入不等于已经暂停」的最小复现。缺陷版里那个「说过要等」的标记只是一个数据记录,它记录「模型说过要等」,不记录「循环已经退出」。

结论落在这一步:语言表达本身不能暂停程序。能被处理的,是被 Runtime 识别出来的结构化信号。


二、停下来的那一刻,长什么样

上一节证明了「信号不能自动停程序」。这一节反过来问:当程序真的停下来时,我们凭什么说它停了?换句话说,什么是「停」的判据。

status == WAITING_FOR_USER(状态标记为等用户)本身不构成证明。它只说明 Runtime 写了一个值;写值的那一刻,和「写完之后没有新调用」,是两件事。要证明停,得看等待之后有没有新的调用发生。

判据写成函数是这样:

def is_run_suspended(result: ModeResult) -> bool:
    state = result.final_state
    model_delta, tool_delta = state.calls_after_wait()
    return (
        state.status is RunStatus.WAITING_FOR_USER
        and state.pending is not None
        and model_delta == 0
        and tool_delta == 0
    )

四个条件同时成立才算停:状态是 WAITING_FOR_USER、留了 pending request(待处理的请求)、等待之后没有新的模型调用、没有新的工具尝试。缺陷版会在这条判据上失败——这正是测试发现它的方式,而不是只证明正确版能通过。

「等待之后」要算得出来,得在迁移的同一刻做快照:

def snapshot_wait(self) -> None:
    self.model_calls_at_wait = self.model_calls
    self.tool_attempts_at_wait = self.tool_attempts

这不是可有可无的一行。如果只在循环结束时同步计数,等待后的增量就会被算成全部调用——判据看起来还在测,其实已经失效。这个缺陷在重构时出现过一次,被测试逮住。

判据要落到一组断言上才算验收。这次 Python 与 Java 各 20 项测试,覆盖的是同一批语义:

验收断言落在哪里
等待后模型调用数不再增加client.calls 在迁移之后不再变化
等待后业务工具尝试数不再增加tool_attempts 在迁移之后不再变化
受保护写入没有被尝试write_draft 从未成功落库
pending request 完整request_id / question / state_before 都在
方法已返回调用方调度循环以 run_exited 结束,不是预算耗尽
非法等待信号被拒缺失状态下 REQUEST_USER_INPUT 不产生迁移

一次等待在 Trace 里留下五类事件,按顺序出现:

signal_received      ← 信号由谁产生(signal_source)
runtime_validated    ← Runtime 是否接受(runtime_decision)
state_transition     ← state_before → state_after
pending_recorded     ← pending_request_id
run_exited           ← Scheduler 退出当前 Run

三个信号来源产生的都是这同一个序列,类型和顺序一致。真正回答「停了吗」的是最后一个事件:run_exited 而非 budget_exhausted。前者是调度器主动退出,后者是把预算用完了。

到这里,我们已经知道「停」长什么样、怎么判。但还没说:谁有资格做那个把状态改掉的动作?


三、Agent 内部,谁有资格让 Run 停

这一节回答的是全文最关键的一处分工:Agent 的模型、Runtime、调度器、界面四层,各自能做什么、不能做什么。

把角色摆到后端工程师熟悉的位置上,整条链只有四层:

层负责什么不负责什么
模型根据当前上下文提出动作、结束、或请求用户输入不改变程序状态、不停止循环、不执行任何动作
Runtime解析模型输出,校验这条信号在当前状态是否合法,决定状态迁移不生成内容、不选业务动作
Scheduler / Agent Loop按 Runtime 给出的状态决定继续循环还是退出当前 Run不解释模型语义、不做合法性判断
UI / Continuation展示问题、接收用户输入、创建下一次执行不修改状态、不推断用户想要什么

换成后端里已有的说法:模型的输出是一条消息(或一个领域事件);Runtime 是消费者 + 状态机;Scheduler 是任务调度器;UI 是等待外部命令的入口。消息本身不会被执行,它要被消费、被解释、被判断,才产生后果。

链上只有一个位置能写 status,就是 Runtime。其余三层都可以提请求,但提请求和改状态是两回事。

这不是某一家的实现偏好,Anthropic 把它写进了 Messages API 的接口契约:每个响应都带一个 stop_reason 字段,说明模型为什么结束生成;调用方检查这个字段,再决定是原样使用、继续对话、重试,还是回退到别的模型。字段的取值里没有任何一个是「程序已暂停」——tool_use 的含义是「我在调工具,你去执行」,end_turn 的含义是「我说完了」。模型交出的是一个原因码,怎么处理由外层决定。

同一份契约在 pause_turn 上表现得更清楚:服务端工具循环达到迭代上限时,响应会带 pause_turn 返回。这不是进程被挂起,而是调用方拿到一段没处理完的响应,需要把它原样发回去让模型接着跑。

把四层串起来,数据流是这样:

模型生成候选决定
    ↓
Runtime 解析决定类型
    ↓
Runtime 检查当前状态是否允许这条决定
    ↓
状态从 RUNNING 迁移到 WAITING_FOR_USER
    ↓
记录 pending request
    ↓
Scheduler 看到 WAITING,退出当前 Run
    ↓
UI 展示问题
    ↓
用户提交下一条输入
    ↓
新的 Run / continuation 根据关联标识继续

最后一步「根据关联标识继续」在这里只做到单进程的程度,跨进程的部分留到下一话。

信号要能被识别,得先有一个共同的格式

三种来源共同遵守的格式,是四种决定类型:

class DecisionType(Enum):
    EXECUTE_TOOL = "EXECUTE_TOOL"
    REQUEST_USER_INPUT = "REQUEST_USER_INPUT"
    FINISH = "FINISH"
    FAIL = "FAIL"

模型、Planner、Workflow 产出的都是同一个结构,而不是执行结果:

@dataclass
class ControlDecision:
    type: DecisionType
    action: Optional[str] = None
    args: Optional[Dict[str, Any]] = None
    question: Optional[str] = None
    reason: Optional[str] = None

字段的可选性是刻意的。REQUEST_USER_INPUT 用 question,EXECUTE_TOOL 用 action 和 args;某一类里字段缺失是正常输入,不应该在解析阶段就崩,校验属于 Runtime 的职责。

调度循环的形状因此变得很平:

while state.status is RunStatus.RUNNING:
    decision = controller.decide(state)
    transition = runtime.handle(decision, state)
    if transition.kind in {"SUSPEND", "COMPLETE", "FAIL"}:
        break

controller.decide() 只提出决定;runtime.handle() 才拥有状态迁移权。这句分工是后面所有代码的前提,它把「谁让 Run 停」从模型和模式里移出来,交给了一个有名字的组件。

runtime.handle() 内部按五个动作走:

第 3 步是新增的一层,也是这里唯一需要做取舍的地方:模型请求等待时,Runtime 要不要校验它此刻有没有等待的对象?如果只做类型校验,模型可以在任何时刻宣称「我要等用户」,而那时根本不存在被等待的东西。所以 REQUEST_USER_INPUT 在纲领生成之前一律拒绝:

if decision.type is DecisionType.REQUEST_USER_INPUT:
    if not state.brief_created:
        return "wait_signal_without_pending_object"
    if not (decision.question or "").strip():
        return "wait_signal_without_question"
    return None

非法的等待信号不会悄悄过去:rejected_signals(被拒信号数)加一,Run 转成明确失败。合法的等待信号则走完整的迁移,留下 pending request:

self._pending_seq += 1
pending = PendingRequest(
    request_id=f"approval_{self._pending_seq:03d}",
    question=(decision.question or "").strip(),
    state_before=state_before,
)
state.pending = pending
state.status = RunStatus.WAITING_FOR_USER
state.snapshot_wait()

state_before 记下迁移前的状态,question 记下要问用户什么,request_id 是用户回来时用来关联的标识。三者一起构成「这一次等待是谁、为了什么、从哪个状态发起」。

这也正好对上 OpenAI Agents SDK 的用法:那里批准一项工具调用的方式是围绕 interruptions 展开的——运行中断后,结果里带回一个待处理数组和一份可恢复的状态,批准就是在这个状态上做的决定。两种写法用词不同,位置一样:改状态的那一步在框架(Runtime)里,不在模型输出里。


四、等待信号从哪来

现在回到上一节开头埋下的问题:模型不是唯一会提「要停」的角色。同一个 Runtime 入口,等待信号可以来自三个地方。三种控制器共用同一套工具、同一个 ActionGateway、同一个 Runtime,差异只留在「谁产生信号」这一处。

  • ReAct:模型每次看到工具结果后重新提出一条决定。等待信号由模型自己在上下文里提出,比如输出 {"type": "REQUEST_USER_INPUT", "question": "纲领已生成,是否继续写正文?"}。Runtime 认出来就停。
  • 计划步:Planner 一次生成整份计划,其中一步是等待。Executor 执行到那一步,把它原样交给 Runtime。计划可以声明等待点,但计划文本本身没有暂停能力。
  • Workflow 节点:节点序列写在代码里,等待是其中固定的一站。节点内部的内容仍然由模型生成,代码固定的是「走到哪个节点」。

三种来源最后汇入同一个入口:

这就是三种模式在这篇文章里真正的位置:它们是「信号来源」,不是「三种对等的 Agent 设计」。谁先提不重要,重要的是提信号和停程序是两件事——无论信号来自模型、计划还是代码,改状态的那一步都在 Runtime。

三个来源的模型调用次数本来就不同,这一点在输出里也如实保留:正确版 react 是 5 次,plan_and_execute 和 workflow 各 1 次。决策粒度不同,调用次数就不该被凑成一个数。

两条补充,都是旧版说错、这次改对的,不影响主线:

关于「模型能不能主动请求确认」,这次跑了一组真实实验:deepseek-v4-flash,6 次,日志在 traces/live-run-1..6.log。结果是这 6 次里没有一次产出可被 Runtime 解释的等待决定——3 次把 8 步预算耗在反复读同一份资料上,另外 3 次的输出无法解析(模型把决定多包了一层 {"type":"json_object","content":{...}} 信封,Runtime 只认顶层的 type,认不出就判失败)。这段证据的强度有明确边界:它只说明在指定模型、提示词、工具协议和输入下模型没有产生预期结构,不能推广成「模型不会请求确认」。同一个服务端在 temperature=0 下输出并不可复现:这 6 次里预算耗尽 3 次、解析失败 3 次,两次分批运行的分布还不同。

关于 ReAct 的旧结论也要改:之前把「绕圈」归因为「每一步只拿到当前观察,看不到任务整体」,这与代码不符——每次请求传入的是完整的观察累积历史,不是最新一条。当前能写的只有:在本次模型、提示词、动作协议和 8 步预算下,模型连续重复读取了同一份资料;Trace 证明了重复行为,不足以把原因唯一归结为 ReAct 模式。

另外两处旧版的不实也一并纠正:Planner 的提示词曾硬编码「先读 material_1,再读 material_2」,而 ReAct 必须自己从工具结果里发现顺序,那不是对等的比较——现在三个控制器拿到同一份初始清单,只知道有哪些资料、不知道先读哪一份;Workflow 曾有一个假分支——代码无条件读取第二份资料,却在 Trace 里标成「读到线索之后触发的分支」,现在第 3 个节点做真实判断,没有线索就不读第二份,branch_taken 如实记为 false。

三种实现可以组合,它们不在同一个对立面上:Workflow 固定阶段,ReAct 在阶段内部按线索选资料,Planner 负责跨子任务的覆盖。组合之后,「停」这件事仍然只有 Runtime 一处负责。


五、为什么不能只用一个布尔值

前面一直在用 status 这个枚举。这一节讲为什么它必须是枚举,不能是 waiting=true 这样的布尔。

状态本身是枚举:

class RunStatus(Enum):
    RUNNING = "RUNNING"
    WAITING_FOR_USER = "WAITING_FOR_USER"
    COMPLETED = "COMPLETED"
    FAILED = "FAILED"

用布尔 waiting=true 表示等待,会丢掉两件需要区分的事:一是「等待」和「完成」「失败」是并列的终态关系,布尔表达不了;二是标记为真和调度退出是两步,布尔把两步压成一步,就再也分不出「标记了但还在跑」这种状态。缺陷版 plan_and_execute 那一行正是这个压缩的后果:它有个「说过要等」的标记,状态却一路跑到了「完成」。

回到那张表补全字段,现在这些代号已经都有中文含义了:

模式terminal_reason
(结束原因)
status
(状态)
waiting_marker
(说过要等)
model_calls
(模型调用)
tool_attempts
(工具尝试)
rejected_actions
(被拦动作)
缺陷版 reactbudget_exhaustedRUNNINGtrue840
缺陷版 plan_and_executecompletedCOMPLETEDtrue151
缺陷版 workflowcompletedCOMPLETEDtrue140
正确版 reactwait_suspendedWAITING_FOR_USERfalse540
正确版 plan_and_executewait_suspendedWAITING_FOR_USERfalse140
正确版 workflowwait_suspendedWAITING_FOR_USERfalse140

正确版三行的其余字段完全相同:pending_request_id=approval_001、draft_written=false、approval_granted=false、model_calls_after_wait=0、tool_calls_after_wait=0。

waiting_marker=true 在那三行缺陷版里都是一个数据标记,它记录「模型说过要等」,不记录「循环已经退出」——正是布尔值把这两件事压成一个所付出的代价。


六、和「动作能不能执行」是两回事

最容易和「暂停」混起来的机制是动作准入。这一节把两个问题的边界切开。

Action GatewayRuntime
回答的问题这个动作能不能执行当前 Run 要不要继续
检查时机副作用发生之前每条决定之后
拒绝的后果这个动作不执行,Run 可以继续状态迁移,调度可能退出
拦住之后一次动作被挡与「已暂停」无关

受保护写入的检查就在写入之前:

def _write_draft(self, args: dict, state: RunState) -> str:
    if not state.approval_granted:
        return "blocked_by_action_gateway"
    ...
    state.draft_written = True
    self.store.save_draft("draft_1", content)
    return "draft_written"

未获批直接返回,不碰 store。这条检查把「写不进去」办到了,但它对「Run 停不停」一句话也没说。缺陷版 plan_and_execute 那一行就是证据:rejected_actions=1,写入被拦住了,循环继续跑到 completed。

Codex 的官方文档把这两种控制分开,用词几乎一样:沙盒模式定义的是「模型生成的命令在技术上可以做哪些操作」,审批策略定义的是「Codex 在执行某项操作之前必须何时停下来询问用户」。文档里那句话可以直接搬过来——沙盒定义技术边界,审批策略决定越界前何时必须停止并询问用户。前者对应 Gateway,后者才对应 Run 的继续与停止。


七、这些产品,其实都这么做

前面把机制讲完了,这一节把视角拉远:这些机制不是我们自说自话,几个主流框架的文档描述的是同一件事。

产品上的现象是清楚的:这些 Agent 会结束当前回合并把问题展示给用户,用户输入之后可以开启后续执行。这说明它们存在某种回合终止与 continuation 机制。

这一段能写到什么强度,取决于有没有官方来源。Codex 是有文档的:审批策略是运行配置,取 on-request 表示默认在沙盒内工作、需要越界时询问;approvals_reviewer 的默认值是 "user",即审批请求路由到用户,也可以设为 auto_review 由审查智能体处理。这说明「停下来问谁」是运行时配置的一部分,而不是模型自身的能力开关。

OpenAI Agents SDK 的文档把暂停这一步写得最直白:当一次工具调用需要审批时,SDK 暂停这次运行,返回待处理项,之后可以从同一个 RunState 恢复。暂停的执行者是 SDK,不是模型——分工和前面一致,只是它把 Runtime 的角色交给了框架。

LangGraph 的 interrupt 走的是同一路线,并且把持久化的一环显式化:在节点里调用 interrupt 之后,图执行在调用点挂起,状态由持久化层保存并无限期等待,恢复时用同一 thread_id 重新调用图。文档同时说明,恢复时整个节点会从头重跑,interrupt 之前的代码会再执行一次。这条细节很有用——它说明「挂着的那一步」和「恢复时怎么重放」是两个要分开设计的约束。

从这些来源能推出来的,只到这里:被观察到的产品行为,与官方框架文档描述的「运行时暂停 + 携带状态恢复」是同一类机制。 再往下不能推:

  • 不能写它们内部一定用了某个类、某张表或某个具体状态机;
  • 不能写 WorkBuddy 与 Codex 的实现相同——WorkBuddy 这一段只有可观察行为,没有可引用的实现契约;
  • 不能把框架文档的接口承诺当成产品内部实现的证明。文档承诺的是「怎么用」,不是「里面怎么写的」。

这条边界不是保守,是证据强度决定的:官方文档能证明「这套机制存在且被这样描述」,证明不了「这个产品内部就是那样实现的」。


八、停只是前半场

三种来源都在同一处收尾:纲领生成,Run 进入 WAITING_FOR_USER,本次调用返回。Trace 里能看到状态、看到等待原因、看到哪些动作被拒绝,唯独看不到一件东西——这次调用退出之后,谁记得有一份纲领在等确认。

单进程里 return 退出的是当前这一次函数调用。pending、brief_id、已经走到哪一步,全在内存里,程序一退就没了。用户几分钟后回来,或者进程中途重启,系统凭什么找到对应的纲领、对应到哪一次等待、恢复到哪个进度?

这一步不是加一行持久化就能收尾的,它牵出四个互相牵连的问题:

  • 等待请求怎么存下来,重启之后还能按标识找回来;
  • 审批结果绑定到哪个对象——是这次 Run,还是被批准的那个具体动作;
  • 恢复之后怎样保证不重复产生副作用;
  • 重复审批、超时、用户直接取消,各自该走到哪个状态。

Anthropic 那句 pause_turn 的语义在这里反过来读也成立:调用方必须把没处理完的内容原样发回去才能接着跑。也就是说,「恢复」不是自动发生的,它需要有人带着上下文重新发起。

下一话接着讲:Run 停下来之后,怎样把状态存到进程之外,又怎样安全地接着跑。


参考资料

正文引用到的来源,按首次出现顺序排列:

配套 Python 与 Java 实现在 GYA 与 GYA-Java 两个仓库的 c11-suspend-protocol 目录,按 README 的命令跑。

留言

欢迎分享你的想法。评论提交后会在审核通过后显示。