Skip to content

🐣 小a的新任务

拆完 pi-ai,小a明白了"怎么调 LLM"。但老z接着问:"光会调 LLM,模型还只是个『会说话的脑子』。怎么让它会调工具、会循环、能记住事?"

小a答:"那是 Agent 干的活,第 7 章讲过 Loop。"

老z点头:"对。但概念是一回事,落地是另一回事。pi-agent-core 这个包,就是 Loop 的真身。它的核心文件 agent-loop.ts 有 22KB——远不止一个 while 循环。今天我们拆开看,那些多出来的复杂性都在解决什么。"


18.1 agent-core 在架构中的位置

先定位。回忆第 16 章的依赖图:

pi-coding-agent → pi-agent-core → pi-ai

                  这一章拆它

agent-core 夹在中间:它用 pi-ai 调 LLM,又被 coding-agent 使用。它的职责——把"会调 LLM"升级成"会自主调工具、维护状态、可中断恢复的 Agent"


18.2 核心一:runAgentLoop —— 循环的真身

第 7 章我们画过 Agent 循环的 5 步。现在看它在 pi 里到底怎么实现。

📄 源码证据 packages/agent/src/agent-loop.ts:95

typescript
export async function runAgentLoop(
  // ... 一堆配置参数
): Promise<AgentMessage[]>

这是循环的入口函数。它的内部结构(简化展示骨架):

📄 源码证据 agent-loop.ts:170(主循环)

typescript
while (true) {                              // ① 外层:轮次循环
  while (hasMoreToolCalls || pendingMessages.length > 0) {  // ② 内层:工具调用循环

    // 处理待处理的消息
    for (const message of pendingMessages) {
      // ③ 把消息喂给模型,流式生成
    }

    // ④ 看 stopReason 决定下一步(第 2 章讲的)
    if (message.stopReason === "error" || message.stopReason === "aborted") {
      // 出错/中断 → 退出
    }
    // ... 包括处理 "length"(被截断)等

    // ⑤ 执行工具调用
    for (const result of toolResults) {
      // 执行 toolCall,得到 toolResult
    }
  }
}

🧙 老z拆这个双层循环:

先解释内层 while 条件里那两个变量:

  • pendingMessages:本轮还要喂给模型的消息(可能多条——比如上一轮的工具结果)
  • hasMoreToolCalls:模型上一次吐出的工具调用还没全执行完

两个条件任一为真,内层循环就继续——要么还有消息没处理,要么还有工具没跑完。

为什么是两层 while?

  • 外层(轮次循环):每一轮 = 一次"喂模型→生成→执行工具"
  • 内层(工具调用循环):同一轮里,模型可能一次生成多个 toolCall(比如同时要 read 三个文件),这些得都执行完

关键:第 2 章讲的 StopReason,就是循环的"方向盘"。 看 ④ 那段——它检查 stopReason:

  • error / aborted → 退出
  • length(被截断)→ 特殊处理
  • toolUse → 继续内层循环(执行工具)
  • stop → 外层这一轮结束

没有 StopReason,循环根本不知道该停还是该继续。 这就是第 2 章为什么花整节讲它的原因——它是 Agent 的核心控制信号。

并发执行工具

注意 ⑤ 那段——for (const result of toolResults)。pi 支持并发执行多个工具调用:

🧙 如果模型一次要 read 三个文件,pi 不会串行一个一个读(慢),而是并发读(快)。这体现在源码的 executeToolCallsParallel(agent-loop.ts:489)——它把多个 toolCall 交给 Promise.all 统一并发执行(540 行)。

这是工程优化:并发执行让 Agent 更快,但也带来复杂性(并发控制、错误处理)。pi 用 keyed-operation-queue(后面讲)管理并发。


18.3 核心二:Agent 类 —— 状态机 + 观察者

光有循环函数还不够。pi 还有个 Agent 类,它把循环包装成一个有状态、可订阅的对象。

📄 源码证据 packages/agent/src/agent.ts:243 —— subscribe 方法

typescript
subscribe(listener: (event: AgentEvent, signal: AbortSignal) => Promise<void> | void): () => void {
  this.listeners.add(listener);      // 注册监听器
  return () => this.listeners.delete(listener);  // 返回"取消订阅"函数
}

🧙 这是观察者模式(Observer):

  • Agent 是"被观察者",维护一个 listeners 集合
  • 外部调 subscribe(listener) 注册监听器
  • Agent 每次发生事件(生成文字、调工具、出错),就通知所有监听器
  • 返回的函数能"取消订阅"

为什么做成可订阅的? 因为 UI 需要实时刷新。Agent 生成一个字,UI 就该显示一个字;Agent 调工具,UI 就该显示"正在执行 xxx"。事件驱动让内核和 UI 解耦——Agent 不关心谁在听,UI 不用轮询。

🐣 小a问:"那 Agent 怎么知道『该通知谁』?"

🧙 "它不关心。 subscribe 是开放的——谁想听谁注册。Agent 只管『发生事件就广播』。这就是观察者模式的好处:发送方和接收方解耦。"

