
❝大模型本身是"过目就忘"的——每次 API 调用都是一张白纸。让 Agent 表现得"有记性",靠的是一层叫 「Session(会话管理)」 的基础设施。而这层基础设施里最核心、最容易决定一个框架天花板的,是一个朴素到容易被忽略的问题:「对话历史,到底用什么数据结构存?」 这篇文章不泛泛而谈"会话管理是什么",而是聚焦一个点钻到底:拆解四种典型的「历史存储模型」——线性数组、事件日志、树形日志、状态快照链。它们决定了你的 Agent 能不能"分支""回到过去""审计重放""扛住千万用户"。我们会结合 Pi、LangGraph、trpc-agent-go 三个真实框架的源码,看它们各自押注在哪种模型上、为什么这么选、代价是什么。❞
在聊四种模型之前,必须先钉死一个认知,否则后面全是糊涂账:
❝「"完整存储的对话历史"和"这一轮真正发给模型的上下文",是两个东西。」❞
很多人对会话管理的想象停留在"维护一个 messages 数组,发请求时整个扔过去"。早期确实如此,但今天所有正经的 Agent 框架,都把这两层「分开」了。

这两层的目标天然矛盾,所以才要拆开:压缩、裁剪只动运行层那份"投影",持久层的原稿一个字不少。后面我们会拆几个真实框架,你会发现它们无一例外都有这道"投影"动作,只是各自起了不同的名字——这是成熟会话管理的共同基因。
「为什么开篇要强调这个?」 因为"存储模型"讨论的全是「持久层」这一侧——它用什么结构存、能不能分叉、能不能回溯。理解了"存储归存储、投影归投影",你才不会把"压缩历史"和"丢失历史"混为一谈。
持久层用什么数据结构存历史,直接决定了这个框架的能力上限。归纳起来有四种典型形态,能力一种比一种强,代价也一种比一种大:

模型 | 一句话 | 天然解锁的能力 | 代价 |
|---|---|---|---|
① 线性数组 | 顺序往后追加 | 简单读写 | 只能往前走 |
② 事件日志 | 不可变事件流 | 审计、状态重放 | 想回到过去要自己重放 |
③ 树形日志 | 节点带父指针 | 分叉、反悔、试错 | 无全局快照 |
④ 状态快照链 | 每步存全量状态 | 时间旅行、任意点分叉 | 体积膨胀最严重 |
下面逐一拆开。前两种是"打底",后两种是"绝活"——而后三种,恰好被三个真实框架分别押中了。
最朴素的形态:一个 messages 数组,新消息往后追加,要发请求时整个数组拼起来扔给模型。这里说的"消息",就是一条「聊天记录」——谁(用户 / 助手 / 系统)说了什么,跟微信里一个个对话气泡差不多。

[ msg1 ] → [ msg2 ] → [ msg3 ] → [ msg4 ] → ...
它的优点和缺点都写在脸上:
线性数组是理解后面三种模型的「基线」。你可以把后三种都看成是在回答同一个问题:"如何在保住简单追加的前提下,多解锁一种能力?"
第一种进化:不再只存"消息",而是把发生的「每一件事」都记成一条「不可变事件(event)」。
"消息"和"事件"听着像近义词,但举个具体例子立刻就能分清。假设用户说了句"帮我查下北京天气",Agent 调了天气工具,最后回答"北京今天晴,25℃"。这一来一回,两种视角记下来的东西「颗粒度完全不同」:
「"消息"视角」(线性数组)——只记对话气泡,像一段微信聊天记录:
用户:帮我查下北京天气
助手:北京今天晴,25℃
「"事件"视角」(事件日志)——把这一回合背后「真正发生的每个动作」都记一笔,像一份执行流水账:
event1 用户消息 :帮我查下北京天气
event2 助手调用工具:weather(city="北京")
event3 工具返回结果:{ 天气:"晴", 气温:25 }
event4 助手消息 :北京今天晴,25℃
event5 状态变更 :last_city = "北京"
看出区别了吗?「"消息"是给人看的对话记录,颗粒度粗;"事件"是给系统看的执行流水,颗粒度细」——连"调了哪个工具、工具吐回了什么、状态被改成了啥"都一条不落地记下来。这种"细颗粒度 + 不可变追加",正是事件日志的精髓。
腾讯开源的 Go 框架 trpc-agent-go 就是事件日志的典型代表,画风是彻头彻尾的「工业级后端服务」。

