部落格
讓對話歷史交給伺服器:行動端 AI 程式開發的對話恢復設計
為什麼 Tactic Remote 1.8.2 把聊天歷史從手機端移到了伺服器,以及這個決定如何讓一整類順序錯亂和歷史遺失的問題從根本上消失。
繼續查看該欄目下的產品與工程更新。
瀏覽此分類這篇文章講的是一個架構決定:Tactic Remote 1.8.2 為什麼把聊天的對話歷史,從手機端移到了伺服器,以及這個決定如何讓一批我們一直反覆修補的問題,從根源上消失。
一套注定會失敗的做法
在 1.8.2 之前,聊天歷史完全由手機端即時拼接。伺服器將 agent 的串流事件推送給手機,手機再把這些事件依序渲染成氣泡,累積成你看到的對話。
在桌機上,這套做法還算夠用,因為連線是穩定的。但到了行動端,它有一個根本缺陷:歷史只存在於當前這一條連線、這一次會話之中。於是問題接連出現。
- 重新連線之後,新連線拿不到舊事件,歷史一片空白,或殘缺不全。
- 強制結束應用程式再重開,手機記憶體裡的對話就消失了,只能從頭來過。
- 多台裝置連線到同一個會話時,每台只看得到自己接入之後的事件,各說各話。
還有一個更隱蔽的問題,關於順序。發送訊息時,手機端會先樂觀地把使用者氣泡顯示出來,而 agent 的第一條回覆,要等它的程序真正啟動後才會到達。中間只要出現一點時序抖動,例如排隊中的訊息在重新連線後才姍姍來遲,使用者氣泡就可能排到回答的下方。這個「使用者氣泡跑到底部」的問題,我們修過好幾次,但每次都只是治標。因為它的根本是結構性的:在整套設計裡,沒有任何一方,對「這段對話真正的順序」負責。
借鑑 tmux 的思路
Tactic Remote 的終端模式一直很穩定,靠的是 tmux。tmux 的模型簡單而有力:終端緩衝區是唯一的事實來源,你接上去(attach),就是把這份事實回放給你。斷線、換裝置、重開,都只是重新接入同一個緩衝區,歷史自然還在。
聊天模式缺的,恰恰就是這一層。所以 1.8.2 的設計,就是把這套思路套用過來:
讓伺服器保管一份權威的對話記錄,客戶端接入時,將它完整地恢復出來。
一旦伺服器成為順序的權威,前面那些問題,就不再是需要逐個修補的 bug,而是從結構上不再可能發生。
具體是怎麼做的
伺服端:每個會話一份環狀緩衝區
伺服器為每個聊天會話維護一份對話記錄。每一個廣播出去的事件,都按順序附加進一個環狀緩衝區。緩衝區有兩道上限,一道限制事件條數,一道限制總位元組數,超出就從最舊的開始裁切。但會話開頭那條初始化事件是恢復的錨點,永遠不裁。一旦發生裁切,緩衝區會被標記為「已截斷」,這樣恢復時,客戶端便能在頂部如實顯示「更早的訊息已省略」,而不是假裝從頭開始。
這裡有一個關鍵步驟:使用者自己發出的訊息,現在也由伺服器產生一條有序事件,寫入這份記錄。在過去,使用者訊息只存在於手機端的樂觀渲染裡,伺服器對此一無所知;執行途中插入的訊息(steer)更是連記錄都沒有。如今伺服器成了順序的權威,使用者的每一輪發言,都落在它真實發生的位置上。「使用者氣泡跑到底部」這個老問題,也就從結構上消失了。
恢復:用一個信封,而不是重送原始事件
最初的方案,是接入時把原始事件重新傳送一遍。這個想法很快就被否決了。因為舊版客戶端只會忽略它不認識的事件,而這些被重送的,恰恰是它認識的事件,會被當成新的即時訊息再附加一次,歷史直接翻倍。
真正採用的方案,是把整段記錄裝進一個全新的事件裡下發:structured_replay,裡面帶著序號、是否為最後一批、是否已截斷、當前是否在執行中,以及事件列表。舊版客戶端不認識這個新事件,會直接忽略,安然無事;新版客戶端則把這個「信封」拆開,將裡面的事件重新送入同一條處理流程,去重建整段對話。
由於 iOS 的 WebSocket 單幀接收上限約為 1 MiB,恢復時會按 256 KB 分片下發,以序號標記先後。至於已經處理過的工具審核,恢復時會補上「已允許 / 已拒絕」的標記,以免一張歷史審核卡因狀態缺失,被誤渲染成一張還在等待操作的卡片。
客戶端:接入即重建,同時阻擋副作用
客戶端收到序號為 0 的那批恢復資料時,先清空本地對話,再將事件列表依序送回同一個處理函式去重建。也就是說,恢復走的是和即時完全一樣的渲染路徑,不需要另外撰寫一套邏輯。
這裡有個必須處理的細節:恢復過程中,那些只應在即時情境觸發的副作用,必須被阻擋掉,例如佇列的自動發送、逾時看門狗、執行狀態的切換、審核的自動就緒。我們用一個旗標把它們統一擋住,直到最後一批資料到達,再採用信封裡那個權威的執行狀態。
至於發送時的樂觀使用者氣泡,我們沒有取消它。因為第一條訊息要等 agent 程序啟動一兩秒,純靠等待恢復會有一段空白,體驗並不好。我們的做法,是把它改成一個「待確認」的預留位置:伺服器的回執一到,就移除這個預留位置,按權威記錄裡的真實位置重新插入。錯亂,也就在結構上無法再持續存在。
相容性:為什麼「忽略未知事件」是一個設計決定
恢復方案能夠安全落地,很大程度上依賴一個早已埋下的約定:客戶端對不認識的事件,一律直接忽略。正是這一條,讓伺服器可以引入 structured_replay 這樣的新事件,而完全不驚動尚未升級的舊版客戶端。它們看不見恢復功能,但也絕不會因此出錯。
在一個由 iOS 應用程式、Mac / Linux / Windows 三端伺服器、加上一個 npm CLI 各自獨立發布、版本長期參差不齊的系統裡,這種「只增不改、遇到未知就忽略」的協定紀律,正是讓伺服端先行、客戶端隨後跟進的前提。
邊界,以及誠實的取捨
這套設計並非沒有邊界,我們更願意把它們說清楚。
- 伺服器緩衝區有上限。 超長會話最早的一段歷史會被裁掉,恢復時以「更早的訊息已省略」明確告知,而不是假裝完整。
- 被回收的會話,從零開始。 會話長時間無人訂閱後,伺服器會將其回收;之後透過
claude --resume恢復的會話,緩衝區從空白重建,更早的歷史不再回放。這是一個有意的取捨:用有限的歷史留存,換取可控的記憶體佔用。 - 客戶端本地持久化,是後續的事。 目前這份權威記錄只在伺服器線上期間存在。把歷史進一步儲存到客戶端本地,是下一步的強化,不在這個版本裡。
與其等你某天發現歷史突然少了一截、再自己去猜原因,我們更願意提前把這些邊界寫明白。
想體驗這種「接入即恢復」的效果?iOS 應用程式已在 App Store 免費上架,配套應用程式與 CLI 同樣免費。如果你對這個版本裡另一塊硬骨頭感興趣,也就是那次「不在業務程式碼裡」的介面卡死,可以讀《追查一次介面凍結》。