我一開始把 Agent 想得太像一個會「理解整個任務」的助手。實際把 Helpdesk Agent 拆開後,感覺比較像一個不停改變狀態的程式:模型看見目前資料,決定要不要叫工具;工具回傳資料;模型再看一次,決定下一步。它可能停下來回答,也可能再選一個工具。
這篇的「思考」只是方便溝通的名稱,不是要宣稱模型真的有可驗證的內在思考過程。安全設計不該依賴猜它腦中想了什麼,而是要看每一輪留下了什麼輸入、做了什麼動作、又拿到了什麼結果。
這個專案目前跑的是一個很小的 ReAct Agent。ReAct 指的是模型先決定下一步,再執行工具、讀取結果,然後重新判斷要不要繼續。
流程可以寫成:
使用者問題
→ Reason:模型根據訊息與目前狀態,決定直接回答或呼叫工具
→ Act:執行 search_it_sop 或 create_ticket
→ Observe:把工具結果加回 Agent 可見的訊息
→ Reason:模型決定下一步
→ 回答或重複
我刻意寫 Reason,而不是把模型的內部思考過程當成可以完整讀取的東西。系統真正能記錄與檢查的,是模型最後選了哪個工具、工具收到了什麼參數、工具回傳了什麼,以及流程是否有停止。
把 ReAct 拆成這幾段後,我比較容易定位安全問題。
模型直接給出錯誤答案時,我會先看它在決定回答前拿到了哪些資訊;模型選到不該選的工具時,問題落在 Reason 到 Act 的邊界;SOP 或工具回傳內容混進不可信文字時,則要回頭檢查 Observe,因為那些內容會重新進入下一輪模型可見的 context。
目前 Helpdesk Agent 可見的工具只有 search_it_sop 與 create_ticket。正常路徑應該先查 SOP,讓 Agent 看過流程資料後,再決定是否建立 mock 工單。
但模型選了工具,不代表它真的有權執行。真正限制它能不能寫入資料的,還是工具邊界與後端規則。
我很容易犯的錯,是把「Agent 跑了六步」直接翻譯成「它叫了六次工具」。這兩件事不是同一個單位。
LangGraph 把 graph 的執行切成 super-step;同一個 super-step 中平行執行的節點仍算同一輪,連續執行的節點才會分成不同輪。recursion_limit 限制的是一個 run 可以走過多少 super-step,超過時會拋出 GraphRecursionError。LangGraph graph API 文件
因此,recursion_limit=6 不是「六次模型呼叫」、不是「六次工具呼叫」,也不是 Token 上限。它只是最外層的保險,防止 graph 在沒有抵達結束條件時一直繞下去。LangGraph 文件也把它定位為:在無法保證 loop 一定會結束時的上限。LangGraph loop 文件
我先把這個限制寫成很小的設定函式:
DEFAULT_RECURSION_LIMIT = 6
MINIMUM_RECURSION_LIMIT = 3
def build_agent_config(recursion_limit: int = DEFAULT_RECURSION_LIMIT) -> dict[str, int]:
if recursion_limit < MINIMUM_RECURSION_LIMIT:
raise ValueError(f"recursion_limit must be at least {MINIMUM_RECURSION_LIMIT}")
return {"recursion_limit": recursion_limit}
這個 6 只是目前練習專案的開發預設值,不是任何通用建議。它小到讓我能很快看出 loop 沒停,也留得下查 SOP、建工單、回覆使用者的空間。真正該設多少,要看任務是不是需要多輪工具、工具有沒有平行分支,還有是否存在人工核准。
本機頁面的「迴圈停止」按鈕沒有呼叫模型,也不會真的對工具重試到失控。它是 deterministic demo:刻意交替寫入 model 和 tool 事件,走完指定的示範步數後加上一筆 recursion_limit 停止事件。
以 recursion_limit=4 測試時,trace 有五筆資料:四筆交替的 model/tool 事件,加上一筆 guardrail 停止事件。我要驗證的是停止訊息、trace 和畫面能否正確呈現,而不是把 API key 花在製造失控情境。
01 model choose_next_step completed
02 tool retrying_lookup completed
03 model choose_next_step completed
04 tool retrying_lookup completed
05 guardrail recursion_limit stopped
這個 demo 的「step」是為了讓畫面可重現而設的示範步數;它不是 LangGraph 對真實 graph 所計算的 super-step。兩者都在說「流程又往下一輪走了」,但不能混著算。

真正的 HelpdeskAgent 會把設定傳給 agent.invoke()。若 LangGraph 拋出 GraphRecursionError,我捕捉它、在 trace 裡記一筆停止事件,並回傳明確的不完整結果。
try:
result = self.agent.invoke(
{
"messages": [{"role": "user", "content": user_message}],
"budget_started_at": time.monotonic(),
},
config=self.agent_config,
context=context,
)
except GraphRecursionError:
trace.add(
kind="guardrail",
name="recursion_limit",
status="stopped",
detail="LangGraph 超過允許步數,已停止本次執行。",
)
回覆會說明「結果可能不完整,請縮小問題後重試」。我不希望使用者以為它已經完成,也不希望把一串 stack trace 當成產品回覆。
這個做法是 reactive:先讓 LangGraph 觸發上限,再在外面處理例外。LangGraph 也支援在 graph 內部根據剩餘步數提早路由到結束節點;那比較適合有明確 partial result 策略的流程。LangGraph graph API 文件
目前這個 Helpdesk Agent 還沒有那種 partial result 邏輯。SOP 查詢失敗時,Day 4 的 fallback 已經能安全降級;loop 用完時則直接停止,避免假裝它已完成。
目前有兩層測試。
test_builds_a_recursion_limit_config 驗證 6 會被正確放進 LangGraph config,2 這種不合理的小值會被拒絕。test_runaway_loop_demo_stops_after_the_budget 用 recursion_limit=4 執行 deterministic demo,確認它回傳 stopped=True、trace 最後一筆狀態是 stopped,而且停止訊息真的有出現。這些測試沒有證明所有 Agent loop 都安全。它們只證明我已經把一個具體的停止邊界接進程式,並且不會因為未來改畫面或 trace 格式而悄悄失效。
Day 7 我會再拆更細:模型呼叫數、工具呼叫數、時間、Token 和成本都不是 recursion_limit 能代表的東西。先把 loop 的單位講清楚,後面的上限才不會變成一堆看似安全、其實各自漏算的數字。