在它的 Session 结构体里,历史就是一条事件流 Events:
type Session struct {
ID string
AppName string // 应用
UserID string // 用户
State StateMap // 会话状态(支持 delta)
Events []event.Event // ← 历史就是这条不可变事件流
Summaries map[string]*Summary
// ...
}
事件日志这套模型,解锁了两个线性数组给不了的关键能力:
「① 状态可重放、可审计。」 每条事件不只是"说了什么",还带着一个 StateDelta——这一步对会话状态做了哪些增量修改。于是 AppendEvent 时,框架追加事件的同时顺手把状态更新了。这意味着:你把事件流从头重放一遍,就能精确重建任意时刻的状态。出了线上问题,这就是天然的审计日志。
「② 天生为多租户设计。」 这是单机工具完全没有的概念。trpc-agent-go 用一个「三元组」<AppName, UserID, SessionID> 唯一确定一个会话——哪个应用、哪个用户、哪次会话,层层隔离。更狠的是它把「状态分了三级作用域」:
作用域 | 前缀 | 活多久 / 给谁看 |
|---|---|---|
App 级 | app: | 整个应用共享的全局配置 |
User 级 | user: | 某个用户跨会话的长期偏好 |
Session 级 | (无前缀) | 仅本次会话 |
临时 | temp: | 用完即弃,不持久化 |
读取会话时,框架自动把 app 级、user 级状态 merge 进来,你在业务里拿到的是一份"合并好的视图"。
它的存储后端也是三家里最丰富的:内存、Redis、SQLite、PostgreSQL、MySQL、ClickHouse、带向量语义检索的 PGVector,还有啥都不存的 noop——全部藏在统一的 Service 接口背后,想换哪个换哪个:
type Service interface {
CreateSession(...); GetSession(...); ListSessions(...); DeleteSession(...)
AppendEvent(...) // 追加事件,顺便更新状态
// ...三级 State 操作、Summary 操作
}
「代价是什么?」 事件日志是「线性追加」的,它没有"父指针"也没有"全量快照"。所以它「没有分支」、「没有时间旅行」——你想"回到过去某个点重新跑",得自己把事件重放到那个点。但对一个面向千万用户的在线服务来说,这恰恰是对的取舍:线性事件流足够直接、足够稳、足够扛并发。线上服务最需要的是"隔离"和"审计",而不是"后悔药"。
第二种进化:在追加的基础上,给每个节点记一个"我的上一个节点是谁"(parentId)。于是历史不再是一条线,而是一棵可以「分叉」的树。
Pi 是一个 TypeScript 写的本地编程 Agent。作为给开发者用的工具,它对"试错"和"反悔"的需求极强——"我想回到三步之前那个版本重新试"是每天都在发生的高频刚需。于是它选了「树形模型」。

在 Pi 里,会话不是数组,而是一棵「只追加的树」。每条记录是一个节点,都带着自己的 id 和父节点 parentId:
interface SessionEntryBase {
type: string;
id: string;
parentId: string | null; // ← 指向上一个节点,串成树
timestamp: string;
}
注意:节点类型不止"消息"——还有模型切换、压缩点、分支摘要、书签标签……它们都是树上平等的一等节点。另一个关键指针是 leafId,它指向"当前所在的节点"。这套设计带来几个很爽的能力:
~/.pi/agent/sessions/ 下),文件本身就是数据库,没有任何外部依赖。leafId 指针挪到某个旧节点上,下次再追加就从那儿岔出新枝。parentId 一路走回根。源码里这个走法朴素得可爱:getBranch(fromId?: string): SessionEntry[] {
const path: SessionEntry[] = [];
let current = this.byId.get(fromId ?? this.leafId);
while (current) {
path.unshift(current); // 头插
current = current.parentId
? this.byId.get(current.parentId) : undefined; // 顺着父指针往上爬
}
return path; // 从根到叶的完整路径
}
createBranchedSession() 把"根到某节点"那条路径复制成一个新文件,于是你能从历史中间岔出一条全新对话,原对话毫发无损。「代价是什么?」 树形模型解决了"分叉/反悔",但它「没有"任意时间点的全局快照"」——它存的是消息节点,不是每一步的完整运行时状态。而且 Pi 是为单人本地场景量身定做的,「没有跨用户、跨租户的概念」。它把"可反悔的树"做到了极致,但也只服务这一种场景。
终极进化:干脆不要纠结存消息还是存事件了——「每走一步,就把当时的完整运行时状态拍一张快照」存下来,快照之间用父子指针串成一条链。
LangGraph(以及建立在它之上的 DeepAgents)来自 LangChain 团队,是目前最"框架化"的一套。它把会话管理收进了一个更通用的抽象:「图状态 + 检查点(Checkpoint)」。

