MapleCheng

在浩瀚的網路世界中無限潛水欸少年郎!

0%

狀態機要記成功,不要記意圖:一次 Slash Command 同步踩坑

最近在處理一個 AI 助理接聊天平台的功能時,我又踩到一個看起來很小、實際上很煩的坑:slash command 明明已經在程式裡加了,服務也重啟了,使用者介面就是看不到。

聽起來很像快取問題對吧?對,一開始我也很想把鍋丟給快取。畢竟聊天平台的 slash command 本來就常有同步延遲,client 端也可能自己留一份舊資料。但這次真正的問題不是平台慢,而是我們自己的狀態機太樂觀:它把「準備同步」記成了「同步成功」。

看起來一樣的 /command,其實不是同一件事

先講第一個容易混淆的地方:同樣長得像 /xxx 的指令,在一個 AI 助理系統裡可能有兩層。

第一層是聊天平台原生的 slash command,也就是你打 / 之後,平台介面會跳出選單、顯示描述、帶 autocomplete,最後送出一個結構化 interaction 給 bot。

第二層是 agent 自己的文字解析器。也就是你直接傳一段文字,例如 /xxx do something,bot 收到訊息後自己解析,當成內部指令執行。

這兩者在使用者眼中都叫 /xxx,但對工程師來說完全不是同一條路徑。

            
            flowchart LR
  A[使用者輸入 /xxx] --> B{平台是否有原生指令?}
  B -- 有 --> C[平台送出 structured interaction]
  B -- 沒有,只是文字 --> D[Bot 收到一般訊息]
  C --> E[Gateway handler]
  D --> F[Agent text parser]
  E --> G[執行 skill / action]
  F --> G
          

這張圖很簡單,但它解釋了很多「明明可以手打,為什麼選單看不到」的問題。

因為手打能執行,只能證明 agent 的文字解析器有支援;不能證明聊天平台上的原生 slash command 已經註冊成功。反過來,平台選單看得到,也不代表文字 parser 一定支援同樣的語法。

這種 UX 最麻煩的地方是:使用者看到的是同一個名字,工程師 debug 的卻是兩個系統。沒有先把路徑拆開,很容易在錯的地方找半天。

真正陰險的是 sync state

把兩層路徑拆清楚後,下一步就是確認原生命令到底有沒有註冊上去。

這類系統通常會做一個最佳化:啟動時算出 command definition 的 fingerprint。如果這次定義跟上次一樣,就不要再打 API 同步,避免每次重啟都去更新遠端設定。

想法沒問題,而且很常見。問題出在「什麼時候寫入這個 fingerprint」。

這次踩到的 bug 大概是這樣:

  1. 啟動時發現本地 command 定義變了;
  2. 系統準備呼叫平台 API 同步;
  3. 在同步真正成功前,先把新的 fingerprint 寫進 sync state;
  4. 後面同步因為某個原因中斷或失敗;
  5. 下次重啟時,系統看到 fingerprint 已經一樣,就判斷「不用同步」。

然後災難就開始了。

本地狀態說:「我同步過了。」

遠端平台說:「我沒收到。」

使用者介面說:「我什麼都看不到。」

工程師說:「蛤?」

這種 bug 特別討厭,因為它不是單純的失敗,而是失敗後留下了一個會阻止自我修復的錯誤狀態。你重啟服務也沒用,因為重啟反而會更堅定地相信那份錯的 state。

狀態機要記成功,不要記意圖

這件事給我的教訓很直白:狀態機應該記錄已確認的事實,不要記錄尚未完成的意圖。

「我要同步」不是狀態。

「我已經成功同步到遠端,而且遠端回應確認」才是狀態。

如果真的需要記錄中間過程,也應該明確分開,例如:

  • pending:偵測到需要同步,但尚未送出;
  • in_progress:同步正在進行;
  • success:遠端確認成功,並記錄成功時間;
  • failed:同步失敗,保留錯誤原因與下一次重試條件。

