Skip to content

04|一次 turn 是怎么跑完的:待办队列和准入检查才是真正的心跳 ​

先别把 Agent 想成"调模型、跑工具"两步循环 ​

第 03 章把循环降级成了可替换的司机。第一次钻进司机肚子的人,还是会去找那个最简单的循环写法:不断调用模型,模型要求跑工具就跑工具,跑完再调模型,直到模型不再要求跑工具为止。

这个描述不算错,但它掩盖了真正值钱的部分。

真正的心跳是:消息先进入一条待办队列,司机在边界上一次性领走一批,一个准入检查决定这批消息能不能进模型,通过了才花一次请求。 循环只是在把这条队列排空。这里有两个概念需要先分清:step(步) 是一次模型请求,加上这次请求引发的工具调用;turn(轮) 是零次或多次 step 的集合,在第一次领走输入之前打开,在不再欠下一次请求时关闭。

把心跳理解成"调模型的循环",你会问错问题:"模型什么时候被叫?"正确的问题是:"谁把消息放进哪一个格子,谁在哪个边界把它领走,谁有权把这批领走的消息否决掉?"


1. 一条队列,两个格子,三种投递方式 ​

先给判断:对外看起来像三个方法的"追加下一轮"、"插入下一步"、"注入上下文",只是同一个底层发送动作的三种固定搭配。没有第四条秘密通道。

底层发送动作按"目标格子 × 是否唤醒司机"两个维度组合:

  • 追加下一轮:写入"下一轮"这个格子,并唤醒司机
  • 插入下一步:写入"下一步"这个格子,并唤醒司机
  • 注入上下文:写入同一个"下一步"格子,但不唤醒司机

用人话说:追加下一轮排进下一轮的先进先出队列并叫醒司机;插入下一步排进下一步队列并叫醒司机;注入上下文排进同一条下一步队列,但不叫醒。空闲时的注入会一直躺着,直到下一次"追加下一轮"或"插入下一步"把司机喊起来。

这份待办队列的活视图确实是一份内存里的投影,但真相在会话日志里,不在这份内存投影里。每次改动队列,都会先往日志上追加一条"队列被裁剪"的记录,再改这份内存投影——同步观察这条日志的人看到的是裁剪之前的列表,能从记录里把被删掉的消息重建出来。所以这份内存队列是日志的投影。恢复一个会话时,构造过程会从种子长度之后重放所有的裁剪记录,队列就回来了。

"领走"是司机的内部动作,不是插件扩展点:先抽走全部"下一步"格子里的内容,如果这次边界是"下一轮",再额外抽走"下一轮"队列的头一条。抽走用的是纯删除,不带取消标记。领走之后,这批消息不在队列里了——后续的否决也不能把它们塞回去。

如果没有这一层会怎样?你会做出三种常见的烂设计。第一种:每次人说话都直接调模型,插一句话、塞一段系统上下文、取消半条队列,全变成循环里的特殊分支。第二种:插入下一步和追加下一轮共用一个先进先出队列,人在模型跑工具时插的一句,会变成下一次全新对话,而不是当前 turn 的下一步。第三种:注入上下文也叫醒司机,每次"给模型看一眼这个文件"都烧掉一轮 turn,日志里全是没人想看的空转。

两个格子消除了这些分支。唤醒是正交的第二根轴。第 03 章的接口合同只暴露这三个固定别名,就是为了不让插件去发明第四种送法。


2. 先开 turn,再领走,再问准入检查 ​

判断:turn 不是"模型开始说话"才存在的。它是一次"领走输入"的记账单位。即使这批输入被否决、被改空、被清掉,日志也要留下这次企图。

一次 turn 的第一件事不是领走消息,而是先在日志上记一笔"turn 开始",确认司机还在运行,算出这是第几轮,然后才进入 step 循环,第一次准入检查的目标是"下一轮"这个边界。

准入检查这一步做的事情顺序固定:

  1. 从队列里领走这批消息——消息已经从队列消失
  2. 组装本步需要的提示词片段和工具描述
  3. 触发一个可以否决的检查点——默认放行,可以携带被领走的消息加上可选的运行时上下文
  4. 检查点返回"拒绝",或者带着组装结果的"放行"

这个检查点的决定只有两种:拒绝,或者放行。没有"先放回队列再决定"这个选项。拒绝,或者第一次放行被改成空批次,仍然会关掉一个没花 step 的耐久 turn,好让日志记下这次企图。

