
第 3 章 一个轮次的一生如果这本书你只有时间读一章就读这一章。官方的「轮次流程」一节是整份架构文档里信息密度最高的部分——它把整个系统的心脏用一段伪代码摊开给你看了。摘要本章深入拆解 Agent 系统最核心的「轮次」机制将官方伪代码逐行翻译成 8 个阶段涵盖轮次与步骤的定义、agent/pre-step拦截点、模型历史派生与冻结、流式请求、工具调用五道关卡、三个事件域的选择、Agent 句柄、inbox 双队列、idle/running 状态、取消机制与agent/created初始化。核心思想是「用数据而非返回值决定流程」帮助读者理解整个系统的心脏如何运转。标签Agent架构轮次机制事件驱动伪代码解析工具调用状态管理inbox队列3.1 先定义两个词轮次与步骤官方原文是这样定义的一个步骤是一次模型请求加上它调用的工具。一个轮次包含零个或多个步骤它在领取首条输入之前打开并在不再欠下任何工作时关闭。翻译成大白话词是什么打个比方轮次turn你按一次回车到模型彻底停手「一盘棋」。从落子到终局步骤step一轮模型请求加上这次请求所调用的那些工具「一回合」。我走一步棋你走一步棋关键在「零个或多个」这四个字。一个轮次可以是零个步骤——比如你要处理的输入被某个策略拒绝了或者决定不进入这一步那么轮次照样打开、照样关闭只是中间没有步骤。这个「空轮次」的概念很重要因为它在日志里会留下痕迹是排查问题时的一个信号。图 10轮次套步骤。绝大多数时候你关心的是步骤内部的流程因为所有扩展点都在那里。3.2 官方伪代码逐行翻译官方文档给出了轮次流程的伪代码。我把它完整抄下来然后逐段翻译成人话turn/start claim next-step input plus one queued message assemble prompt sections tool schemas; project runtime context - agent/pre-step reject | enter(messages, startsRequestSeries?) reject, or a first enter rewritten empty - close the turn with no step step/start agent/request - prepareCall (cancellation commits neither system nor users) reconcile system/message using the prepared call capability append entered messages as user/message; log request/header and request/context as needed derive and freeze model history from the log stream the bound prepared call - llm/stream - agent/assistant-stream start agent/assistant-stream chunk* assistant/message | assistant/attempt - agent/assistant-stream end tool/call* - tools/pre-execute - tools/execute - tools/post-execute - tool/result* step/end tools owe another request, or next-step input arrived - claim - next step - agent/turn-stopping turn/end现在逐段翻译。我按「阶段」把它切成 8 步每一步都说明它在做什么、为什么要有这一步、你想干预时应该挂在哪里。阶段 1打开轮次领取输入turn/start claim next-step input plus one queued message先打开轮次再从 inboxinboxAgent 自己的收件箱有两份等待处理的消息列表——下一轮的、和下一步的 里「领取」一条排队的消息外加所有标记为「下一步」的输入。注意用词是claim领取不是「读取」。领取意味着这些消息从待处理队列里被移走了——它们要么进入这一轮要么被明确丢弃不会既在队列里又已经被用掉。阶段 2组装提示词与工具清单assemble prompt sections tool schemas; project runtime context把这次请求要发给模型的东西拼起来系统提示词的各个段落、所有可见工具的 schema、以及运行时的上下文信息。第 5 章会专门讲这一块。阶段 3agent/pre-step——第一道也是最重要的一道关卡- agent/pre-step reject | enter(messages, startsRequestSeries?) reject, or a first enter rewritten empty - close the turn with no stepagent/pre-step是一个 waterfallwaterfall一种可以拦截、改写、短路的监听方式监听器必须调用 next() 才把决定权传给下游它决定「这一步到底进不进」。它可以reject——拒绝。首次领取就被拒绝或改写为空时这个轮次会不含任何步骤地关闭。enter——放行并可以改写要进入这一步的消息内容。在 enter 时设置startsRequestSeries——指示循环「从这里开始算一个新的请求序列」。官方特别说明了包装监听器该怎么写要通过{ ...decision, messages }保留原决策除非你确实想替换它。这个细节看起来琐碎但它是「多个插件同时监听同一个事件还能正确协作」的关键。关键agent/pre-step是「请求推导之前唯一的 waterfall 监听器链」。也就是说**你想在模型看到请求之前动它这里是唯一的入口。**压缩把太长的历史摘掉也是挂在这里的。阶段 4agent/request 与 prepareCall——决定用哪个模型step/start agent/request - prepareCall (cancellation commits neither system nor users) reconcile system/message using the prepared call capabilityagent/request也是一个 waterfall它拿到的是「冻结的调用配置」可以返回一个替代值来切换提供方、模型、推理强度或采样参数。括号里那句话很值得注意「取消不会提交 system 也不提交 users」。意思是在这两个异步阶段里任何一处发生取消系统提示词和已接纳的用户消息都不会被写进日志。这是为了保证日志里不会留下「请求发出去了一半」的痕迹。然后是reconcile system/message用「已准备好的调用能力」来决定系统提示词该怎么协调——是替换掉最新的那个系统节点还是追加到已缓存的历史之后。这个细节在第 4、5 章会展开。阶段 5把消息写进日志派生并冻结请求append entered messages as user/message; log request/header and request/context as needed derive and freeze model history from the log把上一步决定放行的消息作为user/message事件写进日志按需记录request/header请求信封和request/context路由元数据。然后——这是整个架构最关键的一句话——从日志里「派生」出模型历史并把它冻结。关键模型看到的对话历史不是单独存在某个变量里的。它是每次从会话日志里推算出来的。官方把这条不变量一条必须永远成立的规则。运行时还会主动检查它一旦违反就报错而不是先默默出问题写成了六个字模型可见即已记录Model-visible means logged。运行时还会主动检查模型请求是否可以从日志重建。阶段 6流式请求模型stream the bound prepared call - llm/stream - agent/assistant-stream start agent/assistant-stream chunk* assistant/message | assistant/attempt - agent/assistant-stream end把冻结好的请求发出去通过llm/stream这个 waterfall可以在这里做重试、回放用同一份记录重新走一遍过程得到同样的结果。它是排查问题的终极手段、路由。模型一个字一个字地吐回来每个增量通过agent/assistant-stream实时广播出去——这是给界面看的让你看到打字机效果。当流结束时会落一个持久事件。这里出现了两个分支很值得注意事件什么时候产生它会进入模型历史吗assistant/message模型调用成功。即使返回内容是空的、或者因为max-tokens被截断也会记录会但内容为空的不会进入派生历史assistant/attempt失败、重试、取消、stream error 的尝试走到了「结算」但没有产出可见消息不会。它只保留证据不虚构历史这个设计解决了一个很实际的问题**失败也要留证据但不能污染对话。**你调试的时候能看到「这里试过一次、失败了、原因是这个」但模型不会因此看到一段莫名其妙的对话。阶段 7工具调用五道关卡tool/call* - tools/pre-execute - tools/execute - tools/post-execute - tool/result*模型说要调用工具于是进入工具流水线。tool/call事件在执行之前就写进日志了——这一点很重要意味着即使工具把进程搞崩了日志里也能看到「它曾经被要求做这件事」。然后依次经过三道 waterfallpre-execute允许拒绝询问、execute环绕分派可做超时和重试、post-execute接受替换阻止。最后产出tool/result。第 6 章专门讲这条流水线。阶段 8判断要不要再来一步然后收尾step/end tools owe another request, or next-step input arrived - claim - next step - agent/turn-stopping turn/end步骤结束。判断条件有两个工具还欠一次请求——刚才调了工具结果还没给模型看过所以必须再来一步。又来了新的「下一步」输入——比如用户中途插话steering或者某个插件注入了上下文。只要有一条成立就回到步骤开头继续下一轮。都不成立就进入agent/turn-stopping。agent/turn-stopping是一个serial事件注意不是 waterfall它没有next()。它是「轮次即将关闭」的终局检查点。官方描述了它的工作方式非常有意思轮次即将关闭模型不再欠任何回应没有活的工具调用没有新的 steering。边界提交前会 await 它——如果有监听器反对它就agent.steer(...)一下机器会重新读一遍 inbox有新的 steering 就再跑一步没有就关闭轮次。是数据在做决定所以监听器顺序无法改变结果。反直觉一个监听器想「阻止轮次结束」它不是返回一个 veto 值而是塞一条新消息进 inbox。设计者刻意选择了这个方案如果靠返回值表决那么监听器注册顺序就会影响结果整个系统的行为将变得难以推理。用数据说话顺序就无所谓了。官方还补充了反向的控制想提前结束一个工具循环比如模型在反复试同一个错误命令也是数据决定——一个工具结果只要带上concludesTurn轮次就会在这一步结束后停住。而且它「永远不会短路已经提交的下一步工作」同一步骤里的additionalContexts或者正在竞争的 steering 仍然会跑只有当那个 inbox 排空时轮次才真正关闭。3.3 全局视图把八个阶段画成一张图图 11轮次的完整骨架。虚线方框里的内容会重复执行这就是「多步」的来源。为了让这张图完整把收尾部分单独补上图 12收尾只有两个出口再来一步或者关掉轮次。3.4 三个事件域该用哪个是大多数改动的第一个决定官方有一句很实用的话「事件就是扩展点而选对事件域是大多数改动的第一个决定。」三个域的分工非常清晰事件域代表事件什么时候用它会话事件session eventsturn/start、user/message、tool/result…「这个事实必须在重新加载后依然存在」。它们是追加到日志并通过session/event广播的持久事实Agent 事件agent/*agent/pre-step、agent/status、agent/inbox/*…「要观察或拦截正在进行中的工作」。它们携带活跃的Agentinbox、步骤、状态、请求、验证、续跑能力事件capability eventsfs/*、tools/*、telemetry/*「无需 import 循环就能向某个 seam 附加策略和适配器」图 13三个域的划分标准只有一个这个事实需不需要活过进程重启。怎么快速判断该用哪个域问自己一个问题「如果进程重启、这个会话重新加载这件事还应该存在吗」· 应该 → 会话事件你需要扩展SessionEventMap· 不应该只是要在运行中观察或干预 → Agent 事件或能力事件3.5 Agent 句柄你用来对话的那个对象说了这么多流程那么「谁来驱动这一切」答案是 Agent 句柄Agent每个插件界面、钩子、编排器面向编程的那个句柄对象。官方给的定义是Agent是每个插件UI、钩子、orchestrator面向编程的 surface。它暴露的方法不多但每一个都很关键。下面这张表是理解「怎么和 Agent 说话」的核心方法做什么用一个词概括它的语义followup(msg)排一个普通的后续轮次并唤醒驱动器。这条消息会成为它自己那一轮里唯一的普通消息排队。你说一句我单开一轮处理steer(msg)向最近的一个步骤提交「中途引导」。空闲的驱动会开一轮运行中的驱动会在下一个步骤边界消费它插话。别停我补充一句你下一步就按这个来inject(msg)为下一次 pre-step 排队「模型可见的上下文」不唤醒驱动塞资料。我放这儿你下次开口前看一眼但我不催你send(msg, target, wakeup)把输入送到指定的 inbox 边界并可选地决定要不要唤醒底层原语。上面三个都是它的固定预设别名cancel(cause, opts)清空排队与 steering 工作除非keepInbox中止活跃轮次停。第一个原因生效whenIdle()等待整个 agent 活动到达静止等它彻底停runMaintenance(fn)从真正的空闲阶段跑一次非轮次的维护任务插空干活。比如压缩、整理图 14记住这张图的差别你就能读懂所有「为什么我的消息没生效」的问题。3.6 inbox两条队列不是一条上面反复提到 inbox 有两份列表这里正式说明。Agent 的待处理输入分两个目标列表代号语义等待各自轮次的提示词next-turn每条消息会得到自己专属的一个轮次。界面上连发三条消息就是三条。等待下一个步骤边界的输入next-step会在当前这一轮的下一个步骤被消费。适合「补充信息」和「中途纠偏」。官方描述了一个很有代表性的细节在步骤边界循环会通过纯删除 splice把「即将进入步骤的批次」从 inbox 里移除——包括全部next-step输入外加轮次边界上的一条next-turn消息——而且不发出 discarded 通知随后才逐条发出 claimed 通知。为什么要这么绕因为「被领进步骤」和「被丢弃」是两件不同的事界面需要区分它们。如果一上来就发 discardedUI 会先闪一下「消息被丢弃」再变成「已领取」体验就崩了。官方的另一句补充也很重要**AgentLoop 的持久inbox投影使待处理输入在没有活跃 Agent 时仍可读取。**也就是说即使 agent 已经停了你仍然能看到「还有哪些消息在排队」——因为它是一份从日志折叠把一串按顺序发生的事件累加成一个「当前状态」出来的投影不是内存里的临时变量。3.7 状态的真相idle 与 runningAgent 的生命周期状态只有两个值但官方对它们的解释比字面上要精细得多状态含义idle没有驱动器在活跃running从唤醒输入开始可取消的 pre-step 处理时进入一直持续到驱动器排空、关闭或检查点化各个轮次陷阱官方专门提醒**running描述的是整个驱动器的排空区间可能跨越连续的多个排队轮次它不能证明某个轮次仍然打开。**很多新手会写出「如果状态是 running说明正在处理我这条消息」这样的逻辑——那是错的。要知道某条消息处理到哪了应该去监听 inbox 的 claimed / discarded 通知和轮次事件。另外disposal销毁不是一个状态值。销毁会把 agent 从注册表移除并发出agent/disposed但status里不会出现第三个值。3.8 取消原因是要被记住的取消看起来简单但官方给了它一套完整的类型系统。取消的原因有四种取消原因什么时候出现{ kind: user }用户按了停止{ kind: parent }父 agent 取消了它{ kind: hook, reason }某个钩子策略决定的{ kind: disposed }agent 被销毁了取消时的行为细节官方写得很明确值得逐条读**第一个原因生效。**同一段活动里谁先取消就记谁的原因。如果没有活跃活动取消是空操作而且不会「预埋」给后续工作——不会出现「我提前点了停止结果下一秒发出的消息被立刻取消」这种情况。keepInbox选项会保留排队和 steering 的消息活跃轮次照样中止但没开始的工作能留给之后的轮次而且不会记录一条 cancelled 的 inbox splice。取消原因会随最终的轮次结果一起持久化持久turn/end以{ kind: aborted, reason }记录结果。还有一条很细微但很实用的规则在已取消的活动收敛到 idle 之后提交的「唤醒输入」会被排进下一个轮次但如果取消原因是disposed这条输入就只是停在那里。而这个「唤醒」如果是在已经 idle 的状态下提交的永远会打开它的轮次边界——即使它的消息在驱动器领取之前就被清空了。3.9 agent/created创建 agent 时要等的事最后补一个新手容易栽跟头的地方。创建 agent 时会跑一个串行事件agent/createdAgentLoop 在启动已排队工作前等待串行agent/created初始化。初始化失败会回滚创建。也就是说如果你想在 agent 刚被创建时给它做点什么初始化注册工具、注入一段上下文、配置模型正确的位置是监听agent/created。而且监听器按顺序执行全部 await 完成后创建才算成功。某个监听器抛异常或 reject会让创建失败并且跳过后面的监听器。监听器不能awaitagent.whenIdle()也不能 await 自己 owner 的销毁——那会死锁。更根本的是**进程本地直接挂载插件并不走这条路。**所以别指望「所有东西都在 agent 创建时初始化」。一个更底层的选择改 Agent 还是改 AgentLoop官方在核心包文档里给了一条很明确的指引扩展插件依赖agent而绝不直接依赖agent-loop因此循环保持可替换。前者是「公开契约」后者是「唯一的默认实现」。你写插件时如果需要发起 Agent用ctx.agents不要去找循环包。3.10 这一章要带走的三句话这一章要带走的三句话**轮次套步骤步骤里有五道工具关卡。**所有扩展点都挂在这条链上。**agent/pre-step是请求发出前唯一的拦截点。**压缩、上下文注入、请求改写都在这里。用数据而非返回值决定流程。agent/turn-stopping就是范例想阻止结束就 steer 一条消息而不是投票。