8 min read教程

PiAgent 06|消息模型:内部状态如何进入 LLM

区分 LLM 消息、AgentMessage 与 JSONL entry,理解 convertToLlm 为什么是最关键的翻译边界

PiAgent 06|消息模型:内部状态如何进入 LLM#

区分 LLM 消息、AgentMessage 与 JSONL entry,理解 convertToLlm 为什么是最关键的翻译边界

Agent 系统里的“消息”经常同时指三种东西:模型 API 能理解的消息、Agent 为了记录运行过程定义的内部消息、会话文件里保存的历史 entry。它们相互关联,却不能当成同一种类型。

1. LLM 只需要一组有限的角色#

大多数模型上下文围绕 user、assistant 和 tool result 组织:

text
user      → 提出目标或约束
assistant → 生成文本、thinking 或 tool call
tool      → 返回与 tool call 对应的结果

assistant 的 tool call 必须带调用 ID,tool result 用同一个 ID 配对。缺少配对关系,模型可能把工具结果误认成普通文本,或者认为某个调用从未完成。

模型协议通常还携带 usage、stop reason、缓存和多模态内容,但它不应该承担 UI 状态、压缩过程或宿主内部的审计字段。

2. AgentMessage 为什么更丰富#

Agent 运行时需要记录模型协议之外的事实,例如 bash 执行、压缩摘要、分支摘要、扩展自定义状态:

ts
type AgentMessage =
  | UserMessage
  | AssistantMessage
  | ToolResultMessage
  | CustomMessage;

在会话历史中,还可能出现:

ts
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 多一层:它需要父子关系、时间、类型和会话元数据,以支持回退、分支、压缩和恢复。

text
session header
  └─ model_change
      └─ user message
          └─ assistant message
              └─ tool result
                  └─ compaction summary

一个 entry 可能包含消息,也可能只是模型切换、标签或分支信息。会话管理器负责“历史事实如何保存”,Agent 负责“当前运行如何消费”,模型适配器负责“哪些事实可以被翻译成 Provider 请求”。

4. convertToLlm 是翻译边界#

核心 Agent 通常通过 convertToLlm 把内部消息映射成 LLM 消息:

ts
const llmMessages = convertToLlm(agent.state.messages);

转换过程中需要做几类判断:

  • 普通 user/assistant/tool result 是否可以直接映射。
  • 自定义消息是否要变成一段 user 可读文本。
  • UI 专用事件是否完全排除。
  • bash 输出是否只保留摘要,完整结果放在外部文件。
  • 历史中的旧格式或分支摘要是否需要归一化。

这层不能简单 JSON.stringify(messages)。模型需要的是符合 Provider 协议的消息序列,不是内部状态的对象转储。

5. 一个消息投影示例#

ts
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. 工具调用的配对关系#

工具调用链至少要保持下面的关系:

text
assistant(toolCallId=A, name=query_data, args=...)
  └─ toolResult(toolCallId=A, result=...)

流式阶段可能先收到工具名和参数片段,最终阶段才得到完整调用。转换层或 Loop 必须负责合并增量,不能把每个片段都当成一条独立消息。

如果工具执行失败,仍然应该生成与 A 配对的 tool result,只是带上 isError 或错误内容。直接丢弃失败调用会让下一轮模型看到一个“没有结果的请求”,后续行为不可预测。

7. 哪些内容不应该进入上下文#

不是保存得越多越好。通常可以排除:

  • 纯 UI 的 loading、进度条和渲染状态。
  • 重复的工具进度更新。
  • 包含敏感密钥、内部路径或个人数据的原始日志。
  • 已经由摘要替代的巨大输出。
  • 明确标记 excludeFromContext 的命令结果。

排除时要保留可追溯性:会话或日志中可以记录“完整结果存在哪、为什么没有进入上下文”,避免调试时只剩一段无法解释的摘要。

8. 消息、上下文和会话的职责表#

对象解决的问题不应该负责
LLM MessageProvider 能理解什么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

相关文章