Skip to content

第34章 让记忆续上——Session ​

本章导读: 你会写 src/session.ts 的 JSONL 解析和 src/context.ts 的 trimContext 字符预算裁剪。这一步要分清两份状态:磁盘上的完整记录可审计,发给模型的裁剪版受预算约束——而字符预算只是近似,不等于 token。

Agent 能干活了,但小a把程序关掉再开,发现它什么都不记得了。他问老z:“不是说好了‘能续接’吗?”

“能,但你要先搞清楚一件事:这是库边界已经实现的可选能力。”老z说,“runAgent() 只有收到 sessionPath 才会加载和追加 JSONL,只有收到 contextMaxChars 才会在模型请求前调用 trimContext()。当前 main.ts 与 runCli() 都没有传入这两个选项,所以 npm start 和现有 CLI 不会续接、持久化或裁剪会话。”

小a:“那这算不算‘已经交付了’?”

“把‘函数支持’写成‘CLI 已交付’会混淆装配事实。”老z说,“这一章我们先把库边界做对,装配是下一章的事。”

JSONL 的判别式读取 ​

小a:“那会话文件怎么读写?”

“src/session.ts 以 JSONL 追加消息,loadSession() 逐行 JSON 解析并做 role 判别验证。”老z打开代码:

ts
if (item.role === "user") return { role: "user", content: item.content };
if (item.role === "assistant" && calls(item.toolCalls)) return { ... };
if (item.role === "tool" && typeof item.toolCallId === "string") return { ... };
return undefined;

“解析先要求对象、role 和 string content,再按 role 要求各自字段。合法 user 只需内容;assistant 必须有每项含 id/name/input 的 toolCalls;tool 必须有关联 ID 与布尔错误标志。”老z说,“loadSession() 将 undefined 转成‘第几行非法’的异常,损坏行不会被跳过。”

小a:“为什么 parseSessionMessage 返回 Message | undefined,而不是直接抛错?”

“把‘这行是什么’和‘这行坏了怎么办’分开。”老z说,“parseSessionMessage 只回答判别问题——合法就返回 Message,不合法就返回 undefined;抛错的责任在 loadSession 的 map 里,它拿到 undefined 才抛 invalid session record at line N。判别逻辑保持纯函数,好单测;行号逻辑留在 IO 层,报错时能带上位置。”

“那判别顺序有讲究吗?”小a追问。

“有。真实代码的顺序是:先查公共前提(对象、role 是 string、content 是 string),再按 role 分派。如果 role 本身非法,比如 "nope",三个分支都不命中,落到最后的 return undefined。 这跟 switch 缺 default 是一个道理——没有兜底,未识别的 role 就会被悄悄跳过。”老z列了一张判别表:

条件判定
不是对象,或 role 非 string,或 content 非 string直接 undefined(公共前提不满足)
role === "user" 且前提满足合法,返回 user message
role === "assistant" 且 calls(toolCalls)合法,返回 assistant message
role === "tool" 且 toolCallId/isError 类型正确合法,返回 tool message
其余(含 role 未知、分支字段缺失)undefined

“agent.test.ts 的 rejects malformed session records 正好把四类都喂过一遍:坏 role、assistant 缺 toolCalls、tool 缺 toolCallId,外加一个合法 user 的 round trip。每一条判别分支都有一行测试守着。”

小a盯着 calls() 这个类型守卫看了半天:“为什么 assistant 的 toolCalls 要单独校验?”

“因为它是唯一一个嵌套结构。”老z说,“user 和 tool 的字段都是标量,typeof 就够;但 toolCalls 是数组、每项还得是带 id/name/input 的对象——这不能用一层 typeof 搞定,得递归检查。你看真实的 calls():”

ts
// examples/mini-agent/src/session.ts,节选
function calls(value: unknown): value is ToolCall[] {
  return (
    Array.isArray(value) &&
    value.every((call) => {
      const item = record(call);
      return (
        item !== undefined &&
        typeof item.id === "string" &&
        typeof item.name === "string" &&
        "input" in item
      );
    })
  );
}

