普通请求处理通常在开始执行前就确定输入。Agent 的执行可能跨越多次模型请求和 Tool 调用,在这段时间内,用户、插件、调度器或其他 Agent 都可能继续产生信息。
因此,“再发一条消息”不足以完整描述 Agent Runtime 中的输入操作。
Runtime 至少需要确定四件事:
这条输入什么时候进入执行链
它属于当前连续工作还是后续工作
它是否应该唤醒一个 idle Agent
它是否意味着终止当前正在进行的工作
不同答案会产生完全不同的行为。
如果所有新消息都立即创建新的模型执行链,同一个 Session 会同时被多个 Loop 修改;如果所有消息都等待当前任务完全结束,用户无法及时调整正在进行的方向;如果每次 Context 变化都自动唤醒 Agent,又可能制造大量没有必要的模型调用。
Input Delivery 因而是 Agent Runtime 的独立设计问题。
Pi 的 Steering / Follow-up Queue 与 DeepSeek Harness 的 Inbox,分别代表了两种不同抽象层次。
为什么 prompt() 不能承担所有输入
先看最直接的 API:
agent.prompt(message)
如果 Agent idle,这个语义很清楚:创建一段新的主动工作。
问题出现在 Agent 已经运行时。
假设当前链路是:
Model Request
↓
Tool Call
↓
Tool Execution
↓
Tool Result
↓
Next Model Request
此时又调用一次 prompt(newMessage),Runtime 需要决定:
A. 创建第二条并行 Loop
B. 取消当前 Loop,再启动新 Loop
C. 把消息加入当前 Loop 的下一次模型请求
D. 等当前 Loop 停止后再处理
这四种行为都可以合理,但它们不能共享同一个隐含语义。
Pi 直接禁止在已有 activeRun 时再次调用 prompt(),要求调用方显式选择 steer()、followUp() 或 abort()。[1]
这个限制把并发输入问题从“Runtime 自己猜”变成“调用方明确表达意图”。
输入控制首先依赖执行边界
前两篇已经建立了两个关键边界:
Step Boundary
一次模型决策及其 Tool 工作结束
Turn Boundary
当前连续工作到达停止点
Input Delivery 的语义实际上建立在这两个边界上。
如果 Runtime 连 Step 和 Turn 都没有稳定定义,就很难准确说明:
“尽快生效”到底是现在,还是下次模型调用前
“当前工作结束以后”到底在哪个时刻
因此 Steer 和 Follow-up 对应不同的执行时间位置,分类依据来自 Step 与 Turn 边界。
Pi 用两个 Queue 直接表达两个时间位置
Pi 的 Agent 当前维护:
private readonly steeringQueue: PendingMessageQueue
private readonly followUpQueue: PendingMessageQueue
两个 Queue 都支持 one-at-a-time 和 all 两种 drain mode,默认一次消费一条。[1]
公开 API 为:
agent.steer(message)
agent.followUp(message)
两个 API 最核心的差异是消费时机。
Steering:影响最近的下一次模型决策
Pi RPC 文档对 Steering 的交付时机定义得很具体:当 Agent 正在工作时,Steering Message 先进入队列;当前 Assistant Turn 产生的 Tool Calls 执行完成以后,在下一次 LLM Call 前交付。[2]
执行顺序可以表示为:
Model Request #1
↓
Assistant requests Tool
↓
Tool Execution
│
│ steer arrives
│ ↓
│ steeringQueue
│
↓
Tool Result
↓
Drain Steering Queue
↓
Append Steering Message
↓
Model Request #2
这里有两个关键点。
第一,Steering 不修改已经提交给 Provider 的 Model Request。模型一旦开始生成,这次请求使用的 Context 已经确定。
第二,Steering 也不会默认中断当前 Tool。Tool Call 是前一次模型决策已经产生的 Effect,Pi 让它先完成,再把新的输入放进下一次模型决策。
因此 Steering 的语义可以更准确地表述为:
在不破坏当前已开始工作的前提下,使新输入尽早影响后续模型决策。
这也是 Step Boundary 的价值。
为什么 Steering 不应该隐式等价于 Cancel
用户输入“不要继续这个方向”时,产品层可能希望立即停止 Tool;另一些输入只是补充条件,并不需要取消当前操作。
例如:
“接下来不要修改测试文件”
可以等待当前 Tool 完成后影响下一步。
而:
“停止执行”
通常应该直接取消当前活动。
如果所有 Steering 都隐式触发 Cancel,任何补充信息都会破坏当前 Tool 的完整性;如果所有 Steering 都绝不允许取消,又无法表达紧急停止。
因此 Pi 将两种控制分开:
steer
改变后续决策
abort
终止当前活动
这种分离比试图从消息文本中推断用户意图更稳定。
Follow-up:等待当前连续工作到达停止点
Follow-up 的时间位置更晚。
Pi 只有在当前已经没有 Tool Calls 和 Steering Messages、原本准备结束 Agent 工作时,才消费 Follow-up Queue。[2]
Current Work
├── Model
├── Tool
├── Model
└── Final
↓
Stop Boundary
↓
Follow-up Queue
↓
Next Work
因此 Follow-up 适合表达:
当前工作保持完整
完成以后继续处理新的要求
Pi runLoop() 的两层循环正好编码了这组语义:[3]
Inner Loop
Tool + Steering
Outer Loop
Follow-up
这比“所有消息进入一个 FIFO Queue”多了一层明确的执行关系。
两个 Queue 已经构成一个简单的 Delivery Model
如果只看数据结构,Steering Queue 和 Follow-up Queue 都是待处理消息列表。
真正有意义的是它们被绑定到了不同消费点:
steeringQueue
→ next decision boundary
followUpQueue
→ stop boundary
因此 Pi 实际上已经在表达两个 Delivery Target,只是 Target 直接编码在不同 Queue 和不同 API 中。
这种设计的优点是简单。
调用方只需要选择:
agent.steer(message)
// or
agent.followUp(message)
Runtime 内部也不需要解释额外字段,Queue 本身就代表语义。
代价是当交付策略继续增加时,API 和 Queue 数量可能随之增长。
例如系统还希望表达:
加入下一次 Context,但不要主动启动 Agent
只在某个 Tool 完成后可见
高优先级插队
只对某个子 Agent 生效
两个固定 Queue 就会开始受到限制。
DeepSeek Harness 选择把这层语义进一步参数化。
DeepSeek Harness 用 Inbox 统一输入入口
DeepSeek Harness 当前公开 Agent API 提供:
send(message, target, wakeup)
其中:
type InboxTarget = 'next-turn' | 'next-step'
followup()、steer()、inject() 可以看成对不同参数组合的便捷封装。[4]
于是输入可以拆成:
Payload
+
Target
+
Wakeup
↓
Inbox
这一步比 Pi 两个 Queue 多抽象了一个层次。
Pi 的 API 名本身代表交付方式;DSH 把交付方式显式编码进数据。
target 描述输入属于哪个执行边界
target = next-step 表示这条输入应该在最近的 Step 边界进入后续模型决策。
target = next-turn 表示输入等待当前 Turn 结束,再作为后续工作消费。
于是 Pi 的两个概念可以映射成:
steer
≈ next-step
follow-up
≈ next-turn
这里使用“约等于”更准确,因为不同框架在 idle 状态、wakeup 和 lifecycle hook 上仍有具体语义差异。
关键思想是一致的:输入需要标明它属于哪个执行时间位置。
同一条输入在 idle 与 running 状态下可能产生不同结果
Delivery Policy 还需要结合 Agent 当前状态解释。
可以先画一个简化矩阵:
Agent idle Agent running
next-step
+wakeup=true 启动工作 进入最近 Step
next-step
+wakeup=false 保留等待 进入后续 Step
next-turn
+wakeup=true 启动新 Turn 等当前 Turn 结束
这里最重要的是:target 描述时间位置,wakeup 描述激活权限。两者组合后,Runtime 才能决定当前状态下的实际行为。
如果 API 只提供 send(message),这些差异只能隐藏在实现内部。例如 idle 时自动启动、running 时自动排队,看起来方便,但调用方很难知道某条消息究竟会进入当前工作还是创建后续工作。
显式 Policy 会让协议稍微复杂一些,却能避免状态相关的隐式行为。对于需要 SDK、RPC、Web UI 和插件共同调用的 Runtime,这种稳定性通常更重要。
wakeup 为什么要成为独立维度
只有 target 还不够。
Runtime 可能接收到一条需要影响下一次 Context 的信息,但不希望因此立即发起 Model Request。
例如:
文件索引更新
环境变量变化
插件补充新的上下文
异步任务返回一段参考信息
这些信息可以进入下次模型计算,但如果 Agent 当前 idle,并不一定值得立即唤醒。
如果所有 Inbox Message 都自动启动执行:
background state changed
↓
message appended
↓
agent wakes
↓
model request
系统会把“状态更新”与“创建工作”绑定在一起。
DeepSeek Harness 将 wakeup 独立出来后,可以表达:
next-step + wakeup=false
只影响未来 Context
next-turn + wakeup=true
形成需要执行的新工作
这使“模型应该看到什么”和“Agent 现在是否应该开始运行”成为两个不同控制维度。
inject() 的意义就在这里
inject(message) 最能体现 wakeup 独立后的价值。
它允许把信息加入后续 Context,但不主动唤醒 Agent。[4]
当 Agent 已经 running 时,这条输入可以在合适的 Step Boundary 被 claim;当 Agent idle 时,它保留在 Inbox 中,等待以后真正能够启动工作的输入。
因此可以把三类操作理解为:
followup
时间:next-turn
行为:wakeup
steer
时间:next-step
行为:wakeup / continue current activity semantics
inject
时间:next-step
行为:no wakeup
具体实现细节仍以框架当前定义为准,但从设计上看,DSH 已经把输入从“聊天消息”扩展成 Runtime Mailbox 中的工作信号。
Input、Wakeup、Cancel 应该分成三个维度
把 Pi 和 DSH 放在一起以后,可以得到一个更一般的控制模型。
一条外部输入至少包含三个相互独立的问题:
Content
模型或 Runtime 要接收什么信息
Delivery
这条信息在什么边界生效
Activation
这条信息是否触发 Agent 从 idle 进入 active
Cancel 则属于第四个维度:
Interruption
是否终止当前已有工作
可以表示成:
interface AgentInput {
payload: Message
delivery: {
target: 'next-step' | 'next-turn'
wakeup: boolean
}
}
interface ExecutionControl {
abort?: boolean
}
不一定要真的设计成两个接口。重要的是不要把所有语义隐含在 sendMessage() 这个动作里。
为什么不建议让 Runtime 根据自然语言自动猜 Delivery
产品可以在 UI 上把普通 Enter、Steer、Follow-up、Stop 做成不同按钮,也可以由上层 Agent 自动选择策略。
但底层 Runtime 最好接收已经确定的交付语义。
如果 Runtime 根据消息文本推测:
“顺便补充一下……”
→ follow-up ?
“先别这样做……”
→ steer ? abort ?
行为会依赖语言理解模型,并且难以给调用方稳定契约。
更可靠的分层是:
UI / Orchestrator
决定用户意图和 Delivery Policy
↓
Agent Runtime
严格执行 Delivery Policy
这样 Runtime 可以保持确定性。
Queue 的 drain mode 也属于交付语义
Pi 的 PendingMessageQueue 支持 one-at-a-time 和 all 两种 drain mode。[1]
这个细节说明,即使已经确定了“next-step”,仍然存在批处理策略。
假设 Step 执行期间连续收到三条 Steering:
S1
S2
S3
all 模式可以在下一次模型请求前一次性加入全部输入:
Tool Result
+ S1
+ S2
+ S3
→ Model Request
one-at-a-time 则可能只消费一条,让后续输入在之后的决策边界继续进入。
两种策略会影响模型看到信息的节奏,也影响用户对“连续补充要求”的体验。
因此 Delivery Policy 还可以继续扩展:
mode?: 'one' | 'all'
priority?: number
deadline?: number
是否需要这些字段取决于产品复杂度。核心抽象仍然是交付时机显式化。
Inbox 为什么会从内存 Queue 发展成可观察状态
DeepSeek Harness 当前把 Inbox 描述为 Agent-owned durable projection。它维护 next-turn 与 next-step 两条有序 pending-message list,append、replace、remove、clear、splice、claim 等操作会形成规范化 mutation event。[4]
这意味着 Inbox 不只负责运行时调度。
外部系统还可以观察:
当前有哪些输入正在等待
每条输入属于 next-step 还是 next-turn
哪些输入已经被 claim
哪些输入被删除或替换
于是 UI 可以显示 pending instructions,恢复逻辑也有机会重建等待状态。
普通内存 Queue 的职责通常只有:
enqueue
→ dequeue
DSH Inbox 则更接近一个 Agent-owned Mailbox State。
这也是 DSH Event / Projection 思路向执行控制层的延伸。
第二单元讨论 Session Event Log 时,会进一步处理这种 durable projection。
输入来源增多以后,Mailbox 抽象会比 Chat Queue 更稳定
早期 Agent 产品的输入通常只有 User Message。随着 Harness 能力增加,输入来源会扩展:
User
Plugin
Scheduler
SubAgent
External Event
Approval System
这些输入不一定都应该作为一条可见聊天消息展示。
例如调度器可能只需要唤醒 Agent 执行后台任务,插件可能只需要向下一次 Context 注入环境事实,SubAgent 返回结果则可能需要进入父 Agent 的当前 Step。
如果 Runtime 的核心接口仍然只接受“Chat Message”,上层往往会把各种控制信息伪装成 User Message,最终让对话历史同时承担控制协议和 UI 展示。
Mailbox / AgentInput 模型允许 Payload 进一步泛化:
type AgentInput =
| UserMessageInput
| ContextInput
| AgentResultInput
| SchedulerInput
Delivery Policy 仍然负责决定它们何时被消费。
这使输入通道从“聊天队列”演化为 Runtime 的统一外部事件入口,同时仍然可以由 Context Builder 决定哪些输入最终转成模型可见 Message。
长时间 Tool 会暴露 Steering 边界的代价
Steering 等待当前 Tool 完成再生效,语义清楚,但存在一个明显代价。
如果某个 Tool 运行时间很长:
Tool starts
──────────────────────────────>
complete
steer arrives
↓
waits........................
用户可能感觉 Steering 不够及时。
这里不应该简单修改 Steer 语义,让它强行进入正在执行的 Tool。更合理的处理方式通常是:
Tool 支持 AbortSignal
+
产品允许用户显式 Stop
+
必要时 Stop 后再提交新的 Steer / Prompt
或者对可交互 Tool 定义更细的内部控制协议。
这说明 Step Boundary 带来的稳定性也有成本:边界越粗,新输入的响应延迟可能越高。
Agent Runtime 的控制设计需要在“状态一致性”和“交互即时性”之间选择合适粒度。
多 Tool 并发时需要先定义 Step 完成条件
另一个边界问题是一个 Assistant Message 同时产生多个 Tool Call。
Assistant
├── Tool A
├── Tool B
└── Tool C
如果 A、B、C 并发执行,Steering 应该在 A 完成后立即进入,还是等待整个 Tool Batch 完成?
这取决于 Runtime 对 Step 的定义。
Pi RPC 文档描述的是当前 Assistant Turn 的 Tool Calls 执行完成后,再交付 Steering。[2]
因此可以理解为:
Model Decision
↓
Tool Batch
↓ all relevant tool calls complete
Step Boundary
↓
Steering
这个选择避免 Steering 只影响同一次模型决策产生的一半 Tool Effect。
如果某个 Runtime 希望更细粒度控制,就需要把 Tool Batch 再拆成显式子阶段,否则行为会变得不一致。
自己设计 Runtime 时可以怎样表达 Delivery Policy
如果从 Pi 和 DSH 的实现继续抽象,可以定义:
interface AgentInput<T = Message> {
id: string
payload: T
delivery: {
target: 'next-step' | 'next-turn'
wakeup: boolean
mode?: 'one' | 'all'
}
source?: 'user' | 'plugin' | 'scheduler' | 'agent'
priority?: number
deadline?: number
}
这里不建议一开始就实现所有字段。
最小版本只需要:
target
wakeup
它已经能够覆盖三类常见语义:
next-step + wakeup=true
尽快改变后续决策
next-turn + wakeup=true
当前工作完成后继续
next-step + wakeup=false
只注入后续 Context
Cancel 保持独立控制 API。
后续如果出现多来源、优先级、超时和持久化需求,再扩展 Delivery Policy。
UI 应该暴露意图,Runtime 负责执行语义
Input Delivery 最终会反映到产品交互。
一个成熟 Agent UI 可能同时提供:
Send
正常发起工作
Steer
调整当前工作后续方向
Queue / Follow-up
排在当前工作之后
Stop
立即取消当前执行
这些按钮不只是 UI 差异,它们对应不同 Runtime API。
因此前端不应该只维护:
sendMessage(text)
然后由后端自行猜测当前状态下该怎么处理。
更稳定的协议会携带明确动作或 Delivery Policy:
{
"message": "先检查测试再修改实现",
"delivery": {
"target": "next-step",
"wakeup": true
}
}
后端只需要按照约定入 Inbox,并在正确边界 claim。
这使 UI、Transport 和 Runtime 对同一行为拥有一致语义。
Delivery Policy 还应保持可观测
输入一旦进入 Queue 或 Inbox,外部系统通常需要知道它当前处于什么状态。否则 UI 只能显示“已发送”,无法区分消息已经进入模型、仍在等待,还是因为取消被丢弃。
一个可观察的输入生命周期可以保持很小:
PENDING
↓ claim
CONSUMED
PENDING
↓ remove / cancel
DISCARDED
如果系统采用 Event Log,还可以记录 input/queued、input/claimed、input/removed 等事实。
这类状态对模型推理本身没有直接价值,却对 UI、恢复和调试非常重要。它进一步说明 Input Delivery 同时连接执行层与产品层:执行层关心何时 claim,产品层关心输入是否仍然有效。
Input Delivery 与 Agent Loop 的连接位置
把整个单元的模型放在一起,可以得到:
Agent Inbox / Queue
│
┌────────┴────────┐
│ │
next-step input next-turn input
│ │
▼ ▼
Turn ── Step ── Step ── Stop Boundary ── Next Turn
▲ ▲
│ │
steer inject / queued context
abort
│
└──────────────→ current active execution
这里有两类控制方向:
Input Delivery
决定新的信息何时进入
Execution Control
决定已有工作是否继续
把它们分开以后,Steer、Follow-up、Inject 和 Abort 的边界就会稳定很多。
单元一形成的运行模型
四篇文章到这里可以收敛成一套完整的 Runtime 关系:
Session
│
└── Agent Runtime
│
├── Active Execution
│ │
│ └── Turn
│ │
│ └── Step*
│
├── Context
│ └── Provider Context
│
├── Tool Runtime
│
├── Input Delivery
│ ├── next-step
│ └── next-turn
│
├── Cancellation
│
└── Lifecycle Events
第一篇解决 Runtime 为什么出现;第二篇建立多层生命周期;第三篇用 Pi 源码验证 Loop 与 State 如何推进;第四篇把执行中的新输入放入明确的 Delivery Model。
这一篇最终留下的设计判断是:
Agent 输入应同时描述内容与交付语义。Delivery Target、Wakeup 与 Cancellation 属于不同控制维度;把它们显式化,可以避免 Runtime 根据当前隐含状态猜测消息应该如何生效。
下一个单元开始处理持久化。运行模型确定以后,需要进一步回答:哪些状态必须作为长期事实保存,哪些状态可以在恢复时重新计算;Pi 为什么把 Session 表达成树,DeepSeek Harness 又为什么选择 Event Log 作为 Session 的事实源。
参考资料
[1] Pi Agent source: https://github.com/badlogic/pi-mono/blob/main/packages/agent/src/agent.ts
[2] Pi RPC docs: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/rpc.md
[3] Pi agent-loop.ts: https://github.com/badlogic/pi-mono/blob/main/packages/agent/src/agent-loop.ts
[4] DeepSeek Harness Core / Agent Inbox: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/core.md
[5] DeepSeek Harness Agent Lifecycle: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/agent-lifecycle.md