cd ../back to blog
$Guide//June 4, 2026//8 min read

打造 AI 代理迴圈:工具、記憶與安全迭代

用 OpenAI SDK 打造一個能跑的 AI 代理迴圈 — 工具派發、對話記憶、迭代上限與每次執行成本預算,讓它不會永遠空轉。

工具呼叫放進一個迴圈裡,你得到的就是一個代理。一次工具呼叫回答一個 問題;一個代理則會呼叫工具、讀取結果、決定下一步該做什麼,然後一直 走下去,直到任務真正完成 — 先搜尋,再讀最相關的那一筆,接著查一個 價格,最後寫出答案。這個機制很簡單,而且每一次都是同樣的四個節拍: 模型請求一個工具,你執行它,你把結果回饋回去,你再呼叫一次模型。難的 不是迴圈,而是那些護欄 — 它們讓迴圈不會永遠空轉,也不會在單次執行裡 悄悄向你收 $40。

這篇文章會用 OpenAI Python SDK,對著 https://api.brievio.com/v1 打造一個真正能跑的代理迴圈, 再用四項控制把它包起來,讓它能安心上線:一個 硬性迭代上限帶驗證的工具派發、 一個從誠實權杖數讀出的每次執行成本預算,以及對模型 行為失常各種情況的合理處理。每一段程式碼都能直接照跑;把 claude-sonnet-4-6 換成 gemini-2.5-pro,同一份程式碼就會驅動另一個模型。

迴圈,以及它為何需要一個天花板

整顆引擎就在這裡。它就是你已經熟悉的工具呼叫迴圈,只多加了一處,卻 改變了一切:用 for step in range(MAX_ITERS) 取代 while True

agent_loop.py
# 帶有硬性迭代上限的代理迴圈。模型 -> tool_calls -> 執行 ->
# 把結果回饋進去 -> 重複,直到模型以文字作答,或我們撞到
# 上限。這個上限,就是「代理」與「失控帳單」之間的差別。
from openai import OpenAI
import json

client = OpenAI(
    api_key="sk-brievio-...",
    base_url="https://api.brievio.com/v1",
)

MAX_ITERS = 8   # 多數任務 2~4 輪就能完成;8 是相當寬裕的緩衝。

def run_agent(question: str, model: str = "claude-sonnet-4-6") -> str:
    messages = [
        {"role": "system", "content": "You are a helpful research agent. "
         "Use the tools when you need live data. Answer directly when you "
         "already know enough."},
        {"role": "user", "content": question},
    ]

    for step in range(MAX_ITERS):
        resp = client.chat.completions.create(
            model=model,
            messages=messages,
            tools=TOOLS,
            tool_choice="auto",
        )
        msg = resp.choices[0].message

        # 沒有請求任何工具 -> 這就是最終答案。完成。
        if not msg.tool_calls:
            return msg.content

        # 把 assistant 這一輪「原封不動」地附加進去 — 它帶著
        # 後續訊息必須引用的 tool_call id。
        messages.append(msg)

        # 執行每一個被請求的呼叫,並為每個 id 附加一則 tool 訊息。
        for call in msg.tool_calls:
            result = dispatch(call)              # 見下一段程式碼
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,          # 必須對應該呼叫的 id
                "content": json.dumps(result),
            })
        # 迴圈:模型現在看到工具輸出,並繼續往下走。

    # 撞到上限卻仍未解決。大聲失敗 — 別默默地永遠迴圈下去。
    raise RuntimeError(f"agent did not finish within {MAX_ITERS} iterations")

那個有界的 for,是一個代理裡最重要的一行。一個有能力的 模型在範圍清楚的任務上,兩到四輪就能完成。但模型會犯糊塗:同一個 搜尋呼叫兩次、追進死胡同,或是 — 那個經典的失敗模式 — 呼叫一個工具、 拿到一個它不滿意的結果,於是用幾乎一模一樣的引數再呼叫一次,沒完 沒了。while True 會把這變成一張無上限的帳單和一個卡死 的請求。這個上限,把「永遠空轉」變成了「試 8 次後帶著清楚的錯誤 失敗」,而後者是你能捕捉、記錄、並從中復原的東西。從你的任務挑這個 數字:一次性的查詢需要 2,一個多步驟的研究代理也許要 10。刻意設定 它;別讓它無上限。