“注意最后一行是 "input" in item,不是 typeof item.input === "object"——因为 input 可以是任意 JSON(包括 null),只要字段存在就行。这就是判别式校验的精髓:每种角色校验自己真正需要的字段,不多不少。”

小a:“那为什么 calls 里每一项要先过一遍 record()?”

“因为数组里的元素是 unknown,不能直接 call.id。”老z说,“record() 这个辅助函数先确认‘它是非 null 的对象、不是数组’,才敢当记录访问字段。session.ts 里这个 record 和 model-client.ts 的 toolInput、tools.ts 的 asObject 是同一种手法:不可信 JSON 进来,先做结构收窄,再做字段断言。 这不是重复代码,是每一层的边界都需要一次收窄。”

校验选择为什么是它放弃了什么
"input" in iteminput 可为任意 JSON 含 nulltypeof === "object"(会误拒 null)
typeof id/name === "string"id、name 语义上必须是字符串松散的 truthy 判断
每项先 record()元素是 unknown,需收窄直接索引(会崩)
value.every(...)数组每一项都要过只查第一个(漏后面的坏项)

“注意 calls 校验的是结构不是语义:它不检查 id 是否跟其他消息重复、name 是否真的存在对应工具。语义正确性在 runAgent 分派时由 toolsByName.get(call.name) 兜底——查不到就记录 unknown tool: ${call.name}。解析层只管‘可恢复’,执行层才管‘可运行’。”

写入与恢复的正常路径 ​

ts
export async function appendSession(path: string, message: Message): Promise<void> {
  await appendFile(path, `${JSON.stringify(message)}\n`, "utf8");
}

“一条消息转换为一行 JSON 并追加;这避免每轮重写整个历史。下一次启动 loadSession() 读文件、按换行拆分、跳过空行、逐行 JSON.parse、再调用判别验证。”老z提醒,“append 成功不代表多进程同时写入时仍保持事务性;当前实现没有锁或 fsync 策略。”

损坏记录的错误路径 ​

小a:“那文件写坏了怎么办?”

“错误路径有两层。第一层 JSON 根本不能解析,抛出 invalid session JSON at line N;第二层 JSON 合法但 role 不存在、assistant 缺 toolCalls、tool 缺 call ID 或错误标志,抛出 invalid session record at line N。”老z列了一张表:

行内容判别结果load 行为
{"role":"user","content":"x"}合法 user返回 message
assistant 无 toolCalls不完整行号错误
tool 无 toolCallId无法关联行号错误
截断 JSON不能解析JSON 行号错误

“测试确实覆盖三类非法 role/字段和一个合法 user round trip;它没有覆盖磁盘满、权限拒绝或并发写入。”

“那 loadSession 整条链路长什么样?”小a问。

老z画了一张图:

loadSession(path)
   │
   ├─ readFile 抛 ENOENT ─────────────► return [](首次启动,空会话)
   ├─ readFile 抛其他(权限、IO)──────► 原样向上抛(不吞)
   │
   ▼
   text.split("\n").filter(Boolean)
   │
   ▼
   逐行 map:
      │
      ├─ JSON.parse 抛错 ─────────────► throw `invalid session JSON at line N`
      ├─ parseSessionMessage 返回 undefined ─► throw `invalid session record at line N`
      └─ 合法 ────────────────────────► 返回 Message

“注意 ENOENT 是唯一被吞的错误,而且是刻意吞的:文件不存在代表‘还没有会话’,不是‘会话读坏了’。 权限拒绝、磁盘错误则原样上抛——它们不是能恢复的正常情况。坏行永远拒绝恢复,不让坏数据悄悄进内存,这是 session 和 Skill 目录(第 39 章推演)的关键差别:session 是单条链,坏一行后面全乱;目录是一组并列文件,一个坏不该连累别的。”

上下文单元而不是消息切片 ​

小a:“那裁剪呢?上下文不够了怎么办?”

