PiAgent 06|消息模型:内部状态如何进入 LLM#
区分 LLM 消息、AgentMessage 与 JSONL entry,理解
convertToLlm为什么是最关键的翻译边界
Agent 系统里的“消息”经常同时指三种东西:模型 API 能理解的消息、Agent 为了记录运行过程定义的内部消息、会话文件里保存的历史 entry。它们相互关联,却不能当成同一种类型。
1. LLM 只需要一组有限的角色#
大多数模型上下文围绕 user、assistant 和 tool result 组织:
user → 提出目标或约束
assistant → 生成文本、thinking 或 tool call
tool → 返回与 tool call 对应的结果assistant 的 tool call 必须带调用 ID,tool result 用同一个 ID 配对。缺少配对关系,模型可能把工具结果误认成普通文本,或者认为某个调用从未完成。
模型协议通常还携带 usage、stop reason、缓存和多模态内容,但它不应该承担 UI 状态、压缩过程或宿主内部的审计字段。
2. AgentMessage 为什么更丰富#
Agent 运行时需要记录模型协议之外的事实,例如 bash 执行、压缩摘要、分支摘要、扩展自定义状态:
type AgentMessage =
| UserMessage
| AssistantMessage
| ToolResultMessage
| CustomMessage;在会话历史中,还可能出现:
interface CustomMessage {
role: "custom";
customType: string;
content: string | TextContent[];
display: boolean;
details?: unknown;
timestamp: number;
}CustomMessage 的作用是让扩展和产品保存事实,而不是强行把所有东西伪装成 user 或 assistant。它是否进入模型上下文,由转换策略决定;“已保存”不等于“已发送给 LLM”。
3. Session entry 是持久化单位#
JSONL 会话文件的 entry 还比 AgentMessage 多一层:它需要父子关系、时间、类型和会话元数据,以支持回退、分支、压缩和恢复。
session header
└─ model_change
└─ user message
└─ assistant message
└─ tool result
└─ compaction summary一个 entry 可能包含消息,也可能只是模型切换、标签或分支信息。会话管理器负责“历史事实如何保存”,Agent 负责“当前运行如何消费”,模型适配器负责“哪些事实可以被翻译成 Provider 请求”。
4. convertToLlm 是翻译边界#
核心 Agent 通常通过 convertToLlm 把内部消息映射成 LLM 消息:
const llmMessages = convertToLlm(agent.state.messages);转换过程中需要做几类判断:
- 普通 user/assistant/tool result 是否可以直接映射。
- 自定义消息是否要变成一段 user 可读文本。
- UI 专用事件是否完全排除。
- bash 输出是否只保留摘要,完整结果放在外部文件。
- 历史中的旧格式或分支摘要是否需要归一化。
这层不能简单 JSON.stringify(messages)。模型需要的是符合 Provider 协议的消息序列,不是内部状态的对象转储。
5. 一个消息投影示例#
function convertToLlm(messages: AgentMessage[]): LlmMessage[] {
return messages.flatMap((message) => {
switch (message.role) {
case "user":
case "assistant":
case "toolResult":
return [toProviderMessage(message)];
case "custom":
if (message.customType === "ui_status") return [];
return [{ role: "user", content: formatCustomMessage(message) }];
default:
return [];
}
});
}这里的 flatMap 说明一个事实:一个内部消息可以被投影为零条、一条或多条 LLM 消息。扩展的状态不必全部进入模型,进入模型的内容也不必和 UI 展示完全一致。
6. 工具调用的配对关系#
工具调用链至少要保持下面的关系:
assistant(toolCallId=A, name=query_data, args=...)
└─ toolResult(toolCallId=A, result=...)流式阶段可能先收到工具名和参数片段,最终阶段才得到完整调用。转换层或 Loop 必须负责合并增量,不能把每个片段都当成一条独立消息。
如果工具执行失败,仍然应该生成与 A 配对的 tool result,只是带上 isError 或错误内容。直接丢弃失败调用会让下一轮模型看到一个“没有结果的请求”,后续行为不可预测。
7. 哪些内容不应该进入上下文#
不是保存得越多越好。通常可以排除:
- 纯 UI 的 loading、进度条和渲染状态。
- 重复的工具进度更新。
- 包含敏感密钥、内部路径或个人数据的原始日志。
- 已经由摘要替代的巨大输出。
- 明确标记
excludeFromContext的命令结果。
排除时要保留可追溯性:会话或日志中可以记录“完整结果存在哪、为什么没有进入上下文”,避免调试时只剩一段无法解释的摘要。
8. 消息、上下文和会话的职责表#
| 对象 | 解决的问题 | 不应该负责 |
|---|---|---|
| LLM Message | Provider 能理解什么 | UI、分支、完整审计 |
| AgentMessage | 当前运行记录什么 | 具体 Provider 方言 |
| Session Entry | 历史如何恢复和分叉 | 模型推理 |
| Context | 本轮发送哪些信息 | 永久持久化 |
convertToLlm | 内部信息如何投影 | 工具权限判断 |
常见误区
- 把会话 JSONL 直接当成下一次模型请求体。
- 把所有
CustomMessage原样发送给 LLM,导致上下文污染或泄露。 - 只按 role 判断消息,忽略 tool call ID 和结果配对。
- 压缩时删掉 tool call 却保留 tool result,破坏消息序列合法性。
- 以为 UI 能看到的所有事件,模型也应该看到。
小结
消息模型的核心不是类型数量,而是翻译边界。Agent 可以保存比 LLM 更多的运行事实,Session 可以保存比 Agent 更多的历史结构,但进入 Provider 的上下文必须经过明确投影。convertToLlm、工具调用 ID 和 excludeFromContext 共同决定了“模型究竟看到了什么”,也决定了 Agent 是否能稳定延续工作。
源码定位
- Agent 消息类型:
packages/agent/src/types.ts - Provider 消息类型:
packages/ai/src/types.ts - 内部消息转换:Agent 构造函数的
convertToLlm配置 - 会话 entry:
packages/coding-agent/src/core/session-manager.ts - 压缩与分支摘要:
packages/coding-agent/src/agent-session.ts