跳到正文

Coding Agent 原理与实践

05:会话与持久化:保存记录,恢复任务

更新于:

作者:折腾客

约 3,549 字146 行代码

文章目录

1. 为什么需要会话与持久化?

继续上一章修复求和函数的任务:Agent 已经修改代码,普通用例通过了,负数用例还在失败。此时关闭终端,第二天重新打开,输入“继续修复”。它还能知道昨天做到哪里了吗?

前面几章中,用户要求、模型回复和工具结果都放在 history 数组里。这个数组保存在进程内存中,进程退出后就消失了。项目文件可能已经改过,但仅靠这些文件,Agent 无法还原最初“不改公共接口”的要求,也不知道之前运行过哪些测试。

因此,需要把这段工作的记录保存下来。我们把一次持续对话中的消息、工具调用与结果等记录组织为一个会话;把它们写入文件等存储,让进程退出后仍能读取,就是持久化。

本章要弄明白的是:Agent 怎样保存任务进行中的记录,让用户关闭后重新打开,仍然能接着工作? 下面先看消息怎样保存和恢复,再看中途退出留下的不完整记录怎样处理。

2. 从内存里的消息,到可以恢复的会话

一次完整的保存与恢复,分成三个步骤:

  1. 任务进行时,把新增消息写入会话文件。
  2. 重新打开时,找到对应的会话,读取已保存的记录。
  3. 从记录中重建当前需要的历史,交给 Agent 循环继续使用。

下面主要节选 Ink 的 runner.ts 和 session/store.ts。保留核心语句,省略部分类型、统计和界面提示;片段用于对照流程,不能直接复制运行。

2.1 消息产生后,什么时候保存?

第 01 篇中,Agent 循环会把用户消息、完整的模型回复和工具结果加入 history,并调用 onMessage。调用方就在这个回调里接入持久化:

// 文件:src/runner.ts
// 所在函数:RunnerImpl.runTurnInner
// 节选传给 runAgentLoop 的配置;省略扩展事件通知。
onMessage: (message) => {
  this.sessionStore?.appendMessage(message);
},

sessionStore 管理当前会话;存在会话时,每条新消息都会交给它保存。例如,一次读取操作会先保存包含 read 调用的模型回复,读取结束后,再保存对应的工具结果。

这些记录既包含给用户看的文字,也包含工具名称、参数和结果之间的关联。只保存最终回答,就会丢掉模型继续判断所需的执行过程。

appendMessage 给消息加上记录标识、前一条记录的标识和时间,然后写入文件:

// 文件:src/core/session/store.ts
// 所在函数:SessionStore.appendMessage
const entry: MessageEntry = {
  type: "message",
  id: this.nextId(),
  parentId: this.leafId,
  timestamp: new Date().toISOString(),
  message,
};
this.append(entry);
return entry.id;

id 标识这条会话记录;parentId 指向它接在哪条记录之后。leafId 保存当前所在记录的标识,新消息默认接在这里。恢复时可以沿着这些关联还原记录的先后顺序。

append 把记录序列化为 JSONL,再交给 writeRecords 写入;写入成功后,才更新内存中的索引:

// 文件:src/core/session/store.ts
// 所在函数:SessionStore.append
// 节选调用顺序:lines 是待写入的 JSONL,seed 是可选的模型信息。
// 省略这两个变量的构造,以及写入成功后的模型信息缓存更新。
this.writeRecords(lines, seed);
this.indexEntry(entry);

创建会话对象时,还不会立刻创建会话文件;文件在首次实际写入记录时才创建。 writeRecords 区分两种情况:首次写入时,把会话头和首批记录一起写出;文件已经创建后,才向末尾追加。首次写入使用 "wx" 模式打开文件,若同一路径已经存在文件,会报错而不会覆盖它。首批记录也可能是模型设置等元数据,不一定是用户消息。

这个顺序使内存中的记录索引和当前位置只在写入成功后更新;写入报错时,后面的 indexEntry 不会执行。每次产生消息就保存,也缩小了异常退出时尚未保存的范围;当前路径保存的是完整消息,并不会把每个流式文字片段都写成一条会话记录。

2.2 会话文件是怎么保存会话历史的?