这就是"注入上下文不叫醒司机"的配套设计。空闲时只做注入,队列里有货,但没人叫醒司机,整个循环根本不跑,不会出现一堆"领走上下文、再因为没有用户话而空转"的 turn。有人做了"追加下一轮"或"插入下一步"之后,这些之前躺着的注入消息会和那条叫醒消息一起被第一次领走。

还有一条看起来像怪癖、其实是同一判断的规则:空闲时一次唤醒总是打开一个 turn 边界,哪怕消息在被观察到之前就已经被清掉。领走一个空批次,走"第一步消息为空"那条分支,日志里仍有一对"turn 开始"和"turn 结束"的记录。企图被记录,模型不被打扰。

如果没有"先开 turn 再领走"会怎样?否决要么偷偷丢掉输入(界面和恢复对不上),要么再做一套"把消息放回队列"的补偿逻辑,立刻出现双份投递和取消竞态。先记账、再领走、再允许否决,否决的含义变得干净:这批已经属于这次 turn,它们不会再被领一次。 插在领走之后的新消息,留在队列里等下一个边界。


3. step 只做一件事:花掉一次模型请求 ​

判断:step 穷得很刻意。它不决定下一轮说什么,不拥有工具政策,甚至不拥有重试。它只把"这一次请求"做完,然后把"还欠不欠下一次"交回 turn。

按定义,step 就是一次模型请求,加上它引起的工具执行。step 的实现按这个定义走:

  1. 从会话日志投影出模型历史——不是内存里另养一份消息数组。第 05 章拆这件事。
  2. 触发请求事件之后走真正的流式请求,每个返回片段先记进日志
  3. 流正常结束,写一条完整的模型消息记录,指回那些片段
  4. 失败进入一个可以否决的请求出错检查点;监听器决定重试就继续,不决定就把错误往外抛,turn 记成出错状态
  5. 没有工具调用,step 完成
  6. 有工具调用,交给工具执行流程——工具管道本身是第 07 章的主题。这里只看它交回来的两样东西:附加上下文被插入到下一步队列,以及"是否还欠一次请求"

只要"还欠一次请求"为真,对 turn 来说,turn 还没结束——工具还欠一次模型请求,循环继续,下一次准入检查的目标改成"下一步"。这就是"工具还欠一次请求"这个状态在循环里的落地。

工具已经结了,但下一步队列里又躺着插入或注入进来的上下文?turn 也不直接结束。它会先触发一个"turn 即将结束"的通知(这个通知只是广播,没有否决权),再看一眼队列。还空才真正结束,再记一笔"turn 结束"。

一次唤醒里可以连着排空多条"下一轮"消息。turn 正常结束之后,会检查队列里还有没有待处理的东西:还有货,就换一个新的取消令牌,step 计数归零,开一轮新 turn。拒绝和"空的第一步"走的是直接停止分支,即使后面还排着追加下一轮的消息,司机也停——剩下的队列等下一次唤醒。否决消耗的是这一批,不是整条队列。

如果没有 turn / step 这层壳会怎样?压缩、重试、人插一句话、工具说"我还要看一眼",全挤进同一个循环。你分不清一次失败属于哪次模型调用,界面也不知道该把工具卡片挂在哪一轮对话下面。先有记账单位,政策才有地方站:压缩站在准入检查,重试站在请求出错检查点,人插话进下一步队列,人下一条指令进下一轮队列。循环保持穷,是因为这些格子已经够用。


4. 一次人话,在系统里实际走过的路 ​

把前面三节收成一条时间线。人打了一句"帮我跑测试",界面调用"追加下一轮"这个别名。

中间可能发生的两种插话:有人"插入下一步"说"先跳过端到端测试",消息进入下一步队列并确保司机是醒的,当前 step 结束后的下一次领走会拿到它,不必新开一轮 turn;有插件"注入"工作区规则,同样进入下一步队列,但如果当时司机是空闲的,它会静静躺到下一次有人追加下一轮或插入下一步。

插件要拦截,拦在准入检查、请求发出前、工具执行前后这几个时刻,不要去改循环本身的调度逻辑。插件要说话,走第 03 章那张表上的三个固定别名,不要去直接操作队列的内部实现。

顺带一提,词表里还有一个"round"的概念,那是目标追踪、长期任务这类外层策略的计数单位,不是循环的第三层。看见 round,不要往循环内部去找。


5. 结语:带走一句话 ​

这个司机的心跳不是"再调一次模型",而是"再领一批待办队列"——追加下一轮、插入下一步、注入上下文只是同一条队列上的三种放入方式,准入检查才决定这批货能不能花掉一次 step。