当前位置: 首页 > news >正文

JSONL 树形 session:append-only + 两种 fork

本文以 earendil-works/pi-mono 为样本,分析其会话存储设计。所引用的代码位于packages/agent/src/harness/session/packages/coding-agent/src/core/session-manager.ts

一、为什么不是一条线性消息列表

大多数 chat 应用的会话存储是这样的:

{"id":"chat-123","messages":[{"role":"user","content":"..."},{"role":"assistant","content":"..."},{"role":"user","content":"..."},...]}

够用,但有几个先天问题:

  1. 不能精确回放历史的某一节点。如果用户在第 5 条消息时反悔,想从那里再问一个不同的问题,要么覆盖第 5 条之后的所有内容,要么复制整个会话。
  2. 不能并行探索分支。给 LLM 同一个上下文问两个不同问题,结果应该并存比较,但线性结构里没办法表达。
  3. 崩溃恢复粒度大。整个文件需要事务写入,部分成功部分失败时难以处理。
  4. 多端编辑容易冲突。两个客户端同时往同一会话追加消息,简单 append 也能撞车。

pi 的解法:会话文件是一棵 append-only 的事件树。每条 entry 携带idparentId,逻辑上构成树;物理上仍然按时间顺序追加到同一个.jsonl文件。“当前活跃位置” 用一个独立的leafentry 显式标记。

下文从文件格式开始,逐步展开它的两种 fork 与上下文构建。


二、文件格式

2.1 路径与命名

~/.pi/agent/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl
  • <encoded-cwd>是把工作目录绝对路径编码后的子目录名(这样不同项目的会话天然隔离);
  • 每个会话一个.jsonl文件,不可变更名
  • 文件名带时间戳便于排序,带 UUID 防止冲突。

2.2 行格式

文件每一行是一个 JSON 对象,第一行是 header,之后每行是一个 entry:

{"type":"session","version":3,"id":"<uuid>","timestamp":"...","cwd":"/path","parentSession":"/abs/path/to/parent.jsonl"} {"type":"model_change","id":"<8hex>","parentId":null,"timestamp":"...","provider":"...","modelId":"..."} {"type":"message","id":"<8hex>","parentId":"<8hex>","timestamp":"...","message":{"role":"user","content":"..."}} {"type":"message","id":"<8hex>","parentId":"<8hex>","timestamp":"...","message":{"role":"assistant","content":[...],...}} {"type":"message","id":"<8hex>","parentId":"<8hex>","timestamp":"...","message":{"role":"toolResult","toolCallId":"...","content":[...]}} {"type":"compaction","id":"<8hex>","parentId":"<8hex>","timestamp":"...","summary":"...","firstKeptEntryId":"<8hex>","tokensBefore":N} {"type":"label","id":"<8hex>","parentId":"<8hex>","timestamp":"...","targetId":"<8hex>","label":"v1 attempt"} {"type":"leaf","id":"<8hex>","parentId":"<8hex>","timestamp":"...","targetId":"<8hex>"} {"type":"session_info","id":"<8hex>","parentId":"<8hex>","timestamp":"...","name":"refactor auth flow"}

类型一览:

type用途
session文件 header(仅第一行)
message用户 / assistant / toolResult 消息
model_change切换模型记录
thinking_level_change切换 thinking 等级记录
compaction历史折叠摘要
branch_summary分支被放弃前的摘要
label给某个 entry 打的人类可读标签
leaf当前活跃叶子指针(可多次写)
session_info显示名等元信息
custom_message/custom扩展自定义消息 / 数据

每个 entry 必有四个字段:typeid(在文件内唯一)、parentId(指向上一条逻辑前驱)、timestamp(ISO string)。

2.3 树结构示例

下面这段对话产生的 entry 树(id 简化为字母):

[session header] A: model_change (parentId: null) B: message (parentId: A, role: user, "改一下登录") C: message (parentId: B, role: assistant, "好的,先看一下文件") D: message (parentId: C, role: assistant, toolCall: read auth.ts) E: message (parentId: D, role: toolResult, ...) F: message (parentId: E, role: assistant, "用 X 方案改") leaf: { targetId: F }

物理上是 7 行 JSON。逻辑上是一条从AF的线性链,leaf指向F表示当前活跃位置。


三、append-only 与 leaf 指针

整个 session 文件只追加,不修改。这是 pi 设计中最强的不变量。

3.1 写入只走 appendFile

