10 月 25 日，一位用户在反馈里写了两件事：上下文压缩失败了；智能体经常干着干着就停了。这篇记录我们怎么查这个问题。中间有两次判断是错的，也有一些平台行为是查了才知道的，一并写下来。

## 背景：会话跑在哪里

我们的智能体会话跑在 Cloudflare Durable Objects 上，一个会话对应一个对象（下文简称 DO）。会话的运行时基于 Cloudflare 的 Agents SDK 和 AI Chat 框架，在它们之上我们自己实现了一套事件协议，给控制台和 API 调用方用。模型请求经过我们自己的模型路由转发给上游，这个会话用的是 DeepSeek。

## 压缩失败：一个被吞掉的错误

压缩失败比较好查。路由的账本显示，那次手动压缩请求等了 118.9 秒，上游一直没有返回响应头；我们给压缩设的上限是 120 秒，所以以超时结束，最后记为 502。

用户看到的报错却是「No output generated. Check the stream for errors」。原因在我们的代码顺序：先 `await result.text`，再去看流里捕获到的错误。流已经出错时，`result.text` 会抛出框架的通用错误，真实原因就被盖住了。另外压缩请求设的是 `maxRetries: 0`，失败一次就放弃。

这部分改起来很直接：压缩的首字节等待放到 300 秒，整次请求最长 480 秒；超时、5xx 重试两次；上下文超窗时从最早的轮次截断再试；先检查流里的错误，再取文本。

## 「干着干着就停了」：日志里没有死因

这个问题难查得多。从日志看，这个会话的 DO 在两段时间里一共启动了 17 次，其中 4 次启动时发现有一轮任务还标着「运行中」，只能把它结算掉。这 4 次之前的最后几条日志都差不多：模型刚结束一步，工具开始执行，20 到 70 秒之后，DO 就没了。

没有异常，没有报错，日志就停在那里。我们一开始怀疑是部署替换了实例，但时间对不上任何一次部署。

## 在平台指标里找到 exceededMemoryErrors

线索在 Cloudflare 的 GraphQL 分析接口里。`durableObjectsPeriodicGroups` 按 5 分钟分桶，给出每个对象的内存峰值和超限次数：

```graphql
durableObjectsPeriodicGroups(filter: { namespaceId: $ns, datetimeFiveMinutes_geq: $from }) {
  dimensions { datetimeFiveMinutes objectId }
  max { memoryUsageBytes }
  sum { exceededMemoryErrors }
}
```

查出来，四次结算正好落在四个有内存超限的时间桶里：

| 5 分钟桶（UTC） | 峰值内存 | 超限次数 | 对应的结算时间 |
|---|---|---|---|
| 01:15 | 141.7 MB | 2 | 01:16:01 |
| 01:30 | 157.0 MB | 2 | 01:30:33 |
| 01:35 | 165.8 MB | 2 | 01:38:29 |
| 01:45 | 166.8 MB | 2 | 01:49:32 |

放大到整个生产看：过去 7 天，66 个活跃会话一共记录了 30 次内存超限，按 5 分钟桶算的峰值 P99 是 147 MB。

到这里，我们的第一个假设是「会话越聊越长，历史把内存撑满了」。这个假设很快被推翻了：这个会话第一次被杀是在 00:33:56，也就是创建之后的第一轮，那时聊天记录几乎是空的。

## 平台的内存数字，有两个坑

在读这些数字的过程中，我们踩了两个坑。

第一，**致命的那次分配永远采样不到**。进程在下一次采样之前就已经被重置了，所以记下来的峰值是「死前最后一次采样」，不是「死的那一刻」。141 到 167 MB 只能当下界，不能拿来倒推上限。

第二，**多个 DO 可能共用同一个运行环境的内存**。我们在测试环境里看到，同一个 5 分钟桶里有七个不同会话报出了完全相同的峰值。它们读到的其实是整个运行环境的内存。这意味着一个会话出问题，可能把同一环境里的邻居一起拖下去，也意味着单看某个会话的读数，不一定是它自己用的。

