《AI Agent 开发入门》3.1 朴素循环:跑到没有工具调用为止

本节给 minagent 装上第一个 Agent 循环:run() 反复调用 model.complete,把 assistant 消息与工具结果追加进同一个消息列表,直到某一轮不再产生工具调用为止。我们用 FakeModel 跑通 (2+3)*4 的两步收敛,打印每一步的 Step 轨迹、最终回答与消息条数,并讨论这个朴素实现留下了哪些坑。

3.1 朴素循环:跑到没有工具调用为止

前面两章,我们把「一次模型调用」摸清楚了:Message / ToolCall 定义了对话的原子结构,Model 协议与 FakeModel 定义了「谁来生成下一条消息」。但一个 Agent 的本质不是一次调用,而是一连串调用——模型要工具,我们给工具,模型再要工具,我们再给,直到它说「够了,这是我的答案」。这一节就把这条链路写成代码。

本节把 minagent 推进到「能自主跑完一个任务」:新增 minagent/loop.py,用 run() 把「调用模型 → 执行工具 → 回填结果」串成循环,直到模型不再要求工具为止,并把过程记录成 Step 轨迹。

3.1.1 循环的心跳:complete → 判断 → 执行

抛开所有细节,Agent 循环只有三件事反复做:

  1. 把当前消息列表交给 model.complete();
  2. 看返回的消息里有没有 tool_calls;
  3. 有就执行工具、把结果作为一条 tool 消息追加回去,然后回到第 1 步;没有就结束,那条消息的 content 就是最终答案。

用一句话概括就是:跑到没有工具调用为止。这就是「朴素循环」(naive loop)——不加规划、不加反思、不加并发,只用最直接的方式把工具接进对话。后面第 5 章的 ReAct、Plan-and-Solve、Reflexion,全都是在它之上做变体。

这里有一个关键认知,值得单独强调:控制流属于程序,不属于模型。模型只负责「下一步想要什么」,至于要不要执行、执行几次、什么时候停,全由我们写在 Python 里的循环决定。很多初学者以为「Agent 会自己循环」,其实模型是无状态的——它只看得到你喂进去的那段消息,循环是外面这层代码跑出来的。理解这一点,后面的所有设计都会变得顺理成章。

也正因为控制流在我们手里,每一轮我们都可以插入额外的检查:超时了没有、花了多少钱、工具是不是在报错。这些「循环之外的钩子」正是把一个玩具循环变成生产级运行时的关键,也是本章剩余两节要补的东西。

顺带一提,这类「模型调用 → 工具执行 → 回填 → 再调用」的循环,在业界常被称为 agentic loop(智能体循环)。名字听着玄,骨架就是上面那三步;差别只在于里面塞了多少检查、多少记忆、多少编排。

3.1.2 消息列表是唯一的「状态」

整个循环里没有任何隐藏变量,唯一的状态就是那个 messages 列表。这带来一个非常重要的心智模型:Agent 的每一步,都是对同一个列表的一次追加。

追加时机追加的消息作用
启动system(可选)设定角色与约束
启动user本次任务
每轮assistant(含 tool_calls)模型要求调工具
每次工具执行后tool(带 tool_call_id)工具返回值回填

tool_call_id 是把它配对回那条 assistant 消息的关键——一轮里模型可能同时要多个工具,靠 id 才能一一对应。第 4 章讲并行调用时,这一点会变得格外重要。

把上一节的对话在脑子里跑一遍,messages 列表最终会长成这样(省略 system 时):

0 user      content='请计算 (2+3)*4'
1 assistant content='' tool_calls=[('add', {'a': 2, 'b': 3})]
2 tool      content='5' tool_call_id=c1
3 assistant content='' tool_calls=[('multiply', {'a': 5, 'b': 4})]
4 tool      content='20' tool_call_id=c2
5 assistant content='计算结果是 20。'

这份列表本身就是 Agent 的「思维轨迹」:assistant 行是模型的决定,tool 行是环境的反馈,二者严格交替。你可以把它直接打印出来调试,也可以存盘做回放——第 7 章讲检查点续跑时,靠的就是这份列表。注意第 1、3 行的 content 是空串,因为那一轮模型没有说话,只发了工具请求;这不是 bug,而是协议允许的正常形态。

初学者常犯的一个错误,是把工具结果拼进 assistant 消息的 content 里,或者干脆新建一条 user 消息塞进去。这两种写法都会破坏「tool 消息必须紧跟带 tool_calls 的 assistant 消息」这条协议约束,真实 API 会直接报错。请务必按角色各归其位。

3.1.3 实现 run()

契约在卷首已经立好:run(model, registry, user_input, system="", max_steps=8) -> RunResult。落地成 minagent/loop.py:

# minagent/loop.py
from __future__ import annotations

from .tools import ToolRegistry
from .types import Message, Model, RunResult, Step