存储后端实现里只有appendEntry方法做写入:

// packages/agent/src/harness/session/jsonl-storage.tsasyncappendEntry(entry:SessionTreeEntry):Promise<void>{getFileSystemResultOrThrow(awaitthis.fs.appendFile(this.filePath,`${JSON.stringify(entry)}\n`),...);this.byId.set(entry.id,entry);updateLabelCache(this.labelsById,entry);this.currentLeafId=leafIdAfterEntry(entry);}

没有updateEntry、没有deleteEntry。session 一旦写入就是历史。

3.2 leaf 是一种独立的 entry 类型

"当前我们在哪一条 message 上"这种状态如果存在内存里,崩溃后就丢了。pi 的做法是:leaf 也是一条持久化的 entry

// packages/agent/src/harness/session/jsonl-storage.tsasyncsetLeafId(leafId:string|null):Promise<void>{if(leafId!==null&&!this.byId.has(leafId)){thrownewSessionError("not_found",`Entry${leafId}not found`);}constentry:LeafEntry={type:"leaf",id:generateEntryId(this.byId),parentId:this.currentLeafId,timestamp:newDate().toISOString(),targetId:leafId,};getFileSystemResultOrThrow(awaitthis.fs.appendFile(this.filePath,`${JSON.stringify(entry)}\n`),`Failed to append session leaf${entry.id}`,);this.entries.push(entry);this.byId.set(entry.id,entry);this.currentLeafId=leafId;}

每次切换叶子(用户回退、navigate 到旧分支)都会 append 一个leafentry。重开文件时只需读到最后一个leaf就能恢复出"上次活跃在哪":

functionleafIdAfterEntry(entry:SessionTreeEntry):string|null{returnentry.type==="leaf"?entry.targetId:entry.id;}// loadJsonlStorage 加载时:letleafId:string|null=null;for(leti=1;i<lines.length;i++){constentry=parseEntryLine(lines[i]!,filePath,i+1);entries.push(entry);leafId=leafIdAfterEntry(entry);// ← 普通 entry 推进 leaf;leaf entry 显式跳转}

普通消息 entry 写入会让 leaf 推进到自己;只有leafentry 是显式的"跳过去"。

3.3 上层包装:SessionManager

coding-agent在 storage 之上加了一层SessionManager,把 entry 创建的细节封住:

// packages/coding-agent/src/core/session-manager.tsappendMessage(message:AgentMessage):string{constentry:MessageEntry={type:"message",id:generateId(this.byId),parentId:this.leafId,// ← 当前 leaf 作为 parenttimestamp:newDate().toISOString(),message,};this.fileEntries.push(entry);this.byId.set(entry.id,entry);this.leafId=entry.id;// ← 自动推进 leafthis._persist(entry);returnentry.id;}appendCompaction<T>(summary:string,firstKeptEntryId:string,tokensBefore:number,details?:T):string{constentry:CompactionEntry={type:"compaction",id:generateId(this.byId),parentId:this.leafId,timestamp:newDate().toISOString(),summary,firstKeptEntryId,tokensBefore,details,};// ... 同样 append}

SessionManager还提供branch(branchFromId)来切换 leaf 到一个旧 entry:

// packages/coding-agent/src/core/session-manager.tsbranch(branchFromId:string):void{if(!this.byId.has(branchFromId)){thrownewError(`Entry${branchFromId}not found`);}this.leafId=branchFromId;}

切完 leaf 后下一次appendMessage写入的 entry,parentId就是branchFromId——这样多个孩子共享同一个 parentId,树就长出新分支了。


四、两种 fork:fork 与 navigateTree

pi 提供两种"分叉",底层数据结构相同,但物理位置不同,用法也不同。

4.1 fork:复制路径到新文件

SessionManager.createBranchedSession(leafId)把从根到指定 leaf 的路径复制到一个新的 .jsonl 文件

// packages/coding-agent/src/core/session-manager.tscreateBranchedSession(leafId:string):string|undefined{constpath=this.getBranch(leafId);if(path.length===0){thrownewError(`Entry${leafId}not found`);}// 创建新 session 文件,header 里 parentSession 指回原文件// 然后把 path 上的所有 entry 重新 append(id 不变)...}

效果:

原文件 a.jsonl 新文件 b.jsonl ───────────────── ───────────────── session header (parentSession: -) session header (parentSession: a.jsonl) A: model_change A: model_change ← 复制 B: message (user) B: message (user) ← 复制 C: message (asst) C: message (asst) ← 复制 D: message (asst, toolCall) leaf: { target: C } E: toolResult F: message (asst, "X 方案") leaf: { target: F }

