Agent runtime 的核心不是 LLM call,而是 loop。

拼 messages、请求模型、读取 response 都不难。难的是把多次模型响应、工具执行、用户插话和后续任务放进一条能继续、能中断、也能观察的执行流。

Pi Agent 的实现值得看,因为这些边界没有散落在 UI 和 callback 里。它们都回到 loop 中处理。

两层 Loop,各管一件事

入口在 packages/agent/src/agent-loop.ts 的 runLoop。整体是双层循环。

内层处理当前对话轮:注入 pending message,请求模型,执行工具,再发出 turn_end。

外层处理任务是否延续。当前轮不再调用工具时,它检查 follow-up message。有新消息就再跑一轮,没有就结束整个 run。

Agent 通过 _runLoop 调用 agentLoop 或 agentLoopContinue。运行中不断更新内部状态并发送 AgentEvent。遇到 error 或 abort,则收尾并发出 agent_end。

这个结构把两个时间尺度分开了。内层关心眼前一次模型与工具的交互,外层关心任务是否还有下一段。follow-up 不需要硬塞进上一条回答,也不需要另起一套执行逻辑。

Context 在请求模型前经过两步

Pi Agent 不会把内部消息原样扔给模型。请求前有两个明确的转换点。

transformContext 接收 AgentMessage[]。这一层仍理解 agent runtime 的语义,适合裁剪历史、注入状态或重排上下文。

convertToLlm 再把它转成模型 API 接受的 Message[]。UI message 和自定义类型也在这里过滤或转换。

顺序不能反。先在 agent 语义层决定模型应该看到什么,再适配具体 LLM 的输入格式。这样做以后,context compression、额外观察信息和模型切换都有稳定的插入点。

Steering Message 是运行中的转向

普通 follow-up 表示「做完这轮后,再处理这条消息」。steering message 更急:它要改变正在运行的 agent。

用户调用 steer() 后,消息进入单独的队列。runtime 等当前工具执行完,跳过尚未开始的工具调用,再让模型处理新方向。

这里没有在任意时刻强杀工具。这个边界很重要。工具可能正在写文件或修改外部状态,中途终止很容易留下半成品。Pi Agent 允许用户抢回方向盘,但会先越过一个清楚的执行边界。

loop 会在三个位置检查 steering message:

  • 初始化时,取出已有消息放进 pendingMessages
  • 每个工具完成后,检查是否要跳过剩余工具
  • 没有工具调用的一轮结束后,在 follow-up 之前再检查一次

因此 steering 的优先级高于 follow-up。一个负责纠偏,一个负责续跑。

完整流程

Rendering diagram...

EventStream 解决两种速度

Loop 会持续发出 AgentEvent。模型 token、工具进度和状态变化都是 push-based:数据什么时候产生,runtime 就什么时候推。

UI 和调用方更适合用 pull-based API:

for await (const event of stream) {
  updateUI(event);
}

Pi Agent 的 EventStream 把两边接起来。核心状态可以简化为:

queue[]              // 已产生、尚未消费的事件
waiting[]            // 正在等待事件的消费者
done                 // 流是否结束
finalResultPromise   // 最终结果

如果生产者更快,事件进入 queue。如果消费者先调用 next(),它的 resolver 进入 waiting。下一次 push() 会直接唤醒这个消费者。

生产者 push()
- waiting 有值:直接交给消费者
- waiting 为空:事件进入 queue

消费者 next()
- queue 有值:立刻返回事件
- queue 为空且未结束:进入 waiting
- done:结束迭代

这层缓冲让模型、工具和 UI 不必保持同一个节奏。

一个 Stream,两种读取方式

EventStream 同时提供中间事件和最终结果:

  • for await (const event of stream) 持续读取进度
  • await stream.result() 只等待最终状态

UI 需要 message_update、tool_execution_update 和 turn_end。更上层的调用方可能只关心 run 成功、失败还是 abort。把两种读取方式放在一个对象上,比只返回 Promise 更适合 agent runtime。

它依赖 JavaScript 的 AsyncIterable 协议:

interface AsyncIterable<T> {
  [Symbol.asyncIterator](): AsyncIterator<T>;
}

interface AsyncIterator<T> {
  next(): Promise<{ value: T; done: boolean }>;
}

for await...of 大致等于不断调用 next():

const iterator = stream[Symbol.asyncIterator]();

while (true) {
  const { value, done } = await iterator.next();
  if (done) break;
  handle(value);
}

EventStream 的异步生成器大致如下:

async *[Symbol.asyncIterator](): AsyncIterator<T> {
  while (true) {
    if (this.queue.length > 0) {
      yield this.queue.shift()!;
    } else if (this.done) {
      return;
    } else {
      const result = await new Promise(...);
      if (result.done) return;
      yield result.value;
    }
  }
}

这不是 UI 附属品。它是 runtime 可观察性的底层结构。没有事件流,agent 只能在最后吐出一个结果;有了它,调用方才能看到一条连续、可调试的执行轨迹。

我会借鉴什么

Pi Agent 最值得借鉴的不是某个 class,而是控制点很清楚。

Context 在哪里变换,工具在哪里执行,steering 何时生效,follow-up 何时进入下一轮,error 和 abort 如何收尾,都能在 loop 里找到。

一个可用的 agent loop 必须回答这些问题。更长的 context 和更复杂的 prompt 都替代不了它们。模型负责生成下一步,runtime 负责保证每一步发生在正确的位置。

Reference