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。

相关推荐
ch8561 小时前
别再让 AI 直接报数了:思维链 CoT 全解——从“写步骤“到“自我拆解“,把推理拆到零件级
agent
echoVic1 小时前
Plan 模式为什么不能只是一个权限开关
架构·agent
echoVic1 小时前
为什么 Agent 的每个请求,都要先拍一张快照
架构·agent
烬羽2 小时前
组件拆了,逻辑没拆——自定义 Hook 才是 React 业务逻辑的正当归属
react.js·架构·typescript
小月土星2 小时前
自定义业务 Hook:从零解剖一个 React + TypeScript Todo 应用:用金字塔思维看透前端架构
react.js·架构·前端框架
AI 小老六2 小时前
Brainstorming 与 grill-me 在 AI 产品设计和工程决策中的分工边界
人工智能·ai·架构·创业创新
莫逸风3 小时前
【AgentScope 2.0】05-文件系统(Filesystem)详解
java·ai·agent·springai·agentscope
苏灿烤鱼4 小时前
今日 GitHub 热门|自改进 Agent 首日登顶,+2,180 项目却只排第四
agent
重庆小透明4 小时前
深入探寻微服务【第一篇微服务的坏】
微服务·云原生·架构