新文件从C之后是空的,可以独立追加完全不同的对话。两份文件完全独立,互不影响。

适用场景:

  • 用户在某个用户消息处点 “Fork from here”,想从这里发起一个完全独立的探索;
  • 以一个旧会话作为"模板"开新会话;
  • 给团队成员分享一段对话历史让他从某点继续。

UI 上通常表现为 sidebar 树里多出一个子会话节点(通过 header 里的parentSession字段反查得到)。

4.2 navigateTree:在同一文件里建多分支

AgentSession.navigateTree(targetId)不复制文件,而是把 leaf 指针跳到targetId,下次写入就会形成树的新分支:

单个文件,leaf 跳到 C 之后再 append M1: [session header] A: model_change B: message (user) C: message (asst) D: message (asst, toolCall, parentId=C) ← 旧分支 E: toolResult (parentId=D) F: message (asst, parentId=E) leaf: { target: F } [这里调 navigateTree(C),写一个 leaf entry] leaf: { target: C } [现在再 prompt 一句,会 append 一个 message,parentId=C] M1: message (user, parentId=C) ← 新分支起点 M2: message (asst, parentId=M1) leaf: { target: M2 }

文件里同时存在两个分支:C → D → E → FC → M1 → M2,共享前面的B → C

适用场景:

  • 用户在对话里点回退到第 N 条消息,从那里换个问法;
  • 比较"同一个上下文下两种问法"的效果;
  • LLM 自己生成多个候选答案让用户挑(每个候选作为一个分支)。

UI 上通常表现为同一会话里的"分支选择器"(pi-web 里叫 BranchNavigator):用户能在分支间来回切,每次切都 append 一个leafentry 做持久化。

4.3 两种 fork 的对比

维度fork(createBranchedSession)navigateTree
物理位置新文件同文件
文件 header新文件parentSession反指原文件不变
是否复制 entry是(路径上的所有 entry 重写一份)否(只 append 新的 leaf 跳转)
元数据可继承性弱(独立会话)强(共享所有元数据)
sidebar 表现树里新增子节点同会话内分支选择器
适用完全独立的探索当前对话内"换个问法"

它们底层是同一套树结构,只是物理上的分割粒度不同。这是 pi 比许多框架更成熟的一处设计——大多数项目要么只支持一种、要么把两种概念混淆。


五、上下文构建:buildSessionContext

写入是 append-only 的,但喂给 LLM 的"对话历史"必须是线性的。buildSessionContext把树降维成线:

// packages/agent/src/harness/session/session.tsexportfunctionbuildSessionContext(pathEntries:SessionTreeEntry[]):SessionContext{letthinkingLevel="off";letmodel:{provider:string;modelId:string}|null=null;letcompaction:CompactionEntry|null=null;for(constentryofpathEntries){if(entry.type==="thinking_level_change"){thinkingLevel=entry.thinkingLevel;}elseif(entry.type==="model_change"){model={provider:entry.provider,modelId:entry.modelId};}elseif(entry.type==="message"&&entry.message.role==="assistant"){model={provider:entry.message.provider,modelId:entry.message.model};}elseif(entry.type==="compaction"){compaction=entry;}}constmessages:AgentMessage[]=[];constappendMessage=(entry:SessionTreeEntry)=>{if(entry.type==="message"){messages.push(entry.messageasAgentMessage);}elseif(entry.type==="custom_message"){messages.push(createCustomMessage(...));}elseif(entry.type==="branch_summary"&&entry.summary){messages.push(createBranchSummaryMessage(...));}};if(compaction){// 用 summary 替换 compaction 之前的所有内容(保留 firstKeptEntryId 之后的)messages.push(createCompactionSummaryMessage(...));constcompactionIdx=pathEntries.findIndex((e)=>e.type==="compaction"&&e.id===compaction.id);letfoundFirstKept=false;for(leti=0;i<compactionIdx;i++){constentry=pathEntries[i]!;if(entry.id===compaction.firstKeptEntryId)foundFirstKept=true;if(foundFirstKept)appendMessage(entry);}for(leti=compactionIdx+1;i<pathEntries.length;i++){appendMessage(pathEntries[i]!);}}else{for(constentryofpathEntries){appendMessage(entry);}}return{messages,thinkingLevel,model};}

