# Agent Note: 删除普通 send 的隐式批处理

Status: implemented

[English](2026-07-17-one-send-one-turn.md) | 中文

## 问题

假设调用方连续两次调用 `Agent.send()`，先提交消息 A，再提交消息 B。隐式批处理可能只因为驱动器读取队列时两条消息都在等待，就把 A、B 放进同一个轮次。调用方明明调用了两次，agent loop（智能体循环）却悄悄把它们变成一个工作单元。

这种分组取决于运行时机，而不是调用方的意图。因此，即使所有调用方使用相同 API，来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。

这种分组改变的不只是模型调用次数。一个普通轮次包含提示词准入、`turn/start`、`turn/end` 和持久性检查点。如果消息 B 与消息 A 共用轮次，B 可能直接进入 A 的模型请求，而不是先看到 A 在会话日志中已经关闭的结果。若系统允许一条消息、阻止另一条消息，还需要引入调用方没有请求的混合状态。

## 决策

规则很简单：一次成功的 `send()` 创建一个独立的 FIFO 队列项。该队列项如果运行，就是所在轮次中唯一的普通消息。队列项可能在启动前被丢弃，因此精确保证是最多一个轮次，而不是必定一个轮次；两次 send 绝不会被悄悄合并。

队列项入队之前，`send()` 会检查 agent 状态，并为内容和解析后的来源创建一份脱离调用方对象、经过深度冻结的快照。队列项入队之后，`send()` 发布 `agent/queued`。

如果消息 A、B 都进入处理，B 的轮次只能在 A 记录 `turn/end` 且 A 的持久性检查点处理结束后开始。因此，B 的请求能看到 A 在同一会话日志中留下的已关闭结果。检查点错误会照常报告，但处理结束只表示解除这道顺序屏障，不表示失败的写入已经持久化。广义 `cancel()`、dispose（资源释放）或 `turn/start` 之前的失败也可能丢弃尚未启动的队列项，而不打开一个空轮次。

提示词准入会在轮次打开前，每次只决定一条消息。获准提示词成为该轮次的 `user/message`；被阻止的提示词会被丢弃，不打开轮次，也不写入会话历史。实现中不存在混合批次或全阻止批次分支。

上述不合批规则只适用于普通 `send()`。agent 运行时，`steer()` 会把输入放入 outbox。只要当前轮次仍然打开，agent loop 就会在下一个步骤边界记录该输入，而 steering（中途引导）会默认让循环再执行一个步骤。在到达该边界前发生失败，会让 steering 保持暂存且不唤醒 agent；请求错误的重试动作或后续提示词会取走它，而取消或 dispose 可以将其丢弃。agent 空闲时，`steer()` 委托给 `send()`，因此会创建一个独立的普通队列项。

`inject()` 继续添加面向模型的上下文，而不提交普通消息。轮次打开时，该上下文会留在 outbox 中，等待安全的步骤边界；agent 空闲时，系统会直接追加一条 `user/message`，既不打开轮次，也不运行模型。持久化层独立负责由此产生的即时排空。`cancel()` 仍是面向整个 agent 的操作，可以清空所有尚未启动的普通输入和 steering，并中止当前步骤。`status` 和 `whenIdle()` 描述的也是整个 agent，而不是某一条消息。多个单消息轮次可以共用一个 `running` 区间，因此 `running` 不表示轮次一定处于打开状态。

## 曾考虑的替代方案

**保留普通 send 的自动批处理，以减少模型调用。** 当消息进入队列的速度超过驱动器的处理速度时，这种做法可以提高吞吐量，但会让轮次边界取决于调度，并让后一条消息在前一轮关闭且到达检查点之前运行。本决策保留可预测的边界，并接受额外调用。未来若要加入批处理功能，必须提供调用方可见的显式契约，并有测量结果作为依据。

## 验证

- 单元测试和性质测试从同一调用栈、相邻微任务、不同生产方和重入回调提交 send；每条消息都会得到一个按 FIFO 排序的独立轮次。
- stdio 构建产物测试提交两行输入，并观察到两个模型请求和两个轮次边界。
- 延迟和拒绝第一个轮次的检查点，都能让下一个轮次保持等待，并证明其请求可以看到前一条助手结果。
- 失败路径测试覆盖提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 之前的失败；准入拒绝不会创建轮次，已记录的轮次保持边界平衡，消息不会合并，仍需处理的排队工作也能继续清空。
- 其他测试分别覆盖轮次打开时、轮次失败后和空闲时的 `steer()`，以及 `inject()`、面向整个 agent 的状态和 `whenIdle()`。

## 后果

普通轮次的边界可预测：消息 A、B 始终分开，B 只能在 A 关闭并到达检查点后运行。调用方仍然拿不到逐次 send 的完成或取消句柄；广义取消可以丢弃整个尚未启动的队尾，状态和静止性也仍是面向整个 agent 的观察。

代价是模型请求和检查点都会增加。繁忙队列可能需要更长时间才能清空；如果生产方持续提交消息，队列也可能增长。只有建立显式且经过测量的契约后，才能重新引入普通 send 批处理。
