Pi Agent · Book
M06

第6章:消息系统 —— Agent 的记忆如何组织与传递

5467字 · 含 205 行代码 · 约 28 分钟

上一章我们学了工具系统——模型说”读文件”,Agent Loop 通过五步管道执行 read 工具,最终产生一条 ToolResultMessage。但你有没有注意到:我们一直在说”消息”这个词,却从来没拆开看过它到底长什么样。

UserMessage、AssistantMessage、ToolResultMessage——这三个名字反复出现在前五章里。第 3 章说”消息在 Loop 中流转”,第 4 章说”消息发给模型”,第 5 章说”工具结果是一条消息”。

但消息到底是什么?它的数据结构长什么样?Agent 内部的消息和发给模型的消息一样吗?

这一章就来回答这些问题。你会看到 Pi 消息系统最核心的设计——两层消息:Agent 内部用丰富的格式自由表达,到了 pi-ai 适配边界投影为统一 Message,再由 Provider 适配器翻译成各家私有协议。

校对口径:本章对应 Pi v0.80.2packages/ai/src/types.tspackages/agent/src/types.ts 和 coding-agent 的 messages.ts。这里的 3 种 Message 是 Pi 的统一内部协议;各 Provider 适配器仍会把它们翻译成各自 API 的角色和内容块。


一、开场:一条 Bash 命令的消息之旅

先从一个具体场景说起。

你在 Pi 的终端里输入了一条 Bash 命令 !ls -la,回车执行。命令跑完了,输出了一堆文件列表。

这条命令的信息,在 Pi 内部会变成一条 BashExecutionMessage——它有 command 字段记录命令原文、output 字段记录输出内容、exitCode 字段记录退出码。这些结构化字段让 UI 可以用专用渲染器漂亮地展示终端输出。

但问题来了:当 Agent Loop 准备调用模型时,pi-ai 的统一适配边界根本不接受 BashExecutionMessage。这个边界只接受三种 Messageuser(用户说的)、assistant(AI 回的)、toolResult(工具返回的)。BashExecutionMessage 不属于这三种中的任何一种;再往下,各 Provider 适配器还会把这三种内部消息翻译成自己的 API 协议。

那这条消息是怎么被 LLM 看到的?中间经历了什么变化?

这一章我们就跟着这条 BashExecutionMessage,从它的诞生走到 LLM 看到它的那一刻。


二、第一层:Pi 发给适配器的消息只有三种

在了解消息怎么变换之前,先搞清楚“变换的目标”长什么样。Pi 在 AI 层定义的统一格式叫 Message,只有三种;Anthropic、OpenAI 等真实 API 并不直接共享这套结构,第 4 章的适配器会继续翻译。

它只有三个成员:

Message 联合类型(pi-ai 统一内部格式)

├── UserMessage        ← 用户说的话 / 发的图片
├── AssistantMessage   ← LLM 的回复(含思考、工具调用)
└── ToolResultMessage  ← 工具执行后的结果

每种消息的具体数据结构

UserMessage——最简单的一种,用户输入:

{
    role: "user",
    content: string | (TextContent | ImageContent)[],  // 纯文本或内容块数组
    timestamp: number                                    // Unix 毫秒时间戳
}

content 可以是一个纯字符串,也可以是一个内容块数组。这意味着用户消息既能发文字,也能发图片。

AssistantMessage——LLM 的回复,字段最多:

{
    role: "assistant",
    content: (TextContent | ThinkingContent | ToolCall)[],  // 三种内容块
    api: Api,                   // 使用的 API 类型(如 "anthropic-messages")
    provider: ProviderId,       // 提供商(如 "anthropic")
    model: string,              // 模型名(如 "claude-sonnet-4-6")
    usage: Usage,               // token 用量统计
    stopReason: StopReason,     // 停止原因(第3章讲过的5种值)
    errorMessage?: string,      // 错误信息
    timestamp: number
}

这里最值得关注的是 content 字段——它不是字符串,而是一个内容块数组,可以包含三种东西:

AssistantMessage 的 content 内容块

├── TextContent       ← 普通文本
│     { type: "text", text: "..." }

