現在很多 AI 旅遊工具,本質上是「很會講話的建議產生器」:它給你一段漂亮的行程文字,然後你要自己把每個景點、每個時間搬進行程表,一起出發的朋友再各抄一份。
我們做 DimTrip 時決定反過來:AI 不寫一段文字給你,而是直接在那份大家共用的行程上動手。這聽起來只是介面上的差別,實際上改變了整個架構 —— AI 從「聊天功能」變成「一個會寫資料庫的協作者」,權限、同步、錯誤處理都得重新想一次。這篇整理我們怎麼做,以及一路上踩到的坑。
瀏覽器把使用者自己的 access token 帶給 AI 伺服器,伺服器原封不動地拿它去建立 Supabase client,所以 AI 的每一次讀寫都經過同一套 RLS。
這換到一個很好推理的性質:AI 能改的範圍,就是這個使用者本來就能改的範圍。 權限判斷只寫在資料庫一個地方,不需要在 AI 那層再實作一次,也不用擔心兩邊的規則慢慢長歪。
多人共編本來就靠 Supabase Realtime,把每一筆變更推給同一趟行程的所有人。AI 的寫入走的是同一個資料庫、同一組頻道,所以朋友畫面上出現 AI 新增的景點,跟出現另一個人新增的景點完全一樣 —— Realtime 那一層的程式碼,完全不知道 AI 的存在。
AI 直接改資料,最大的風險是「它到底改了什麼,我不知道」。我們做了三件事:
execute。模型只能「提出」刪除,前端顯示確認卡片;使用者按下確認後,刪除是由瀏覽器走 App 自己的 store 執行,跟使用者手動按刪除是同一條路。可以自己開啟自動核准,預設是關閉的。叫 AI「幫我排一趟五天的行程」,它會連續呼叫工具好幾分鐘,其中有些工具呼叫會安靜很久、完全沒有輸出。閒置這麼久的回應,在 Worker 和瀏覽器之間被切斷了:前端顯示錯誤,使用者按「重試」,模型就再建一趟一模一樣的行程。
解法有三個部分:
data-heartbeat 片段,連線就不會閒置;前端的 useChat 會丟掉暫時性片段,所以不會出現在對話裡。Worker 端另外用 waitUntil 把生成跑完,前端就算真的斷線,對話記錄還是會存下來。create_trip 如果發現同一位使用者在 15 分鐘內建過同名、同起訖日期的行程,就直接回傳那一趟(標記 reused: true),不再建第二趟。ignoreIncompleteToolCalls),對話不會再被卡死。換成串流更快的模型之後,長回合開始隨機出現「Something went wrong」。追下去是 React 的錯誤 #185(Maximum update depth exceeded)。
原因是兩件事疊在一起:useChat 每收到一個串流片段就通知 React 更新一次,一個長回合的片段估計有上萬個;同時有一個 effect 會在 render 之後呼叫 setSuggestions([]) —— 就算清單本來就是空的,這個呼叫還是會排進一次新的 render。只要一次網路讀取剛好帶進大約 50 個片段,巢狀更新的次數就超過 React 允許的 50 次,整個畫面直接中止。
解法是把更新合併,再把多餘的 setState 擋掉:
const chat = useChat({
id: threadId,
transport,
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
experimental_throttle: 50, // 串流更新合併成每 50ms 一次
});
// effect 裡:沒有建議要清,就不要 setState
if (hasSuggestionsRef.current) setSuggestions([]);
另外補了一個 e2e 測試,在同一個回應裡一次灌進 2,000 個串流片段,確認這個錯誤不會再回來。
對話記錄會存進資料庫(每則訊息的 parts 存成 JSONB),重新打開時還原成訊息列表。問題在於:如果上次最後一個前端工具呼叫還沒有結果,還原之後前端會把它當成「剛收到的新呼叫」照樣執行,然後自動把結果送回去,讓 AI 接續一個早就結束的回合;開著自動核准的話,還原出來的刪除也會被直接套用。
解法是在還原時分兩種處理:
這個坑背後的原則值得記下來:從資料庫還原的狀態,不是新的事件。
訂位確認常常是一張截圖或一份 PDF。圖片的難點其實不是「傳圖」,而是順序:使用者可能寫「這是飯店〔圖〕,這是航班〔圖〕,兩個都加進去」。SDK 預設的格式會把所有檔案放在文字前面,模型就分不清哪張圖對應哪句話。
我們在插入圖片的游標位置放一個 [image N] 標記,送出時依照標記把文字切開,變成「文字、圖片、文字、圖片」依序排列的片段,每張圖都緊跟在描述它的那段文字後面。每則訊息最多 6 張圖,送出前在瀏覽器裡縮到最長邊 1600px 的 JPEG,每張不超過 2MB。
PDF 則不另外做文字抽取:pdf.js 在第一次用到時才載入、跑在 Web Worker 裡,把每一頁畫成圖片,走跟截圖同一條路 —— 程式碼只有一條路徑,掃描檔也一樣處理。Android 手機上還可以從系統的分享選單,把檔案直接丟進 DimTrip 的輸入框(PWA 的 share target;iOS Safari 不支援),檔案只會放進輸入框,不會自動送出。
AI 需要知道「使用者現在在看哪一天、哪個畫面」,但這些資訊每一回合都在變。我們不把它塞進 system prompt,而是讓前端用另一個欄位送過來,伺服器只附加在送給模型的最後一則使用者訊息上,而且不會存進對話記錄。system prompt 只放語系、今天日期、主要貨幣這類同一天內不會變的資訊,加上固定的工具定義,同一位使用者當天的每一回合,前綴都一樣,前綴快取才有機會命中。
最想知道的是:大家實際會叫 AI 助手做什麼,以及在哪些情境下,它的改動不如預期。
如果你也在做「讓 AI 直接動手改資料」的產品,很想交流權限和確認流程怎麼設計 —— 例如哪些動作該直接做、哪些該先問,歡迎留言討論。