def run(model: Model, registry: ToolRegistry, user_input: str,
        system: str = "", max_steps: int = 8) -> RunResult:
    messages: list[Message] = []
    if system:
        messages.append(Message(role="system", content=system))
    messages.append(Message(role="user", content=user_input))

    steps: list[Step] = []
    for i in range(max_steps):
        resp = model.complete(messages, registry.schemas())
        msg = resp.message
        messages.append(msg)

        if not msg.tool_calls:
            steps.append(Step(index=i, kind="final", detail=msg.content))
            return RunResult(messages=messages, steps=steps, final=msg.content)

        for tc in msg.tool_calls:
            result = registry.call(tc)
            messages.append(Message(role="tool", content=result,
                                    tool_call_id=tc.id))
            steps.append(Step(index=i, kind="tool_call",
                              detail=f"{tc.name}({tc.arguments}) -> {result}"))

    steps.append(Step(index=max_steps, kind="error", detail="达到 max_steps 仍未收敛"))
    return RunResult(messages=messages, steps=steps, final="")

读这段代码时,注意几个刻意的设计:

  • assistant 消息被原样追加,包括它的 tool_calls。这是必须的——供应商 API 要求工具结果必须跟在一个带 tool_calls 的 assistant 消息之后,否则下一轮会报「找不到对应请求」。
  • registry.call(tc) 不抛异常,出错时返回 {"error": ...} 的 JSON 字符串。这样工具失败也能作为一条正常的 tool 消息喂回模型,让模型有机会自己纠错。
  • max_steps 是保险丝,不是功能。正常情况下模型会在若干轮后自然停下,它只在模型「卡住」时兜底。

返回值 RunResult 有三件东西,各有用处:

字段类型用途
messageslist[Message]完整对话历史,可存盘、可续跑、可回放
stepslist[Step]结构化的执行轨迹,用于日志、UI 展示与测试断言
finalstr模型的最终文本回答,调用方最常直接取这个

把 steps 单独拎出来,是为了让「过程」也变成可编程的数据。第 16 章讲确定性测试时,我们会直接断言 steps 的 kind 序列;第 15 章讲可观测性时,每个 Step 会扩展成一根 span。现在它只是一个「第几步、什么类型、什么细节」的三元组,但已经够用了。

3.1.4 跑一遍 (2+3)*4

工具用一个加法、一个乘法,模型用脚本化的 FakeModel:第一轮要 add(2, 3),第二轮要 multiply(5, 4),第三轮直接给文本答案。

# demo_31.py
from minagent import FakeModel, Tool, ToolCall, ToolRegistry, run


def build_registry() -> ToolRegistry:
    reg = ToolRegistry()
    reg.add(Tool(name="add", description="计算两个整数之和",
                 parameters={"type": "object",
                             "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
                             "required": ["a", "b"]},
                 func=lambda a, b: a + b))
    reg.add(Tool(name="multiply", description="计算两个整数之积",
                 parameters={"type": "object",
                             "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
                             "required": ["a", "b"]},
                 func=lambda a, b: a * b))
    return reg


model = FakeModel([
    [ToolCall(id="c1", name="add", arguments={"a": 2, "b": 3})],
    [ToolCall(id="c2", name="multiply", arguments={"a": 5, "b": 4})],
    "计算结果是 20。",
])

result = run(model, build_registry(), "请计算 (2+3)*4", system="你是一个计算助手。")

for step in result.steps:
    print(f"[{step.index}] {step.kind:<9} {step.detail}")
print(f"最终回答: {result.final}")
print(f"消息条数: {len(result.messages)}")
print("角色序列:", " -> ".join(m.role for m in result.messages))

用 venv 里的 Python 3.14.6 跑出来:

[0] tool_call add({'a': 2, 'b': 3}) -> 5
[1] tool_call multiply({'a': 5, 'b': 4}) -> 20
[2] final     计算结果是 20。
最终回答: 计算结果是 20。
消息条数: 7
角色序列: system -> user -> assistant -> tool -> assistant -> tool -> assistant

轨迹读起来非常清楚:第 0 步算加法得 5,第 1 步把 5 和 4 相乘得 20,第 2 步模型给出文本答案并结束。消息条数是 7——system + user + 两次「assistant 请求 + tool 回填」共 4 条 + 最后的 assistant 文本,正好对得上。角色序列里 assistant 与 tool 交替出现,这就是「工具调用轮」的固定节奏。

这个例子还有一个额外价值:它是确定性的。同一个脚本、同一个注册表,每次跑出来的 steps 都一模一样。你可以把它直接写成一个断言:

assert [s.kind for s in result.steps] == ["tool_call", "tool_call", "final"]
assert result.final == "计算结果是 20。"
assert len(result.messages) == 7

