藏在进度标签里的内存溢出崩溃
9 月下旬的一个晚上,一位用户工作区中的 AI 智能体在一轮对话进行中因内存溢出(out-of-memory)而崩溃。最明显的嫌疑对象——同时运行太多子智能体,或者上下文增长得太大——都不是原因。真正的原因是一个状态标签:模型每次只流式输出几个字符来生成一条很大的 shell 命令,而我们每发送一个片段,都会把到目前为止的完整命令重新作为进度标签发送一次。一条 124 KB 的命令,产生了大约 41,000 个事件,以及约 690 MB 排队中的字符串。
智能体如何流式输出工作
在 Mythex 上构建时,智能体运行在你项目专属的私有云沙盒中。模型生成内容时,输出会以流的形式回传到你的浏览器:文字逐词出现;当它运行某个工具——例如写入文件的 shell 命令——时,你会看到该工具的标签实时更新,让你知道它正在做什么。
模型以很小的片段进行流式输出。以我们当时使用的模型来说,这些片段往往只有三个字符。
最初的假设是错的
这次崩溃看起来像是规模问题,所以我们先从那里入手。
- 并行的子智能体? 这一轮只用了两个,每个持有大约 1.5 MB 的状态。不足以导致崩溃。
- 保留的追踪数据? 我们的追踪功能会在内存中保存一棵运行树,但它的大小远不足以解释崩溃时的堆大小。
这两种说法都很有道理,而且都很容易相信。但两者都无法复现这次崩溃。我们一再重新学到的教训是:如果无法复现故障,它就还不是原因。
真正地复现它
我们搭建了一个临时沙盒,内存限制与生产环境相同,并使用一个假模型,以真实的三字符片段流式输出一个很大的文件。我们还让另一端的读取者变慢,模拟真实的网络。
这样就成功复现了。堆内存不断攀升,进程以同样的错误退出。
一旦能够观察它,机制就很简单了。模型在写入一条很大的命令时——在这个例子中是一个写入 124 KB 文件的 heredoc——我们的流式代码会在每个片段上更新工具的标签。标签的内容不是新的三个字符,而是到目前为止的整条命令。因此事件不断增长:3 个字符,然后 6 个,然后 9 个……一直到 124 KB,共约 41,000 次。套接字发送的速度跟不上生成的速度,于是这些内容在它的写入缓冲区中以字符串的形式堆积起来,总计约 690 MB。
修复
- 限制标签长度。 状态标签只需要说明正在发生什么。现在它被限制为 200 个字符,并且我们会限制重新扫描部分命令的频率。
- 停止二次方级的字符串拼接。 用重复的字符串拼接来连接流式片段,对于大输出是 O(n²) 的。我们改用基于分块的方法,以线性时间追加。
然后我们检查了其他可能在大量输出洪流下无限增长的地方,并为它们设定了上限:
- 聊天中显示的实时命令输出会被批量处理,并且只保留最后 8,000 个字符。
- 单行事件的长度上限为 4 MB。
- 浏览器在重新连接后用于追赶进度的回放缓冲区,每个项目的上限为 8 MB。
- 旧的检查点会随着轮次推进被修剪,只保留最新的状态以及早前轮次所需的锚点。
诚实地测试洪流
有两件事让这次测试比应有的更难:
- 真实模型不会配合。 要求模型输出一个巨大的文件,它通常会拒绝、做摘要,或者更合理地写入。要测试洪流,你需要一个脚本化的模型,它会严格按照你的指示行事,同时运行在真实的智能体代码之中。
- 脚本必须表现得像模型。 我们最初的脚本化模型在每次回复中都复用了相同的响应 ID,因此智能体框架把每次回复都当作对上一次的替换,于是提前结束了轮次。这看起来像是界面刷新的 bug,实际上不是——是测试工具在误导我们。我们还必须在脚本中强制执行模型真实的上下文限制,否则内存数据就不真实。
我们的收获
- 流式界面会放大小错误。 任何「按片段」发送的东西,都会被发送数万次。要么发送增量,要么限制载荷大小。
- 为每一个跨越慢速边界的缓冲区设置上限。 套接字、回放日志和检查点都需要一个上限。
- 先复现,再修复。 我们最初的两种解释听起来都很对,但都是错的。
如果你最近在 Mythex 聊天中看到一个很长的文件出现,而智能体没有卡住,这就是部分原因。