“trimContext() 是字符近似预算,不是 token 预算。它从最新消息向前保留,并把带多个 tool call 的 assistant 与对应 tool results 当作一致单元。”老z敲出一段真实骨架:

ts
// examples/mini-agent/src/context.ts,节选
const units: Message[][] = [];
for (let index = messages.length - 1; index >= 0;) {
  const message = messages[index];
  if (message.role === "tool") {
    // 向前找声明该 toolCallId 的 assistant
    let assistantIndex = -1;
    for (let i = index - 1; i >= 0; i--) {
      const candidate = messages[i];
      if (
        candidate.role === "assistant" &&
        candidate.toolCalls.some((call) => call.id === message.toolCallId)
      ) {
        assistantIndex = i;
        break;
      }
    }
    if (assistantIndex >= 0) {
      units.unshift(messages.slice(assistantIndex, index + 1));
      index = assistantIndex - 1;
      continue;
    }
  }
  units.unshift([message]);
  index--;
}

“从尾部遇到 tool result 时,函数向前寻找声明该 call ID 的 assistant,然后把这一段作为一个 unit。这样不会留下‘结果没有请求’或‘请求没有结果’的半段。预算小于最小 unit 会报错,而不是返回空数组;字符数只是本地近似,不能代表模型 tokenizer。”

单位构造的逐步过程 ​

小a:“能举个具体的例子吗?”

“假设历史末尾是 assistant(调用 a、b)、tool a、tool b。反向扫描先遇到 tool b,向前找到声明 b 的 assistant;实现把 assistant 到当前 tool 的 slice 当成 unit。随后处理其他消息。”老z说,“这里的直觉是‘把一次工具往返当作一张收据’,但准确术语是消息关联 ID 与一致裁剪单位。”

“每一步走下来长什么样?”小a问。

老z画了反向扫描的过程:

历史: [user] [assistant{calls:a,b}] [tool a] [tool b]
                                          ↑index=3
反向扫到 tool b:向前找声明 id=b 的 assistant
   ─► 找到 index=1 的 assistant,slice(1, 4) 成一个 unit
        units = [ [assistant, tool a, tool b] ],index 跳到 0
再扫到 user:没有关联,单条成 unit
        units = [ [user], [assistant, tool a, tool b] ]
翻转、从尾部(最新)开始装:先装 [assistant,tool a, tool b],再装 [user]

“注意 slice 是从 assistant 一路切到当前 tool——tool a 因为夹在中间,也被一起带进 unit。成组的是‘一整趟往返’,不是‘刚好两条消息’。 如果某次循环碰巧只调了一个工具,unit 就恰好是 assistant + tool 两条;调用两个,unit 就三条。unit 的大小由调用结构决定,不由预算决定。”

“这样做好在哪?”小a问。

“模型不会只看到 tool result 而不知是谁请求它;代价是一个单元可能很大,导致比逐条裁剪更早触发预算错误。当前测试覆盖含两个 tool calls 及两个 results 的保留和预算过小错误,未覆盖复杂交错调用或跨轮重复 ID。”

裁剪粒度优点代价
逐条消息最大利用剩余预算可能留下“只有结果没有请求”的半截
一致单元保证往返完整单元大时更早触顶、更早报错
摘要/compaction保留语义还省预算需额外模型调用与失真策略,未实现

“预算小到装不下最小 unit 时报错而不是悄悄给空——因为空上下文等于让模型回答一个没有前文的问题。宁可失败,不装傻。”

字符预算的正常和失败路径 ​

小a:“那字符预算到底怎么算的?”

“estimateContextChars() 对每条完整 Message 使用 JSON.stringify(message) 的字符长度,再加分隔开销。循环引用或不可序列化 message 会按索引报错;这是为了让预算失败可定位,并非 token 预算:中文、ASCII、Provider 隐藏开销与服务端序列化都不会精确反映。”老z说,“正常路径从最近 unit 开始加入,直到下一 unit 超限;若已保留至少一个 unit,停止并返回;若第一个单位已超限,抛出‘最小一致单元不能容纳’。”

