M06. 消息:内部状态如何翻译给 LLM#
“消息”这个词在 Agent 系统里经常同时指三种不同东西:模型 API 能理解的消息、Agent 为了记录运行过程而定义的消息,以及 session file 里的 entry。它们相关,却不能混为一谈。
1. LLM 的最小词汇#
大多数模型接口至少围绕 user、assistant 和 tool result 组织上下文。assistant message 里可能带文本、thinking 或 tool call;tool result 通过 call id 与调用对应。这个协议足以完成一轮 ReAct,但不足以表达 UI 状态、文件变化或宿主自定义事件。
2. AgentMessage 为什么更丰富#
Agent 内部可能需要记录自定义消息,例如 bash 执行过程、压缩摘要、分支摘要或某种产品状态。Pi 使用可扩展的 AgentMessage 联合类型表达这些信息,再在发送给 LLM 之前进行投影。
type AgentMessage =
| UserMessage
| AssistantMessage
| ToolResultMessage
| CustomMessage;这段是概念示意,不是完整类型定义。重点在于:内部消息可以“富”,模型边界必须“严”。
3. convertToLlm 是翻译边界#
核心 Agent 通过 convertToLlm 把内部消息转换为模型消息。自定义消息通常不会原样传给模型,而是变成一条可读的 user 形式,或者被完全排除。
const llmMessages = agent.convertToLlm(agent.state.messages);转换的目标不是保留每一个内部字段,而是保留对下一次推理有用的语义。比如工具的展示细节可以留在 UI 和日志里,模型只需要知道工具做了什么以及是否成功。
4. 为什么还要有 transformContext#
convertToLlm 解决“消息类型翻译”;transformContext 解决“本次调用前,上下文要怎样被裁剪、排序或补充”。两者分开后,你可以改变上下文策略,而不必改写每种消息的类型转换。
一个典型顺序是:
AgentMessage[]
→ transformContext:截断、过滤、补充项目资料
→ convertToLlm:转换成标准 Message[]
→ provider adapter:转换成供应商请求顺序很重要:如果先翻译再做 Agent 级过滤,很多自定义字段已经丢失;如果把供应商字段混进 AgentMessage,核心层就会被某家 API 绑架。
5. excludeFromContext 的价值#
有些消息需要持久化,却不该进入模型上下文;有些消息只用于当前 UI,也不该污染下一轮推理。一个布尔字段或等价的过滤协议,就能把“记录过”和“让模型看过”分开。
可以把消息可见性分成三层:
| 可见范围 | 例子 |
|---|---|
| 只对宿主可见 | 调试计时、内部 ID、审计字段 |
| 对 UI 可见 | 工具进度、文件变更提示 |
| 对 LLM 可见 | 工具结果、用户要求、必要的摘要 |
越靠近 LLM,内容越应该短、稳定、可行动。不要为了“让模型知道一切”把所有日志都放进上下文。
6. 类型扩展要保持可判别#
自定义消息应有稳定的 role、customType 或等价判别字段,并为转换、持久化和渲染分别定义处理。TypeScript 的声明合并可以让扩展消息进入联合类型,但运行时仍需要真正的分支处理;类型声明不会自动替你序列化或恢复对象。
7. 一次工具调用的消息变化#
UserMessage: “检查配置”
→ AssistantMessage: toolCall(read_config)
→ ToolResultMessage: { ok: true, content: ... }
→ AssistantMessage: “配置有效”UI 可能在中间插入进度事件,但这些事件不一定成为 LLM 消息。会话层可能额外记录模型切换、标签或压缩 entry;它们又不一定全部进入上下文。
8. 消息系统的双重一致性#
消息系统必须同时满足两种一致性:对 Agent 来说,历史能够按顺序恢复;对 LLM 来说,当前上下文符合目标模型的消息协议。两者不能靠同一个数组自动保证。持久化、UI 展示和模型投影应各自声明可见性与生命周期,任何自定义消息都必须在这三条路径上有明确的处理或明确地被排除。
资料
本文依据 Pi Agent 核心源码、SDK 文档重新组织。