## 先测清楚：到底多少内存会被杀

文档写的 DO 内存上限是 128 MB，但我们记录到的峰值超过了它，会话却没有每次都死。我们不想靠猜，就部署了一个一次性的 Worker，专门测这件事，测完删掉。结果是：

| 做法 | 结果 |
|---|---|
| 一次请求里分配 2 GB 可回收的垃圾，同时只保留 4 MB 存活 | 活着 |
| 分几次请求累积约 240 MB 真正存活的数据，平台读数 254 MB | 活着 |
| 一次请求里同时持有 300 MB | 被重置，日志写着内存超限 |

这组结果改变了我们找问题的方向：决定生死的不是总共分配了多少，而是同一时刻还活着的数据有多少。垃圾产生得再快，只要回收得掉就不致命；几份大副本同时活着，才会越线。所以要找的不是「谁分配得多」，而是「什么时候、哪几份东西同时活着」。

测量方法和更多场景单独写在了[《实测 Durable Objects 的内存上限》](/blog/durable-objects-memory-limit-measured)。

平台日志里其实也有一句明确的死因：`Durable Object's isolate exceeded its memory limit and was reset.`，只是它不在我们自己会话的日志流里，一开始没有看到。

## 堆快照：内存到底花在哪

我们在本地的 workerd 里起会话 DO，接上 inspector 拍堆快照。为了结果可重复，模型回复用录好的帧，工具输出用预先准备好的夹具，不调用真实模型。一个小坑：workerd 不响应 `HeapProfiler.collectGarbage`，只能靠拍快照本身触发一次完整 GC。

快照给出了三个结果。

**一是基线很高。** 还没开始任何会话，代码本身就占了大头：只有代码时 57.4 MiB，空会话 58.4 MiB，跑完第一轮 72.7 MiB。第一轮多出来的十几 MiB，主要是分词器第一次加载。分词用的 BPE 词表常驻约 12 MiB，bash 命令权限表常驻约 12.5 MiB，两者都为了查得快，被展开成了大量小对象。

**二是工具输出没有上限，而且被复制了好几份。** bash 的标准输出整段经 RPC 进入 DO，然后在解析、拼接、保存证据的过程中同时存在四到五份副本，兜底路径上还有一份 base64。一条 20 MB 的输出，就足以把一个已经占了 60 MB 的进程推过线。这和「工具开始执行后几十秒 DO 消失」完全对得上。

**三是没有泄漏，但每一步都把整份历史重新铺开。** 连续跑 50 轮，GC 之后的常驻内存只多了约 3 MiB。可是模型每走一步，我们都要把完整历史转换格式、估算 token 数、和上一次比对、再序列化，几份副本同时存活。另外 AI SDK 默认会把每一步发出去的完整请求体保留到这一轮结束，步数越多，留着的整份历史越多。

## 改了什么，改完是多少

我们按「降内存效果 / 改动风险」排了顺序，一项一项改，每项都在内存实验室里测改动前后的峰值：

| 改动 | 场景 | 峰值前 → 后 |
|---|---|---|
| 命令输出留在执行它的机器上，DO 只拿开头和结尾；需要完整证据时从机器流式写进对象存储 | 一条 20 MB 输出 | 122 → 76 MiB |
| 分词词表和权限表改成紧凑的二进制数据，按需解析 | 空会话基线 | 60.6 → 38.0 MiB |
| 不保留每一步的请求体（`experimental_include: { requestBody: false }`）；token 估算按事件缓存；只为变化的消息生成记录 | 每轮 5 条命令、每条输出 100 KB | 177 → 154 MiB |
| 分叉会话改成分批读、分批导入，不再把整份历史拼成一个数组传过去 | 分叉一个 36 MB 的会话 | 131 → 95 MiB |

最后一项原来其实根本做不成：整份数据超过了服务之间单次传输的上限，分叉直接失败。

这些改动有一条硬约束：功能不能变。每一项改动前后，发给模型的请求、持久化的内容、推给客户端的事件，都用同一组录好的会话逐字节比对，必须完全一致。权限表换成二进制之后，我们用 13 万条权限判断逐条对过；分词器换成紧凑格式之后，用 3,036 段文本对过 token 数。

