隱藏在進度標籤中的記憶體不足崩潰
九月底的一個晚上,一位用戶工作區中的 AI 代理,在一個回合進行到一半時因記憶體不足而崩潰。我們最先懷疑的兩個原因——同時運行的子代理太多,或上下文增長得太大——都不對。真正的原因是一個狀態標籤:模型以每次幾個字元的方式串流一個大型 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,因此代理框架把每個回覆都視為取代上一個,並提早結束回合。這看起來像是介面刷新的錯誤,但並不是——是測試框架在誤導我們。我們還必須在腳本中強制執行模型真實的上下文限制,否則記憶體數字就不切實際。
我們的心得
- 串流介面會放大細微錯誤。 任何「每個片段」都要傳送的東西,都會被傳送數以萬計次。傳送增量,或限制負載大小。
- 為每個跨越慢速邊界的緩衝區設定上限。 套接字、重播紀錄和檢查點都需要一個天花板。
- 先重現,再修復。 我們最初兩個解釋聽起來都對,但都錯了。
如果你最近在 Mythex 聊天中看到一個很長的檔案出現,而代理並沒有停頓,這正是其中一部分原因。