├── ThinkingContent   ← 思考过程(第3章讲过,模型"在想"但不直接告诉用户的部分)
│     { type: "thinking", thinking: "..." }

└── ToolCall          ← 工具调用(第5章讲过,触发五步管道的入口)
      { type: "toolCall", id: "...", name: "read", arguments: { path: "..." } }

一条助手消息可能同时包含文本和工具调用。 比如 LLM 一边说”让我帮你看看这个文件”,一边发出一个 Read 工具调用——这两个内容会放在同一个 AssistantMessage 的 content 数组里。第 3 章讲的”模型回复中包含 ToolCall”就是这个结构。

进阶细节:content 块里还有一些 *Signature 字段(textSignaturethinkingSignaturethoughtSignature 等),是某些 Provider 要求的不透明 ID 或密文,下一轮请求可能需要原样回传。日常理解不需要深究,知道它们服务于同一 Provider 协议内的上下文连续性即可;它们不是跨 Provider 通用的签名格式。

ToolResultMessage——工具执行的结果(第 5 章的五步管道终点产物):

{
    role: "toolResult",
    toolCallId: string,                             // 对应哪个 ToolCall
    toolName: string,                               // 工具名
    content: (TextContent | ImageContent)[],        // 结果内容
    details?: TDetails,                             // 结构化详情(给 UI 看的)
    isError: boolean,                               // 工具边界内是否执行失败
    timestamp: number
}

ToolResultMessage 通过 toolCallId 字段和 AssistantMessage 里的 ToolCall 对应起来。第 3 章提到的”工具结果必须精准关联回调用请求”就是靠这个字段。details 字段携带结构化信息给 UI 渲染用,LLM 通常不需要看这个字段。

一个完整的对话示例

把这三种消息串起来,一个典型的对话片段长这样:

messages 数组:

├── [0] UserMessage
│       role: "user"
│       content: "帮我看看 auth.ts"

├── [1] AssistantMessage
│       role: "assistant"
│       content: [
│           { type: "text", text: "让我帮你看看这个文件" },
│           {
│               type: "toolCall",
│               id: "tc_001",
│               name: "read",
│               arguments: { path: "auth.ts" }
│           }
│       ]
│       stopReason: "toolUse"     ← 第3章讲过:调了工具,循环继续

├── [2] ToolResultMessage
│       role: "toolResult"
│       toolCallId: "tc_001"      ← 和上面的 id 对应
│       content: [
│           { type: "text", text: "import { auth } from '...' ..." }
│       ]
│       isError: false            ← 第5章讲过:正常结果

└── [3] AssistantMessage
        role: "assistant"
        content: [
            { type: "text", text: "auth.ts 是一个认证模块..." }
        ]
        stopReason: "stop"        ← 没调工具,循环结束

这就是 pi-ai 统一消息协议表达的世界——用户说了什么、AI 回了什么、工具返回了什么,就这么三种。真实 Provider API 看到的角色和内容块,还要经过下一层适配。


三、矛盾:Agent 里的消息不止三种

好,现在回到开头的场景。你执行了 !ls -la,Pi 内部需要记录这次执行的信息。

这里其实藏着一个更普遍的问题:Agent 内部除了”LLM 对话”这件事,还有大量功能性数据要管理——Bash 命令的执行记录、上下文被压缩后的摘要、同一 Session Tree 上被放弃路径的摘要、用户上传的附件元信息……

这些功能性数据有两个独立的读者,两个读者的需求是冲突的:

  • UI 端需要结构化字段——Bash 执行要分别拿到 commandoutputexitCodecancelledtruncated,才能在终端里漂亮地渲染(命令用高亮、输出用等宽字体、退出码用颜色标识)
  • LLM 端只需要看一段扁平文本——“用户执行了 ls -la,输出是 file1.txt\nfile2.txt\n...”,这一段文本塞进 UserMessage.content 就够用了

冲突点在哪?如果为了 LLM 把字段提前拍扁存进 UserMessage,UI 就再也拿不回结构化数据了——你已经搅成一锅粥。反过来,如果只存结构化的自定义消息、不进 LLM 上下文,那 LLM 就会失忆——下一轮它不知道用户刚才执行了什么。

