Day 6 的 recursion_limit 解決了一件事:graph 不該無限繞圈。但我很快發現,step 數根本不能回答「這次到底花了多少」。一次 step 可能只是選擇下一步,也可能包含昂貴的模型回覆、外部工具請求,或一段很長的輸出。
所以今天我不再把「最多六步」當成成本控制,而是把不同花費拆開算。我用 mock workflow 和固定 usage data 驗證停止條件,避免為了做 demo 真的把 API key 跑到超額。
| 邊界 | 目前設定 | 它限制的是什麼 |
|---|---|---|
LangGraph recursion_limit |
6 |
graph 可執行的 super-step,Day 6 的最外層煞車。 |
| 模型呼叫數 | 3 |
一次 run 可以要求模型幾次。 |
| 工具呼叫數 | 4 |
一次 run 可以執行幾次工具。 |
| 單次模型 HTTP timeout | 20 秒 |
一個模型請求不能一直卡住。 |
| 單次輸出 | 600 tokens |
每次模型回覆的輸出長度。 |
| 累積 Token | 3,000 |
已回傳的 input + output usage。 |
| 執行時間 | 45 秒 |
到下一個模型或工具動作前,這個 run 已跑多久。 |
| 估算成本 | 部署設定 | 依 input/output token 價格估算的金額。 |
前三項的單位都不同。recursion_limit 是 graph 的 super-step;模型呼叫與工具呼叫是可觀察的外部動作。Day 6 已經說過,它們不能互相代替。
LangChain 有 ModelCallLimitMiddleware 與 ToolCallLimitMiddleware 可限制單次 run 的次數;官方文件也把它們列為控制模型成本與工具執行的 middleware。Prebuilt middleware 文件
我把它們和自訂預算 middleware 一起掛進 Agent:
middleware=[
ExecutionBudgetMiddleware(
limits=self.budget_limits,
pricing=self.token_price,
),
ModelCallLimitMiddleware(run_limit=MAX_MODEL_CALLS, exit_behavior="end"),
ToolCallLimitMiddleware(run_limit=MAX_TOOL_CALLS, exit_behavior="end"),
]
exit_behavior="end" 的意思是限額到達後正常結束這次 run,不是丟出一串框架例外給使用者。這不代表回覆一定完整,所以前端和 trace 都要把「停止」當成一種結果,而不是成功。
以目前設定看,最多三次模型呼叫、每次最多 600 output tokens。只算輸出,理論上是 1,800 tokens;但這不是一次 run 的總 token 上限。
每次模型呼叫都還會帶入 system prompt、使用者訊息、工具結果與前幾輪對話,這些都是 input tokens。模型 provider 有回傳 usage 時,LangChain 會把 token 數放在 AIMessage.usage_metadata;沒有回傳時,程式不能憑空知道用了多少。LangChain models 文件
我目前只累積確實拿到的 input_tokens 與 output_tokens:
@dataclass(frozen=True)
class BudgetLimits:
max_elapsed_seconds: float = 45.0
max_total_tokens: int = 3_000
max_estimated_cost_usd: Decimal | None = None
這個 3,000 是停止下一個動作前的判斷門檻,不是 provider 層的硬性 token cap。假設第三次模型回覆才讓累積值超過 3,000,那次回覆已經發生;程式會阻止後面的模型或工具動作,不能把已送出去的請求倒帶。
這也是我不想把「設定了 Token budget」寫成「永遠不會超過 3,000 tokens」的原因。上限的精度取決於可以在哪個時點量到 usage,也取決於 provider 有沒有提供它。
Token 本身不是價格。input、output、cache 和不同模型的計價方式都可能不同,直接把某個模型價格寫死在程式裡,很容易過期。
我把價格放在 .env:
MODEL_INPUT_PER_MILLION_USD=
MODEL_OUTPUT_PER_MILLION_USD=
RUN_COST_BUDGET_USD=
三個都填好才啟用成本上限。價格沒填時,模型呼叫數、工具呼叫數、時間和總 Token 上限仍會生效;程式不會用猜的成本把 run 停掉。
本機成本 demo 使用的是刻意簡化的測試價格:input 每百萬 tokens $1、output 每百萬 tokens $2。第一筆 usage 是 800 input 加 300 output,估算 $0.0014;第二筆加上 1,200 input 與 600 output 後,累積是 2,900 tokens、$0.0038。因為 demo 的成本預算是 $0.0030,它在下一個動作前停止。
這些數字只用來驗證計算與停止順序,不是任何模型的報價。
這是我今天補上的一層。原本的 before_model 只能在下一次模型呼叫前檢查;如果上一輪模型剛超標又回傳一個 tool call,單靠它不足以保證工具不會先被執行。
現在我在 wrap_tool_call 做同一份檢查。超標時不呼叫 handler,而是回傳一個帶原 tool call ID 的 ToolMessage(status="error"):
def wrap_tool_call(self, request: ToolCallRequest, handler: Callable[[ToolCallRequest], Any]) -> Any:
exceeded = self._exceeded_limit(request.state)
if exceeded is None:
return handler(request)
return ToolMessage(
content=_budget_stop_message(exceeded),
tool_call_id=request.tool_call["id"],
status="error",
)
LangChain 的 middleware 可以包住每一次模型或工具呼叫,正適合放這種執行前的規則。Custom middleware 文件
工具還是可能因為網路、timeout 或資料格式失敗,那些是 Day 8 的 retry 與 circuit breaker 要處理的事。這一層只回答一件事:已知預算用完時,還要不要讓 Agent 做下一個副作用?答案是不要。

我新增了兩層測試。
1,000 tokens,再要求呼叫工具。測試確認 handler 沒有被執行,回傳的 tool result 是 error。tool_handler_called 為 false、tool_status 為 error,以及回覆明確說出 Token 上限。本次結果是 37 個 Python 測試通過;Promptfoo 5 passed、0 failed、0 errors。Promptfoo 在這裡仍然是本機 deterministic eval,不是用真實模型反覆燒 Token 的壓力測試。
時間上限也有同樣的邊界。ChatOpenAI(timeout=20) 限制一個模型 HTTP 請求;累積 45 秒的預算則在模型呼叫前與工具執行前檢查。若目前有一個已經送出的請求卡住,後面的檢查無法把它從網路上抓回來。
所以現在的設計是:限制單次模型請求、限制整個 run 的下一步,並留下停止訊息。之後接上外部 API 或 MCP 工具時,每一個 client 仍要有自己的 timeout;不能以為 Agent 外層的 45 秒會自動取消所有事情。
Day 8 會處理另一種更危險的情況:工具失敗後,Agent 一直重試。上限能讓它停下來,但不會告訴它該等多久、何時改走 fallback,或何時應該直接升級給人處理。