讓大量圖片的代理回合留在記憶體之內
正式環境中的一個代理回合,在從六份商品目錄掃描檔裁切商品並檢視裁切結果時,耗盡了 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 的存活記憶體。堆積取樣顯示,所有的成長都發生在模型用戶端函式庫中負責編碼請求內容的部分。
原因是一個中止監聽器。當你按下 Stop,該回合的 abort signal 會被觸發,所有監聽它的請求都會取消。我們把這個整個回合共用的訊號傳給每一個請求。用戶端函式庫會在你給它的訊號上加入一個 "abort" 監聽器,而且從不移除。
於是,每個請求都會一直被該回合的訊號所參照,只要回合持續進行:監聽器持有請求的控制器,控制器持有請求,請求持有整個請求內容,包括圖片。
修正方式:每個請求一個訊號
現在每個請求都有自己的訊號。它只在該請求執行期間才連結到回合的訊號,對於串流回應,則是直到最後一個區塊被讀取為止,然後立即解除連結。按下 Stop 仍然能中止任何正在進行的請求,但回合的訊號同一時間只會持有一個監聽器。
測試涵蓋了這三個部分:
- 經過 12 次呼叫後,回合的訊號上沒有任何監聽器殘留。修正前則有 12 個。
- 每個 8 MB 的 15 次請求,記憶體維持持平。修正前則增加了 128 MB。
- 按下 Stop 仍能在串流中途中止請求。
第三個成因:追蹤保留了每次呼叫的輸入
堆積快照顯示了第三個持有者:每次模型呼叫大約保留 6 MB。這來自代理框架的事件串流。它會把回合中的每一次模型呼叫記錄為該回合的子項目,包括輸入,直到回合結束為止。附加在每次呼叫上的每張圖片都會跟著留在記憶體中。
修正方式:呼叫返回後清空圖片
圖片只會附加在為特定模型呼叫而建立的訊息上,從不會儲存在對話的保存狀態中。因此,呼叫返回後,清空那些訊息中的圖片資料是安全的。追蹤記錄仍會保留該呼叫的文字紀錄;像素則被釋放。
有一個細節很重要:圖片必須就地清空。追蹤器可能透過訊息上的多個參照存取同一份圖片資料,因此如果換成一個新的空清單,原來那份(連同像素)仍然可以被存取。已保存的對話狀態從不會被修改。
一項測試在相同的事件串流下執行代理的圖形,每次呼叫都附加一張 4 MB 的圖片。沒有修正時,16 次呼叫後記憶體增加了 77 MB。有修正後,記憶體維持持平。
我們的收穫
- 記憶體問題可能分層出現。 三個各自的成因都讓圖片保留過久,三者都必須解決,回合才能維持在記憶體限制之內。
- 交給函式庫的任何東西都可能被保留。 訊號與追蹤都比它們所服務的呼叫存活得更久。當記憶體隨每次呼叫增長時,請檢查這次呼叫碰到了哪些長壽命的物件。
- 不要傳送對方會丟棄的東西。 模型本來就會捨棄的像素,卻在每次呼叫時消耗我們的記憶體。
- 直接測試記憶體。 每項修正都附帶測量堆積成長或監聽器數量的測試,因此回歸會以數字呈現,而不是以當機呈現。
這是近期第二個與代理有關的記憶體故事;第一個是一個進度標籤重複傳送了 41,000 次。如果你想了解代理是什麼、如何運作,請參閱我們的AI 代理指南與對話文件。