也有一些过程中的插曲：

- 权限表最初的生成脚本已经找不到了，我们只好把现有的生成结果当作事实源，从它反推出二进制格式，再加一道漂移检查。
- 换成二进制数据文件之后，测试需要专门的加载器。我们自己的证明命令都带着它，所以一直没发现；直到发布前，另一位同事直接跑 `bun test`，看到压缩相关的 26 个测试挂了 25 个。线上不受影响，打包工具认这种文件，但测试环境的配置漏了，补上之后才放行发布。

## 回头看：有些东西框架已经做了

查到一半我们发现，不少「同时活着的副本」来自我们在框架之外重复做的事：框架已经存好的消息，我们又全量读一遍；框架已经比对过的变更，我们又比一遍；框架有卡死检测和中断恢复，我们也各写了一套。

于是我们拿 Agents SDK 0.30.1 和 AI Chat 0.12.2 的源码逐项对照，列出了 15 处重叠，大部分改回用框架。卡死检测直接用框架的，空闲 300 秒判定为卡住。

有几处试过之后决定先保留自己的。最典型的是重启后的续跑：交给框架之后，用户批准一次工具调用，模型会被请求两次（真实 DO 测试里是 2 次，我们自己的实现是 1 次）。框架也没有公开的办法在用户点停止时撤销已经排好的恢复，「长时间没有进展就终止」的判断，要等下一次恢复尝试时才会执行。这几点我们已经整理成需求提给上游，等框架补上再切过去。

最后定下来的分工是：会话内部怎么跑（消息存储、请求组装、断线续传、中断恢复）尽量交给框架；对外的事件流、序号、游标、权限过滤由我们自己负责，因为我们对外的协议是一条事件流，而框架的模型是整份消息列表。

## 一个粗略的内存账本

把这些放在一起，我们现在用这样一个式子估算一个会话的内存峰值：

```
峰值 ≈ 基线 + 常驻窗口 + max(单条外来内容上限, 请求组装单元) × 复制倍数 + GC 余量
```

前面查到的每个问题，回头看都是式子里某一项没有上限，或者复制倍数失控：工具输出没有上限，历史每一步被复制好几份，基线里有两张展开的大表。这个式子并不精确，它的用处是改代码时能问一句：「这条新路径带进来的内容，算在哪一项里，上限是多少？」我们把它写进了架构决策记录，再配上几道机器检查：会话里禁止全量读取历史、禁止整份序列化对话，打包体积有预算，内存实验室按固定场景测「常驻内存随轮数的增长」和「单步峰值随历史长度的增长」，两条曲线都必须是平的。

监控上，我们接了平台的内存指标：内存超限立刻告警，连续逼近上限提前预警。会话 DO 每次启动时也会记下上一次是怎么结束的：是部署替换，还是被平台重置，以及死之前最后在做什么。下次再有人说「它干着干着就停了」，至少能直接看到停在哪一步、为什么停。

## 现在的状态，和还不知道的

这些改动在 10 月 26 日发布到了生产。发布前的 7 天里，生产上 87 个活跃会话一共记录了 36 次内存超限，单个会话峰值的 P99 是 183 MB。发布后的对比要等至少三天的数据，拿到之后补在这里。

还不知道的有两件：

- 平台真正的杀死线在哪。我们只测到它落在 254 MB 到 300 MB 之间，所以设计目标留了余量，也不再按文档的 128 MB 来算。
- 框架什么时候补上我们需要的几个能力：读取未经压缩的原始历史、用户停止时撤销已排好的恢复、批准后续跑不重复请求。在那之前，相应的部分还是我们自己的实现。

这次最大的体会是，「干着干着就停了」这种问题，单看自己的日志是查不出来的。死因在平台的指标里，真正的规则（同时存活的数据量）要自己测，内存花在哪要靠可重复的堆快照。我们这次是被用户反馈推着走的，下一次希望告警先响。
