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 数决定什么时候压缩,算错一点都会改变行为。所以不能换成估算,只能少算。

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

  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 之间。

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

开始构建你的服务智能体
Sean

Praxis 知行云创始人。