前面看到,每条新消息都会写入会话文件。要在重新打开后继续工作,除了用户要求、模型回复和工具结果,还需要保存历史压缩的结果,才能恢复压缩后的上下文。

Ink 把这些内容写成不同类型的记录,保存在同一个 JSONL 文件中:每一行是一个独立的 JSON 对象,新记录追加到文件末尾。先看与保存、恢复历史直接相关的三种记录:

类型保存的内容恢复时的作用
session会话标识、创建时间、工作目录等确定这是哪个会话,位于文件第一行
message用户消息、模型回复和工具结果还原对话与执行过程
compaction历史摘要和保留原文的近期消息恢复压缩后的历史

沿着修复求和函数的任务,看看这些记录分别长什么样。下面使用教学数据和缩短的标识;为方便阅读,把每条记录展开显示,实际文件中每条只占一行。消息与压缩记录省略时间戳等非关键字段。

文件首次写入时,会话头位于第一行。

{
  "type": "session",
  "version": 1,
  "id": "session-demo",
  "timestamp": "2026-09-18T09:00:00Z",
  "cwd": "/work/demo"
}

这里的 id 标识整个会话,cwd 记录工作目录。会话头描述这段对话所属的会话,不是一条要交给模型的消息。

用户提出任务,保存为一条 message 记录。

{
  "type": "message",
  "id": "m1",
  "parentId": null,
  "message": {
    "role": "user",
    "content": "修复求和函数,不改公共接口,并运行测试。"
  }
}

外层的 id、parentId 记录消息之间的前后关系,内层的 message 保存前面几章介绍的 Agent 消息。第一条记录没有前驱,所以 parentId 为 null。

模型要求读取代码,工具调用随模型回复一起保存。

{
  "type": "message",
  "id": "m2",
  "parentId": "m1",
  "message": {
    "role": "assistant",
    "blocks": [
      {
        "type": "toolCall",
        "id": "t1",
        "name": "read",
        "arguments": { "path": "src/sum.ts" }
      }
    ]
  }
}

这仍然是一条 message 记录。工具调用保存在模型回复的 blocks 中,并没有另存为一种叫作 tool 的会话记录。示例省略了模型回复中的用量统计和停止原因。

读取完成后,工具结果也保存为一条 message 记录。

{
  "type": "message",
  "id": "m3",
  "parentId": "m2",
  "message": {
    "role": "toolResult",
    "results": [
      {
        "toolCallId": "t1",
        "toolName": "read",
        "content": "export const sum = (a, b) => String(a) + String(b);",
        "isError": false
      }
    ]
  }
}

这里有两种关联:parentId: "m2" 表示这条记录接在 m2 后面;toolCallId: "t1" 表示结果对应模型发出的那次读取调用。前者组织会话历史,后者配对工具调用与结果。

任务继续推进,压缩历史时,追加一条 compaction 记录。

假设之后又完成了修改和测试,最新两条消息是一组测试调用与结果。压缩把较早的过程整理成摘要,并保留这两条消息的原文:

{
  "type": "compaction",
  "id": "c1",
  "parentId": "m9",
  "summary": "任务是修复求和函数,不改公共接口。已找到字符串拼接问题并修改函数,需要验证修改结果。",
  "retainedTail": [
    {
      "role": "assistant",
      "blocks": [
        {
          "type": "toolCall",
          "id": "t7",
          "name": "bash",
          "arguments": { "command": "npm test" }
        }
      ]
    },
    {
      "role": "toolResult",
      "results": [
        {
          "toolCallId": "t7",
          "toolName": "bash",
          "content": "普通用例通过;sum(-2, 3) 预期为 1,实际为 5。",
          "isError": false
        }
      ]
    }
  ]
}

这里省略了中间的 m4 至 m9,以及压缩记录的长度估算和消息中的用量等字段。c1 接在最后一条记录 m9 后面;summary 保存较早历史的摘要,retainedTail 直接保存近期的 Agent 消息,不是这些消息的记录 ID。示例中的测试命令正常结束,但输出包含失败用例,isError: false 不代表测试全部通过。

原来的消息仍留在文件中。重新打开时,Agent 可以使用这份摘要和近期原文,恢复压缩后的历史,而不必重新生成摘要;下一小节就看这个恢复过程。

