10 月 26 日，我们发布了一轮会话内存修复，并在[上一篇记录](/blog/session-memory-peak-from-configuration)的结尾留了一句：发布后的对比要等数据。数据回来之后并不好看：长会话还在被平台重置，单个会话的峰值还能到 200 MB。这篇记录后面发生的事：我们差点用一个更省事的办法把问题盖住，后来改用线上真实会话做本地重放，才找到一笔一直没算进去的开销。

## 现象：大会话还是会死

智能体会话跑在 Cloudflare Durable Objects 上。上一轮修掉了大输出的多份副本，也把基线从 60 MiB 压到了 38 MiB，但长会话还是会被重置。我们的模型支持 100 万 token 的上下文窗口，很自然地，我们先怀疑上下文太大。

## 第一个想法：把上下文限小

最直接的推论是上下文太大，那就设一条容量线，比如限制在 15 万 token，超过就早点压缩。内存一定会降，改动也小。

我们没有这么做。100 万 token 是用户在用的能力，把它砍到 15 万，等于用模型的能力换我们的内存。更重要的是，这个推论当时只是推论：我们并没有量过上下文本身到底占多少内存。

另一个怀疑是框架泄漏。上一篇里连续 50 轮的常驻增长只有约 3 MiB，但那是实验室夹具，不是真实的长会话，这条证据不够用来排除。

## 把真实会话变成夹具

实验室的夹具有个缺点：它只包含我们想到过的情况。所以这次我们把一个真实触发过内存超限的会话导出到本地，在内存实验室里加了一个配方：用这份历史做种子，按它线上的上下文窗口跑真实的步骤。导出的数据只留在本地缓存里，不进仓库。

堆快照只能看到此刻活着的对象，这次要看的是每一步到底做了什么，所以又加了一个分配剖析：通过 inspector 打开采样，按函数记录分配，包括已经被回收的。

```ts
await cdp.send("HeapProfiler.startSampling", { samplingInterval: 16384 /* ... */ });
// 跑真实步骤
const profile = await cdp.send("HeapProfiler.stopSampling");
```

结果有两个：

- 这份 2.2 MB 的会话历史，放进内存之后，上下文本身带来的增长只有约 3.4 MiB。容量线的推论不成立。
- 分配量的 71% 来自 `measureContext`。每走一步，它都把整份请求分词两次，一步要分配约 117 MiB。

## 为什么分词会抬高峰值

上一篇说过，决定生死的是同一时刻活着的数据，不是累计分配量。所以 117 MiB 的分配不能直接当成峰值来读，它到底抬高了多少，要改完再量。

这笔开销也有改的余地：同一段文本的 token 数不会变，而每一步都在把整份请求重算两遍。

## 改了什么

我们的约束和上一轮一样：不改功能，也不降精度。token 数决定什么时候压缩，算错一点都会改变行为。所以不能换成估算，只能少算。

每个消息部分现在按三层解析：

1. 内存里按对象身份的 `WeakMap`，同一个对象不重复算；
2. DO 自己的 SQLite 里按内容哈希存的计数，表上限 4096 行，重启之后还能复用；
3. 两层都没命中，才真的分词。

分词器内部也改了切片方式，用 `lastIndex` 和 `slice` 走一遍文本，不再生成整组中间字符串。

| 场景（真实会话重放） | 轮末峰值 前 → 后 |
|---|---|
| 第一步 | 95 → 73 MiB |
| 后续步骤 | 78 → 58 MiB |

和上一轮一样，我们逐段比对 token 数与旧分词器相等；有模型 usage 时，总数和压缩决策都不变。

## 被杀的会话什么也留不下

这次排查里最难受的不是找原因，而是每次被重置都没有现场。平台杀进程时不给任何回调，代码里也读不到 isolate 的内存。

能做的是提前记。现在每个循环边界都把这一步的形状写进生命周期记录：第几步、消息数、历史和请求的字节数、请求 token 数、当前工具及其输出大小。它和活动状态是同一次写入，不多花一行。下一次启动时读到它，打出一条 `agent.session.died`，带上对象 id。对象 id 能和平台的内存分桶对上，于是一次重置可以追到具体会话和具体步骤。平台日志里那句内存超限也接了告警和 runbook。

用户那边也改了：重置期间读会话不再返回一个说不清的内部错误，先按 500 毫秒重试两次，等不到就返回可重试的 503 `SESSION_RESTARTING`。写操作不重试。

## 我们读错了一次数据

修复上线后第一次看数据，我们得出了“没有改善，每天还有约 5 次”的结论，差点回头去改别的。后来发现查询窗口混进了修复前的数据。按部署时间重新切开之后，结论反过来了。对比前后之前，要先确认窗口里没有另一边的数据。

## 现状和还不知道的

截至 11 月 2 日：

| 窗口 | 活跃会话 | 内存超限 | 单会话最大峰值 |
|---|---|---|---|
| 发布前 7 天（上一篇基线） | 87 | 36 次 | P99 183 MB |
| 最近 7 天（含修复前几天） | 505 | 14 次，集中在 4 个会话 | 200.5 MB |
| 最近 24 小时 | 85 | 0 次 | 144.2 MB |

24 小时不算长，这个数字还要继续看。

还不知道的有三件：

- 有两次重置，黑匣子记录到的请求只有约 1.1 MB、驻留约 8 MB，也没有内存超限的证据。这两次的原因还没查到。
- 144 MB 这个峰值里有多少属于会话自己，还说不清。上一篇提过，多个 DO 可能共用同一个运行环境。
- 平台真正的杀死线仍然只知道落在 254 到 300 MB 之间。

这次的体会是，线上的真实样本比我们自己想出来的夹具诚实。那个“上下文太大”的推论听起来完全合理，如果不重放，我们大概已经把容量线加上去了。
