互動模式在 API 設計那天就被鎖死:User-First Agent 的 Run 模型設計
選完語言之後(第一篇),我開始設計 Agent 的 API。backend 工程師的肌肉記憶立刻接手:POST /chat,收 request,跑完 agent loop,回一包完整的 JSON。乾淨、無狀態、好測試,我過去寫的 API 都長這樣。
然後需求一條一條進來:回應要逐字顯示、要有停止按鈕、使用者關掉分頁再回來對話要還在。我發現每一條需求都不是「加個 endpoint」能解的,它們全部指向同一件事:我那個「收 request 回 response」的 API 形狀,從第一天就把這些互動堵死了。UI 只能渲染你的 API 給得出來的東西,互動模式不是前端的事,是 API contract 在設計那天就決定好的。
讀完精華版(2 分鐘),你會理解:
- 面向 machine 的 API 和面向 User 的 Agent API,價值觀差在哪裡
- 為什麼 SSE 事件詞彙表就是 UI 的能力上限,以及「停止生成」按鈕的真實 backend 含義
- 三個看似無關的需求(斷線重連、頁面重整、human-in-the-loop)為什麼收斂成同一個設計決策
這篇不是 SSE 教學,也不是某個 web 生態的入門。它講的是 run 模型的設計:當 API 的消費者從機器變成一個正在等待的人,哪些過去不存在的東西變成了一級設計對象。
精華版
| 面向 machine 的 API(過去) | 面向 User 的 Agent API(現在) | |
|---|---|---|
| 消費者 | 另一個服務、排程、pipeline | 一個正在等待的人 |
| 回應形狀 | 完整的資源,一次給完 | 隨時間展開的事件流 |
| 延遲指標 | p99 total latency | TTFT(time to first token)+ 過程可見性 |
| 「慢」的代價 | retry / alert | 使用者關掉分頁 |
| 中途取消 | 幾乎不存在的需求 | 一級功能(停止生成、重新生成) |
| 部分失敗 | 整批 fail 然後 retry | 一個 tool 掛了,對話要繼續 |
- 事件詞彙表:SSE 事件流裡存在哪些事件型別,直接決定 UI 能顯示哪些狀態,沒有
tool_start事件的 API,UI 就只能在 tool 執行的 30 秒裡轉圈圈。 - 可中斷性:「停止生成」按鈕的 backend 含義是一條從 HTTP 層一路傳進 LLM 呼叫的 AbortSignal,只停 loop 不停底層呼叫,token 照樣計費。
- Run 狀態機:把一次 agent 執行(run)設計成有明確狀態、可查詢、可取消的公開狀態機,而不是一個綁死在 HTTP 連線上的長函式。
幾個關鍵決策問題:
Q:為什麼說互動模式在 API 設計時就被鎖死? A:UI 只能渲染事件流裡存在的事件。沒有 tool 進度事件,就沒有「正在搜尋」提示;沒有 cancel endpoint,就沒有停止按鈕。事後想補,API 和 UI 要一起改。
Q:Agent loop 該寫成一個函式,還是一個狀態機? A:沒有互動需求時,函式就夠。只要出現「暫停等使用者批准」「斷線重連」任何一項,loop 就必須能中斷、狀態能落地、之後能恢復,那就是狀態機。這個決定越晚做,重構越痛。
Q:真正的分水嶺是哪個決策? A:run 的狀態與事件是否活在 process 之外(DB/Redis,而不是記憶體變數)。這一個決策同時解掉 stateless 擴展、SSE 斷線重連、human-in-the-loop 暫停恢復三件事。
以下是完整版,按需取用。
面向 machine 和面向 User 的 API,價值觀差在哪裡?
差在消費者:過去 API 的消費者是另一個服務,現在是一個正在等待的人,這讓 API 設計的每個決定都直接變成使用者的體感。我做資料處理 backend 的年代,API 的價值是正確性和吞吐,回應慢兩秒沒有人會知道,反正下游是排程。Agent API 不是,回應慢兩秒,是一個人盯著空白畫面兩秒。
最能說明這件事的是延遲指標的轉變。過去我看 p99 total latency,現在最重要的數字是 TTFT,第一個 token 多快出現。兩個設計,總耗時都是 20 秒:一個等全部生成完一次回傳,使用者盯著空白等 20 秒;另一個 0.5 秒出第一個字然後逐字流出,使用者從頭到尾覺得系統在工作。backend 沒有多算一毫秒,體感差十倍。
這就是為什麼互動模式是 API 層的責任。streaming 不是前端的加分項,是回應形狀的根本改變:從「完整的資源」變成「隨時間展開的事件流」。而一旦回應是事件流,接下來的問題就是:流裡面該有哪些事件?
事件詞彙表:UI 能顯示什麼,由你的事件流決定
SSE 事件詞彙表(event vocabulary)是 UI 的能力上限:UI 能顯示的每一種狀態,都必須對應事件流裡的一種事件。設計事件詞彙表的時候,你其實是在設計 UI 未來能做什麼:
// 事件詞彙 —— 每一種事件對應一種 UI 可以渲染的狀態
type AgentEvent =
| { type: "text_delta"; text: string } // 逐字輸出
| { type: "thinking_delta"; text: string } // 「思考中」可摺疊區塊
| { type: "tool_start"; name: string; input: unknown } // 「正在搜尋…」進度提示
| { type: "tool_result"; name: string; ok: boolean } // 工具完成/失敗徽章
| { type: "waiting_approval"; action: PendingAction } // 跳出批准對話框
| { type: "error"; message: string; recoverable: boolean }// 可讀的錯誤 + 能否重試
| { type: "done"; usage: Usage }; // 收尾 + 成本
每一行都值得反著讀一次:如果詞彙表裡沒有 tool_start,agent 呼叫 tool 的 30 秒裡事件流靜默,UI 唯一能做的就是轉圈圈,使用者以為系統當了。如果沒有 waiting_approval,human-in-the-loop 的批准流程根本做不出來。
我看過(也差點犯過)兩個經典失誤:
只流 text_delta。 最省事的 streaming 實作:把 LLM 的文字輸出轉發出去,結束。文字生成的時候體驗很好,tool 執行的時候整個世界靜止。事後想加進度事件,API 的 schema 要改,UI 的渲染邏輯要改,兩邊一起動。
直接轉發 SDK 的原始 event。 另一種省事:把 LLM SDK 吐出來的內部 event 原封不動流給前端。短期能動,長期是災難:UI 被迫理解你的實作細節,哪天換 SDK、加中間層、改 agent 架構,前端全部跟著破。事件詞彙表是對外 contract,和「DB schema 不直接外露」是同一個原則。
這一節的工程含義:事件詞彙表要在第一天用產品語言(而不是 SDK 語言)設計,寧可先定義了事件型別而暫時沒有 UI 用它,也不要反過來。
「停止生成」按鈕的 backend 含義是什麼?
一顆停止按鈕,backend 的對應物是一條從 HTTP 層一路傳播到最底層 I/O 的取消鏈。這是 machine-facing API 幾乎不存在的需求:pipeline 沒有人會中途喊停,人會,而且很常。
// 每個 run 掛一個 AbortController,取消 API 觸發它
const controllers = new Map<string, AbortController>();
app.post("/runs/:id/cancel", (c) => {
controllers.get(c.req.param("id"))?.abort();
return c.json({ ok: true });
});
// agent loop 裡,signal 要傳進每一層 I/O
await anthropic.messages.create({ ... }, { signal });
await fetch(toolUrl, { signal });
程式碼看起來簡單,魔鬼在三個語意細節:
取消要傳播到底。 只停 agent loop、不停底層的 LLM 呼叫,是最常見的半吊子取消:使用者看到畫面停了,LLM 那邊還在生成,token 照樣計費,連線照樣佔著。signal 要穿過每一層 I/O,一層漏了就是漏。
取消後狀態要一致。 使用者按「停止」的預期是保留已經輸出的文字,不是整段消失。所以取消不只是中斷執行,已生成的部分內容要落地、run 要標記為 cancelled。這是 UX 慣例反過來規定 backend 資料語意的好例子。
「重新生成」= 取消 + 引用。 重新生成按鈕的語意是:取消目前的 run,用同一個 user message 開一個新 run。這代表 run 之間要能互相引用,run 不是用完即丟的執行過程,是一筆有身份的資料。
到這裡可以回頭看那個更根本的問題:run 到底是什麼?它顯然已經不是「一次函式呼叫」了。
為什麼 run 要設計成公開的狀態機?
因為有三個看起來無關的需求,最後都要求同一件事。把需求攤開:
| 使用者做的事 | 對 backend 的真實要求 |
|---|---|
| 按「停止生成」 | run 可取消,取消後狀態一致 |
| 關掉分頁再回來,對話還在跑 | run 的執行不能綁在 HTTP 連線的生命週期上 |
| 網路閃斷,重連後不漏事件 | 事件要持久化,支援從斷點續傳 |
| 批准/拒絕 tool call(human-in-the-loop) | loop 要能「暫停 → 狀態落地 → 等人 → 恢復」 |
第一篇講過 Agent 系統的本質是編排等待;這一篇的對應結論是:一次 run 是一個隨時間演進、可被觀察、可被介入的狀態機,不是一個 request-response。狀態轉移長這樣:
GET /runs/:id 查詢,cancel 是狀態轉移不是 ctrl+CAPI 面對應四個 endpoint:
POST /chat → 回 runId,開始 SSE
GET /runs/:id → 目前狀態 + 已產生的內容(頁面重整用)
GET /runs/:id/events → SSE,支援 Last-Event-ID 續傳(斷線重連用)
POST /runs/:id/cancel → 取消
注意 GET /runs/:id/events 這條:它的存在意味著「事件流」和「產生事件的執行」是兩回事。agent 在背景跑,事件寫進儲存,SSE 連線只是一個觀看視窗,斷了再開一個,用 Last-Event-ID 從上次看到的地方續播。run 與 connection 解耦之後,「關掉分頁對話還在跑」「多裝置看同一個對話」這些需求就不再是 feature,是免費的推論。
心智模型:UI 是觸發器,能力全長在 backend。 把「可暫停、可取消、可重連」的 run 模型做對,前端誰來寫都只是消費它;反過來,把 agent loop 寫成一個不可中斷的長函式,之後每一個互動需求都是一次重構。
真正的分水嶺:run 的狀態活在 process 之外嗎?
整篇講的三個支柱,最後收斂成一個 yes/no 的決策:run 的狀態與事件,是活在 process 的記憶體裡,還是活在 process 之外(DB/Redis)?
活在記憶體裡的版本:controllers 是一個 Map、對話歷史是一個變數、事件流直接從 loop pipe 到 HTTP response。第一天跑起來最快,demo 完全沒問題。代價是上面每一個互動需求都做不了:process 重啟 run 就蒸發、開第二個 replica 就找不到 run、SSE 斷線就是斷了。
狀態外部化的版本:run 的狀態和事件即時寫進 DB,process 只是執行者。這一個決策同時解掉三件事:
- stateless 擴展:任何 replica 都能回答
GET /runs/:id,服務可以水平擴 - SSE 斷線重連:事件在儲存裡,
Last-Event-ID續傳是一個查詢 - human-in-the-loop 暫停恢復:「暫停等人」就是 run 停在
waiting_approval狀態,恢復是任何 process 都能接手的事
我的建議不是第一天就把全套做完,而是第一天就把介面設計對:事件詞彙表用產品語言定義、run 有 id 有狀態、取消是 endpoint 不是 ctrl+C。狀態存記憶體還是存 DB 是實作細節,可以晚點換;但 API contract 裡沒有 runId、沒有事件流、沒有 cancel,那就不是換實作能解的了,是重新設計。
互動模式在 API 設計那天就被鎖死,反過來說也成立:API 設計那天把 run 模型做對,之後的每個互動需求都只是渲染工作。
結語:當 API 的消費者從機器變成一個正在等待的人,run 就不再是一次函式呼叫,而是一個可觀察、可介入、活在 process 之外的狀態機。