会话文件默认位于 ~/.ink/sessions/ 下,按工作目录分组。查找工作由 session/manager.ts 负责:用户可以选择当前目录最近的会话,或按会话标识指定要恢复哪一个。

2.3 重新打开,怎样重建历史?

先看恢复入口。resolveSession 找到会话文件并读取,buildContext 再从记录中构造消息:

// 文件:src/runner.ts
// 所在函数:RunnerImpl.resumeSession
// 省略会话不存在时的报错和返回信息。
const store = resolveSession(this.options.cwd, {
  resume: id,
  baseDir: this.options.sessionBaseDir,
});
// 此处已确认 store 非空。
const loaded = store.buildContext();
this.restoreModelFromSession(store, false);
this.sessionStore = store;
this.history.length = 0;
this.history.push(...loaded.messages);
this.estimateFloor = loaded.compactionBoundary;
this.restoreThinkingFromSession(store);
this.systemText = this.assembleSystem();

这里不只恢复消息,还会按会话记录和当前配置恢复模型、思考级别,并恢复上下文估算所需的压缩边界。estimateFloor 标记从哪里开始寻找可用的用量记录,避免把压缩前的旧用量误当作压缩后历史的大小;具体估算机制见第 04 篇。

随后使用的系统指令通过 assembleSystem 重新组织,可用工具仍由当前 Agent 实例提供。恢复会话并不是把上次进程的所有内存照搬回来,也不会无条件覆盖当前显式设置,例如启动时指定的思考级别会优先保留。

buildContext 要回答的是:保存的记录中,哪些应该成为这次继续对话的历史? getBranch() 取得当前对话路径上的记录,并按从早到晚排列。没有发生压缩时,取出其中的消息;已经压缩过时,从这条路径最近一次压缩记录开始恢复。

// 文件:src/core/session/store.ts
// 所在函数:SessionStore.buildContext
// 节选压缩后的消息重建;省略未压缩路径、其他记录类型、压缩边界记录和返回信息。
const branch = this.getBranch(); // 当前所在路径,按从早到晚排列。
let lastCompactionIndex = -1;
for (let i = 0; i < branch.length; i++) {
  if (branch[i]?.type === "compaction") lastCompactionIndex = i;
}

const messages: AgentMessage[] = [];
if (lastCompactionIndex !== -1) {
  const compaction = branch[lastCompactionIndex] as CompactionEntry;
  messages.push(summaryToMessage(compaction.summary));
  messages.push(...compaction.retainedTail);
  for (let i = lastCompactionIndex + 1; i < branch.length; i++) {
    const entry = branch[i];
    if (entry?.type === "message") messages.push(entry.message);
    // 此处省略其他记录类型的处理。
  }
}

它把三部分接起来:摘要消息 + 压缩时保留的近期原文 + 压缩之后新增的消息。恢复时直接使用已保存的摘要,不需要重新调用模型生成一遍。

重新打开后,哪些记录进入 history?

磁盘:保存的会话记录

较早的消息任务、读取代码、修改过程……
压缩记录 Csummary:任务约束与已完成的修改retainedTail:最近的测试调用与失败结果
压缩后新增的消息 N用户:下次先排查负数用例。

以上记录均保留在文件中。

重建:buildContext()

  1. 摘要消息从 C.summary 转换而来。
  2. 近期原文从 C.retainedTail 取出测试调用和结果。
  3. 后续消息接上 N:先排查负数用例。
恢复到内存中的 history等待新的输入:“继续修复”。

较早的原始消息不再逐条加入本次上下文。

教学示意:C、N 用来标识图中的记录。重启时使用已保存的压缩结果,不重新生成摘要。

图中重新输入的“继续修复”会接在恢复出的 history 后面。下一次调用模型时,它既知道原任务和此前的修改,也能看到尚未通过的测试。

终端还可以把恢复的消息重新展示给用户。Ink 的 replaySession 也从 buildContext() 取得消息,用于回放已有内容。回放工具结果只更新显示,不会再次读取文件或运行测试。

3. 执行中途退出,哪些记录可能不完整?

