返回课程首页

10

多轮对话到底保存了什么

区分 Agent Message、LLM Message、UI Message 与 Context。

源码基线:Pi v0.82.0 · commit 518855d

桌面 Agent 已经能安全地调用工具。接下来,用户会自然地连续提问:

用户:读取 src/config.ts,告诉我默认端口。
Agent:默认端口是 3000。
用户:把它改成 8080。

第二句话里的“它”指什么?

模型若只收到最后一句,就不知道“它”是 src/config.ts 中的端口。桌面应用必须把前面的对话带入下一次请求。

但“保存对话”不是把界面里的所有文字拼成一个字符串。Pi 至少涉及四种不同概念:

  • pi-ai 的 LLM Message;
  • pi-agent 的 Agent Message;
  • 发送模型前的 Context;
  • 桌面应用自己渲染的 UI Message。

本章从这个连续提问的例子出发,逐步拆开它们。

1. 最小多轮对话

使用 Agent 时,多轮记忆已经包含在它的运行状态中:

const agent = new Agent({
  initialState: {
    model,
    systemPrompt: "你是桌面开发助手。",
    tools: [readFileTool, editFileTool],
  },
  streamFn: models.streamSimple.bind(models),
});

await agent.prompt(
  "读取 src/config.ts,告诉我默认端口。",
);

await agent.prompt("把它改成 8080。");

第一次 prompt() 完成后,agent.state.messages 已经包含这一轮产生的消息。第二次 prompt() 会从当前状态创建 Context 快照,再追加新的 User Message。

概念上的消息序列是:

sequenceDiagram
    participant U as 用户
    participant A as Agent
    participant M as 模型
    participant T as 文件工具

    U->>A: 读取 config.ts,告诉我默认端口
    A->>M: 历史 + 第一句
    M-->>A: Tool Call: read_file
    A->>T: 读取文件
    T-->>A: Tool Result: 文件内容
    A->>M: 历史 + Tool Call + Tool Result
    M-->>A: 默认端口是 3000
    U->>A: 把它改成 8080
    A->>M: 全部相关历史 + 第二句

模型不是从 Agent 的 JavaScript 变量中“记住”上一轮。它是在下一次请求中再次看到了相关消息。

2. LLM Message:模型协议认识的消息

packages/ai/src/types.ts 定义:

type Message =
  | UserMessage
  | AssistantMessage
  | ToolResultMessage;

UserMessage

interface UserMessage {
  role: "user";
  content:
    | string
    | (TextContent | ImageContent)[];
  timestamp: number;
}

它表示用户输入,也可以同时包含文字和图片。

AssistantMessage

Assistant Message 不只有回答文本,还保存:

  • TextContent
  • ThinkingContent
  • ToolCall
  • Provider、API 和模型标识;
  • Usage;
  • Stop Reason;
  • Provider 返回的签名或响应标识;
  • 错误与诊断信息。

ToolResultMessage

interface ToolResultMessage {
  role: "toolResult";
  toolCallId: string;
  toolName: string;
  content: (TextContent | ImageContent)[];
  details?: unknown;
  usage?: Usage;
  addedToolNames?: string[];
  isError: boolean;
  timestamp: number;
}

toolCallId 把结果和 Assistant Message 中的 Tool Call 配对。

不要把一轮工具对话扁平化成:

Assistant 调用了 read_file,结果是……

原始结构包含 Provider 继续对话可能需要的 Tool Call ID、Thinking Signature,以及延迟工具加载使用的 addedToolNames 等信息。恢复会话时应持久化完整的标准消息,而不是只保存可见文本。

3. AgentMessage:Agent 运行时认识的消息

基础定义在 packages/agent/src/types.ts

interface CustomAgentMessages {}

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

默认情况下,CustomAgentMessages 是空接口,所以 Agent Message 看起来和 LLM Message 相同。

但应用可以通过 TypeScript declaration merging 扩展它:

type ApprovalNotice = {
  role: "approvalNotice";
  toolCallId: string;
  decision: "allowed" | "denied";
  timestamp: number;
};