在 LangGraph 里,一次会话叫一个 「thread」,由调用时传入的 thread_id 标识:
config = {"configurable": {"thread_id": "abc-123"}}
await agent.astream(input, config=config) # 带同一个 thread_id 就能续聊
state = await agent.aget_state(config) # 随时把历史捞出来
它的存储模型是「快照链」:每执行一步,Checkpointer 就把当时的完整状态存成一张带 checkpoint_id 的快照,快照之间用 parent_checkpoint_id 串起来。后端可换:测试用内存版 MemorySaver,DeepAgents 生产环境默认用 AsyncSqliteSaver 落到本地 SQLite。
快照链的「杀手锏是时间旅行」:因为每一步的完整状态都被独立保存了,你理论上能指定任意一个历史 checkpoint_id,回到那一刻、甚至从那儿岔出一条新分支。(有意思的是:LangGraph 框架层支持这个能力,但 DeepAgents 这层产品暂时没把它暴露给最终用户。)
「代价是什么?体积。」 这是快照模型绕不开的痛点:每步都存全量状态,消息一多,存储量会按 「O(N²)」 膨胀(第 N 步的快照里塞着前 N 条消息,N 步累加就是平方级)。DeepAgents 为此专门做了优化——用一个叫 DeltaChannel 的机制,平时只存增量、每 50 步才拍一次全量快照,把增长从 O(N²) 压回 「O(N)」:
class _DeepAgentState(AgentState):
"""messages 上挂 DeltaChannel,把 checkpoint 增长从 O(N²) 降到 O(N)。"""
messages: Required[Annotated[
list[AnyMessage],
DeltaChannel(_messages_delta_reducer, snapshot_frequency=50) # 每 50 步全量
]]
这其实暴露了快照模型的本质:「它用空间换时间旅行能力」。当空间代价太大时,就得引入"增量 + 周期全量快照"这种典型的存储优化手段把它压回来——这思路和数据库的 WAL、Redis 的 RDB+AOF 混合持久化如出一辙。
把四种模型摆在一起,规律非常清晰:「能力是递增的,代价也是递增的,没有免费的午餐。」
维度 | ① 线性数组 | ② 事件日志 | ③ 树形日志 | ④ 状态快照链 |
|---|---|---|---|---|
代表框架 | 多数 demo | trpc-agent-go | Pi | LangGraph |
数据结构 | 数组 | 不可变事件流 | 带父指针的树 | 带父链的全量快照 |
恢复对话 | ✅ | ✅ | ✅ | ✅ |
审计 / 重放 | ❌ | ✅ 天然 | 一般 | ✅ |
分支 / fork | ❌ | ❌ | ✅ 一等能力 | ✅ 框架支持 |
时间旅行 | ❌ | 需自己重放 | ❌ | ✅ 天然 |
多租户 / 状态作用域 | ❌ | ✅ 四级作用域 | ❌ | 长期记忆走 Store |
存储体积 | 最小 | 小 | 中 | 最大(需优化) |
最适合 | 玩具 / 教程 | 多用户在线服务 | 本地编程助手 | 通用编排 / 复杂流程 |
选型其实不难,对号入座即可:
钻完四种模型,有几个跨模型的认知值得单独拎出来,做任何"有记忆的 AI 系统"都用得上:
「① "快照"和"日志"是两种世界观。」 快照链存的是每一步的完整状态,所以天然能时间旅行,代价是体积;事件日志存的是增量事件,省空间、扛并发,但想"回到过去"就得自己重放。没有谁对谁错,只有一句话——「你的产品需不需要后悔药?」 需要,就为快照的体积买单;不需要,就享受日志的轻快。
「② 压缩必须"非破坏式"。」 不管哪种存储模型,历史压缩都应该「不删原稿」:Pi 把原始 JSONL 全留着、DeepAgents 把旧历史落盘到 .md 文件、trpc 的摘要也不动原始事件。原因很现实——"上下文偏大"顶多多花点钱,"历史被永久删掉"则无法挽回。回到开篇那句话:压缩只动「运行层的投影」,别动「持久层的原稿」。
「③ Session ≠ Memory,存储模型只管前者。」 本文讲的四种模型,管的都是「某一次会话的短期历史」。而跨会话的「长期记忆」("这个用户是左撇子""上次帮他建过一个 React 项目")是另一套东西——LangGraph 用独立的 Store 存它,trpc-agent-go 有独立的 Memory 服务。设计时先想清楚:"这条信息该活一次对话,还是该跟着用户一辈子?"别把长期记忆塞进会话历史里。
回头看,会话管理这层基础设施有个特别的地方:「它做得越好,你越感觉不到它的存在。」 用户只觉得"这个 AI 真聪明,记性真好",根本不会意识到背后有一套精密的存储结构在默默运转。
而这套存储结构的选择,几乎在项目第一天就悄悄定下了产品的能力天花板:
❝选了「线性数组」,你就放弃了分叉和审计; 选了「事件日志」,你拿到了审计和多租户,但放弃了时间旅行; 选了「树形日志」,你拥有了反悔,但要接受没有全局快照; 选了「状态快照链」,你解锁了时间旅行,但要为体积持续买单。 ❞
没有最优解,只有最匹配你产品形态的那一个。下次当你设计任何需要"记住上下文"的 AI 系统时,不妨在写第一行代码前先问一句:
❝「我的用户,到底需不需要"分叉""审计""回到过去"?」 ❞
想清楚了这一个问题,存储模型自然就选定了——剩下的,都是工程细节。