正常退出后的恢复比较直接。异常退出则可能发生在“操作已经开始,但结果还没保存”的间隙。下面只看影响会话恢复的两种情况。

3.1 文件最后一行只写了一半

JSONL 可以逐行解析,但进程突然退出时,最后一行可能是不完整的 JSON。Ink 的 SessionStore.open 在读取记录时,允许跳过解析或校验失败的最后一行,并输出提示;如果损坏出现在文件中间,则报错停止读取。

中间记录可能是后续消息的前驱,随意跳过会破坏对话关系。即使每一行都能解析,getBranch 还会检查父链能否追溯到起点,遇到缺失的前驱或循环引用也会报错。

读取时跳过坏末行,不等于磁盘文件已经修好。 当前普通会话恢复走的 SessionStore.open 和 resumeSession 都不会自动删掉坏尾部。假如文件末尾停在 {"type":"message",直接追加下一条 JSON,只会把新内容接在这段残缺文本后面。

子代理续跑则有专门的修复步骤。 child-resume.ts 中的 buildContinuationHistory 先检查工具调用与结果能否配对;检查通过后,再调用 repairTornFinalLine,让文件尾部适合继续追加:

文件尾部的情况修复方式
最后一条记录解析或校验失败,读取时已被跳过截去这段无效尾部,保留此前有效记录
最后一条记录完整有效,只缺少结尾换行符只补一个换行符,保留这条记录

第二种情况不能直接删掉末行:它已经是一条有效记录,后续消息还可能接在它后面。这项自动修复目前接在子代理续跑路径中,不能推广为普通会话恢复也会自动修好文件。

3.2 保存了工具调用,却没有保存结果

例如,模型要求运行测试,包含工具调用的回复已经写入文件,但进程在测试结果保存之前退出。恢复后会话里只剩调用,没有对应结果。

Ink 在能够处理的强制退出路径中,会调用 persistMissingToolResults,找出没有结果的工具调用,补上一条错误结果并保存。下面是生成补齐消息的核心部分:

// 文件:src/core/loop.ts
// 所在函数:synthesizeMissingToolResults
// dangling 来自前面的扫描:包含尚未找到对应结果的工具调用。
const results = dangling.map(call => ({
  toolCallId: call.id,
  toolName: call.name,
  content: reason,
  isError: true,
}));
return [{ role: "toolResult", results }];

reason 由调用方传入,用于说明退出原因;结果中的 toolCallId 仍对应原来的调用。这让历史保留了这次调用未正常收尾的信息,而不是伪造一个成功输出。这个退出钩子无法覆盖断电或进程被立即终止的情况,也不能据此认为任意损坏会话都会自动修复。

如果子代理已经退出,来不及运行这个钩子,续跑时还有一次补齐机会。上节的 buildContinuationHistory 只允许补齐最后一条模型回复中的未配对工具调用,且它后面只能有工具结果消息。如果缺口之后已经出现新的用户消息或模型回复,或者结果重复、找不到对应调用,就拒绝续跑,不猜测这些记录应该怎样配对。

例如,最后一条模型回复请求工具 A、B,文件里只保存了 A 的结果便中断;续跑时可以给 B 补上一条 isError: true 的结果,明确说明“结果尚未记录,实际执行结果未知,可能已部分执行”。修复文件尾部、保存这条补齐结果后,再重新构造历史;这一步不会自动重新执行 B。

更重要的是,缺少结果记录,只能说明没有保存结果,不能证明工具没有执行。 文件可能已经修改,外部操作也可能已经生效。当前退出提示中的“工具尚未运行”不能当作这一事实的证明。恢复后应先确认实际状态,再决定是否重试;会话记录本身无法保证一次操作只执行一次。

4. 保存工作过程,也让用户看见工作状态

回到开头的“继续修复”:Agent 从会话文件恢复任务要求、已有操作和最新测试结果,再把新的用户输入接上去。历史经过压缩时,恢复过程会使用已保存的摘要、近期原文和后续消息。

接下来还有一个用户能直接感受到的问题:这些状态怎样呈现在终端上?模型正在回答、工具正在运行时,用户追加要求或按下中断键,又该怎样处理?第 06 篇继续看交互与运行控制,第 07 篇再展开界面的设计与实现。