返回首页
06
消息系统声明合并convertToLlm类型设计可见性控制

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

内富外严的两层消息设计——自定义消息自由表达,LLM 边界统一翻译

带着问题读

读完本章,你应该能回答这三个问题:

  1. 1Pi 内部有 7 种消息类型,但 LLM 只认 3 种——自定义消息(如 BashExecutionMessage)是怎么被 LLM 看到的?答案见正文中对应的「面试题 1」气泡
  2. 2Pi 用 TypeScript 声明合并来扩展 AgentMessage 联合类型,为什么不用继承或泛型?答案见正文中对应的「面试题 2」气泡
  3. 3怎么实现'一条消息 UI 能看到、但 LLM 看不到'?Pi 的 excludeFromContext 机制为什么比直接删除消息更好?答案见正文中对应的「面试题 3」气泡

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

前五章反复出现 UserMessage、AssistantMessage、ToolResultMessage 这三个名字,但消息到底是什么?Agent 内部的消息和发给模型的消息一样吗?本章回答这些问题,核心是 Pi 消息系统最重要的设计——两层消息:Agent 内部用丰富的格式自由表达,到了 LLM 边界翻译回严格的标准格式。

一条 Bash 命令引出的问题

你在 Pi 终端里输入 !ls -la,执行完毕后,Pi 内部会记录一条 BashExecutionMessage——command 字段记命令原文、output 记输出、exitCode 记退出码,UI 用这些结构化字段漂亮地渲染终端输出。

但 Agent Loop 准备调用 LLM 时问题来了:LLM 的 API 根本不认识 BashExecutionMessage,它只认 user / assistant / toolResult 三种。这条消息是怎么被 LLM 看到的?

第一层:LLM 认识的消息只有三种

Message 联合类型定义在 packages/ai/src/types.ts,只有三个成员:

// UserMessage——用户输入,content 可以是纯文本或内容块数组(能发图片)
{ role: "user", content: string | (TextContent | ImageContent)[], timestamp: number }

// AssistantMessage——LLM 的回复,字段最多
{
    role: "assistant",
    content: (TextContent | ThinkingContent | ToolCall)[],  // 三种内容块
    api: Api, provider: ProviderId, model: string,
    usage: Usage,                    // token 用量
    stopReason: StopReason,          // 第 3 章的 5 种值
    errorMessage?: string,
    timestamp: number
}

// ToolResultMessage——五步管道的终点产物
{
    role: "toolResult",
    toolCallId: string,              // 关联到 AssistantMessage 里的 ToolCall
    toolName: string,
    content: (TextContent | ImageContent)[],
    details?: TDetails,              // 结构化详情,给 UI 看
    isError: boolean,                // 第 5 章"永不抛出"的标记
    timestamp: number
}

两个细节值得注意:一条 AssistantMessage 可以同时包含文本和工具调用("让我帮你看看这个文件" + Read 调用放在同一个 content 数组里);content 块里的 *Signature 字段(textSignature、thinkingSignature)是某些 Provider 要求的不透明签名,下一轮请求必须原样回传——这是跨 Provider 的上下文连续性机制。

矛盾:Agent 内部的消息不止三种

Agent 内部除了 LLM 对话,还有大量功能性数据要管理:Bash 执行记录、压缩摘要、分支切换记录、附件元信息……这些数据有两个需求冲突的读者:

  • UI 端要结构化字段——command、output、exitCode、cancelled、truncated 分开,才能分别高亮渲染;
  • LLM 端只要一段扁平文本——"用户执行了 ls -la,输出是 ..."塞进 UserMessage.content 就够。

如果为 LLM 提前拍扁,UI 永远拿不回结构;如果只存结构化消息不进上下文,LLM 就失忆。 Pi 两边都不妥协:结构化存进 context.messages(满足 UI/持久化),在调用 LLM 的边界上做一次性翻译(满足 LLM)。翻译是最后一刻发生的、有损的、单向的——损失掉的字段 UI 早就用过了,无所谓。

coding-agent 在 messages.ts 里定义了 4 种自定义消息:BashExecutionMessage(Bash 执行记录)、CustomMessage(扩展注入)、BranchSummaryMessage(分支切换摘要)、CompactionSummaryMessage(压缩摘要)。自定义消息带来三个独立能力:UI 专用渲染(按 role 分派渲染器)、持久化恢复(session 存结构化数据,重启后精确还原渲染状态)、精细化可见性控制(excludeFromContext 让消息对 LLM 隐身)——标准消息做不到最后一点,一旦进了 messages 数组就一定会被翻译发给 LLM。

第二层:AgentMessage——内富外严

核心代码在 packages/agent/src/types.ts:314:

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

AgentMessage = 3 种标准消息 + 自定义消息。 而 CustomAgentMessages 接口在核心包里是空的:

export interface CustomAgentMessages {
    // Empty by default - apps extend via declaration merging
}

核心包完全不知道有什么 BashExecutionMessage,它只提供一个"插槽"。应用层靠 TypeScript 声明合并注入自己的类型:

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