Pi 的设计是两边都不妥协以结构化形式存进 Agent 状态和 Session(满足 UI/持久化),每次 run 再复制成 AgentContext 快照;在交给 pi-ai 适配器前做一次投影(满足模型调用)。这样 UI 永远有完整的结构化数据可用,LLM 也能看到它需要的扁平版本。投影是在请求边界发生的、有损的、单向的——损失掉的结构化字段仍保留在状态和 Session 中。

Pi 的解法是:允许应用自定义消息类型。 pi-agent-core 在 AgentMessage 联合类型里预留了一个扩展点(叫 CustomAgentMessages,下一节会展开它的实现原理),应用通过 TypeScript 的声明合并往里加自己的消息类型。每个应用只注册自己需要的——agent-core 不必反向依赖具体宿主,宿主仍能获得完整的联合类型检查。

以 pi 自带的 coding-agent 为例,它在 packages/coding-agent/src/core/messages.ts 里定义了 4 种自定义消息:

coding-agent 的自定义消息类型

├── BashExecutionMessage       ← Bash 命令执行记录
├── CustomMessage              ← 扩展注入的通用消息
├── BranchSummaryMessage       ← 同一 Session Tree 中被放弃分支的摘要
└── CompactionSummaryMessage   ← 上下文压缩后的摘要

每种都有自己的结构化字段。以 BashExecutionMessage 为例:

{
    role: "bashExecution",
    command: string,          // 命令原文:"ls -la"
    output: string,           // 输出内容:"file1.txt\nfile2.txt\n..."
    exitCode: number | undefined,  // 退出码:0
    cancelled: boolean,       // 是否被取消
    truncated: boolean,       // 输出是否被截断
    fullOutputPath?: string,  // 截断时的完整输出文件路径
    timestamp: number,
    excludeFromContext?: boolean  // 是否排除在 LLM 上下文之外
}

结构化字段全保留下来了。但这里要停下来强调一下——自定义消息不只是”翻译给 LLM 用的中间格式”那么简单,它本身带来了三个独立的能力

1. UI 专用渲染。UI 根据 role 字段做分派——bashExecution 用终端样式渲染、compactionSummary 用摘要卡片渲染,命令、输出、退出码各占一行,互不干扰。如果没有自定义消息,UI 只能拿到一段扁平文本,所有渲染花样都得退回到”全是大段文字”。

2. 持久化恢复。session 文件存的是完整的结构化数据。下次启动 Agent 时,UI 能精确还原上次的渲染状态——退出码仍然有颜色、命令仍然高亮、截断标识仍然在。如果只存翻译后的扁平文本,这些信息重启后就永久丢失了。

3. 精细化可见性控制。因为自定义消息有自己的 role,可以在 convertToLlm 翻译时做特殊处理。coding-agent 的 BashExecutionMessage 就带有 excludeFromContext 字段:为 true 时,内置转换器不把它发给 LLM,但 UI 仍可渲染。pi-ai Message 没有这个内置字段;宿主当然仍可在 transformContext 中过滤任意消息,只是要自行定义规则。

所以后面 §五 讲到 convertToLlm 翻译、§七 讲到 excludeFromContext 过滤时,请记住:翻译是为了把宿主结构投影成 AI 层认识的格式,过滤则是 coding-agent 对某类消息采用的策略。自定义消息让这两个决策可以按消息类型集中处理。

但这里有个根本性的问题:Message 联合类型是封闭的——只有 UserMessage、AssistantMessage、ToolResultMessage 三种。 这 4 种自定义消息不属于 Message 类型。那它们怎么被 Agent 系统接受并处理的?


四、第二层:AgentMessage —— 内富外严的双层设计

这就是 Pi 消息系统的核心设计:不用一种格式打天下,而是用两层——内层丰富、外层严格。

两层消息——内部结构与 Provider 适配边界
两层消息——内部结构与 Provider 适配边界

配图说明:顶部 AgentMessage 联合类型一分为二——左支 Message(3 种统一消息)、右支 CustomAgentMessages(4 种扩展)。中间红框是 convertToLlm() 投影门。底部 pi-ai 适配器只接收 3 种统一消息;自定义消息要么被过滤、要么被投影成 UserMessage,之后才由 Provider 适配器继续翻译。

