cd ../返回博客
$Guide//2026年6月4日//8 min read

搭建一个 AI 智能体循环:工具、记忆与安全迭代

用 OpenAI SDK 搭一个能跑的 AI 智能体循环——工具分发、对话记忆、迭代上限和单次运行成本预算,让它不会永远空转。

工具调用放进一个循环里,你得到的就是一个智能体。一次工具调用回答 一个问题;而一个智能体会调用工具、读取结果、决定下一步,然后一直 往下走,直到任务真正完成 —— 先搜索,再读取排名最靠前的结果,接着 查一个价格,最后写出答案。机制很简单,而且每次都是同样的四个节拍: 模型请求一个工具,你执行它,你把结果喂回去,你再次调用模型。难的 不是这个循环。难的是那些护栏 —— 它们要拦住循环,不让它永远空转, 也不让它在一次运行里悄悄向你收下 $40。

本文用 OpenAI 的 Python SDK,针对 https://api.brievio.com/v1 搭建一个真正能跑的智能体 循环,再用四道控制把它包起来,使它足以放心上线:一道 硬性迭代上限带校验的工具分发、 一道从诚实 token 数算出的单次运行成本预算,以及 对模型行为失常时各种情况的稳妥处理。每段代码都可以原样运行;把 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:
        # 模型幻想出了一个工具。别让循环崩掉 —— 把错误作为
        # 工具结果交回去,让模型自己纠正。
        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) 意味着一个模型 —— 不管真假 —— 永远只能调用你显式注册过的函数。那个字典就是你的 爆炸半径。如果一个工具会删除数据、刷一笔卡,或发一封邮件,就把它 挡在一道显式确认之后,而不是让循环自动地把它触发。

预算护栏:循环会重新发送不断增长的上下文

迭代上限限定了你调用模型的次数。它并不限定每次调用的成本 —— 而在一个循环里,成本逐轮攀升。原因是结构性的: 每一轮都会重新发送到目前为止的整段对话,外加追加在其后的每一条工具 结果。第一轮也许是 800 个输入 token;到了第六轮,五条工具输出已经 堆叠起来,就可能是 6,000 个。八个便宜的回合,悄悄加起来就成了一次 不便宜的运行。对策是给花费设一道第二重、相互独立的上限, 从每次调用返回的真实 token 数算出来:

budget_guard.py
# 单次运行的成本/token 预算护栏。每一轮循环都会重新发送一份
# 不断增长的上下文(历史 + 工具输出),所以成本逐轮攀升。每次调用后
# 读取诚实的 usage 对象,给它定价,一旦本次运行超出预算就停下来
# —— 这与迭代上限相互独立。
from decimal import Decimal

# Brievio 公开费率,美元/每百万 token(约比官方参考价低 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)   # 每一轮都累计真实 token
        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 带着真模型实际处理的 诚实输入和输出 token 数 —— 所以那个累计总额是真金白银,不是估算。 每一轮之后读取 usage 并在 RUN_BUDGET 处停下,意味着一个本来会烧穿八个昂贵回合的糊涂智能体,会在跨过一毛钱 的那一刻被切断,不管它为此用了多少次迭代。两道上限,覆盖两种不同的 失败模式:迭代上限拦住无限循环,预算拦住昂贵的 循环。两者你都想要,因为一个循环可以又短又贵,也可以又长又便宜, 而单凭其中一道,都护不住你免于另一种。

算账时值得知道一点:失败的 4xx/5xx 调用在 Brievio 上不计费,所以针对一个不稳定的工具或一次瞬时上游 错误的重试,并不会消耗运行预算 —— 你只为真正返回了结果的调用累计 成本。这让花费曲线跟踪的是完成的工作量,而不是被吸收掉的错误。给 每次调用、每个用户的花费设上限的完整模式,在 为 API 花费设上限这篇 指南里。

在循环增长时把 token 账单压住

给成本设上限是一回事;把它降下来是另一回事。因为每一轮都会重新发送 一段不断增长的前缀,同一份上下文要被一遍又一遍地付费 —— 而这恰恰 正是提示缓存为之而生的形态。把请求里的静态部分(系统提示、工具 定义)标记为可缓存,从第二轮起,所有没变过的部分,你都只按输入费率 的一小部分付费。在一个每一轮都重新发送同样几千 token 的工具目录和 系统提示的循环里,那就是账单上最大的那根杠杆。