输入是pathEntries:从 leaf 沿parentId回溯到根的 entry 序列。处理三件事:

  1. 沿路径累计配置thinkingLevelmodel用最后一次出现的为准(assistant message 也会更新 model,因为它记录了"这条消息实际用的什么模型");
  2. 跳过非消息类 entryleaflabelsession_infothinking_level_changemodel_change都不进入 messages;
  3. Compaction 折叠:如果路径上有compactionentry,把它之前的内容用 summary 替换,但保留firstKeptEntryId之后的 entry(这是 compaction 算法决定要保留的近期上下文)。

注意这是纯函数——每次 turn 开始都会重算一次。pi 不缓存"对话历史",而是从 source of truth(session 文件)每次推导。这让任何对 session 的修改都能立刻反映到下一个 turn。

SessionManager暴露的buildSessionContext()包了一层,从当前 leaf 开始:

buildSessionContext():SessionContext{returnbuildSessionContext(this.getEntries(),this.leafId,this.byId);}getBranch(fromId?:string):SessionEntry[]{constpath:SessionEntry[]=[];conststartId=fromId??this.leafId;letcurrent=startId?this.byId.get(startId):undefined;while(current){path.unshift(current);current=current.parentId?this.byId.get(current.parentId):undefined;}returnpath;}

getBranch沿parentId回溯,得到从根到 leaf 的线性路径。即使文件里有多个分支共存,只有当前 leaf 所在的那一条会被读出来。


六、和"线性消息列表"的对比总结

能力线性 messagespi JSONL 树
写入整文件覆盖或 appendappend-only
回退到旧消息截断后续 / 复制全部写一条leafentry
同一上下文多分支不支持navigateTree 原生支持
跨会话分支复制整个文件fork (createBranchedSession)
历史折叠重写文件写一条compactionentry
崩溃恢复整文件事务行级,最后一行损坏只丢一个 entry
多端编辑易冲突append 天然顺序,但仍需 leader
元数据(model 切换、label)混在 message 里独立 entry 类型

代价:

  • 文件读取要解析整棵树,复杂度比线性高(pi 的实现是把 entry 全部读进Map<id, entry>一次构建);
  • 历史 entry 不能删除(pi 通过 compaction 来"逻辑删除",把折叠的内容用 summary 替换);
  • 文件会持续增长,需要 compaction + 必要时手工归档。

收益:

  • 任意一条 entry 都能稳定引用(id 永久有效);
  • 任何"修改 session"的操作都不会破坏历史;
  • 重开文件能精确恢复到上次活跃位置;
  • 分支不需要新文件,单文件能表达完整对话探索过程。

七、设计中的几个关键不变量

  1. session 文件 append-only。任何代码不允许 truncate 或 update 已有行(需要重写整个文件的场景,比如 cascade reparent,pi 通过完全重写新文件来实现,原文件保持只读)。

  2. leaf 由 entry 承载。内存里的leafId字段只是缓存,source of truth 是文件里最后一个leafentry(或最后一条普通 entry)。

  3. id 在文件内唯一generateEntryId(byId)在每次 append 前确保 id 不重复,跨文件可以重复。

  4. parentId 可以为 null。表示这是路径的根(通常是第一条 model_change 或 message)。

  5. compaction entry 替换历史,不删除。LLM 看到的是 summary,但原始 entry 仍在文件里——如果将来需要,可以读取原始内容做审计或重新 compact。

  6. buildSessionContext 是纯函数。从 entry 数组到 messages 数组的转换不依赖任何外部状态,意味着任何对 session 的改动都能在下个 turn 立即生效。


八、可借鉴的工程要点

如果你正在做需要"历史回放"或"分支探索"的对话系统,从 pi 这套设计里能直接搬走的几条:

  1. 每条记录配 id + parentId。即使一开始只用线性,后续要加分支也只是切 leaf。

  2. 当前位置作为独立 entry 持久化。运行时缓存可以丢,文件里的leafentry 不能丢。

  3. "复制文件"和"同文件分支"分别提供 API,因为它们是两种用户意图。混用会让 UI 设计变得困难。

  4. 元数据用独立 entry 类型(model 切换、label、session_info),不要塞 message 字段。这让文件的语义层级更清晰。

  5. 历史折叠用 entry 而不是重写compactionentry 是逻辑层的"折叠",物理层文件不变,给审计、回滚、二次处理留了空间。

  6. buildContext 永远从 source 重算。不要做"增量维护一个内存对话历史"的优化,每次 turn 重算才能保证多分支切换 / compaction / extension 写入都立即生效。