老z把装预算的算法画出来:

units(从新到旧)
   │
   ▼
kept = []
对每个 unit(从最新到最旧):
   │
   ├─ estimateContextChars(unit ∪ kept) > maxChars?
   │     ├─ kept 为空 ──► throw "context budget cannot contain the smallest consistent message unit"
   │     └─ kept 非空 ──► break(不再往前装)
   └─ 未超限 ──► kept.unshift(...unit),继续下一个 unit
   │
   ▼
kept 仍为空 ──► throw "context budget cannot be empty"
返回 kept

“两个 throw 对应的测试都在:trimContext(messages, 100) 触发最小单元报错(context.test.ts),trimContext(messages, 1) 同理(agent.test.ts 的 keeps a multi-tool assistant unit with its results)。预算失败必须可定位、可复现,不能是‘不知道为什么丢了一截’。”

小a:“estimateContextChars 为什么要单独抛‘不可序列化’?JSON.stringify 遇到循环引用本身就会抛啊。”

“stringify 抛的错消息没有消息索引。”老z说,“循环对象埋在第 5 条消息里,你拿到的是 Converting circular structure to JSON,找不到是哪一条。包一层、带上 message ${index},定位成本从‘全量检查’降到‘看一眼索引’。错误消息里带位置,是让调试不用猜。 另外 stringify 对 undefined、函数等会返回 undefined 而不是抛错,所以要单独判 serialized === undefined——两条失败路径,一个抛、一个返回空,都得挡住。”

小a:“那为什么不干脆自动总结?总结一下不是更省吗?”

“总结需要另一次模型调用、额外成本与失真策略。”老z说,“当前产物选择明确报错或裁剪,避免假装拥有语义 compaction。”

会话与上下文的责任边界 ​

小a:“那这几份状态到底各归谁管?”

“先看一份状态进 runAgent 后走哪条路。”老z画了张图:

runAgent(options)
   │
   ├─ options.sessionPath 存在? ──► loadSession → messages(磁盘完整记录)
   │        不存在?            ──► messages = [](空会话开始)
   │
   ▼
   每轮 client.stream 前:
   │
   ├─ options.contextMaxChars 存在? ──► trimContext(messages, maxChars) → 发给模型
   │        不存在?             ──► messages 原样发(不裁剪)
   │
   ▼
   每轮 record():
       messages.push(内存态)
       sessionPath 存在时 appendSession(磁盘态)

“注意两条虚线是互不感知的:trimContext 只读 messages 数组,不知道它来自磁盘;appendSession 只写 JSONL,不知道模型收到的是裁剪版。完整记录和裁剪输入是两份状态,各走各的管道,只在 messages 这个内存数组里短暂汇合。 这一章导读里那句话就是这么来的:磁盘可审计,发给模型的受预算约束。”

老z列了一张责任表:

状态保存位置失败语义当前未覆盖
完整事件JSONL坏行拒绝恢复加密、锁、删除
本轮输入trimContext 返回值预算过小报错token 计数、摘要
工具关联assistant/tool unit成组保留复杂并发关联
长期偏好未实现不适用来源、过期、隐私

设计代价与走查 ​

“JSONL 易读但没有查询索引;字符裁剪确定却不理解任务;保留完整文件有审计价值也会带来敏感数据保留风险。”老z说,“源码走查时依次打开 record()、appendSession()、loadSession()、parseSessionMessage()、trimContext(),用一份合法三行 JSONL 和一份损坏第二行 JSONL 手工跟读分支。”

小a:“这几个文件里最容易走错的是什么?”

“两处。”老z说,“一是 parseSessionMessage 的公共前提——role 和 content 的 typeof 检查放在最前面,三个分支共享;如果把它挪进某个分支,另两个分支就得重复检查,容易漏。二是 trimContext 的双层循环——外层从尾部反向,内层向前找 assistant,assistantIndex >= 0 才成组,否则 tool 落成单条 unit。单条 tool unit 是合法的吗?是——tool result 找不到声明它的 assistant 时,它只能单独留下。 这是裁剪层对脏历史的宽容,但它不补造 assistant。”

