第 12 章 · 多轮对话设计
Harness 锚点|判别清单第 3 项「状态持久化」——会话格式与状态保存。
一次谈崩了又救回来的报价
做外贸的老周,手底下有个 Agent 专门陪他谈单。上周有个德国客户来问一批户外椅的报价,来回聊了二十多轮:先是确认材质和认证,再算海运和清关,最后卡在付款方式上。聊到第十八轮,老周手一抖把终端关了。
他心里咯噔一下——这二十轮的上下文,客户的每一句偏好、Agent 算过的每一笔运费,全在里头。要是没了,等于从头再谈一遍。
结果他重开 pi,会话原样回来了:材质、认证、运费,一条不落。他松了口气,接着往下谈。谈到付款方式时又出岔子——Agent 顺着“预付 30%”的方向算,越算越发现客户其实想要信用证(L/C)。老周没有把这一路推倒重来,而是回到第十五轮那个岔路口,从那里另开一条分支,让 Agent 沿“信用证”重新往下推。原来那条“预付”的路还在,随时能切回去对比。
一次会话,关掉能恢复、走错能回退、还能同时留着两条路。这不是聊天框里“清空/重来”两个按钮能干的事,它背后是一整套多轮对话设计:会话(session)怎么结构化、状态怎么持久化、以及一个很多人没意识到的关键——会话本质上是一棵树,不是一条线。
消息不是纯文本,是带角色的结构化记录
先纠正一个常见误解:多轮对话的“历史”,不是把几段文字拼在一起的长字符串。它是一串结构化消息(structured messages),每条消息都带一个角色(role)。
第 3 章的 ReAct 循环里,我们不停地往 session.messages 里 push 东西——push 进去的正是这些带角色的消息。角色一共就几种,务必记牢:
user(用户):人说的话,或系统代表用户塞进去的输入。assistant(助手 / 模型):模型的回复,可能是纯文本,也可能包含一个或多个工具调用请求(tool call,第 4 章)。tool(工具结果):工具执行后回填的结果,也就是 ReAct 里的 Observation。它通过toolCallId和上面那条 assistant 消息里的调用请求一一对应。system(系统):系统提示(system prompt),设定 Agent 的身份、规则、可用能力,通常排在最前面。
为什么非要区分角色?因为模型是靠角色来理解对话结构的:哪句是人问的、哪句是自己上轮说的、哪个是工具吐回来的事实。角色错乱,模型就会把工具结果当成用户指令、或者忘了自己刚说过什么。一条消息除了 role 和内容,通常还挂着元数据(metadata):时间戳、token 用量、模型名、工具调用 id。概念上一条消息长这样:
interface Message {
role: "user" | "assistant" | "tool" | "system";
content: string | ContentBlock[]; // 可能是纯文本,也可能是文本/图片/思考等内容块
timestamp: number; // Unix 毫秒
toolCallId?: string; // tool 消息用它对应到某次调用请求
}
把内容拆成内容块(content block)是现代 harness 的普遍做法:一条 assistant 消息里可以同时有文本块、思考块(thinking)、工具调用块。这样图片、推理过程、调用请求就能在同一条消息里各归各位,而不是硬塞进一段字符串。
状态持久化:关掉还能接着聊
有了结构化消息,下一个问题是:怎么让会话关掉重开还在? 这就是状态持久化(state persistence)。
做法说穿了不神秘:把整个会话的消息序列 + 元数据,序列化(serialization)成一种能落盘的格式(文本或二进制),写进磁盘;下次要用时再反序列化读回内存。能这样存了再原样读回来的会话,就叫可恢复的(resumable)。
老周关掉终端还能接着谈,靠的就是这个——他的二十轮对话早就一条条写进了磁盘文件,重开时按序读回,内存里的会话和关掉前一模一样。
这里有个容易被忽略的点:“状态”不只是消息。要真正恢复到原样,得连同上下文一起存。一个完整的会话状态,通常包含:
- 消息序列(含每次工具调用的请求与结果)
- 元数据:用的什么模型、累计 token 用量、花了多少钱、各条时间戳
- 当前工作目录 / 项目上下文:这个会话是在哪个项目下跑的
- 已加载的技能(skills)、已激活的扩展(extensions)
漏掉任何一项,恢复出来的会话就“缺了点什么”——比如少了工作目录,Agent 就不知道该在哪个项目里干活;少了工具调用结果,模型就看不到自己上轮查到的事实。所以持久化的粒度,决定了恢复的保真度。
会话是一棵树,不是一条线
现在讲这一章最反直觉、也最重要的一点。
大多数人默认会话是线性的(linear):消息一条接一条,像聊天记录一样从上滚到下。这个模型够用,但不够。真正强大的会话应该理解成一棵树(session tree)。
想想老周的场景:谈到第十五轮,他想试试“信用证”这条路,又不想丢掉“预付”那条。如果会话是线性的,他要么覆盖掉原来的路(丢了),要么复制一整份(乱了)。但如果会话是树,他只需要从第十五轮那个节点分叉(fork)出一条新分支——两条路共享前十五轮,从第十六轮开始各走各的:
用户问报价 → … → 第15轮(算完运费)
├── 预付分支:第16轮 → 第17轮 → …
└── 信用证分支:第16轮 → 第17轮 → …(fork 出来的新枝)
树结构的价值在几个场景里立刻显现:
- 回退重试:Agent 走错了方向,回到岔路口重来,前面的上下文全部保留,不用从头。
- 多方案并行:同一个问题,让 Agent 从同一个节点分叉出几条思路,横向对比。
- 调试:排查“它当时为什么这么答”,可以回到那个节点,改一句提示再往下跑,其他分支不受影响。
线性列表不是被否定了,而是树退化成单条路径的特例。一旦你需要“回到过去换个方向”,树的价值就压倒性地显现出来。实现上,树的秘密很简单:每条消息除了自己的 id,还记一个 parentId(父节点 id)。顺着 parentId 往上就是一条完整路径;一个父节点下挂两个子节点,就是一次分叉。
用 pi 实现:会话格式与会话树
道理讲完,看 pi 怎么落地。pi 把会话格式做得非常显式,文档里有专门的 session-format.md。
存在哪。 会话数据存在 ~/.pi/agent/sessions/ 下,每个项目一个目录。目录名会编码来源路径——把工作目录里的 / 换成 -,比如项目 /Users/mk/codespace/agno 就存进 --Users-mk-codespace-agno--。这就是为什么 pi 能“按项目”帮你找回上次的会话:它按 cwd 分组。
用什么格式。 每个会话是一个 JSONL(JSON Lines) 文件——一行一条 JSON 记录。这个选择很讲究:JSONL 天生适合“只追加(append-only)”,每来一条消息就往文件尾巴上加一行,不用重写整个文件,崩溃时也顶多丢最后一行。
怎么成树。 关键就在上一节说的:每条记录带 id 和 parentId。pi 的会话树导航实现在 agent-session-tree.ts(packages/coding-agent/src/core/)里,靠这两个字段在同一个文件内原地分叉(in-place branching),不必为每条分支另开新文件。pi 的会话格式还带版本号,老版本(线性序列)加载时会自动迁移到新版本(树结构),历史会话不会因为格式升级而读不了。
概念上,恢复和分叉的代码是这样(概念示意):
// 加载一个已有会话,反序列化回内存,恢复到上次关掉时的状态
const session = await pi.loadSession(sessionId);
// 从某条消息 fork 出一条新的探索分支
// 底层就是:新建一个节点,把它的 parentId 指向 messageId
const branch = session.forkFrom(messageId);
branch.append({ role: "user", content: "换个思路:如果改用信用证付款呢?" });
loadSession 对应可恢复,forkFrom 对应会话树的分叉。这两个操作合起来,就撑起了老周那次“关掉能救回、走错能回退”的体验。
还有一层:因为会话是结构化、可寻址(addressable)的——每个会话有 id、每条消息有 id——它就能被远程驱动。pi 的 RPC 模式(packages/coding-agent/src/modes/rpc/)正是靠这一点:远端发来“加载会话 X、在节点 Y 追加一条消息、跑一轮”这样的指令,本地照着操作。RPC(Remote Procedure Call,远程过程调用)在这里的意思是,一个进程可以隔着网络驱动另一个进程里的会话,就像调本地函数一样。pi-web / pi-app 的远程访问,底层就是这套 RPC。
真实案例:pi-app 里的会话管理
pi 的 Web + macOS 界面 pi-app(github.com/asiachrispy/pi-app)把“会话是可持久化的树”这件事,变成了看得见摸得着的功能。它不另存一份数据,而是直接读取本机 ~/.pi/agent/sessions/ 里的会话文件——和 CLI 同一份。于是你能:
- 按项目找回历史:所有会话自动按工作目录(cwd)分组,不用在终端里翻文件、记路径。点开某个项目,历史会话一目了然——这正是“目录名编码来源路径”那条设计的用户侧回报。
- 放心试不同方向:从任意一条历史消息重新开始,或复制出一条独立分支去探索新方案——原来的对话丝毫不乱。这正是本章“会话是树、可从任意点分叉”的直接落地,老周那次“回到第十五轮开信用证分支”,在 pi-app 里就是点一下的事。
- CLI 与 Web 无缝续接:终端里跑到一半的会话,浏览器或 macOS 应用里打开就能接着聊,因为状态都在磁盘上、可寻址。反过来也一样,Web 上开的会话,回终端
/resume也能续。 - 远程访问:pi-app 支持配对(pairing)、扫 QR、token、以及 Tailscale / Cloudflare 隧道等方式远程连回你的机器——手机上就能盯着家里那台机器上跑的 Agent。它的顶栏还实时显示上下文占用、累计花费、压缩状态、系统提示,让你对会话的“健康度”心里有数。这些远程能力,底层都走上一节说的 RPC。
pi-app 的设计约束里有一条说得很干脆:“会话文件格式以 pi 为准,不 fork 独立存储。” 界面再花哨,也只是同一份会话数据的不同视图。这不是偷懒,而是刻意的分层:会话格式是唯一事实来源(single source of truth),CLI、Web、macOS 三个前端都只是这份数据的渲染器。这样任何一端做的改动(新加一轮、fork 一条分支),其他端立刻看得到,永远不会出现“CLI 和 Web 各存一份、对不上”的分裂。
动手看看
想把这一章从“听懂”变成“确信”,最好的办法是去翻 pi 的真实文件。
先读那份权威文档 session-format.md(在 pi 仓库的 packages/coding-agent/docs/ 下,或你本机 node_modules/@earendil-works/pi-coding-agent/docs/ 里)。重点看三处:消息的内容块类型(TextContent / ThinkingContent / ToolCall …)、每条记录的 id / parentId 字段、以及会话的版本号和自动迁移说明——对照本章讲的“结构化消息”“树结构”验证一遍。
再去 ~/.pi/agent/sessions/ 底下逛逛。用 ls 看看目录名是怎么把项目路径编码进去的(那些 --...-- 的目录);挑一个 .jsonl 文件,一行一行读,看每条消息的 role、id、parentId 长什么样。如果你之前 fork 过会话,试着顺着 parentId 把树的形状画出来——你会直观地看到“两条分支共享前半段”。
实战中的几个坑
坑一:恢复时丢了非消息状态。
- 现象:会话恢复后消息都在,但 Agent 突然在错误的目录里干活,或者忘了之前加载过的技能。
- 原因:持久化时只存了消息序列,漏掉了工作目录、已激活扩展等上下文。
- 对策:把完整会话状态(消息 + 元数据 + cwd + 技能/扩展)一起持久化,别只存消息。
坑二:工具结果和调用请求对不上。
- 现象:恢复或分叉后,模型报“找不到对应的工具调用”,或把工具结果当成用户输入。
- 原因:
tool消息的toolCallId没和对应的 assistant 调用请求一起保留,配对断了。 - 对策:序列化时保证“调用请求 + 工具结果”成对完整;fork 时不要在一次调用和它的结果中间切断分支点。
坑三:把 fork 当成复制整份会话。
- 现象:每 fork 一次就复制一整个大文件,磁盘暴涨,分支之间还各存各的前半段。
- 原因:用“整份拷贝”来实现分叉,而不是靠
parentId在同一文件内原地分枝。 - 对策:用父子指针(
id/parentId)表达树,分支共享公共祖先,只追加各自新增的节点。
坑四:会话越滚越长,拖慢又烧钱。
- 现象:长会话每轮都把全部历史发给模型,越到后面越慢越贵,甚至撑爆上下文窗口。
- 原因:持久化解决了“存得下”,但没解决“每轮都全量发”。
- 对策:配合上下文压缩(compaction,第 9 章)——把久远的历史摘要化;需要细节时再从会话树里回溯原始节点。
对比:其他框架
就“多轮会话的状态怎么存、怎么恢复”这件事,五家各有侧重:
| 框架 | 会话状态怎么存/恢复 | 特点 |
|---|---|---|
| LangGraph | 用 checkpointer 持久化图的 state,可从任意检查点恢复,甚至从检查点分叉出新分支 | 和 pi 的“树 + 恢复”理念最接近,只是存的是“图的状态”而非“消息树” |
| CrewAI | 偏“任务跑一趟”,多轮会话不是一等抽象;有 memory 组件跨执行保留信息,完整会话恢复/回溯需自接持久化 | 以任务为中心,不为长会话优化 |
| PydanticAI | 把一次运行的消息暴露为可序列化的 message history,自己存下、下次回灌以续接 | 只给“消息可存取”这块积木,树/分叉/按项目恢复要自己搭 |
| Agno | 内置 storage + memory,能把 session 落库并跨轮恢复,还带长期记忆 | “开箱即用的会话持久化”最省事,但抽象偏应用层,不暴露会话树 |
| pi | 会话以 JSONL 结构化文件按项目存盘(id/parentId 成树),loadSession 恢复、forkFrom 分叉,并以此支撑 RPC 远程驱动 | 把会话树 + 持久化做成 harness 核心能力,可从任意节点精细操作 |
底层其实都是同一件事:把消息序列 + 元数据序列化存下来,再加载回去。真正的分水岭在两点——存的是消息树还是图状态,以及有没有把分叉/回溯当成一等能力。
小结
- 多轮对话的历史是一串带角色的结构化消息(structured messages),
role分user/assistant/tool/system,靠toolCallId把工具结果和调用请求配对——不是拼在一起的长字符串。 - 状态持久化(state persistence)把消息 + 元数据序列化(serialization)落盘,使会话可恢复(resumable);要恢复到原样,还得连同工作目录、技能、扩展一起存。
- 会话本质是一棵会话树(session tree)而非线性列表:靠
id/parentId表达父子关系,可从任意节点fork(分叉),让回退重试、多方案并行、调试各得其所。 - pi 把会话存成按项目分组的 JSONL 文件,用
loadSession恢复、forkFrom分叉,并因会话可寻址而支持 RPC 远程驱动;pi-app 直接读同一份数据,做到 CLI/Web/macOS 三端一致。 - 会话格式是唯一事实来源——所有前端只是它的视图,这条分层约束保证了多端永不分裂。
会话解决了“记得住、恢复得了、走错能回头”,让单个 Agent 有了连贯而可探索的对话。但会话再完整,Agent 也还只是在“想一步做一步”。要让它面对复杂任务时先谋后动、把大目标拆成有序的小步骤,就得给它加上规划(Planning)的能力——这正是下一章要展开的。