写在最后

把会话历史从"线性数组"升级成"append-only 事件树",看上去只是数据结构的小调整,落到产品上是一系列能力解锁:精确回放、并行探索、历史折叠、崩溃恢复、跨会话 fork。这些能力 ChatGPT 与 Claude 在产品层都做了,但在公开的开源项目里,pi 是少有的把这套数据结构清晰暴露出来的样本。

更难得的是它的实现非常克制——核心代码不到一千行,没有引入任何额外的数据库、没有索引、没有事务系统,只用appendFile加几个 entry 类型就把所有能力承载起来。这是一种"用约束换能力"的工程哲学:越是对写入做严格约束(append-only),就越能稳定地暴露丰富的读取语义(历史回放、分支、折叠)

仓库地址:https://github.com/earendil-works/pi-mono
关键文件:

  • packages/agent/src/harness/session/jsonl-storage.ts
  • packages/agent/src/harness/session/session.ts
  • packages/coding-agent/src/core/session-manager.ts
http://www.jsqmd.com/news/915116/

相关文章:

  • 2026 玻璃钢喷淋塔厂家玻璃钢净化塔厂家等四类设备生产厂家综合实力榜单 - 栗子测评
  • 跨越天际:从智能汽车到 eVTOL 的适航与系统级开发9——故障树分析(FTA)与共因失效(CCF)
  • SCAMPER框架:电力系统隐蔽通道与安全防御实践
  • 手机号码定位终极指南:3秒快速查询归属地的完整教程
  • 破除设备依赖壁垒:视频孪生无感技术重构核电人员监管模式
  • ESPHome入门17-实战总结(高级玩法:全屋智能方案设计与部署清单)
  • 【极简监控】挖出被遗忘的 JMX 金矿:用 Jolokia + Hawtio 把 VisualVM 搬进浏览器
  • PVE8.0下点心云虚拟机频繁失联?可能是SR-IOV直通或网卡驱动的锅
  • VirtualBox虚拟机网络设置详解:选对“网卡模式”,让FinalShell告别Connection refused
  • 别再让GC卡顿你的游戏了!Unity对象池实战:从入门到精通(含扩容/收缩策略详解)
  • 2026年Prompt实战|用Gemini去AI痕迹!3组高阶降重指令+3款神器,将99%AI率拉回10% - 降AI实验室
  • android已经成功使用app打开抖音
  • 数据挖掘实战|基于CNN深度学习算法构建英文文本分类模型|全网独家复现NLP建模篇 引入多尺度并行卷积特征提取机制,助力英文短语语法捕捉、长文本语义挖掘、噪声文本降噪过滤、细粒度文本分类、通用NLP分
  • 解决TFLite模型大激活缓冲区问题的两种方案
  • 告别模拟器!手把手教你将NXP GUI Guider 2.2的LVGL界面移植到雅特力AT32F403A开发板
  • 超越基础查询:在Unity中利用SqlConnection实现玩家数据存档与加载的实战案例
  • 百度网盘全速下载终极指南:5分钟破解限速,免费享受高速下载
  • 别再为微信支付V3回调头疼了!.NET6 + Furion 实战,两种SDK(Senparc/OSS.Pay)完整处理流程对比
  • 2026河北无人机定制厂家、消防无人机生产厂家推荐 - 栗子测评
  • 卖洁净室工程怎么找客户?下游工厂在哪里
  • 告别Unity2021安卓打包坑:手把手教你将Assets/Plugins/Android/res资源迁移到AAR库(附避坑点)
  • 人工智能【第51篇】AI Agent实战:构建智能体系统
  • 靶场练习-BUUCTF-Misc 25~32
  • UVa 12384 Span
  • 电商退款算法精度陷阱:Python Decimal 实战与促销引擎 trace 凭证设计
  • 别再死记硬背YAML了!手把手带你用Python代码‘画’出YOLOv5s的Backbone结构图
  • 告别单调终端!FinalShell SSH工具保姆级美化教程:自定义背景、字体、快捷键全搞定
  • 构建结构化ModelOps流水线:从模型到运营的工程化实践
  • 核电常规岛外来流动人员全域无感定位管控方案解析
  • 《Java 100 天进阶之路》第33篇:Java中的static关键字详解