Agent 的会话为什么是一棵树,而不是一串消息

对话记录怎么回退到旧节点?如果你把会话当成一串消息,答案只有两个:删掉尾部重来,或者复制一份新会话。pi 两件事都没做。它让存储保持 append-only,再用 parentId 表示每条 entry 接在哪个节点之后。文件里的行仍然按发生时间向后追加,模型上下文却只取 active leaf 所在的祖先路径。

这个设计同时保留了两类事实:旧分支做过什么,当前分支又选中了什么。它也解释了上一章为什么必须在保存点控制写入顺序------普通 append 总是把当前 leaf 作为 parent,把新 entry 变成下一个 leaf。插入位置一旦错了,改变的不只是展示顺序,而是整棵树的拓扑。

Entry 比 Message 多得多

所有 SessionTreeEntry 都有 typeidparentId 和 ISO 时间戳。联合类型除 message 外,还包含 thinking level、model、active tools、compaction、branch summary、custom、custom message、label、session info 与 leaf。

也就是说,会话树不只保存聊天正文,也保存沿分支生效的运行配置、摘要与导航记录。模型变更、thinking level、active tools 的修改都复用 Session.appendMessage() 的追加规则:storage 先生成 id,parentId 读当前 leaf,时间在追加时产生,然后 appendEntry()

配置因此也属于一条分支。从更早的节点另开分支时,不必继承原分支后面才发生的模型变更------这是「配置是分支数据」的直接推论。

把一段会话写成 JSONL,行顺序是时间,箭头由 parentId 决定:

bash 复制代码
1  {type:"message", id:"u1", parentId:null}
2  {type:"message", id:"a1", parentId:"u1"}
3  {type:"message", id:"u2", parentId:"a1"}
4  {type:"message", id:"a2", parentId:"u2"}
5  {type:"leaf",    id:"nav1", parentId:"a2", targetId:"u1"}
6  {type:"message", id:"a3", parentId:"u1"}

按文件顺序读,u2/a2 仍然存在;按当前 leaf a3 回溯,active branch 只有 u1/a3。第 5 行记录了一次「从 a2 移到 u1」的动作,它自身不成为对话的 active leaf。storage 看到 leaf entry 时,把 targetId 而不是 leaf entry 自己的 id 设为 pointer。

Leaf 是指针,也是一条可重放记录

Session.moveTo() 先校验目标存在,再调用 storage 的 setLeafId(entryId)。JSONL backend 并不只改内存变量:它构造一条 type: "leaf" 的 entry,parent 指向移动前的 current leaf,target 指向移动后的节点,先 append 文件,再更新内存索引与 current leaf。

重新打开文件时,loader 顺序扫描所有 entry:普通 entry 让 leaf 变成自己的 id,leaf entry 则让 leaf 变成 targetId。

这比只在 header 覆盖一个 activeLeafId 多写了一行,却保留了导航发生的先后。内存 backend 遵循同一合同;SQLite backend 可以额外维护 materialized branch,但对上仍实现相同的 SessionStorage 接口。

moveTo() 若收到 summary,会在移动 pointer 后追加 branch_summary,其 parent 正是目标 entry。这样旧分支的摘要成为新分支的第一个可见节点,新的 current leaf 也随之变成 summary entry。

模型读到的是 active path 的投影

Session.getEntries() 返回 append log;Session.getBranch() 从显式 fromId 或 current leaf 调用 getPathToRootOrCompaction()buildContextEntries() 在这条 path 上先执行默认 compaction transform,再执行应用提供的 entry transforms;buildContext() 进一步把 entry 映射为 AgentMessage[],同时派生 thinking level、model 与 active tools。

默认投影也不是「entry.payload 原样交给模型」:message 直接成为 AgentMessage;custom message 转成 role: custom;compaction 与 branch summary 各自变成摘要消息;普通 custom entry 默认返回空数组,只有注册了对应 entryProjectors[customType] 才能贡献消息。label、session info、leaf 和几种配置 entry 都不直接出现在 message 列表里。

所以要区分三个集合:

集合 来源 包含旧分支吗 直接模型可见吗
append log getEntries() 包含
active path getBranch() 只含当前祖先路径(遇 compaction 还有边界)
context messages buildContext() 来自 active path 的选择与投影 是,之后还要经过 convertToLlm

UI 可以用 append log 画整棵树,provider request 却不应把旧分支一起带上。反过来,某条 entry 没进入 provider context,也不代表它没有持久价值:label 用于导航提示,session info 用于列表名称,leaf entry 用于重建 pointer。

用同一套测试观察两种 backend

session.test.ts 把相同 suite 同时跑在 in-memory 与 JSONL storage 上:先追加 user1 → assistant2,再把 leaf 移回 user1,追加 branched。重新用同一 storage 创建 Session 后,context roles 仍只有 user、assistant,label 与 session name 可读。JSONL suite 还检查文件确实包含 header 和 leaf entry。

拆完这一章,我的感受是:「会话历史」这个词误导了很多人。它听起来像一串按时间排列的消息,但真正撑起「回到过去」这个能力的,是树拓扑加一个可重放的指针。把会话设计成树,代价是每次 append 都要小心 parent 是谁;收益是回退、分叉、摘要导航都变成同一种操作------追加一条记录,而不是删除什么。

源码依据:packages/agent/src/harness/types.ts L375-L464;session/session.ts L214-L281、L338-L357、L159-L200、L92-L147;session/jsonl-storage.ts L103-L146、L247-L287,pi v0.83.0,commit 845d6ff1f6643aba440341cce877ce1c43ebbc39。

相关推荐
Shota Kishi6 小时前
Solana Shreds 的 UDP 直投架构:Shredstream UDP Forwarding 的设计与实现
网络协议·架构·udp·区块链·solana
mldong10 小时前
一份 JSON,一条能跑的审批流:把报销流程送上工作流引擎
后端·架构
65岁退休Coder12 小时前
PI Agent 开发一个生产级 Harness
后端·node.js·agent
贾伟康12 小时前
【HarmonyOS 7新能力|026】Agent Framework Kit工程封装:把接入逻辑放进可维护的分层结构
agent·harmonyos·arkts·a2a·harmonyos 7
这个DBA有点耶12 小时前
异构数据集成怎么做?5 种同步方案对比 + 金融级 CDC 实战解析
数据库·oracle·架构
深蓝电商API13 小时前
MCP 与 AI Agent 如何改变爬虫开发模式?
爬虫·agent·mcp
这个DBA有点耶13 小时前
MySQL 8.0.20移除了Block Nested Loop,之前学的JOIN优化知识还适用吗?
数据库·mysql·架构
陆柒14514 小时前
从洋葱模型到 Elpis Core:我对 Node.js 服务端开发的理解
架构
懂软件的胡子个哥14 小时前
微信 API 消息回调怎么设计,才能避免丢消息和重复处理
运维·微信·架构·wechatapi·个人微信号二次开发
Ticnix14 小时前
多租户 RAG:让每个用户只检索到自己的知识库
后端·python·agent