设计取舍选择了什么放弃的替代
记录格式JSONL 追加单文件全量重写(每轮 O(n) 写放大)
恢复策略坏行抛错坏行跳过(丢证据,静默错位)
裁剪度量字符近似token 计数(需引入 tokenizer 依赖)
上下文压缩裁剪/报错自动摘要(多一次模型调用)
并发安全未加锁文件锁/fsync(复杂度与当前场景不匹配)

正常和失败序列 ​

小a:“那直接把 runAgent 调起来,正常和失败分别长什么样?”

“直接调用 runAgent 并传入两个可选参数时,正常序列是:append 写一条 JSON → 下次 load 逐行验证 → Agent 用完整记录恢复 → trim 选择可容纳单位。失败:坏 JSON、非法 role 或缺字段报告行号;预算过小报告最小一致单位不能容纳。”老z说,“没有摘要时,旧对话一旦裁剪就不可从模型输入恢复,磁盘原文仍保留。现有 CLI 没有进入这条序列。”

机制当前保证未保证
JSONLappend 与判别验证加密、并发写锁
trim确定字符上限token 精确性、语义保留
tool unit调用/结果成组跨会话业务一致性

小结 ​

会话层做了一个关键区分:磁盘上的完整记录和发给模型的裁剪上下文,是两份不同的状态。前者每行一条完整消息,可审计、可回放;后者受预算约束,必须裁剪。把这两者混为一谈,就会要么让上下文无限膨胀,要么让磁盘记录丢失关键信息。这两份状态各走各的管道,只在 runAgent 的内存 messages 数组里短暂汇合——trimContext 不知道消息来自磁盘,appendSession 不知道模型收到的是裁剪版,职责边界靠这种互不感知维持。

JSONL 的解析很严格,而且是分层严格的。parseSessionMessage 保持纯函数,先查公共前提再按 role 分派,合法的返回 Message、不合法的返回 undefined;行号责任留在 loadSession,拿到 undefined 才抛 invalid session record at line N。非法的 role、缺失的字段、截断的 JSON 都按行号报错,不让坏数据悄悄混进去。record() 与 calls() 的结构收窄,同 model-client.ts 的 toolInput 是同一种手法——不可信 JSON 进来,先收窄结构,再断言字段。解析层只保证“可恢复”,不保证“可运行”:工具名是否存在,由执行层的 toolsByName 在分派时回答。

裁剪用的 trimContext 是字符近似预算,从尾部向前保留,并把带多个 tool call 的 assistant 和它对应的 results 当成一个不可分割的单元——这样模型不会看到“有结果没有请求”或“有请求没有结果”的半截对话。但字符预算不等于 token 预算:estimateContextChars 用 JSON.stringify 的长度做近似,中文字符、ASCII 标点、Provider 的隐藏开销都不会被精确反映。字符串化失败要带消息索引报错,预算小于最小一致单元时报错而不是返回空数组——空数组会让模型面对一个没有前文的孤立请求。

当前产物没有自动摘要或 compaction——它选择明确报错或裁剪,而不是假装拥有语义压缩能力。这两条路都指向同一个判断:不引入 tokenizer 依赖、不多做一次模型调用,用确定的字符近似换实现简单,并如实标注它不精确。CLI 也尚未装配 sessionPath,所以会话续接目前只在显式调用 runAgent 时生效;会话这一层的“函数支持”和“CLI 已交付”是两件不同的事,装配是下一章的活。

阶段验收 ​

运行 npm test:现有测试通过 fake client 直接调用 runAgent,可核验显式 sessionPath 的追加与恢复;独立的 context 测试核验字符估算和一致单元裁剪。它们验证的是库边界,不是 npm start/runCli() 的会话交付;当前测试也没有证明入口已传递 contextMaxChars。