留意另一道藏在明處的護欄: 處理模型「不」呼叫工具的情況。當 msg.tool_calls 是空的,那是模型判斷它已經有足夠資訊 可以作答 — 那是你的出口,不是錯誤。一個假設「每一輪都會產生工具 呼叫」的迴圈,要嘛崩潰,要嘛永遠不會終止。每一次迭代,都要對兩種 結果分別處理。

工具派發:模型提議,你的程式碼裁決

模型永遠碰不到你的系統。它吐出一個函式名稱和一段 JSON 字串的引數, 然後停下;由你的程式碼決定要不要照辦。那道邊界,就是一個 代理的整個安全故事,所以驗證就活在派發函式裡 — 不是為了好看,而是 因為每一個引數都是不可信的模型輸出,就跟一個陌生人 填進去的表單欄位一模一樣。

dispatch.py
# 帶驗證的工具派發。模型負責「提議」一個呼叫;你的程式碼
# 負責「裁決」它。每一個引數都是不可信的模型輸出 — 先解析它、
# 確認名稱是你註冊過的,並在執行前驗證型別。
def get_weather(city: str, unit: str = "celsius") -> dict:
    if not isinstance(city, str) or not city.strip():
        raise ValueError("city must be a non-empty string")
    if unit not in ("celsius", "fahrenheit"):
        raise ValueError(f"unsupported unit: {unit!r}")
    return {"city": city, "temp": 18, "unit": unit, "sky": "clear"}

# 白名單:模型只能呼叫你明確註冊過的東西。
TOOL_IMPLS = {"get_weather": get_weather}

TOOLS = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Get current weather for a city.",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {"type": "string"},
                "unit": {"type": "string",
                         "enum": ["celsius", "fahrenheit"]},
            },
            "required": ["city"],
        },
    },
}]

def dispatch(call) -> dict:
    name = call.function.name
    fn = TOOL_IMPLS.get(name)
    if fn is None:
        # 模型幻覺出一個工具。別讓迴圈崩潰 — 把這個錯誤
        # 當成 tool 結果回傳,讓模型可以自我修正。
        return {"error": f"unknown tool: {name}"}

    try:
        args = json.loads(call.function.arguments)   # 永遠是一個 JSON 字串
    except json.JSONDecodeError:
        return {"error": "arguments were not valid JSON"}

    try:
        return fn(**args)
    except (TypeError, ValueError) as e:
        # 引數有問題(型別錯、缺欄位、超出範圍)。把訊息
        # 回饋回去;模型通常會用修正過的呼叫重試。
        return {"error": str(e)}

這裡處理了三種失敗模式,而且全都把錯誤回饋給模型,而不是讓迴圈 崩潰。一個幻覺出來的工具名稱 — 模型自己發明、你卻 從沒註冊過的 — 白名單會攔下它。引數字串裡的 格式錯誤 JSON — 在真正的旗艦模型上很少見,但你還是 防禦性地去解析它。還有不良的引數值 — 型別錯、缺少 必填欄位、一個模型自己編出來的 enum。在每一種情況裡, 把 {"error": "..."} 當成工具結果回傳,都比拋出例外 更好,因為模型會在下一輪讀到那則訊息,而且通常會修正自己的呼叫。一個 能從自身錯誤中復原的代理,遠比一個一遇到不良引數就掛掉的代理來得 強健。

把白名單收得緊。TOOL_IMPLS.get(name) 意味著一個模型 — 無論是不是真的 — 永遠只能呼叫你明確註冊過的函式。那一個 dict,就是 你的影響半徑。如果某個工具會刪除資料、刷一張卡,或寄出一封電子 郵件,就把它擋在一道明確的確認後面,而不是讓迴圈自主地觸發它。

預算護欄:迴圈會重送一份不斷成長的脈絡