AgentMessage 联合类型

packages/agent/src/types.ts:314 有一行关键代码:

export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];

翻译成人话:AgentMessage = pi-ai 统一消息 + 自定义消息。 它是 Message(三种统一格式)和 CustomAgentMessages(自定义扩展)的联合类型。

用一张图看最清楚:

AgentMessage(Agent 内部使用的消息格式)

├── Message(可交给 pi-ai Provider 适配器的统一消息)
│   │
│   ├── UserMessage          ← role: "user"
│   ├── AssistantMessage     ← role: "assistant"
│   └── ToolResultMessage    ← role: "toolResult"

└── CustomAgentMessages(仅 Agent 内部使用的扩展消息)

    ├── BashExecutionMessage      ← role: "bashExecution"
    ├── CustomMessage             ← role: "custom"
    ├── BranchSummaryMessage      ← role: "branchSummary"
    └── CompactionSummaryMessage  ← role: "compactionSummary"

Agent Loop 每次 run 使用的 context.messages 快照是 AgentMessage[]——它可以混合存放统一消息和自定义消息。一条消息到底是哪种成员,看 role 字段就能判断。

CustomAgentMessages:默认为空的扩展点

关键在 CustomAgentMessages 这个接口:

export interface CustomAgentMessages {
    // Empty by default - apps extend via declaration merging
    // 默认为空 - 应用通过声明合并扩展
}

注意:这个接口在核心包(pi-agent-core)里是空的。 核心包完全不知道有什么 BashExecutionMessage、CompactionSummaryMessage 这些东西。它只提供了一个”插槽”,让应用层往里插。

声明合并:类型安全的扩展魔法

应用层怎么”插”?靠 TypeScript 的声明合并(Declaration Merging)。coding-agent 通过 declare module 语法把自己的 4 种消息类型”注入”进去:

declare module "@earendil-works/pi-agent-core" {
    interface CustomAgentMessages {
        bashExecution: BashExecutionMessage;
        custom: CustomMessage;
        branchSummary: BranchSummaryMessage;
        compactionSummary: CompactionSummaryMessage;
    }
}

这段代码的效果是:编译器自动把这 4 种类型加入 AgentMessage 联合类型。 以后在 coding-agent 项目里,AgentMessage 就变成了 7 种消息的联合(3 种 pi-ai Message + 4 种自定义),TypeScript 会帮你做完整的类型检查。

为什么不直接用继承或者泛型? 继承会把扩展关系放进基类体系;泛型则可能要求消息类型参数沿多层 API 传递。声明合并在这里的好处是:agent-core 不需要导入具体宿主的消息定义,coding-agent 却能把自定义类型纳入同一个联合类型检查。

不同应用可以通过同一个声明合并入口注册各自的消息类型。v0.80.2 的仓库内可直接验证的是 coding-agent 上述 4 种;应用侧可能增加什么,不属于核心包的承诺。


五、转换边界:convertToLlm —— 自定义消息被投影或过滤

现在我们知道 Agent 内部用 7 种消息类型自由表达。但每次调用模型时,pi-ai 的 Provider 适配器入口只接受 3 种 Message。怎么办?

答案是:在调用 LLM 之前的最后一刻,做一次翻译。 这个翻译器就是 convertToLlm 函数。

翻译发生在什么时候?

streamAssistantResponse 函数里(第 3 章讲过的”调用模型”那一步),翻译发生的时机非常精确:

每次 LLM 调用前的消息处理管道:

context.messages: AgentMessage[]        ← coding-agent 内部使用 7 种已声明类型


[1] transformContext (可选)             ← AgentMessage[] → AgentMessage[]
        │                                  裁剪旧消息、注入外部上下文

[2] convertToLlm (必须)                 ← AgentMessage[] → Message[]
        │                                  自定义消息投影成统一格式

llmContext.messages: Message[]          ← pi-ai 适配器接收的统一消息


streamFunction(model, llmContext, ...)  ← 适配成 Provider 私有协议并调用模型

注意顺序:先 transformContext(同层变换),再 convertToLlm(跨层翻译)。 为什么分两步?后面会讲。

转换规则:可见的自定义消息统一投影为 User

