10 月 26 日,我们发布了一轮会话内存修复,并在上一篇记录的结尾留了一句:发布后的对比要等数据。数据回来之后并不好看:长会话还在被平台重置,单个会话的峰值还能到 200 MB。这篇记录后面发生的事:我们差点用一个更省事的办法把问题盖住,后来改用线上真实会话做本地重放,才找到一笔一直没算进去的开销。
现象:大会话还是会死
智能体会话跑在 Cloudflare Durable Objects 上。上一轮修掉了大输出的多份副本,也把基线从 60 MiB 压到了 38 MiB,但长会话还是会被重置。我们的模型支持 100 万 token 的上下文窗口,很自然地,我们先怀疑上下文太大。
第一个想法:把上下文限小
最直接的推论是上下文太大,那就设一条容量线,比如限制在 15 万 token,超过就早点压缩。内存一定会降,改动也小。
我们没有这么做。100 万 token 是用户在用的能力,把它砍到 15 万,等于用模型的能力换我们的内存。更重要的是,这个推论当时只是推论:我们并没有量过上下文本身到底占多少内存。
另一个怀疑是框架泄漏。上一篇里连续 50 轮的常驻增长只有约 3 MiB,但那是实验室夹具,不是真实的长会话,这条证据不够用来排除。
把真实会话变成夹具
实验室的夹具有个缺点:它只包含我们想到过的情况。所以这次我们把一个真实触发过内存超限的会话导出到本地,在内存实验室里加了一个配方:用这份历史做种子,按它线上的上下文窗口跑真实的步骤。导出的数据只留在本地缓存里,不进仓库。
堆快照只能看到此刻活着的对象,这次要看的是每一步到底做了什么,所以又加了一个分配剖析:通过 inspector 打开采样,按函数记录分配,包括已经被回收的。
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 数决定什么时候压缩,算错一点都会改变行为。所以不能换成估算,只能少算。
每个消息部分现在按三层解析:
- 内存里按对象身份的
WeakMap,同一个对象不重复算; - DO 自己的 SQLite 里按内容哈希存的计数,表上限 4096 行,重启之后还能复用;
- 两层都没命中,才真的分词。
分词器内部也改了切片方式,用 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 之间。
这次的体会是,线上的真实样本比我们自己想出来的夹具诚实。那个“上下文太大”的推论听起来完全合理,如果不重放,我们大概已经把容量线加上去了。