迭代上限限制的是你呼叫模型的次數。它並沒有限制每一次呼叫的花費 — 而在迴圈裡,成本每一輪都在攀升。原因是結構性 的:每一輪都會重送到目前為止的整段對話,外加附在後面的每一筆工具 結果。第一輪也許是 800 個輸入權杖;到了第六輪,當五筆工具輸出堆疊 起來後,可能變成 6,000 個。八個便宜的回合,悄悄加總成一次不便宜的 執行。解法是第二道、各自獨立的天花板,架在花費上,由每次 呼叫回傳的真實權杖數算出來:

budget_guard.py
# 一個「每次執行」的成本/權杖預算護欄。每一輪迴圈都會重送一份
# 「不斷成長」的脈絡(歷史 + 工具輸出),所以成本每輪攀升。每次
# 呼叫後讀取那個誠實的 usage 物件,替它定價,並在本次執行
# 超過預算時停下 — 與迭代上限各自獨立。
from decimal import Decimal

# Brievio 公布的費率,每 100 萬權杖的美元數(約比官方參考價低 15%)。
RATES = {
    "claude-sonnet-4-6": {"in": Decimal("2.55"), "out": Decimal("12.75")},
    "claude-haiku-4-5":  {"in": Decimal("0.85"), "out": Decimal("4.25")},
}

def call_cost(model: str, usage) -> Decimal:
    r = RATES[model]
    m = Decimal("1000000")
    return usage.prompt_tokens * r["in"] / m + usage.completion_tokens * r["out"] / m

RUN_BUDGET = Decimal("0.10")   # 每次代理執行 10 美分,硬性上限。

def run_agent_budgeted(question: str, model: str = "claude-sonnet-4-6") -> str:
    messages = [{"role": "user", "content": question}]
    spent = Decimal("0")

    for step in range(MAX_ITERS):
        resp = client.chat.completions.create(
            model=model, messages=messages, tools=TOOLS, tool_choice="auto",
        )
        spent += call_cost(model, resp.usage)   # 每一輪都累計「真實」權杖
        if spent > RUN_BUDGET:
            raise RuntimeError(f"run exceeded ${RUN_BUDGET} (spent ${spent:.4f})")

        msg = resp.choices[0].message
        if not msg.tool_calls:
            return msg.content

        messages.append(msg)
        for call in msg.tool_calls:
            messages.append({"role": "tool", "tool_call_id": call.id,
                             "content": json.dumps(dispatch(call))})

    raise RuntimeError(f"agent did not finish within {MAX_ITERS} iterations")

關鍵在於,Brievio 上的 resp.usage 帶著真正的模型實際 處理過的、誠實的輸入與輸出權杖數 — 所以那個累計總額是真金白銀,不是 猜測。每一輪後讀取 usage 並在 RUN_BUDGET 處停下,意味著一個原本會把八個昂貴回合燒光 的糊塗代理,會在它一跨過一毛錢的那一刻就被切斷,無論那花了多少次 迭代。兩道天花板,涵蓋兩種不同的失敗模式:迭代上限阻止無限迴圈,預算阻止昂貴的迴圈。兩者你都需要,因為一個迴圈可能 又短又貴,也可能又長又便宜,而單靠任何一道,都護不住另一種情況。

算帳時值得知道:失敗的 4xx5xx 呼叫在 Brievio 上不計費,所以對一個不穩的工具或一個短暫的上游錯誤 重試,並不會耗掉本次執行的預算 — 你只會替真正回傳了結果的呼叫累計 成本。這讓花費曲線追蹤的是完成的工作,而不是被吸收掉的錯誤。每次 呼叫與每位使用者的花費上限,完整的模式在 為 API 花費設上限這篇 指南裡。

隨著迴圈成長,把權杖帳單壓下來

限制成本是一回事;降低成本是另一回事。因為每一輪都會重送一份不斷 成長的前綴,同一段脈絡會被一次又一次地付費 — 而這正是提示快取 (prompt caching)設計的對象。把請求裡靜態的部分(系統提示、工具 定義)標記為可快取,從第二輪起,所有沒有變動的內容,你只要付輸入 費率的一小部分。在一個每一輪都重送同一份數千權杖工具目錄與系統提示 的迴圈裡,那是帳單上單一最大的槓桿。

