藏在進度標籤裡的記憶體不足當機
九月底的一個晚上,某位使用者工作區中的 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 次。Socket 無法像產生的速度那樣快速送出,於是它們在寫入緩衝區中堆積成字串,約 690 MB。
修正方式
- 限制標籤長度。 狀態標籤只需要說明正在發生什麼。現在它被限制在 200 個字元,並且我們會節流重新掃描部分命令的頻率。
- 停止二次方的字串串接。 以重複字串串接來合併串流片段,對大型輸出而言是 O(n²)。我們改用以區塊為基礎的做法,以線性時間附加。
接著我們檢查了在大量輸出湧入時,是否還有其他可能無限制增長的地方,並為它們加上上限:
- 聊天中顯示的即時命令輸出會被批次處理,並只保留最後 8,000 個字元。
- 單行事件上限為 4 MB。
- 瀏覽器在重新連線後用來追趕進度的重播緩衝區,每個專案上限為 8 MB。
- 舊的檢查點會隨著回合進行而修剪,保留最新的狀態,以及早期回合需要的錨點。
如實測試大量輸出
有兩件事讓這件事比應有的更難測試:
- 真實模型不會配合。 要求模型印出一個巨大的檔案,它通常會拒絕、摘要,或以更合理的方式寫出。要測試大量輸出,你需要一個依你指示精確行動的腳本模型,並透過真實的代理人程式碼執行。
- 腳本必須表現得像模型。 我們第一個腳本模型在每次回覆時都重用同一個回應 ID,因此代理人框架把每次回覆都當成是上一次的替代,並提早結束回合。看起來像是 UI 重新整理的 bug,其實不是——是測試框架在騙我們。我們還必須在腳本中強制執行模型真實的上下文上限,否則記憶體數字就不真實。
我們從中學到什麼
- 串流 UI 會放大小錯誤。 任何「每個區塊」都要送出的東西,都會被送出上萬次。要嘛送出增量,要嘛限制負載大小。
- 為每個跨越慢速邊界的緩衝區設定上限。 Socket、重播紀錄與檢查點都需要一個天花板。
- 先重現,再修正。 我們最初的兩個解釋聽起來很合理,但都是錯的。
如果你最近在 Mythex 聊天中看到很長的檔案出現,而代理人沒有停頓,這也是原因之一。