效果是:在 coding-agent 项目里 AgentMessage 自动变成 7 种消息的联合,TypeScript 做完整类型检查。为什么不用继承或泛型? 继承要改基类——你改不了 pi-agent-core 包;泛型要到处传参数——每个用到 AgentMessage 的函数签名都会被污染。声明合并让核心包零依赖、扩展包全栈类型安全。不同应用可以注册不同的自定义消息(比如曾经的 Web UI 注册过 artifact 等类型——该包已被官方移除,此处仅作机制示例),每个应用只看到自己需要的。

两层消息——内富外严的双层设计

转换边界:convertToLlm——一切自定义消息终将变成 User

翻译发生在每次 LLM 调用前的最后一刻,顺序是先 transformContext(同层变换),再 convertToLlm(跨层翻译):

context.messages: AgentMessage[]        ← 最多 7 种类型
        ▼
[1] transformContext(可选)            ← AgentMessage[] → AgentMessage[],裁剪/注入
        ▼
[2] convertToLlm(必须)                ← AgentMessage[] → Message[],跨层翻译
        ▼
streamFunction(model, llmContext, ...)  ← LLM 只看到 3 种

coding-agent 的转换规则按 role 分派:三种标准消息直接透传;bashExecution 看 excludeFromContext,为 true 就过滤、否则转 UserMessage;custom / branchSummary / compactionSummary 都转成 UserMessage(后两者加 XML 标签包裹)。

关键洞察:所有自定义消息都变成 user 角色。 为什么?因为 LLM API 要求对话是 user → assistant → user → ... 交替的,不能连续两个 assistant;自定义消息本质是"系统注入的信息",放 user 角色中最安全。

BashExecutionMessage 的实际转换:role: "bashExecution" → "user";command / output / exitCode 被格式化成一段文本(Ran \ls -la`\n\n...\n");cancelled、truncated 等布尔标志融进文本描述,不再是独立字段。

为什么 transformContext 和 convertToLlm 分两步

职责分离:transformContext 处理 AgentMessage 级别的操作(裁剪旧消息、注入外部上下文、触发压缩),前后类型不变;convertToLlm 处理跨类型翻译,类型从 AgentMessage[] 变 Message[]。分开后两者可独立替换:换上下文管理策略只改前者,换应用类型(自定义消息变了)只改后者。

注意一个易混点:换 LLM 提供商(Claude → GPT)时 convertToLlm 不需要动。它的输出是统一的 Message[],"自定义消息 → 标准消息"已完成;把 Message 翻译成各家 Provider 私有格式是 pi-ai 层(第 4 章)翻译器的工作——"消息类型翻译"和"Provider 协议翻译"在两层独立的抽象里,互不干扰。

消息处理管道

过滤机制:有些消息 LLM 不该看

用 !! 前缀执行命令(如 !!secret_cmd)时,执行结果对 LLM 不可见。实现只需一个布尔字段:

case "bashExecution":
    if (m.excludeFromContext) {
        return undefined;   // 后续被 filter 掉
    }

消息仍存在于 context.messages,UI 照常渲染——只是在调用 LLM 那一刻被"隐身"。综合起来,Pi 有三种可见性级别:

可见性级别 LLM UI 实现方式
全可见 ✓ ✓ convertToLlm 正常转换
LLM 不可见 ✗ ✓ excludeFromContext = true
仅持久化 ✗ ✗ UI 渲染跳过 + convertToLlm 过滤(如 Web UI 的 ArtifactMessage,该包已移除,仅作机制示例)

总结:两个读者,两层架构

整章围绕一个朴素思想——设计数据结构时同时照顾模型和功能两个读者。模型只要三种标准消息(协议强制,不能改);功能层要丰富结构化字段(每多一种字段多一种能力)。Pi 的方案:

层 关心谁 怎么实现
AgentMessage(内层) 功能层 联合类型 + 声明合并,核心包零依赖、应用层类型安全
Message(外层) 模型 LLM 调用边界做 convertToLlm 翻译——有损、单向、最后一刻发生

套用到任何"对外有协议约束、对内有丰富需求"的系统,三步走:识别两个读者各要什么(协议不能改的部分 vs 可自定义的部分);以内层结构化为"源"、外层翻译为"流"(不要为协议方便提前拍扁数据——一旦拍扁,UI 和持久化永远拿不回结构);用类型系统扩展点做"核心 + 应用"分层(核心包留空插槽,应用包声明合并注入)。

至此前六章结束,你已经建立了对 Pi-Agent 核心机制的完整理解:三层架构是骨骼,Agent Loop 是引擎,模型调用层抹平供应商差异,工具系统是管住手脚的管道,消息系统则是串起这一切的记忆组织方式——每层都遵循同一条主线:识别分层点、划清职责边界、用扩展点而非修改来生长。从下一章开始进入进阶议题:那个在前五章反复出现却始终没深入的东西——事件。Agent Loop 每做一步都发事件,工具执行时发出 tool_execution_start / update / end,这些事件怎么传到外部?为什么 Agent 发完事件要"同步等待"监听器处理完?下一章,事件驱动架构。


本章关键源码索引:ai/src/types.ts:322-408(Message 联合类型)、agent/src/types.ts:305-314(CustomAgentMessages + AgentMessage)、coding-agent/src/core/messages.ts:70-77(声明合并)、messages.ts:82-195(转换规则)、messages.ts:38-39(excludeFromContext)。本文改编自 CC-BY-SA-4.0 许可的开源教程。