declare module "@earendil-works/pi-agent-core" {
  interface CustomAgentMessages {
    approvalNotice: ApprovalNotice;
  }
}

此后 Agent 的消息序列既可以包含模型协议消息,也可以包含应用自定义消息。

flowchart LR
    L["LLM Message"] --> A["AgentMessage"]
    C["Custom Agent Message"] --> A
    A --> S["Agent 状态与事件"]

这就是为什么 Agent Loop 全程使用 AgentMessage[],只在真正调用模型前才转换成 Message[]

4. UI Message:桌面应用自己的显示模型

Pi 没有定义一个名为 UIMessage 的通用核心类型。它应由桌面应用根据界面需求设计:

type ChatItem =
  | {
      kind: "user";
      id: string;
      text: string;
    }
  | {
      kind: "assistant";
      id: string;
      text: string;
      isStreaming: boolean;
    }
  | {
      kind: "tool";
      toolCallId: string;
      label: string;
      state:
        | "running"
        | "success"
        | "error";
    }
  | {
      kind: "approval";
      toolCallId: string;
      decision: string;
    };

UI Message 可以包含:

  • 是否展开;
  • 动画状态;
  • 头像和颜色;
  • Tool 卡片进度;
  • 是否正在流式生成;
  • 复制按钮状态;
  • 本地错误提示;
  • 审批弹窗状态。

这些数据通常不应该发送给模型。

5. 三种消息不要共用一个类型

flowchart TD
    E["AgentEvent"] --> R["UI Reducer"]
    R --> U["ChatItem[]:用于渲染"]

    E --> A["Agent.state.messages:Agent transcript"]
    A --> T["transformContext"]
    T --> C["convertToLlm"]
    C --> L["Message[]:发送给模型"]

三个集合承担不同职责:

集合 用途 是否直接发送模型
ChatItem[] 桌面界面渲染
AgentMessage[] Agent 运行时 Transcript 转换后才发送
Message[] Provider 请求协议

如果直接把 UI 状态塞进 User Message,模型会看到“折叠”“复制成功”“窗口失焦”等无关文字。反过来,如果只保存 UI 展示文本,Tool Call 的结构化信息又会丢失。

6. Agent Context 不只是消息

基础 Agent 的 Context 是:

interface AgentContext {
  systemPrompt: string;
  messages: AgentMessage[];
  tools?: AgentTool[];
}

真正交给 pi-ai 的 Context 是:

interface Context {
  systemPrompt?: string;
  messages: Message[];
  tools?: Tool[];
}

因此模型一次请求看到的是:

System Prompt
+ 转换后的消息历史
+ 当前可用工具定义

System Prompt 不需要伪装成 User Message。工具定义也不是普通对话文字,它们由 Provider 适配层转换成对应模型协议。

7. 第二次 prompt 时发生了什么

packages/agent/src/agent.ts 的关键步骤是:

  1. prompt() 把字符串变成 User Message;
  2. AgentState 创建 Context 快照;
  3. runAgentLoop() 把新消息追加到快照;
  4. Loop 发出新 User Message 的生命周期事件;
  5. 调用模型;
  6. Assistant Message 和 Tool Result 继续追加到当前 Context;
  7. Agent 消费事件,把最终消息写回 state.messages
flowchart TD
    P["agent.prompt('把它改成 8080')"] --> U["创建 UserMessage"]
    U --> S["复制 state.messages / tools"]
    S --> A["追加新 UserMessage"]
    A --> M["请求模型"]
    M --> R["追加 AssistantMessage"]
    R --> T{"有 Tool Call?"}
    T -->|是| X["执行并追加 ToolResult"]
    X --> M
    T -->|否| E["agent_end"]
    E --> Q["state.messages 保留完整 Transcript"]

这里的“快照”是顶层数组复制,不是递归深拷贝。应用不要在一次运行进行中原地修改历史消息对象。

8. 真正发送模型前的两道转换

packages/agent/src/agent-loop.tsstreamAssistantResponse() 按顺序执行:

let messages = context.messages;