還有幾個實務習慣也有幫助。讓系統提示與工具定義在整次執行中保持穩定 — 在迴圈中途加入一個工具,或在系統提示裡放一個時間戳, 都會讓快取失效,並悄悄讓你的輸入成本翻倍。而如果某個工具會回傳 一大片資料(一整個網頁、一個上千列的查詢),就在把結果附加到 messages 之前先摘要或截斷它;模型很少需要全部,而你 附加的每一個位元組,都會在後續每一輪被重送。代理迴圈對脈絡膨脹格外 敏感,正是因為脈絡被重送了 N 次,而不是一次。

對話記憶:在多次執行之間要帶什麼

上面所有內容,都是單次執行之內的記憶 — 那個 messages 串列就是代理的工作記憶,而往裡頭附加,就是 模型記住它已經查過什麼的方式。對於一個橫跨多個請求、與使用者對話的 多輪代理,你會把那個串列往後帶:把 messages 依工作階段 持久化(Redis、一個資料庫欄位,哪裡都行),在下一個請求時重新載入 它,再附加新的使用者那一輪。迴圈一模一樣;改變的只有起始狀態。

需要管理的是無上限的成長。一個長壽的工作階段會不斷累積歷史,直到 它在每一次呼叫上都變得昂貴,最終撐爆脈絡視窗。兩種常見策略:保留 最近 N 輪的滑動視窗並丟掉最舊的,或定期把較舊的歷史摘要成一則精簡 的備註,再用它取代原始的那幾輪。兩者都用一些保真度,換來一個有界、 可預測的脈絡大小。無論你選哪一個,上面那道每次執行的預算護欄依然 適用 — 它就是那道後盾,接住一個長得比你預想還大的工作階段。

一把金鑰,撐起一個會升級的代理

把這套東西建在 Brievio 後面,有一個好用的特性:一個代理可以在任務 中途切換它所用的模型,而不必更動其他任何東西。便宜的回合跑在較小的 模型上,只在任務變難時才升級到旗艦 — 把簡單的工具派發走 Haiku 4.5,輸入 $0.85/輸出 $4.25,在需要大量推理 的最終答案上,再退回 Sonnet 或另一個系列。因為 一把金鑰涵蓋每一個模型,都在同一個 base_url 後面,那次升級只是迴圈裡那串 model 字串的一行更動 — 沒有第二套 SDK、沒有第二套驗證 機制、沒有第二段計費關係。完整的請求/回應契約,包含工具相關欄位, 都在 Chat Completions 文件裡,而帶有 確切 id 的即時模型清單,則在 模型頁面上。

當然,這一切只有在另一端的模型是真貨時才有意義:一個代理迴圈對 降級的替身毫不留情,因為一個搞錯工具引數、或無視某個工具的模型, 會在追逐自己的錯誤時燒掉迭代與花費。Brievio 提供的是真正的第一方 模型、原生支援工具呼叫,並回報誠實的權杖數 — 這正是讓迴圈與預算算 帳兩者都真正成立的東西。

重點整理:四道護欄,然後上線

迴圈本身就十幾行。讓它能上線的,是它周圍那道邊界:

  • 替迭代設上限。用一個有界的 for 取代 while True。在天花板處大聲失敗, 而不是永遠空轉。
  • 處理「無工具」的情況。空的 tool_calls 是出口,不是錯誤。每一輪都要對它分支判斷。
  • 驗證每一次派發。把工具名稱列入白名單、解析引數、 檢查型別 — 並把錯誤回饋給模型,而不是讓它崩潰。
  • 替本次執行設預算。每一輪讀取 usage, 依公布費率替它定價,在一道硬性花費天花板處停下。盯著那份不斷成長 的脈絡,並快取靜態前綴,把被重送的成本壓下來。

把這四件事做對,你就有了一個會做真正多步驟工作、能從自身錯誤中 復原、而且最壞情況成本是你自己選的、而不是在帳單上才發現 的代理。如果你需要先掌握單次呼叫的機制,就從 工具呼叫指南 開始,再把它包進迴圈與上面那四道護欄裡。