第 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)”,每来一条消息就往文件尾巴上加一行,不用重写整个文件,崩溃时也顶多丢最后一行。

怎么成树。 关键就在上一节说的:每条记录带 idparentId。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 文件,一行一行读,看每条消息的 roleidparentId 长什么样。如果你之前 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 核心能力,可从任意节点精细操作

底层其实都是同一件事:把消息序列 + 元数据序列化存下来,再加载回去。真正的分水岭在两点——存的是消息树还是图状态,以及有没有把分叉/回溯当成一等能力。

小结

  1. 多轮对话的历史是一串带角色的结构化消息(structured messages),roleuser / assistant / tool / system,靠 toolCallId 把工具结果和调用请求配对——不是拼在一起的长字符串。
  2. 状态持久化(state persistence)把消息 + 元数据序列化(serialization)落盘,使会话可恢复(resumable);要恢复到原样,还得连同工作目录、技能、扩展一起存。
  3. 会话本质是一棵会话树(session tree)而非线性列表:靠 id/parentId 表达父子关系,可从任意节点fork(分叉),让回退重试、多方案并行、调试各得其所。
  4. pi 把会话存成按项目分组的 JSONL 文件,用 loadSession 恢复、forkFrom 分叉,并因会话可寻址而支持 RPC 远程驱动;pi-app 直接读同一份数据,做到 CLI/Web/macOS 三端一致。
  5. 会话格式是唯一事实来源——所有前端只是它的视图,这条分层约束保证了多端永不分裂。

会话解决了“记得住、恢复得了、走错能回头”,让单个 Agent 有了连贯而可探索的对话。但会话再完整,Agent 也还只是在“想一步做一步”。要让它面对复杂任务时先谋后动、把大目标拆成有序的小步骤,就得给它加上规划(Planning)的能力——这正是下一章要展开的。