最怕的是用一個欄位同時代表「我想做」、「我做了」、「我做成功了」。平常看起來省事,出事時就是一坨糊。

尤其整合外部平台時,成功不能只看本地流程有沒有跑到最後。你要問的是:遠端實際狀態是不是我以為的那樣?如果沒有讀回確認,至少也要把 API 的成功回應當成寫入 state 的最低門檻。

Debug 時不要相信單一真相來源

這種同步問題,我現在會刻意看三份資料:本地定義、本地 sync state、遠端實際命令。

本地定義回答的是:「程式想要註冊什麼?」

本地 sync state 回答的是:「程式以為自己註冊到哪個版本?」

遠端實際命令回答的是:「平台上現在真的有什麼?」

三者只看其中一個都不夠。只看程式碼,你會覺得功能已經寫了;只看 state,你會被錯誤 fingerprint 騙;只看使用者介面,又可能被平台或 client cache 誤導。

所以我比較喜歡用一個很土的方法:直接打遠端 API 查目前註冊的 commands。不要猜,不要拜託快取快一點,也不要一直重啟服務求神明保佑。

工程裡很多 debug 最後都會回到這句話:看實際資料。

不是看你以為送出去什麼,也不是看程式理論上會做什麼,而是看對方現在真的收到了什麼。

Autocomplete 也有產品細節

同一輪處理中,還有另一個小坑:slash command 的 autocomplete 不是無限列表。很多平台會限制一次最多顯示固定數量的 choices,例如 25 筆。

這代表如果你把一大堆可執行能力都塞進同一個選單,使用者打開 /skill 看到的可能只是排序前面的那一小段。他要找的指令其實存在,但因為沒有輸入關鍵字,根本不會出現在候選清單。

這不是後端功能壞掉,而是產品體驗壞掉。

所以後來我會傾向把常用能力拉成少數原生 shortcut,剩下的放在通用 skill runner 裡,並且讓 autocomplete 搜尋描述、別名與命令 key。換句話說,不要把平台選單當資料庫瀏覽器,它承載不起完整知識庫。

這也是 AI Agent 做產品化時很容易忽略的地方:能力很多不等於體驗好。使用者需要的是「我想做的事很快找得到」,不是「理論上所有東西都可以被呼叫」。

我會怎麼修

如果要把這類同步流程做得比較穩,我會抓幾個原則:

第一,fingerprint 只能在遠端同步成功後更新。失敗就保留舊狀態,讓下次啟動仍然知道需要重試。

第二,state 裡要保留 last_success_atlast_error、遠端回應摘要。不要只留一個 hash,因為 hash 告訴你版本,卻不告訴你過程。

第三,提供人工驗證或修復工具。像是「列出遠端目前有哪些 command」、「強制重新同步」、「清掉本地 sync state」這種操作,平常用不到,出事時會救命。

第四,補 regression test。這類 bug 很容易修完一次,半年後重構又回來。測試至少要覆蓋「同步失敗時不能寫入成功 fingerprint」以及「舊 state 不應該阻止必要同步」。

結論:整合系統最怕假成功

這次踩坑其實不大,但很典型。

AI 助理、聊天平台、技能系統、遠端 API、client cache,全部串在一起後,最危險的不是單點失敗,而是假成功。因為失敗會吵,假成功會安靜地讓你相信世界已經照你的意思更新了。

做整合型系統時,我越來越在意「確認」這件事。寫入前可以樂觀,寫入後一定要保守。狀態機不要記你的願望,要記已經被驗證的事實。

如果一個系統能誠實地說「我還沒同步成功」,那其實很好修。最麻煩的是它笑笑地告訴你「都好了」,然後遠端什麼都沒有。

這大概也是工程裡最樸素、但最有用的原則之一:不要把意圖當成果,不要把本地狀態當世界真相。尤其當你的使用者正在介面前面找一個看不到的 /command 時,這句話會突然變得非常有感。