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 分钟分桶,给出每个对象的内存峰值和超限次数:

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

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

5 分钟桶(UTC) 峰值内存 超限次数 对应的结算时间
01 141.7 MB 2 01:16
01 157.0 MB 2 01:30
01 165.8 MB 2 01:38
01 166.8 MB 2 01:49

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

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

平台的内存数字,有两个坑

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

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

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

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

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

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

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

测量方法和更多场景单独写在了《实测 Durable Objects 的内存上限》。

平台日志里其实也有一句明确的死因: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 来算。
  • 框架什么时候补上我们需要的几个能力:读取未经压缩的原始历史、用户停止时撤销已排好的恢复、批准后续跑不重复请求。在那之前,相应的部分还是我们自己的实现。

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

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

Praxis 知行云创始人。