Agent 的状态

📄 源码证据 agent.ts —— subscribe 注释提到

typescript
/**
 * Current agent state.
 * ...
 */

Agent 维护着当前状态(消息历史、当前模型、思考强度等)。每次循环轮次,状态都可能变化。Agent 是个状态机——循环推进状态,事件通知外部。


18.4 核心三:harness —— 给裸 Agent 穿衣服

裸的 Agent + runAgentLoop 还不能直接用——它需要"穿衣服":系统提示词、工具集、会话存储。这就是 harness(马具/装备)目录干的事。

📄 源码证据 packages/agent/src/harness/ 目录

harness/
├── agent-harness.ts        ← 装配器:把零件组装成一个可用 Agent
├── system-prompt.ts        ← 系统提示词模板
├── prompt-templates.ts     ← 提示词模板
├── messages.ts             ← 消息处理
├── skills.ts               ← 技能加载
├── compaction/             ← 上下文压缩
│   ├── compaction.ts
│   ├── branch-summarization.ts
│   └── utils.ts
├── session/                ← 会话持久化
│   ├── session.ts
│   ├── repository.ts
│   ├── jsonl-store.ts
│   ├── memory-store.ts
│   ├── fork.ts             ← 会话分叉
│   └── search-backend.ts
├── tools/                  ← 通用工具集
│   ├── read.ts / write.ts / edit.ts / bash.ts / image.ts
│   └── tool-context.ts
└── env/nodejs.ts           ← 运行环境抽象

🧙 harness 模式(装备模式):

"harness"原意是"马具"——给马套上鞍、缰绳,马才能被人骑。这里比喻:给裸 Agent 套上系统提示词、工具、会话存储,它才能被实际使用。

裸 Agent(内核) vs 穿衣 Agent(产品):

  • 裸 Agent:只有循环逻辑,通用、抽象
  • 穿衣 Agent:加了系统提示词(定人设)、工具集(给能力)、会话存储(能记忆)

为什么分开? 因为裸 Agent 可以被复用——你想做个"客服 Agent",给它穿客服的衣服;想做"编程 Agent",穿编程的衣服。内核不变,衣服可换。 pi-coding-agent 就是给 agent-core 穿上"编程"的衣服。


18.5 会话持久化:Agent 怎么"记住"

第 8 章讲过 Memory——Agent 要维护历史。看 pi 怎么落地。

📄 源码证据 harness/session/ 目录

文件干什么
session.ts会话接口定义
repository.ts会话仓库(管理多个会话)
memory-store.ts内存存储(临时,重启丢)
jsonl-store.tsJSONL 文件存储(持久,可恢复)
fork.ts会话分叉(从某点分出一条新线)
search-backend.ts会话搜索(找历史)
keyed-operation-queue.ts并发操作队列(防冲突)

🧙 两种存储:内存 vs JSONL

  • memory-store:会话存在内存里,快,但程序退出就没了
  • jsonl-store:会话存成 JSONL 文件(每行一条消息),持久,重启能恢复

为什么默认要持久化? 因为 Agent 任务可能跑很久(改一个 bug 跑半小时),中途崩了或关了,得能恢复。JSONL 是最简单的持久化——一行一条消息,append-only,崩溃也不容易坏。

会话分叉(fork):"试这条路不通就退回去"

📄 源码证据 harness/session/fork.ts

🧙 fork 是什么?

有时候你想"试一条路"——比如让 Agent 用方案 A 改代码,不行就退回来用方案 B。fork 让你从会话的某个点,分出一条新线,两条线互不影响。

老z打比方:像 git 的分支——从某个 commit 分出去,主线不受影响,试验完了可以合并或丢弃。

这背后靠的是 git worktree(附录 G 讲 Paseo 时提过同样的机制)——会话分叉隔离历史,worktree 隔离工作区,本质都是"分身不打架"。


18.6 上下文压缩:窗口满了怎么办

第 1 章埋的伏笔——"小a记性差",这里彻底回收。

📄 源码证据 harness/compaction/ 目录

compaction/
├── compaction.ts              ← 压缩主逻辑
├── branch-summarization.ts    ← 分支总结
└── utils.ts

🧙 compaction(压缩)解决什么?

Agent 循环每转一轮,消息就多几条。窗口迟早被塞满(第 1 章的"记性差")。pi 的解法不是粗暴截断,而是分支总结:

旧消息(占了很多窗口):
  [user] 改 config.ts
  [assistant] 好,我读一下... + toolCall: read
  [toolResult] config.ts 内容是...
  [assistant] 找到 3 处,我来改... + toolCall: edit
  [toolResult] 已修改
  ... (还有很多)

        ↓ compaction(总结成)

[summary] 之前的工作:读了 config.ts,改了 3 处硬编码为环境变量。

把冗长的历史,总结成一段摘要,腾出窗口空间,但保留了"发生过什么"的关键信息。

为什么不直接截断?