if (config.transformContext) {
  messages = await config.transformContext(
    messages,
    signal,
  );
}

const llmMessages =
  await config.convertToLlm(messages);

const llmContext = {
  systemPrompt: context.systemPrompt,
  messages: llmMessages,
  tools: context.tools,
};

两者职责不同:

transformContext

输入和输出都是 AgentMessage[],适合:

  • 压缩旧历史;
  • 裁剪不相关消息;
  • 注入外部上下文;
  • 在 Agent Message 层调整请求视图。

convertToLlm

AgentMessage[] 变成 Provider 能理解的 Message[],适合:

  • 把自定义消息转成 User Message;
  • 过滤只供 UI 使用的消息;
  • 保留标准 user、assistant、toolResult。

顺序固定为:

AgentMessage[]
  → transformContext
  → AgentMessage[]
  → convertToLlm
  → Message[]
  → pi-ai Context

9. 返回新数组只改变本次请求视图

假设桌面应用临时裁剪很老的通知:

const agent = new Agent({
  initialState: {
    model,
    tools,
  },
  streamFn,
  transformContext: async (messages) => {
    return messages.filter(
      (message) =>
        message.role !== "approvalNotice",
    );
  },
});

transformContext() 返回的新数组用于本次模型请求,并不会自动替换 agent.state.messages

因此可以同时拥有:

  • 完整的运行 Transcript;
  • 更短的模型 Context;
  • 独立的 UI View Model。

这是后面会话存储和上下文压缩的基础。

但 Context 快照只复制顶层数组,里面的消息对象仍与 Agent State 浅共享。转换器应按纯函数方式工作:

  • 返回新数组;
  • 不要原地改写传入数组;
  • 不要原地修改消息对象;
  • 需要改消息时创建新对象。

原地修改消息对象可能污染 agent.state.messages;原地修改 Context 数组也会影响当前 Run 后续 Turn 的上下文。“只改变本次请求视图”成立的前提,是转换器没有修改共享输入。

转换函数的契约要求不要抛错。基础 Loop 不会把这里的异常自动转换成正常的 Assistant Error Message。应用应返回原消息或另一个安全回退值。

10. 默认 convertToLlm 会做什么

基础 Agent 的默认转换很保守:

function defaultConvertToLlm(
  messages: AgentMessage[],
): Message[] {
  return messages.filter(
    (message) =>
      message.role === "user" ||
      message.role === "assistant" ||
      message.role === "toolResult",
  );
}

也就是说,自定义 Agent Message 默认不会发送给模型。

如果自定义消息确实要影响模型,应用必须明确转换:

function convertMessages(
  messages: AgentMessage[],
): Message[] {
  return messages.flatMap((message) => {
    if (message.role === "approvalNotice") {
      return [{
        role: "user",
        content: [{
          type: "text",
          text:
            `用户${message.decision === "allowed"
              ? "批准"
              : "拒绝"}了工具调用。`,
        }],
        timestamp: message.timestamp,
      }];
    }

    if (
      message.role === "user" ||
      message.role === "assistant" ||
      message.role === "toolResult"
    ) {
      return [message];
    }

    return [];
  });
}

决定一条消息是否进入模型 Context 的,是 convertToLlm,不是它是否在 UI 中可见。

11. coding-agent 增加了哪些自定义消息

packages/coding-agent/src/core/messages.ts 通过 declaration merging 增加四类:

Role 作用
bashExecution 表示用户直接执行的 Shell 命令
custom 扩展注入的自定义消息
branchSummary 从一个分支返回时的摘要
compactionSummary 压缩旧历史后的摘要

coding-agent 自己提供的 convertToLlm() 会:

  • 把普通 user、assistant、toolResult 原样保留;
  • 把 Bash 执行格式化成 User Message;
  • 把 Custom Message 转成 User Message;
  • 给 Branch Summary 和 Compaction Summary 加标记后转成 User Message;
  • 过滤 excludeFromContext: true 的 Bash Message。

这说明“自定义 Agent Message”不等于“仅供 UI 使用”。最终行为取决于转换器。

12. display 不等于模型可见性

