iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0

我一開始把 Agent 想得太像一個會「理解整個任務」的助手。實際把 Helpdesk Agent 拆開後,感覺比較像一個不停改變狀態的程式:模型看見目前資料,決定要不要叫工具;工具回傳資料;模型再看一次,決定下一步。它可能停下來回答,也可能再選一個工具。

這篇的「思考」只是方便溝通的名稱,不是要宣稱模型真的有可驗證的內在思考過程。安全設計不該依賴猜它腦中想了什麼,而是要看每一輪留下了什麼輸入、做了什麼動作、又拿到了什麼結果。

我把一次 Agent 執行看成一個 ReAct loop

這個專案目前跑的是一個很小的 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 工單。

但模型選了工具,不代表它真的有權執行。真正限制它能不能寫入資料的,還是工具邊界與後端規則。

一個工具呼叫,不等於一個 step

我很容易犯的錯,是把「Agent 跑了六步」直接翻譯成「它叫了六次工具」。這兩件事不是同一個單位。

LangGraph 把 graph 的執行切成 super-step;同一個 super-step 中平行執行的節點仍算同一輪,連續執行的節點才會分成不同輪。recursion_limit 限制的是一個 run 可以走過多少 super-step,超過時會拋出 GraphRecursionErrorLangGraph 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:刻意交替寫入 modeltool 事件,走完指定的示範步數後加上一筆 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。兩者都在說「流程又往下一輪走了」,但不能混著算。

image

真實 Agent 撞到上限後,我不讓例外直接噴給使用者

真正的 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_budgetrecursion_limit=4 執行 deterministic demo,確認它回傳 stopped=True、trace 最後一筆狀態是 stopped,而且停止訊息真的有出現。

這些測試沒有證明所有 Agent loop 都安全。它們只證明我已經把一個具體的停止邊界接進程式,並且不會因為未來改畫面或 trace 格式而悄悄失效。

Day 7 我會再拆更細:模型呼叫數、工具呼叫數、時間、Token 和成本都不是 recursion_limit 能代表的東西。先把 loop 的單位講清楚,後面的上限才不會變成一堆看似安全、其實各自漏算的數字。

本日程式碼

day-06-loop-engineering


上一篇
Day 5|我還沒讓 Agent 上線,先用 Promptfoo 打它一拳
系列文
從 LLM、Agent 到 Guardrails:30 天打造可控、安全、可驗證的 AI Agent6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言