coding-agent 的 convertToLlm 核心逻辑是一个 switch 语句,按 role 字段分派处理:

role怎么处理
"user"直接透传,不做任何修改
"assistant"直接透传
"toolResult"直接透传
"bashExecution"excludeFromContext=true → 过滤掉;否则 → 转换成 UserMessage
"custom"转换成 UserMessage
"branchSummary"转换成 UserMessage(加 XML 标签包裹)
"compactionSummary"转换成 UserMessage(加 XML 标签包裹)

关键洞察:coding-agent 声明的四种自定义消息中,bashExecution 可以先被过滤;其余情况都会投影成 user 角色。

为什么可见的自定义消息都变成 user?这些内容代表宿主或用户一侧注入给模型的信息,而不是一次模型输出。转换后可能出现连续的 user 消息;convertToLlm 在 v0.80.2 中不会合并它们,是否需要规范化由后续 Provider 适配器处理。

具体例子:BashExecutionMessage 的转换

回到开头的场景。一条 BashExecutionMessage 从创建到被 LLM 看到,数据结构发生了这样的变化:

Before —— BashExecutionMessage(Agent 内部格式):

{
    role: "bashExecution",
    command: "ls -la",
    output: "total 32\ndrwxr-xr-x  5 user  staff  160 May 30 10:00 .\n...",
    exitCode: 0,
    cancelled: false,
    truncated: false,
    timestamp: 1748568000000
}

After —— UserMessage(pi-ai 适配器收到的统一格式):

{
    role: "user",
    content: [{
        type: "text",
        text: "Ran `ls -la`\n```\ntotal 32\ndrwxr-xr-x  5 user  staff  160 May 30 10:00 .\n...\n```"
    }],
    timestamp: 1748568000000
}

变化总结:

  • role"bashExecution""user"
  • commandoutput 总会被格式化进文本;无输出时会写成 (no output)
  • 其余状态是条件性序列化cancelled=true 才追加取消说明;否则仅非零 exitCode 会追加退出码;truncated=true 且存在 fullOutputPath 时才追加完整输出路径
  • 这些字段即使出现在文本里,也不再保留独立的结构化形态;示例中的 exitCode=0cancelled=falsetruncated=false 因此不会产生额外文字

其他自定义消息(CompactionSummary、BranchSummary)的转换模式完全一样——把摘要文本用 <summary> 标签包裹,前面加一句说明,变成 UserMessage 的 content。


六、两阶段管道:为什么 transformContext 和 convertToLlm 分开?

消息处理管道
消息处理管道

配图说明:横向数据流——AgentMessage[7] → transformContext(同层变换,类型不变)→ convertToLlm(跨层翻译)→ Message[3] 交给 pi-ai Provider 适配器。下方标注 excludeFromContext 过滤分支;Provider 私有协议翻译发生在图的下一层。

回到管道图,有一个设计细节值得追问:为什么分两步,而不是一步到位?

答案是职责分离

  • transformContext 处理的是 AgentMessage 级别的操作:过滤、重排或注入请求级上下文。它处理前后都是 AgentMessage[],类型不变。v0.80.2 的 coding-agent 在这里调用扩展系统的 context hook;内置 Compaction 的触发、摘要生成、Session Tree 写入和 agent.state.messages 替换都发生在 AgentSession 层,不是这个钩子完成的。
  • convertToLlm 处理的是 跨类型翻译:把 AgentMessage 翻译成 Message。处理前是 AgentMessage[],处理后是 Message[],类型变了。

分开的好处是:许多变化可以只落在其中一个边界上。

  • 换了请求级上下文策略(比如过滤某类临时消息、注入 Git 状态),可以只改 transformContextconvertToLlm 不需要动。若策略还要持久化摘要或重建 Session Tree,就不能只靠这个请求级钩子,coding-agent 的内置 Compaction 正是由 AgentSession 另行编排。
  • 换了应用类型(比如把 coding-agent 改造成一个 Web 客服 Agent,自定义消息从 BashExecution/CompactionSummary 变成 TicketEvent/OrderNote 这类业务消息),只需要改 convertToLlm——它处理”如何把自定义消息翻译成 UserMessage”。transformContext 不需要动。

