總覽與定位
從「一段跑在記憶體裡的 Agent Loop」升格成「一個要對外提供 API、可並發、會當機重啟、有 SLA 的正式後端服務」。
前一篇《LLM 應用編排與 Agent 模式》把 Agent Loop 講成一個概念上的迴圈:Thought → Action → Observation 反覆到收斂。那是理解要不要用 Agent 的心智模型。但當這段迴圈要真的上線——被許多使用者同時呼叫、跑到一半機器重啟、要計費、要保證回應時間——它就不再是「一段 while 迴圈」,而是一個必須認真設計的後端服務。本篇談的正是這件事:把單一 Agent 當成正式後端服務來設計。
核心的視角轉換只有一句話,但它改變了後面所有的工程決策:
傳統 API 多半是無狀態、短命、單步的:進一個 request、算完、回一個 response。但一次 Agent 請求是有狀態(累積的 context 會在多輪之間變化)、可能長時間(多輪 LLM 往返+工具執行,動輒數十秒到數分鐘)、且包含多個副作用步驟(每個 tool call 都可能扣款、送信、寫 DB)。一旦你用「作業」而不是「一次呼叫」來看待它,session 狀態、逾時、取消、idempotency 這些需求就會自然浮現。
順著這個視角,一個正式的 Agent Runtime 要處理的工程關切可以收斂成四塊,剛好對應本篇後續章節:
而「模型怎麼選、工具能不能碰、協定長什麼樣」這些,刻意不歸本篇管——它們是鄰篇的守備範圍。先把邊界畫清楚,才不會把 runtime 寫成一顆什麼都塞的巨石:
| 主題 | 誰負責 | 本篇如何處理 |
|---|---|---|
| 狀態機、session、memory、可靠性 | 本篇 Agent Runtime | 服務骨架與可靠性——這是全篇主軸 |
| 工具 authz / sandbox / allowlist / 惡意呼叫防護 | Tool Gateway 篇 | 只講執行編排,安全點名後轉介 |
| MCP/A2A 訊息格式、transport、discovery 流程 | MCP 篇 | 只把它當「工具如何被呼叫」的抽象邊界 |
| 模型路由、fallback、rate limit、成本、semantic cache | LLM Gateway 篇 | 只劃出 runtime ↔ Gateway 的契約 |
本篇定位一句話:服務骨架與可靠性。凡是「模型側」「工具側安全」「協定格式」,一律 externalize 給鄰篇。
請求生命週期:Runtime 的狀態機
把 Agent 編排篇那段概念上的 while 迴圈顯式化成一台狀態機——這是整個 runtime 的骨幹;本章 Demo 接著量化它:這台狀態機在你設的護欄下,跑得完的機率有多少。
Agent 編排篇的 Agent Loop 若直接寫成程式,長得像這樣:
while not done:
reply = call_llm(context) # 推理
if reply.has_tool_calls:
results = run_tools(reply) # 執行
context += results # 觀察回寫
else:
done = True # 收斂
這在 demo 裡沒問題,但作為後端服務它有個致命缺陷:狀態全藏在區域變數與呼叫堆疊裡。機器一重啟,這個 while 迴圈連同它的 context 就蒸發了;你也無法從外部知道「這個請求現在跑到哪」、無法中途取消它、無法在另一台機器上接手。解法是把這個隱式迴圈顯式化成一台狀態機,讓「現在在哪個狀態」成為一份可以被讀取、儲存、恢復的資料。
stateDiagram-v2
[*] --> pending
pending --> running: 排程取件
running --> tool_calls: 輸出 tool call
tool_calls --> running: 結果回寫
running --> final: 不含 tool call
running --> timed_out: 撞 wall-clock
running --> cancelled: 取消旗標
tool_calls --> failed: 重試耗盡
final --> [*]
看 running ⇄ tool_calls 這條雙向邊——它跑幾圈事前不可知,正是後面所有護欄(迭代上限、逾時預算)存在的理由;右側三個旁支都是終態,但善後方式各不相同。
pending:請求已受理、job 已建立(含 idempotency key),還沒開始跑。running:正把當前 context 送進 LLM(經 LLM Gateway)推理下一步。tool_calls:LLM 決定呼叫工具,runtime 正在執行它們並準備回寫結果。running與tool_calls之間可以來回多次——這就是 Agent Loop。final:模型輸出不含 tool call 的最終答案,終態,job 可回收。- 旁支終態:
timed_out(撞逾時預算)、cancelled(使用者取消)、failed(不可回復錯誤)——都是「終止」,但語意與善後各不相同(本篇談可靠性的那一節詳談)。
顯式狀態機的價值不在畫得漂亮,而在於每一條「邊」都可以落地存檔:轉移發生時,把「新狀態+當前 context+迭代次數」寫進 job store。這一存,就同時換來四件事——可恢復(當機重啟後從最後的 checkpoint 續跑)、可觀測(外部隨時查得到請求跑到哪,呼應 Agent 編排篇談的可觀測性)、可中斷(在狀態邊界檢查取消旗標)、可跨進程(狀態在 store 裡,任何一台 runtime 都能接手)。
有了顯式狀態,接著要決定怎麼把結果交給呼叫端。這取決於請求會跑多久:
| 執行模型 | 怎麼運作 | 適合 | 代價 |
|---|---|---|---|
| 同步 blocking | 一個 HTTP 請求開著,跑完直接回 | 短任務(一兩輪、數秒內收斂) | 連線久掛、易被 gateway/LB 逾時切斷、難水平擴展 |
| 非同步 job | 先回 job_id,狀態機在背景跑,用輪詢或 streaming 取結果 |
長任務(多輪工具、可能數分鐘) | 需要 job store 與取結果的管道,架構較重 |
| Streaming(SSE) | 連線開著,把每次狀態轉移/token 逐步推給前端 | 要即時呈現「思考中/呼叫工具中」的互動體驗 | 斷線重連要能從 checkpoint 續傳,實作較細 |
短任務同步就好,別過度設計;一旦請求可能長到分鐘級,就該切成非同步 job——這也是第 7 節選型階梯的第一道分岔。
狀態機畫得出來,不代表它跑得完。running ⇄ tool_calls 要來回幾次事前不可知,於是「這一次請求到底會不會抵達 final、要花多少、最慢多慢」變成一個機率問題——而它的答案,完全由你設的護欄與成本參數決定。下面的 Demo 把這些參數做成旋鈕,把整棵結果樹確定性地列舉一遍算給你看:
任務仍是「我的訂單 #A1234 什麼時候會到?」。每一輪,模型要嘛直接給出最終答案、要嘛丟出一個 tool call;工具可能失敗、失敗可能重試、重試要等退避。Demo 把每一輪的每一種分支全部列舉出來(不用隨機數),逐輪推進機率質量,算出四種結局各佔多少、以及 token 與延遲的期望與尾部。
每根柱子是「進入該輪的機率質量」,由下而上切成續跑(藍)、抵達 final(綠)、撞迭代上限(灰)、重試耗盡 failed(琥珀)、撞預算 timed_out(紅)。要看的是藍色基座逐輪收窄的速度——那就是護欄真正發揮作用的地方。
模型簡化(誠實申報):① 每輪 LLM 的 prompt token = 固定底 1,200 tok(system prompt + 工具 schema + 使用者訊息)+ 前面每一輪各累加一次「每輪 context 增量」,工具結果與錯誤訊息的 token 已併入該增量,不另計。② 成本以 每百萬 token 3.0 美元 換算,單價是常數、金額由 token 算出。③ 退避基數 500 ms、指數退避上限 4,000 ms。④ 預算檢查是合作式的,只發生在狀態邊界,所以實際耗時會超過預算才被判 timed_out——而且單一慢工具的逾時可能遠大於剩餘預算(p99 常常比預算還長),這正是本篇談可靠性的那一節主張「wall-clock 與單工具 timeout 要並存」的原因。⑤ 重試耗盡一律走 failed;若你的 runtime 選擇「把錯誤回餵成 observation」,那會變成再多跑一輪,不在本模型內。
while 迴圈顯式化成 pending → running ⇄ tool_calls → final 的狀態機,每次轉移落一個 checkpoint,就換來可恢復、可觀測、可中斷、可跨進程;長任務再配非同步 job/streaming 交付結果。
狀態的三個層次:Conversation State、Session、Memory
「狀態」這個詞在 Agent 裡被過度重載——先把三個常被混為一談的層次拆開,儲存選型才有依據。
第 2 節的狀態機在「一次請求內」推進;但一個對話式 Agent 的狀態其實有三個時間尺度,各有不同的生命週期與儲存位置。分不清它們,就會犯「把長期記憶塞進單輪 context」或「每輪都重載整段歷史」這類昂貴的錯:
| 層次 | 時間尺度 | 內容 | 典型儲存 |
|---|---|---|---|
| Conversation State (working state) |
單次請求 / 一輪 loop | 當前 messages、pending tool calls、迭代次數——就是第 2 節狀態機攜帶的那份 | 狀態機的 checkpoint(job store) |
| Session (conversation thread) |
跨多輪對話 | 這條對話串的歷史訊息,供下一則使用者訊息載入接續 | Redis / DB,用 session_id 索引 |
| Memory (長期記憶) |
跨 session、跨天 | 使用者偏好、過往事實摘要——不屬於任何單一對話 | DB + 向量檢索(可插拔的讀寫端) |
由內而外三圈:working state 活在一次請求裡、session 活在一條對話串裡、memory 活在使用者身上。搞混層次,成本與正確性都會出事。
memory 的取回怎麼做(embedding、相似度檢索、chunking、重排)是向量資料庫那條線的主題。對 runtime 而言,memory 只是一個滿足「給我這個使用者相關的記憶片段」介面的元件——它可以是一次向量查詢、也可以是一張 key-value 表。把 memory externalize 成可插拔端點,而不是硬寫進 runtime,是這裡唯一要守住的設計原則。
Agent 編排篇談 Agent Loop 時留了一個伏筆:context 隨迴圈膨脹——每輪都把歷史全文重送,token 成本與延遲逐步疊加。在 runtime 這一層,這不是一句提醒,而是一組必須實作的context window 管理策略:
| 策略 | 做法 | 取捨 |
|---|---|---|
| Truncation | 超出預算就砍最舊的訊息 | 最簡單,但可能砍掉仍相關的早期資訊 |
| Summarization | 把舊訊息壓成摘要,替換原文 | 省 token 又留重點,但摘要本身要花一次 LLM 呼叫、且可能失真 |
| Sliding window | 只保留最近 N 輪 + 固定的系統前綴 | 行為可預測,但跨越窗口的長程依賴會斷 |
| Externalize | 把細節寫進 memory,context 只放指標/摘要,需要時再撈 | context 最精簡,但多一次檢索、且檢索不準會漏關鍵資訊 |
實務常組合使用:sliding window 保近況、summarization 壓中段、externalize 收長期記憶。目標都是同一個——別讓 context 無上限膨脹。
如果狀態全外置(working state 在 job store、session 在 Redis/DB、memory 在後端),那麼 runtime 本身就是無狀態的:任何一台 runtime 都能接手任何請求,掛掉一台換一台就好,加機器就能加吞吐。反之,只要有任何狀態黏在單一進程的記憶體裡,你就綁死了「這個請求只能這台機器處理」,擴展與容錯同時受害。這是把 Agent 做成正式後端服務時,最值得先立好的一根柱子。
工具執行迴圈作為服務:並發、順序與副作用
Agent 編排篇說「模型只決定、runtime 才執行」;本章談的就是「執行」這一側,作為服務該有的編排與韌性。
當狀態機走到 tool_calls,runtime 拿到的是模型輸出的一批 tool call 意圖。把它們穩妥地執行、把結果正確回寫,是這一步的全部工作。看似簡單,但一旦當成服務,就有三個必須處理的細節。
其一:一輪可能不只一個 tool call,要決定串行還是並行。現代模型常在單輪輸出多個 tool call。若彼此無依賴(例如同時查訂單與查庫存),並行執行能大幅砍掉延遲——這正是 Agent 編排篇工程挑戰表裡「工具串行導致延遲累加」的解法。但若後者依賴前者的結果(先查到物流單號才能查物流),就只能串行,甚至得回到 running 讓模型看到第一個結果後再決定下一步。
其二:結果回寫的順序與結構要對得上。每個 tool 結果必須用它的 tool_call_id 對應回原來的呼叫,模型才知道哪個結果對應哪個請求:
# 一輪並行兩個工具後,回寫 context 的訊息結構(概念示意)
{ "role": "assistant", "tool_calls": [
{ "id": "call_1", "name": "get_order", "arguments": {"order_id": "A1234"} },
{ "id": "call_2", "name": "get_stock", "arguments": {"sku": "X-9"} }
] }
{ "role": "tool", "tool_call_id": "call_1", "content": "status=已出貨, tracking=SF123" }
{ "role": "tool", "tool_call_id": "call_2", "content": "in_stock=true, qty=42" }
關鍵是 tool_call_id 的配對:並行執行、順序回寫,模型靠 id 而非位置來對應結果。
其三:每個 tool call 是一個有自己 timeout 與 retry 的子作業,且工具會失敗。外部 API 會逾時、會回 500、會限流。此時的關鍵決策是:要讓整個請求失敗,還是把錯誤當成一則 observation 回餵給模型?
| 工具失敗的處理 | 做法 | 適用 |
|---|---|---|
| 回餵為 observation | 把 error 當工具結果寫回 context,讓模型重試或換策略 | 失敗可繞過(換工具、換參數、用既有資訊作答)——多數唯讀查詢 |
| runtime 自動重試 | 對暫時性錯誤(逾時、429、5xx)退避重試,不驚動模型 | 已知的暫時性故障,且工具本身安全可重試(見第 5 節的冪等) |
| 直接 failed 終止 | 錯誤不可回復或會累積傷害,狀態機轉 failed |
關鍵前置步驟失敗、或再試也無意義 |
「把錯誤回餵給模型」是 Agent 相對傳統 pipeline 的韌性來源——它能讀懂錯誤並改道(呼應 Agent 編排篇工程挑戰表裡「工具錯誤回饋給模型重試」)。但別無限重試,否則就撞上第 5 節的逾時與迭代護欄。
本章只管「怎麼把工具穩妥地執行、把結果正確回寫」——並發、順序、失敗重試。至於「這個 tool call 是不是幻覺出來的、呼叫端有沒有權限、要不要 sandbox 隔離、參數要不要過白名單與 schema」,那是強制執行點(enforcement point)的職責,屬於 Tool Gateway、權限與安全邊界 那篇。runtime 呼叫工具時,應假設有一道 gateway 在前面把關,而不是自己重造安全邏輯。
tool_call_id 配對回寫、以及工具失敗時在「回餵 observation/自動重試/failed 終止」之間抉擇;工具的權限與 sandbox 則留給 Tool Gateway。
可靠性核心:Idempotency、超時、取消
這是把 Agent 從「能跑」推到「敢上線」的分水嶺——在重試、當機、使用者反悔的現實下,仍然正確。
前面的狀態機在「一切順利」時很漂亮。但正式服務要面對的是:client 會重試、機器會當機重啟、使用者會中途反悔。這三件事單獨看都不難,難在它們都會撞上 Agent 的一個本質特性——每個 tool call 都可能是不可逆的副作用(扣款、送信、建單)。三個機制正是為此而生。
① Idempotency(冪等)——為什麼 Agent runtime 特別需要它?因為有三條路徑都會導致「同一件事被做兩次」:
- client 重試整個請求:網路抖動、沒收到回應,前端就再送一次——若沒有去重,訂單就下了兩張。
- runtime 當機後重放狀態機:從 checkpoint 恢復時,可能重跑「當機前其實已經執行過」的那一步 tool call。
- 工具層自動重試:第 4 節的退避重試,若工具非冪等,重試就等於重複副作用。
對應也是三層防線,由外而內:
| 層級 | 機制 | 擋住的問題 |
|---|---|---|
| Request 級 | client 帶 idempotency key;同 key 命中已完成 job 直接回既有結果,不重跑狀態機 | client 重試導致整個請求被執行兩次 |
| Tool-call 級 | 記錄每個 tool_call_id 的執行結果;重放時已完成的直接取結果、不重打外部 API |
當機恢復或狀態機重放時重複執行同一 tool call |
| 副作用工具本身 | 要求有副作用的工具接受冪等鍵(如 payment(idempotency_key=...)) |
最後一道防線:即使前兩層漏了,工具自己也能去重 |
呼應 Agent 編排篇「執行點就是風險點」:副作用工具是風險的最深處,冪等要一路守到那裡。三層只要漏掉最內層,前兩層擋不住的那條路徑就會真的扣兩次款。
② 超時(Timeout)——不是一個逾時,是一組分層預算。Agent 的耗時來自多個嵌套的來源,每一層都要有自己的上限,否則一個慢工具就能拖垮整個請求:
| 層級 | 限制什麼 | 撞到上限的處置 |
|---|---|---|
| 單次 LLM 呼叫 | 一次模型往返的時間 | 重試或改路由(交給 LLM Gateway 處理) |
| 單個 tool call | 一個外部工具的執行時間 | 回餵 timeout error 給模型,或退避重試(第 4 節) |
| 整個 request(wall-clock) | 這一次請求從進到出的總時間 | 狀態機轉 timed_out,回傳部分結果或明確失敗 |
| max iterations | Agent Loop 最多跑幾輪 | 把 Agent 編排篇的「概念護欄」落成與 timeout 並列的預算,撞到就中止 |
wall-clock 與 max-iterations 是兩把不同的尺:前者防「單步很慢」,後者防「步數失控(鬼打牆)」——兩者要並存。
使用者關掉頁面或按下「停止」時,你不能硬砍執行緒——正跑到一半的工具、待回寫的狀態都會壞掉。正式做法是合作式取消(cooperative cancellation):取消訊號只設一個旗標,狀態機在下一個安全邊界(兩個狀態轉移之間)檢查它,乾淨地停在 cancelled 並存檔。但要認清一個殘酷事實——已經送出去的副作用工具(例如已扣的款)無法靠取消收回,只能靠補償交易(退款)或前述的冪等設計來善後。取消止的是「未來的步驟」,不是「已發生的副作用」。
與周邊系統的邊界:LLM Gateway、Tool Gateway、Persistence
把 runtime 的上下游契約畫清楚——這一章專門負責「什麼不該由 runtime 做」,也就是本系列的分工地圖。
一個好的 Agent Runtime,定義它的不只是「它做什麼」,更是「它把什麼交出去」。runtime 的職責應該收斂在三件事:決策編排(跑狀態機)、狀態管理(第 3 節)、可靠性(第 5 節)。其餘一律透過清楚的邊界委外。把這張圖記在心裡,就不會把 runtime 寫成一顆什麼都塞的巨石:
| 方向 | 對象 | runtime 關心的契約 | runtime 不碰、留給對方的 |
|---|---|---|---|
| 上游 | API / Client | 同步 / 非同步 job / streaming 的交付方式(第 2 節)、idempotency key | — |
| 模型側 | LLM Gateway | 一次 model call 的 timeout / retry 語意、拿到結構化 tool call | 模型路由、多供應商 fallback、rate limit、成本、semantic cache |
| 工具側 | Tool Gateway | 決定呼叫哪個工具、如何編排與回寫結果(第 4 節) | authn / authz、最小權限、sandbox、allowlist、審計、幻覺呼叫防護 |
| 協定 | MCP / A2A | 「工具如何被發現與呼叫」的一個抽象介面 | Host/Client/Server 角色、transport、訊息格式、discovery 流程 |
| 狀態側 | Persistence | job store(checkpoint)、session store、memory 讀寫端 | 向量檢索的內部實作(embedding、索引、重排) |
runtime 站在中央調度,但每個方向都有一條明確的接縫。Tool Gateway 篇、MCP 篇與 LLM Gateway 篇,正好各自展開其中一條接縫。
因為這兩者的變化理由與 runtime 完全不同:模型路由與成本會隨供應商報價、流量而頻繁調整(LLM Gateway 的節奏);工具權限與安全政策會隨合規要求演進(Tool Gateway 的節奏)。若把它們寫進 runtime,runtime 就得為了「換個模型」或「改條權限」而重新部署。把它們 externalize 成獨立的 gateway,runtime 才能專注在它真正難的事情上——把狀態機跑得可靠。這也是整個 Agent 後端系列拆成四篇的根本理由。
面試折衷與實務選型
最值錢的判斷不是「會不會搭 Agent Runtime」,而是「知道什麼時候不需要把它做這麼重」。
Agent 編排篇的準則是「能用 Workflow 就別上 Agent」。本篇在服務層再加一級遞進:就算決定用 Agent,也不代表你需要一整套非同步、狀態持久化的重型 runtime。先問這個請求會不會長、會不會有不可逆副作用、要不要能恢復——答案決定你該停在哪一階:
| 如果…… | 就用 | 理由 |
|---|---|---|
| 一兩輪、數秒收斂、無不可逆副作用 | 同步 request handler | 連 job store 都不必,blocking 回最省事——別過度設計 |
| 可能長(多輪工具)、但當機重來一次無妨 | 非同步 job + 記憶體/輕量狀態 | 切非同步取回吞吐與體驗,但不必上重型持久化 |
| 長任務 + 有不可逆副作用 + 要能恢復 | 完整狀態機 + 持久化 checkpoint + 冪等 | 本篇的完整型態;付出架構複雜度換正確性與韌性 |
| 多個長流程、要跨服務協調且保證恰好一次 | durable execution 引擎承載狀態機 | 把「持久化狀態機 + 重放 + 恢復」交給成熟基礎設施,別自己重造 |
選型階梯:能停在同步就別上非同步,能用輕量狀態就別上重型持久化——與 Agent 編排篇「能用 Workflow 就別上 Agent」是同一種節制的紀律。
本篇刻意講的是與工具、廠商無關的骨架,因為工具會換、原理不會。但落地時你通常不必從零手刻:截至撰稿時,常見做法是用圖/狀態機式的 agent 編排框架(例如 LangGraph 這類把 Agent Loop 建模成可持久化圖的工具)承載狀態機;用 durable execution 引擎(例如 Temporal 這類)處理「持久化、重放、恰好一次」的可靠性難題;長任務則普遍放進 job queue,並搭配雲廠商漸增的託管 agent runtime 服務。選型的判準始終回到本篇:你需要多強的狀態持久化與可靠性保證?需求不到,就別背上那份複雜度。
Q:為什麼一次 Agent 請求不能當成普通 API 來寫?
答:因為它有狀態、可能長時間、且含多個不可逆副作用步驟,比較像一個「作業」。這帶出 session 狀態、逾時、取消、idempotency 等普通無狀態 API 不需要的需求。
Q:為什麼要把 Agent Loop 做成顯式狀態機?
答:隱式 while 迴圈的狀態藏在記憶體,當機即失、無法中斷或跨機接手。顯式狀態機讓每次轉移可存 checkpoint,換來可恢復、可觀測、可中斷、可水平擴展。
Q:Agent runtime 為什麼特別在意 idempotency?
答:client 重試、當機重放、工具重試三條路徑都會重複執行帶副作用的 tool call;要用 request 級、tool-call 級、工具自身冪等三層防線去重。
Q:使用者按取消,已經扣的款能退回嗎?
答:不能靠取消退回。取消是合作式的、只止住未來步驟;已發生的副作用要靠補償交易或冪等設計善後。