阶段一 · 架构与核心机制
消息与记忆 —— 对话历史如何组织与传递
模型没有记忆——它看到的"记忆"就是每次请求时喂给它的消息数组,而这个数组由 harness 维护。消息设计的第一原则是 role 判别:每条消息用
role字段标明来源,解析、持久化、渲染都按 role 分发。这一章还回答:跨会话的"长期记忆"和对话历史是什么关系。
学习目标
- 理解消息为什么按
role判别,以及内容块的类型与顺序。 - 理解工具调用与结果如何成对回填(
toolCallId配对)。 - 分清两类记忆:对话历史(harness 维护)与跨会话记忆(单独存储、按需筛选)。
一、问题:模型的记忆从哪来
模型每次推理都是一次独立的函数调用,它不记得上次说了什么。所谓"记忆",是 harness 在每次请求时把整个消息数组重新喂给模型。因此:
- 谁说的——
role字段(判别字段)让 Provider 知道每条消息的来源。 - 说了什么——内容块(文本、思考、图片、工具调用)按顺序排列。
消息是判别联合(AgentMessage),靠 role 区分类型:
| role | 来源 | 内容 |
|---|---|---|
user |
用户 | 纯文本,或文本/图片块 |
assistant |
模型 | 文本、思考块、或 toolCall(工具调用请求) |
toolResult |
工具执行 | 结果文本/图片,带 toolCallId 与调用配对 |
二、工具调用与结果必须成对
assistant 发出 toolCall(带唯一 id),工具执行后产生 toolResult(带同一个 toolCallId)。Provider 要求它们成对出现——如果上下文里有一个没有结果的工具调用,Provider 会拒绝请求。
这个约束的影响贯穿全书:压缩时"成对不拆"(07 章)、恢复会话时审计工具记录(05 章)——根子都在这里。
三、消息之上:产品消息与摘要消息
基础的三类消息之上,Pi 通过声明合并扩展产品消息,不破坏统一的 role 判别:
bashExecution:承载命令、输出、退出码、截断标记——bash 工具的专属消息。compactionSummary/branchSummary:压缩与分支摘要,用 XML 包装成模型可读的指令,留在上下文里保留因果。
这些特殊消息依然走同一个消息管线:解析、持久化、渲染都按 role 分发。
四、两类记忆的分工
| 记忆 | 存哪 | 生命周期 | 职责 |
|---|---|---|---|
| 对话历史 | 消息数组 / 会话文件 | 会话内 | 模型推理的完整上下文 |
| 跨会话记忆 | 单独的记忆库 | 跨会话 | 事实、偏好、修正,按需筛选注入 |
跨会话记忆的价值在筛选,不在积累:不是把所有历史都存下来,而是把值得复用的事实(用户偏好、项目约定、失败教训)挑选出来,在需要时注入。存储是次要问题,筛选策略才是记忆系统的核心——这也是社区记忆类扩展(如 pi-hermes-memory)的着力点。
当前 Pi 行为
- 消息是 append-only 的:循环只追加,不修改历史(异常修复除外)。
- 工具结果通过
toolCallId配对;持久化时消息序列化进 JSONL(见 05 章)。 - 消息类型在类型层就是判别联合:
AgentToolCall直接从 assistant 内容里Extract出来(types.ts53 行),而不是单独声明一个平行类型。 - 事件流按消息生命周期发:
message_start/message_update(流式 delta)/message_end(types.ts439–442)。 - 截断保护在消息层兜底:
length截断的整批工具调用会被失败回填,保证 toolCall/toolResult 成对(见 02 章)。
源码证据表
| 教学结论 | Pi 路径 / 符号 | 说明 |
|---|---|---|
| 工具调用是内容块的一种 | packages/agent/src/types.ts AgentToolCall(53) |
Extract<AssistantMessage["content"][number], {type:"toolCall"}> |
| toolResult 配对字段 | packages/agent/src/types.ts(397) |
toolCallId |
| 消息事件三段式 | packages/agent/src/types.ts(439–442) |
start / update / end |
| 追加而非改写 | packages/agent/src/agent-loop.ts runLoop |
messages.push(result) |
失败与边界实验
消息数组是循环的命脉,两类经典失败都发生在"配对"上:
- 只回填一半。 模型发起 3 个工具调用,executor 抛异常后只回填了 2 个结果——下一轮请求会被 API 拒绝(toolCall 无对应 toolResult)。修法:循环层保证"每个 toolCall 必有 toolResult",异常转成
is_error=True的结果回填。 toolCallId配错对。 结果回填给了错误的调用 ID,模型会把 B 工具的结果当成 A 的。串行时不易发现,并行执行时是真实风险——这也是并行调度要求工具作者声明executionMode的原因之一。- 截断消息直接入库。
length截断的 assistant 消息若原样保存,其残缺 toolCall 会在下一次会话恢复时引爆。Pi 的处理是在入列前就把这批调用失败化(failToolCallsFromTruncatedMessage),保证任何进入历史的消息都是成对完整的。
python3 -m unittest tests.test_13_mini_agent -v
在 Pi 里怎么操作
- 会话文件(
~/.pi/agent/sessions/*.jsonl)就是消息序列化的产物,可用 11 章 的 session_inspector 只读统计。 - 跨会话记忆需要安装记忆类扩展(如 pi-hermes-memory,见 06 章 的社区扩展表)。
Python 实验
mini_agent.py 用 dataclass 实现同一个判别联合,role 是默认字段值:
@dataclass(frozen=True)
class ToolResultMessage:
role: str = "toolResult"
tool_call_id: str = "" # 与 assistant 的 toolCall 配对
tool_name: str = ""
content: str = ""
is_error: bool = False
JSONL 持久化(dump_messages / load_messages)按 role 重建消息,未知 role 的记录原样保留为 dict——与 Pi"解析工具应保留未知记录而非静默重写"的约定一致。Tau 的 tau_agent/messages.py 用 Pydantic 的 Field(discriminator="role") 实现同一模型。
python3 -m learn_pi_lab lab mini-agent # 输出含 loaded_roles:JSONL 往返后的角色序列
这一模块的核心代码在核心代码导览 · 消息回填有逐段解读。
验证方式
python3 -m unittest tests.test_13_mini_agent -v
边界与安全
- 消息内容可能包含敏感数据(密钥、日志):持久化与日志输出都应脱敏。
- 不要信任历史中的工具结果:恢复会话后应审计工具调用记录。
- 消息模型是兼容性契约:新增 role 必须向后兼容,未知记录保留而不是丢弃。
回顾
- role 判别:谁说的、说了什么,按 role 分发处理。
- 成对回填:工具调用与结果靠
toolCallId配对,Provider 要求成对。 - 两类记忆:对话历史由 harness 维护;跨会话记忆价值在筛选不在积累。