🐣 小a问:"截断最早的消息不就行了?干嘛要总结?"

🧙 "因为截断会丢上下文。 如果直接删掉最早的消息,Agent 就忘了"我一开始要干嘛"——可能改完代码忘了当初的任务。总结保留了语义,截断只省空间不保信息。 pi 选了更聪明的路:总结,不是截断。"

代价:总结本身要调一次 LLM(让模型读旧消息,生成摘要),费 token。但相比"丢上下文导致任务失败",这点成本值得。


18.7 并发控制:同 key 串行、异 key 并行

最后讲一个工程细节——并发控制。pi 在两层用了同一个"同 key 串行、异 key 并行"的模式,别混为一谈:

📄 源码证据 harness/session/keyed-operation-queue.ts + harness/tools/file-mutation-queue.ts

🧙 层一:会话存储的并发控制(KeyedOperationQueue)

harness/session/keyed-operation-queue.tsKeyedOperationQueue,用于会话存储层——memory-store.ts 按 session id 排队、jsonl-store.ts 按 operationKey 排队。它防止的是:多个并发操作同时读写同一个会话(比如两个事件同时要追加到同一个会话文件),而不是文件内容冲突。

🧙 层二:文件写操作的并发控制(withFileMutationQueue)

harness/tools/file-mutation-queue.tswithFileMutationQueue,才是按文件路径(canonical path)排队的那个——它被 edit.tswrite.ts 使用,防止"两个工具同时改同一个文件互相覆盖"。

🧙 事故现场(层二解决的问题):

假设 Agent 一轮里并发执行两个 toolCall:

  • 操作 A:edit(config.ts, 改第 10 行)
  • 操作 B:edit(config.ts, 改第 20 行)

两个操作同时读 config.ts、各自改、同时写回——后写的会覆盖先写的,其中一个改动丢失。这就是并发竞态(附录 E.7)。

withFileMutationQueue 按规范化路径 config.ts 排队——同一个文件的操作串行执行(A 写完 B 才开始),不同文件并发执行(改 config.ts 和改 README.md 互不耽误)。

老z打比方:超市多个收银台——同一个收银台(同一个 key)顾客排队,但不同收银台(不同 key)同时服务,互不影响。会话层和文件层用的都是这个"收银台"思路,只是排队的对象不同:一个是会话,一个是文件。


18.8 代价:事件驱动 + 状态机的复杂性

老z如实告诉小a这套设计的代价:

🧙 代价一:事件驱动难调试

Agent 是事件驱动的(subscribe 那套)。事件一多、异步一多,调试就难——"这个事件谁触发的?为什么先于那个?" 跟同步代码比,排查链路长。

🧙 代价二:状态机复杂

Agent 本质是状态机,状态(消息历史、模型、思考强度)会随循环变化。状态多、转换多,容易出"状态不一致"的 bug。

🧙 代价三:强依赖 pi-ai

注意:agent-core 强依赖 pi-ai(看第 16 章依赖表)。它通过 streamFn 抽象降低耦合,但本质上还是绑在 pi-ai 的类型体系上。所以你不能"只拿 agent-core 的 loop,不要 pi-ai"——这点 Part III 自己造 Agent 时要注意(我们要从零写 loop)。


本章小结

🐣 小a的第十八课

┌──────── agent-core 的真身 ────────┐
│                                  │
│  • runAgentLoop:双层循环         │
│    外层=轮次,内层=工具调用       │
│    靠 StopReason 控制方向         │
│    支持并发执行工具               │
│                                  │
│  • Agent 类:状态机 + 观察者      │
│    subscribe 让 UI 实时刷新       │
│                                  │
│  • harness:给裸 Agent 穿衣服     │
│    system-prompt(人设)          │
│    tools(能力)                  │
│    session(记忆)                │
│    compaction(压缩)             │
│                                  │
│  • session 持久化:               │
│    memory(临时)/ jsonl(持久)   │
│    fork(分叉试验)               │
│                                  │
│  • compaction:总结而非截断       │
│    → 第 1 章"记性差"伏笔回收     │
│                                  │
│  • 代价:事件驱动难调试 +         │
│    强依赖 pi-ai                  │
└──────────────────────────────────┘

关键认知:agent-core 把"调 LLM"升级成"自主 Agent"。核心是循环 + 状态 + 事件 + 持久化。它的复杂性,都是在解决"让 Agent 真正好用"的具体问题(并发、记忆、压缩、恢复)。


课后实验

  1. 读 runAgentLoop:打开 agent-loop.ts,找到第 95 行的 runAgentLoop,顺着读到双层 while。画出它的控制流(什么情况继续,什么情况退出)。
  2. 找观察者:在 agent.ts 里搜 subscribelisteners,理解事件怎么注册和广播。
  3. 理解 fork:读 session/fork.ts 的注释,理解"会话分叉"和 git 分支的相似之处。

下一章:Agent 会跑了,但它在终端里怎么显示得好看又不闪?这是 pi-tui 干的事。 → 第 19 章 · pi-tui 源码