还有几个实用习惯也有帮助。让系统提示和工具定义在整次运行中保持稳定 —— 中途加进一个工具,或系统提示里有一个时间戳,都会 让缓存失效,并悄悄把你的输入成本翻倍。还有,如果一个工具可能返回 一大墙数据(一整个网页、一个上千行的查询结果),在把它追加进 messages 之前,先做摘要或截断;模型很少需要全部内容, 而你追加的每一个字节,都会在之后的每一轮里被重新发送一遍。智能体 循环对上下文膨胀格外敏感,恰恰是因为上下文会被重新发送 N 次,而 不是一次。

对话记忆:在多次运行之间该带上什么

上面所讲的一切,都是单次运行之内的记忆 —— messages 列表就是智能体的工作记忆,而往里追加内容,正是 模型记住自己已经查过什么的方式。对一个跨越多次请求、与用户对话的 多轮智能体来说,你要把那个列表向前带:按会话持久化 messages(Redis、数据库的一个列,放哪儿都行),在下一次 请求时把它重新加载回来,再追加新的用户回合。循环完全一样;只是起始 状态变了。

要管理的是无界增长。一个长期存活的会话会不断累积历史,直到它在每次 调用上都很贵,并最终撑爆上下文窗口。两种常见策略:保留最近 N 个回合 的滚动窗口、丢掉最旧的;或者周期性地把较旧的历史摘要成一段紧凑的 笔记,用它替换掉原始的那些回合。两者都用一些保真度,换来一个有界、 可预测的上下文大小。无论你选哪个,上面那道单次运行的预算护栏依然 适用 —— 它是那道兜底,能接住一个长得超出你预期的会话。

一把密钥,撑起一个会升级的智能体

把这套东西搭在 Brievio 背后,有一个很有用的特性:一个智能体可以在 任务中途切换它所用的模型,而其他什么都不用改。让便宜的回合在一个 更小的模型上跑,只在任务变难时才升级到旗舰 —— 把简单的工具分发 路由给 Haiku 4.5,$0.85 输入 / $4.25 输出,在需要 重度推理的最终答案上,再回退到 Sonnet 或另一个家族。因为 一把密钥覆盖每一个模型,都在同一个 base_url 背后,那次升级就是把循环里的 model 字符串改一行的事 —— 没有第二个 SDK、没有第二套鉴权方案、没有第二段 计费关系。完整的请求/响应契约,包括工具相关的字段,都在 Chat Completions 文档里,而带有精确 id 的实时模型清单,则在 模型页上。

当然,这一切只有在另一端的模型是真货时才有意义:一个智能体循环对 被降级的替身毫不留情,因为一个把工具参数搞砸、或干脆无视某个工具的 模型,会在追逐自己的错误时烧掉迭代和花费。Brievio 提供真正的第一方 模型,尊重原生工具调用,并上报诚实的 token 数 —— 而这正是让循环和 预算算账都真正成立的前提。

要点:四道护栏,然后发车

循环本身就十几行。让它具备生产级水准的,是它周围那道边界:

  • 给迭代设上限。用一个有界的 for 取代 while True。在触顶时响亮地报错, 而不是永远空转。
  • 处理无工具的情况。空的 tool_calls 是出口,不是错误。每一轮都为它分支处理。
  • 校验每一次分发。把工具名加白名单、解析参数、 校验类型 —— 并把错误喂回给模型,而不是让它崩掉。
  • 给运行设预算。每一轮都读取 usage, 对照公开费率给它定价,在一道硬性花费上限处停下。盯住不断增长的 上下文,并缓存那段静态前缀,把被重新发送的成本压下来。

把这四点做对,你就拥有了一个能完成真正多步工作、能从自己的错误中 恢复、而且最坏情况成本是你主动选定而非在账单上才发现的 智能体。如果你需要先掌握单次调用的机制,就从 工具调用指南 起步,再把它包进上面那个循环和那四道护栏里。