这里有一个容易混淆的点要强调:换了 LLM 提供商(比如从 Claude 换成 GPT),convertToLlm 不需要动。为什么?因为 convertToLlm 的输出是统一的 Message[](3 种统一消息),它已经把“自定义消息 → pi-ai 消息”这件事做完了。再往下,把 Message 翻译成各家 Provider 的私有格式是 pi-ai 层(第 4 章讲过)的工作——那一层有自己的翻译器(anthropic-messages、openai-completions 等),跟 convertToLlm 是完全独立的两层。换句话说:Pi 把“宿主消息投影”和“Provider 协议翻译”放在了两层不同的抽象里,互不干扰


七、过滤机制:让一条记录只对 UI 可见

到目前为止,未被过滤的自定义消息最终都变成 UserMessage。接下来看看 bashExecution 如何只留给 UI。

excludeFromContext:一个布尔字段的过滤力

Pi 的 Bash 工具有个功能:当你用 !! 前缀执行命令时(比如 !!secret_cmd),这条命令的执行结果对 LLM 不可见。

实现方式非常简单——BashExecutionMessage 有一个 excludeFromContext 字段。在 convertToLlm 里,检查这个字段:

case "bashExecution":
    if (m.excludeFromContext) {
        return undefined;   // 直接返回 undefined,后续被 filter 掉
    }
    // ... 否则正常转换

注意:excludeFromContext = true 的消息仍然存在于 Agent 状态和 Session 中;run 内生成的 AgentContext 快照也不会被这个转换就地改写。UI 仍然可以看到它、渲染它。只是在构造本次 pi-ai Message[] 时,这条消息被“隐身”了。

这就是”UI 能看、LLM 不能看”的机制——一个布尔字段,在翻译边界做过滤,数据本身不需要删除。

v0.80.2 内置的三种可见性

就 coding-agent 已声明的 7 种消息而言,这里可以观察到三种内置行为。除了 bashExecution.excludeFromContext 控制“只给 UI 看”,CustomMessage.display 还控制 TUI 与 HTML 导出是否渲染;它不影响 convertToLlm

可见性级别LLM 是否看到UI 是否看到实现方式典型消息
全可见convertToLlm 正常转换普通的 BashExecution、User、Assistant
LLM 不可见excludeFromContext = true!! 前缀的 Bash 执行
UI 不展示CustomMessage.display = false;仍转换成 UserMessage扩展注入的隐藏上下文

扩展可以再定义自己的消息与过滤规则,但“仅持久化、两边都不可见”不是本章这份 v0.80.2 内置类型表中的现成类别。这里的“UI 是否看到”特指 coding-agent 的交互 TUI / HTML 导出;RPC 的原始事件消费者仍可自行观察消息。


八、完整数据流:从用户操作到 LLM 看到的消息

把全章内容串起来,一条消息从诞生到被 LLM 看到的完整路径:

用户在终端输入 !ls -la(假设 Agent 当前空闲)


[1] 创建消息
    BashExecutionMessage {
        role: "bashExecution",
        command: "ls -la",
        output: "...",
        ...
    }


[2] 存入 agent.state.messages,并写入 session
    [...原有消息, 新的 BashExecutionMessage]


[3] Bash 命令处理结束;这里不会自动调用 LLM


[4] 用户稍后提交普通 prompt,Agent 创建 AgentContext 快照并启动 Loop
    快照中包含此前的 BashExecutionMessage


[5] transformContext(可选)
    输入/输出都是 AgentMessage[]
    过滤、重排、注入请求级上下文


[6] convertToLlm(必须)
    AgentMessage[] → Message[]
    BashExecutionMessage → UserMessage
    excludeFromContext → 过滤掉


[7] pi-ai Provider 适配器收到统一 Message[]
    llmContext.messages = [
        ...之前的消息,
        {
            role: "user",
            content: "Ran `ls -la`\n```\n...\n```"
        }
    ]


[8] Provider 适配器翻译成私有协议并调用模型


[9] LLM 回复
    → 产生新的 AssistantMessage
    → 可能触发工具调用
    → ToolResultMessage(第 5 章的五步管道)
    → 写回 Agent 状态,继续循环

