一次 Agent Turn 到底如何发生
很多 Agent 教程把主循环写成十几行:把 messages 发给模型,如果返回 tool calls 就执行,再把结果塞回 messages,直到模型给出 final answer。
这个示例能解释最小闭环,却解释不了生产问题:用户在工具运行时发来新消息怎么办?只想给下一步补充上下文、但不想立即唤醒怎么办?请求失败后的 chunk 是否留在历史?一次工具调用产生多个模型请求,它们算一轮还是多轮?取消落在两个异步边界之间时,新输入会不会丢?
dsh 用 Inbox、Turn、Step 和 append-only Session 四组概念回答这些问题。
第一层:三个输入入口,看似相近,调度语义完全不同
Agent 的公开输入最终进入同一个 Inbox,但有三条常用入口。
followup(message) 把消息放进 next-turn 并唤醒 driver。它的语义是“开启后续一轮工作”。即使 Agent 正在运行,也不会把它偷塞进当前已经形成的请求。
steer(message) 把消息放进 next-step 并唤醒。Agent 空闲时,它会启动 Turn;Agent 运行时,它在最近的后续 Step 边界被认领,适合改变正在进行的方向。
inject(message) 同样放进 next-step,但不唤醒。它用于插件提供模型上下文:如果没有其他消息启动工作,它会安静等待;如果某次 pre-step 已经完成认领,它也可能要到再下一次请求才进入。
这三个入口共享 FIFO 与消息身份,却没有模糊成一个 send() 的公共语义。因为“内容相同”不代表“调度意图相同”。
第二层:Turn 是工作边界,Step 是请求边界
driver 从 idle 被唤醒时,公开状态同步进入 running。它打开一个新的 turn/start,然后从 Inbox 认领 next-turn 和已有的 next-step 输入,组装 system prompt 与 runtime context,调用 agent/pre-step waterfall。
pre-step 的返回值是权威决定:
reject:本轮被阻止; enter(messages):把最终消息批次带入 Step; 如果第一个 enter 被重写为空,本轮正常关闭,但不发模型请求。
这解释了为什么一个 Turn 可以有零个 Step。即使输入被策略拒绝,或在认领后被改写为空,系统仍记录 turn/start 与 turn/end。尝试发生过,只是没有花费一次模型调用。
一旦进入 Step,driver 先写 step/start,再把最终进入的每条消息写成 user/message。之后它从 Session 调用 deriveMessages(),而不是从某个内存 messages 数组直接取历史;同时组装 system prompt、tool schemas 和 LLM config。
请求经过 agent/request waterfall,再由选定 adapter prepareCall/stream。每个流式块写成 assistant/chunk,供 UI 实时显示和精确回放;成功结束后组装为 assistant/message,记录 provider、model、usage、replay state 和对应 chunk seq。
如果 adapter 返回错误或终端 in-band failure,driver 调用 agent/request-error waterfall。插件可以返回 retry;否则原始结构化错误成为权威失败。重试会重建请求,不把失败尝试伪装成成功 assistant message。
若最终 assistant message 没有 tool call,Step 返回 completed。若有工具调用,则进入工具 scheduler;调用结果按模型顺序写入 tool/result。只要工具要求再请求一次,或 next-step inbox 有新输入,本 Turn 就继续下一个 Step。自然停止且 next-step 为空时,系统触发串行的 agent/turn-stopping 检查点,最后写 turn/end。
当 inbox 还有 next-turn 工作时,driver 可以在同一活动中继续下一 Turn;全部排空后公开状态才回到 idle。
第三层:这套状态机的意义,是把并发输入变成可证明的顺序
公开 AgentStatus 只有 idle 与 running。默认实现内部还有 maintenance 相位:它可以在真正空闲时执行非 Turn 维护任务,但对外仍是 idle。若 maintenance 期间有唤醒消息,系统锁存 wake,任务完成后再启动 driver。
取消也不是简单设置一个 boolean。running 相位拥有 AbortController。若 waking input 落在旧活动已 abort、但尚未收敛到 idle 的窗口,消息会被重新分类到 next-turn,并锁存唤醒,避免加入一个注定结束的活动。cancel({keepInbox:true}) 可以中止已认领工作而保留未认领队列。
对开发者,这里有三个可迁移原则。
第一,提交边界要先于可见状态。dsh 在 append Session event 的地方提交 durable fact,再由 session/event 广播。UI 不应自行猜测一次模型调用是否“算成功”。
第二,输入要携带调度意图。followup、steer、inject 不是三个按钮文案,而是不同的时序合同。把它们压成一个 message queue,会在并发时失去可解释性。
第三,公开状态应小于内部相位。外部只依赖 idle/running,内部可以继续细化 maintenance、abort window 和 step position,减少 API 兼容面。
对产品负责人,Turn/Step 分层直接影响成本与体验。一次用户任务可能触发多个模型 Step;“一轮多少钱”不能只数用户消息。反过来,被策略阻止的 Turn 可以零调用关闭,系统仍保留审计边界。
这就是成熟 Agent Loop 与教学 while loop 的差别:不是循环更长,而是每一次输入、请求、结果、取消和恢复都拥有可重建的时序位置。
源码核验索引
Agent 输入与公开状态: packages/core/agent/src/runtime-types.ts默认状态机: packages/core/agent-loop/src/agent.ts生命周期图: docs/agent-lifecycle.mdSession 事件与消息投影: packages/core/session/src/index.tsInbox: packages/core/agent/src/inbox.ts
事实边界:本文描述默认 ReactLoopAgent;dsh 的 Agent 接口允许其他实现。内部 maintenance 不是公开第三状态,不应在外部协议中写成 AgentStatus。
夜雨聆风