coding-agent 的 CustomMessage 有一个字段:

interface CustomMessage {
  role: "custom";
  customType: string;
  content:
    | string
    | (TextContent | ImageContent)[];
  display: boolean;
  details?: unknown;
  timestamp: number;
}

display 表达显示意图。当前 convertToLlm() 转换 Custom Message 时没有检查它:

case "custom":
  return {
    role: "user",
    content: normalizedContent,
    timestamp: message.timestamp,
  };

所以:

display: false

不代表:

不会发送给模型

相反,一条消息可以:

  • UI 不显示,但进入模型 Context;
  • UI 显示,但被自定义 convertToLlm 过滤;
  • UI 和模型都看到;
  • 两边都不保留,只作为瞬时状态。

桌面应用必须分别定义 visibleInUIincludeInModelContext,不要用一个布尔值承担两个安全含义。

13. details 通常用于 UI,不会自动发送

Tool Result 的 content 是模型可见内容,details 是任意结构化数据:

return {
  content: [{
    type: "text",
    text: "找到 3 个文件",
  }],
  details: {
    files: fullFileMetadata,
  },
};

Provider 的 LLM Message 转换使用 content,不会自动把 details 当成对话正文。

因此可以让:

  • 模型只看到简洁摘要;
  • UI 使用 details 渲染表格、图标或调试信息。

details 仍可能被 Session 持久化、日志记录或扩展读取。它不是秘密保险箱,敏感信息依然需要最小化和脱敏。

14. 哪些 UI 状态不应该发送给模型

通常应留在 View Model:

  • 输入框草稿;
  • 光标和选区;
  • 某条消息是否折叠;
  • 滚动位置;
  • 动画进度;
  • “复制成功”提示;
  • 当前窗口大小;
  • Tool 卡片的视觉状态;
  • 审批弹窗是否获得焦点;
  • 网络重连次数;
  • 本地调试堆栈。

可能需要转成模型消息的业务事实:

  • 用户拒绝了某项操作;
  • 某个外部任务已经完成;
  • 用户上传了一张图片;
  • 工具返回了错误;
  • 分支或压缩历史的摘要。

判断标准不是“UI 上有没有显示”,而是:

模型要正确完成下一步,是否必须知道这个事实?

15. 流式消息应该保存哪一个版本

生成中,Agent 会不断发出:

  • message_start
  • 多个 message_update
  • message_end

在 Assistant 的 message_update 阶段,streamingMessage 保存当前 Partial Assistant Message。实现也会在其他消息的 message_startmessage_end 之间,让它短暂指向该 User、Custom 或 ToolResult Message。因此桌面 UI 更适合直接按事件类型和消息角色归约,而不是仅凭 streamingMessage 推断内容类型。

一个最小 Reducer 应处理完整生命周期:

function reduceMessageEvent(
  state: ChatState,
  event: AgentEvent,
): ChatState {
  if (event.type === "message_start") {
    if (event.message.role === "assistant") {
      return startAssistantCard(
        state,
        event.message,
      );
    }

    if (event.message.role === "user") {
      return appendUserCard(
        state,
        event.message,
      );
    }

    if (event.message.role === "toolResult") {
      return startToolResultCard(
        state,
        event.message,
      );
    }

    return state;
  }

  if (event.type === "message_update") {
    return replaceStreamingCard(
      state,
      event.message,
    );
  }

  if (event.type === "message_end") {
    if (event.message.role !== "assistant") {
      return finalizeNonAssistantCard(
        state,
        event.message,
      );
    }

    return finalizeMessageCard(
      state,
      event.message,
    );
  }

  return state;
}

message_update 只会为 Assistant Message 发出,而 message_startmessage_end 也会覆盖 User、ToolResult 和自定义 Agent Message。最终 Transcript 保存完整的消息;UI 增量只是生成同一条 Assistant Message 的过程,不是几十条对话历史。

16. 一份桌面端分层设计

type DesktopConversationState = {
  transcript: AgentMessage[];
  items: ChatItem[];
  composerDraft: string;
  activeApproval?: {
    toolCallId: string;
  };
};