后面每改一次循环逻辑,就跑一遍这个断言,改动是否破坏了原有行为立刻见分晓。这正是 FakeModel 存在的意义——它把「Agent 行为」变成了可回归测试的对象,而不是每次都要连真实 API 才能验证。

再补一个容易被忽略的细节:工具返回值统一被 str() 成字符串再放进 tool 消息。上面 add 返回的是整数 5,但进消息列表时是 "5"。这是刻意的——Message.content 的类型是 str,工具结果可能是 dict、list、数字,统一序列化成字符串可以避免类型污染,也贴近真实供应商的线格式(工具结果在协议里就是字符串)。

3.1.5 朴素在哪里

这个循环能跑,但它只覆盖了最理想的情况。把它当成一个「最小可用骨架」,以下问题都还没有答案。请特别注意:这些不是「边界情况」,而是真实使用中迟早会发生的日常。模型会被截断、工具会超时、模型会犯傻重复同一个动作——如果你不主动处理,它们就会以最难看的方式暴露在生产环境里。

隐患后果处理章节
模型一直要工具、永不收敛死循环,烧钱3.2 停止条件
模型输出了重复的同一个工具调用原地打转3.2 重复动作检测
工具连续报错无休止重试3.2 连续失败上限
响应被 max_tokens 截断半截答案被当最终结果3.2 停止原因检查
函数散落各处、无法复用每次都要重搭一套3.3 抽成 Agent 类

换句话说,run() 现在只做对了「正常路径」,而 Agent 工程里真正难缠的恰恰是「异常路径」。下一节会把这五条逐一补上。

举一个最典型的崩法。假如模型陷入「查了没查到、再查、还是没查到」的循环,轨迹会是这样:

[0] tool_call search({'q': 'minagent'}) -> {"error": "timeout"}
[1] tool_call search({'q': 'minagent'}) -> {"error": "timeout"}
[2] tool_call search({'q': 'minagent'}) -> {"error": "timeout"}
...

max_steps 到了会停下,但在此之前它已经白白烧掉好几轮 token。更糟的是,如果 max_steps 没设或设得过大,这个循环可以一直跑到账户余额见底。一个健壮的循环必须能在「模型没犯错但也没进展」时主动喊停——这正是下一节的主线。

把本节要点收束成四条:

  • 朴素循环 = 「调用模型 → 有工具就执行并回填 → 没有就结束」的固定三步。
  • 唯一的状态是 messages 列表,assistant 与 tool 消息严格交替,靠 tool_call_id 配对。
  • RunResult 把「结果」与「过程」分开:final 给用户,steps 给日志与测试。
  • 现在的实现只保证正常路径,异常路径(死循环、重复、连续失败、截断)留待下一节。

3.1.6 真实模型下会怎样

FakeModel 的脚本是「预言」好的:我们知道它第二轮会要乘法,于是提前写好。真实模型不这样——它会读完整段历史再决定下一步。把上面的 system 换成「你是一个计算助手,遇到多步计算要拆开逐步做」,真实模型(例如 Claude)第一轮可能就自己把 (2+3)*4 算成 20 直接回答,一个工具都不调;也可能调一次 multiply(2+3, 4)。它甚至可能在拿到 add 的结果后改变主意。

这正说明为什么循环逻辑要和模型解耦:循环只管「有工具调用就执行、没有就结束」,至于模型这一步想干什么,由模型自己决定。用假模型我们换来的是可断言的确定性;换真实适配器时,run() 一行都不用改。两者的差异只在于「下一条消息从哪来」,接口完全一致。

还有两个假模型不会暴露、真实模型一定会暴露的差异:

维度FakeModel真实模型
延迟瞬时每轮几百毫秒到数秒,多轮叠加可观
成本零每次调用都计费,轮数直接等于花费
稳定性100% 确定同一输入两次结果可能不同
工具选择脚本写死可能一次要多个工具,也可能一个都不要

后两条正是下一节的动机:真实模型的不确定性意味着「跑不完」是常态而非意外,循环必须自带止损。而「延迟」与「成本」则解释了为什么 max_steps 的默认值要偏保守——它不只是防死循环,也是在给「最坏情况花多少钱」定一个上界。

还有一个真实世界独有的现象:模型有时会在文本里「假装」调用了工具。它可能写「我已经调用了 search 工具」,却根本没有发出结构化的 tool_calls。循环读的是结构化字段,所以会直接把这个文本当成最终答案返回——用户看到的是一句「我查过了」但实际什么都没查。这种「幻觉调用」只能靠提示词约束与输出校验来防,是纯假模型永远造不出来的坑。

run() 已经能跑完一个任务了,但它对「跑不完」毫无防备。下一节 3.2 停止条件与循环上限 会给这个循环装上止损线。想回看 Message 与 ToolCall 的字段设计,见 1.2 消息协议与角色 。

阅读导航:上一节:2.3 Token 预算与截断策略 · 下一节:3.2 停止条件与循环上限 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距