核心规律:coding-agent 在 v0.80.2 中使用 7 种消息类型(3 种 pi-ai Message + 4 种自定义),到 pi-ai 适配边界时,自定义消息被过滤或投影为 3 种 Message。这一步不会直接产生某家 API 的请求体;Provider 兼容性由下一层适配器完成。


九、总结

一条主线:数据结构要同时照顾两个读者

回看整章,Pi 的消息系统所有设计都围绕一个朴素的思想——在设计数据结构时,要同时考虑模型要用的、和功能层面要用的,然后根据需求自定义、用合理的架构把两者组合起来

具体到消息系统,这两个”读者”的需求是分裂的:

  • AI 抽象层的 Provider 适配入口只接收 Pi 归一化后的三种消息(User/Assistant/ToolResult);不同 Provider 的真实协议还会在下一层继续翻译
  • 功能这边(UI、持久化、可见性控制)需要丰富的结构化字段——每多一种字段就多一种能力

如果只为模型设计,结构化字段全丢,功能层退化;如果只为功能设计,模型看不懂,对话就断了。Pi 的方案是两层各管各的

关心谁数据形式怎么实现
AgentMessage(内层)功能层coding-agent 声明 7 种消息(3 种 pi-ai Message + 4 种自定义),字段丰富用联合类型 + 声明合并,避免 agent-core 依赖具体宿主,同时保留类型检查
Message(外层)pi-ai Provider 适配器3 种统一消息,字段精简在 Provider 适配前做一次 convertToLlm 翻译,有损、单向、最后一刻发生

这一章的具体设计——声明合并扩展点、transformContext / convertToLlm 两阶段管道、excludeFromContext 可见性控制——都服务于这条主线。主线是“两个读者,两层消息”,不要把第 5 章的工具类型层次混进消息转换。

把这条主线用到自己的项目里

下次你设计一个对接外部协议的系统(不只是 Agent,可以是任何”对外有协议约束、对内有丰富需求”的场景),可以套用这三步:

第一步:识别”两个读者”分别要什么。 协议规定什么(不能改的部分)?功能层需要什么(可以自定义的部分)?把它们列出来,明确各自的需求。

第二步:以内层结构化为”源”,外层翻译为”流”。 存储和功能层用原始的、结构化的数据(不丢字段、不拍扁);到协议边界再做一次有损翻译。不要为了协议方便而提前拍扁数据——一旦拍扁,UI 和持久化就再也拿不回结构。

第三步:用类型系统的扩展点做”核心 + 应用”分层。 核心包定义协议接口(封闭)、留一个空的扩展插槽;应用包通过声明合并注入自己的具体类型。这样核心包不必依赖具体应用,应用包仍有端到端类型检查——不需要继承,也不必把泛型参数传遍调用链。

本章讲到的「Tool → AgentTool → ToolDefinition」三层递进(第 5 章)也是同样的思想:每一层只加自己这个层级需要的能力,不越界。识别”分层点”、给每层划清职责边界,是这种设计法的核心。


十、下一站

前六章到此结束——你已经建立了对 Pi-Agent 核心机制的完整理解。

从第 7 章开始进入进阶章节。回看前五章,有一个东西反复出现但我们始终没深入:事件。第 3 章说“Agent Loop 每做一步都发事件让 UI 实时更新”,第 5 章说“工具执行时发出 tool_execution_starttool_execution_updatetool_execution_end 事件”。这些事件怎么从 Agent 内部传到外部?agent-core 为什么等待自己的监听器?这个等待跨过 AgentSession 后,是否还会覆盖 UI 的异步回调?

下一章,我们打开 Agent 的”神经系统”——事件驱动架构。


本章关键源码索引

  • packages/ai/src/types.ts:322-408 — Message 联合类型 + 三种消息接口
  • packages/agent/src/types.ts:305-314 — CustomAgentMessages + AgentMessage
  • packages/coding-agent/src/core/messages.ts:70-77 — coding-agent 声明合并
  • packages/agent/src/agent-loop.ts:275-308 — 转换管道(transformContext → convertToLlm)
  • packages/coding-agent/src/core/messages.ts:82-195 — 自定义转换规则
  • packages/coding-agent/src/core/messages.ts:38-39 — excludeFromContext 字段