职责可以拆成:

flowchart LR
    P["持久层"] --> A["AgentMessage[]"]
    A --> G["Agent"]
    G --> E["AgentEvent"]
    E --> U["UI Reducer"]
    U --> V["ChatItem[]"]
    A --> X["Context Policy"]
    X --> L["Message[]"]
    L --> M["模型"]
  • 持久层保存可重建的 Transcript;
  • Agent 管理当前运行;
  • UI Reducer 管理展示状态;
  • Context Policy 决定模型能看到什么;
  • Provider 只接收标准 Message。

下一章会继续解决 Transcript 如何写入磁盘并在重启后恢复。

17. 完整走一遍“把它改成 8080”

第一次请求结束后,Agent Message 可能是:

UserMessage
AssistantMessage(toolCall: read_file)
ToolResultMessage(file content)
AssistantMessage("默认端口是 3000")

第二次 User Message 追加:

UserMessage("把它改成 8080")

在下一次模型调用前:

  1. transformContext 选择本次要保留的 Agent Message;
  2. convertToLlm 过滤或转换自定义消息;
  3. 组装 Context 的 System Prompt、Message 和 Tool;
  4. Provider 适配器把统一结构转成厂商协议;
  5. 模型从历史 Tool Call、Tool Result 和回答中理解“它”;
  6. 模型生成 edit_file Tool Call;
  7. 上一章的 Permission Gate 再决定这次修改是否允许。

这也把前面的知识串起来了:

flowchart LR
    H["历史 AgentMessage"] --> C["Context 转换"]
    N["新 UserMessage"] --> C
    C --> M["模型"]
    M --> T["edit_file Tool Call"]
    T --> P["Permission Gate"]
    P --> R["Tool Result"]
    R --> H

18. 常见误区

“模型服务会替我长期保存对话”

不要依赖这个假设。Pi 每次构建 Context,并由 Provider 适配器发送当前请求需要的历史。

“界面上看到的文字就是模型收到的全部内容”

错误。System Prompt、工具定义、隐藏的自定义消息和结构化 Tool Result 都可能进入 Context。

“AgentMessage 和 Message 完全相同”

错误。AgentMessage 可以包含应用自定义类型,必须转换后才能发给模型。

“display: false 就是安全地隐藏模型上下文”

错误。在 coding-agent 的 Custom Message 中,它不控制 convertToLlm

“只存 Assistant 的最终文本就足够恢复多轮对话”

错误。Tool Call ID、Tool Result、图片、Thinking Signature 和其他协议字段可能影响后续兼容性。

“details 没发给模型,所以可以随便放秘密”

错误。它仍可能被持久化、展示或被扩展读取。

19. 本章小结

这一章从连续追问“把它改成 8080”拆开了 Pi 的消息层:

  • Messagepi-ai 和 Provider 能理解的协议消息;
  • AgentMessage 在此基础上允许应用增加自定义消息;
  • AgentContext 保存 System Prompt、Agent Message 和工具;
  • transformContext 先调整 Agent Message 请求视图;
  • convertToLlm 再过滤和转换成标准 Message;
  • ChatItem 或 UI Message 是桌面应用自己的 View Model,不应直接等同于模型 Context;
  • coding-agent 的 display 与模型可见性不是同一个开关;
  • 流式 Update 应更新同一张消息卡片,最终只保存完整 Assistant Message;
  • 多轮“记忆”本质上是下一次请求重新携带相关历史。

20. 自测

  1. MessageAgentMessage 的关系是什么?
  2. 为什么桌面应用还需要独立的 ChatItem
  3. transformContextconvertToLlm 的调用顺序是什么?
  4. 自定义 Agent Message 默认会不会被基础 Agent 发给模型?
  5. coding-agent 的 CustomMessage.display 能否控制模型可见性?
  6. Tool Result 的 contentdetails 分别适合放什么?
  7. 为什么不能把每次 message_update 都追加成一条历史消息?
  8. 第二次 prompt("把它改成 8080") 时,模型怎样理解“它”?