让图片密集的代理回合保持在内存范围内
生产环境中的一个代理回合在从六份目录扫描件中裁剪出产品并查看裁剪结果时,内存耗尽,达到 952 MB。问题并不在于一个泄漏,而是三个。每次调用模型时,都会以原始大小重新发送该回合的图片;我们用来调用模型的库会把每个请求保持存活,直到回合结束;代理框架的追踪功能则会在整个回合期间保留每次调用的输入,其中包括图片。我们修复了这三处:图片在发送前会先缩小,并且每次调用的图片会在调用完成后立即释放。
代理如何看到图片
在 Mythex 上构建时,代理在你项目的云端沙盒中工作。它可以查看图片,例如你附加的截图、设计稿、项目中的文件,或者它自己刚生成的图片,方法是把这些图片与对话一起发送给模型。
回合是代理为响应你的一条消息所做的全部工作。在一个回合中,代理会多次调用模型:思考、运行工具、查看结果、再次思考。每次调用都会发送到目前为止的对话内容,其中包括该回合的图片,并以文本形式编码(base64,这会使其大约增大三分之一)。
崩溃的那个回合所做的事情本身是合理的。它拿到六份约 2.4 MB 的目录扫描件,从中裁剪出单个产品,并且反复读取裁剪结果来核对。这意味着要进行很多次模型调用,而每次调用都带着好几张大图。
原因一:每次调用都重新发送原始大小的图片
每次模型调用都会用原始大小的图片重新构建请求体,每次大约 20 MB 的 base64 数据。请求体是在代理的内存中作为一个字符串构建的,因此每次调用都会再分配约 20 MB。
而且以原始大小发送根本没有额外的好处。模型会自行把图片缩小到长边约 1,300 像素,多出来的像素只会增加内存占用和上传时间。
修复:先缩小一次,再发送小图
- 超过 400 KB 的图片,在发送前会转换为长边最多 1,568 像素 的 JPEG。较小的图片保持原样。
- 转换使用 ImageMagick,它预装在每个 Mythex 沙盒中,并且每个文件只运行一次。在一个回合中反复出现相同扫描件的情况下,会复用已缩小的副本。该缓存以文件的路径、大小和修改时间为键,因此被编辑过的文件会重新转换,并且缓存本身有大小上限,不会变成填满内存的元凶。
- 单次模型调用中图片的预算从 16 MB 降到 6 MB。
同样的六份扫描件,现在每次调用的成本是 4.4 MB,而不是 19.7 MB。
原因二:模型客户端从不释放请求
单个请求的大小只是一部分。内存还会随着一次次调用而不断增长。在我们的预发布环境中,同类回合即使在一次完整的垃圾回收之后(即所有不可达的对象都已被释放的时刻),仍然占用 369 MB 的存活内存。堆采样显示,所有增长都集中在模型客户端库中负责编码请求体的部分。
原因在于一个中止监听器。当你按下停止时,该回合的中止信号会触发,所有监听它的请求都会被取消。我们把这一个覆盖整个回合的信号传给了每个请求。而客户端库会在它所收到的信号上添加一个「abort」监听器,并且从不移除它。
因此,一个回合中的每个请求,只要该回合持续运行,就一直可以从该回合的信号上访问到:监听器持有请求的控制器,控制器持有请求,请求又持有整个请求体,包括其中的图片。
修复:每个请求一个信号
现在每个请求都有自己的信号。它只在该请求运行期间与回合的信号相连,对于流式响应,则是直到最后一个数据块被读取完毕为止,随后立即断开。停止仍然能够中止正在进行的任何请求,但回合的信号在任何时刻只持有一个监听器。
测试覆盖了这三个方面:
- 经过 12 次调用后,回合的信号上不再残留任何监听器。修复前有 12 个。
- 在 15 个各 8 MB 的请求中,内存保持平稳。修复前,它增长了 128 MB。
- 按下停止仍然能够中止流式传输中途的请求。
原因三:追踪保留了每次调用的输入
一次堆快照显示了第三个持有者:每次模型调用大约保留 6 MB。它来自代理框架的事件流。框架会把回合中的每一次模型调用记录为该回合的子项,包括输入,直到回合结束为止。附加在每次调用上的每张图片都随之留在内存中。
修复:调用返回后清空图片
图片只会被附加到为特定模型调用而构建的消息中,它们从不会保存在对话的持久状态中。因此,一旦调用返回,清空这些消息中的图片数据就是安全的。追踪记录保留了调用的文本内容,而像素数据则被释放。
有一个细节很关键:图片必须原地清空。追踪器可能通过消息上的多个引用访问同一份图片数据,因此如果换成一个新的空列表,原来的图片(连同其像素)仍然可以被访问到。已保存的对话状态完全不会被修改。
有一项测试在同样的事件流下运行代理的图,每次调用都附带一张 4 MB 的图片。没有修复时,16 次调用后内存增长了 77 MB。修复之后,内存保持平稳。
我们从中得到的经验
- 内存问题可能是分层的。 三个独立的原因各自都在把图片保留过久,三者都必须消除,回合才能保持在内存范围内。
- 交给库的任何东西都可能被保留。 信号和追踪都比被赋予的调用存活得更久。当内存随每次调用增长时,应当查看这次调用触及了哪些长生命周期的对象。
- 不要发送对方本来就会丢弃的东西。 模型无论如何都会丢弃的像素,却在每次调用时占用我们的内存。
- 直接测试内存。 每项修复都配有一项测量堆增长或监听器数量的测试,因此回归会以一个数字的形式呈现,而不是以崩溃的形式出现。
这是近期关于代理输出的第二个内存故事;第一个是一个进度标签被重复发送了 41,000 次。如果你想了解代理是什么以及它如何工作,请参阅我们的 AI 代理指南和聊天文档。