Skip to content

工具调用循环(Tool Loop)—— Agent 的心跳 ​

用户问:“订单 ORD-2026-001 到哪里了?”模型现在只有订单号,查不到物流。它得先调用订单工具拿到物流单号,再用物流单号查询运输进度,最后才能回答。

这里有两个前后依赖的工具调用。第二步需要第一步的结果,无法在第一次请求中提前完成。Tool Loop 要做的,就是让“模型提出调用、应用执行、模型读取结果”这组动作重复运行,直到模型不再需要工具。

1. 循环中谁负责什么 ​

先把模型和应用的职责分开:

text
用户问题
   ↓
应用把 messages 和 tools 发给模型
   ↓
模型返回 tool_calls ───────→ 应用校验并执行工具
                                  ↓
模型读取工具结果 ←──── 应用追加 role: "tool" 消息
   ↓
模型继续调用工具,或者给出最终回答

模型只会生成一份工具调用请求,并不会替应用运行 Python 函数、访问数据库或请求物流接口。真正的执行权始终在应用手里。

每一轮收到模型回复后,先看有没有 tool_calls:

  • 有 tool_calls,保存这条 assistant 消息,执行其中的调用,再把结果发回模型。
  • 没有 tool_calls,把模型的文本作为最终回答,结束循环。

一些 API 还会返回 finish_reason="tool_calls" 或 finish_reason="stop"。它可以帮助排查问题,但不同厂商、不同 API 的字段并不完全一样。循环的主判断应放在结构化工具调用是否存在,而不是只依赖 finish_reason。

2. 跟着订单查询跑三轮 ​

假设模型可以使用两个只读工具:

text
query_order(order_id)          查询订单状态和物流单号
query_logistics(tracking_no)   查询物流轨迹

第 1 轮:先找物流单号 ​

开始时,messages 里只有用户问题:

python
messages = [
    {"role": "user", "content": "订单 ORD-2026-001 到哪里了?"}
]

模型发现自己缺少订单信息,于是提出第一次调用:

json
{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    {
      "id": "call_order_1",
      "type": "function",
      "function": {
        "name": "query_order",
        "arguments": "{\"order_id\":\"ORD-2026-001\"}"
      }
    }
  ]
}

应用先保存这条 assistant 消息,再执行 query_order。工具返回:

json
{
  "role": "tool",
  "tool_call_id": "call_order_1",
  "content": "{\"status\":\"shipped\",\"tracking_no\":\"SF123456\"}"
}

tool_call_id 必须和上面的 id 一致。它像取餐号码:即使一轮发出了多个调用,模型也能知道每份结果对应哪张单。

第 2 轮:用上一步的结果继续查 ​

应用把刚才的 assistant 消息和 tool 消息都放进 messages,再次请求模型。模型从工具结果里读到 tracking_no,于是提出第二次调用:

json
{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    {
      "id": "call_logistics_1",
      "type": "function",
      "function": {
        "name": "query_logistics",
        "arguments": "{\"tracking_no\":\"SF123456\"}"
      }
    }
  ]
}

应用执行物流查询,并把结果按相同方式追加回去:

json
{
  "role": "tool",
  "tool_call_id": "call_logistics_1",
  "content": "{\"status\":\"in_transit\",\"latest_event\":\"杭州转运中心已发出\",\"updated_at\":\"2026-09-18T09:20:00+08:00\"}"
}

第 3 轮:没有工具调用,循环结束 ​

第三次请求时,模型已经拿到回答所需的信息,不再返回 tool_calls:

json
{
  "role": "assistant",
  "content": "订单已经发货,最新进度是“杭州转运中心已发出”,更新时间为今天 09:20。"
}

这才是一个真正的多轮循环:第二次工具调用依赖第一次的 Observation,而不是在同一轮里并行查两个互不相关的数据。

3. messages 为什么不能少一截 ​

跑完上面的例子,消息顺序大致如下:

顺序role保存的内容
1user用户的订单问题
2assistantquery_order 调用及其 id
3tool订单状态和物流单号
4assistantquery_logistics 调用及其 id
5tool最新物流轨迹
6assistant最终回答

assistant 的工具调用消息不能只留下函数名,也不能被 tool 结果替换。下一轮模型需要同时看到“我请求了什么”和“应用返回了什么”。缺少其中一条,轻则重复调用,重则被 API 判定为消息格式错误。

在最小示例里,messages 承担了主要的会话状态,但它不是生产 Agent 的全部状态。运行中的最大轮数、累计费用、用户权限、审批结果、取消信号和 checkpoint 通常由应用另外保存。长任务也不会让消息无限增长,而会裁剪旧消息、压缩上下文,或把大结果放到外部存储中。相关方法见记忆机制(Memory)和记忆压缩(Memory Compaction)。

4. 一步步搭出循环 ​

下面的代码使用 Chat Completions 风格字段演示结构。不同 SDK 的对象类型会有差异,先抓住每一步的职责。

第一步:只完成一次模型请求 ​

python
def ask_model(messages):
    response = client.chat.completions.create(
        model="your-model",
        messages=messages,
        tools=tools,
        tool_choice="auto",
    )
    return response.choices[0].message

这个函数只负责请求模型,不在里面访问订单库。保持边界简单,后面才容易分别记录模型耗时和工具耗时。

第二步:建立工具白名单 ​

不要用模型返回的字符串动态导入函数,也不要写 eval。明确列出允许调用的处理函数:

python
TOOL_HANDLERS = {
    "query_order": query_order,
    "query_logistics": query_logistics,
}


def execute_tool(name, arguments):
    handler = TOOL_HANDLERS.get(name)
    if handler is None:
        return {
            "ok": False,
            "error": {"type": "unknown_tool", "retryable": False},
        }

    validate_arguments(name, arguments)
    return {"ok": True, "data": handler(**arguments)}

validate_arguments 需要复用工具定义里的 schema,并继续检查订单归属、字段范围和登录用户权限。具体写法见 Function Calling。工具白名单只能防止调用未知函数,不能代替业务鉴权。

第三步:把请求、执行和回传连起来 ​

python
import json


def run_tool_loop(user_text, max_turns=6):
    messages = [{"role": "user", "content": user_text}]

    for _ in range(max_turns):
        assistant = ask_model(messages)
        messages.append(assistant)

        if not assistant.tool_calls:
            return assistant.content

        for call in assistant.tool_calls:
            try:
                arguments = json.loads(call.function.arguments)
                result = execute_tool(call.function.name, arguments)
            except json.JSONDecodeError:
                result = {
                    "ok": False,
                    "error": {"type": "invalid_json", "retryable": True},
                }
            except TimeoutError:
                result = {
                    "ok": False,
                    "error": {"type": "timeout", "retryable": True},
                }
            except Exception:
                logger.exception("tool execution failed")
                result = {
                    "ok": False,
                    "error": {"type": "internal_error", "retryable": False},
                }

            messages.append({
                "role": "tool",
                "tool_call_id": call.id,
                "content": json.dumps(result, ensure_ascii=False),
            })

    return "任务执行轮数已达到上限,暂时无法继续。"

先顺着代码走一遍:模型返回调用,应用保存 assistant 消息,逐个执行工具,把每个结果带着原来的 tool_call_id 追加进去,然后自然进入下一轮。如果模型直接回答,assistant.tool_calls 为空,函数就在这里返回。

示例捕获了通用异常,但没有把 str(exc) 交给模型。详细异常应写入受控日志;模型只拿到稳定、脱敏、可判断是否重试的错误类型。否则内部路径、SQL、主机名甚至第三方响应中的敏感信息都可能进入上下文。

5. 一轮有多个调用时怎么办 ​

模型可能在同一轮返回多个 tool_calls。是否并发执行,要看它们之间有没有依赖和副作用:

  • “同时查北京和上海天气”是两个独立只读请求,可以并发。
  • “先查订单,再用物流单号查物流”存在数据依赖,必须分成两轮。
  • “发邮件、扣款、创建工单”会改变外部状态。即使调用互不依赖,也应由服务端决定顺序、审批和幂等策略,不能看到多个调用就全部并发。

并发完成后,仍要为每个结果保留正确的 tool_call_id。返回顺序可以按 SDK 协议处理,但调用和结果绝不能错配。

6. 让循环知道什么时候停 ​

max_turns 是最后一道保险,还需要根据业务增加几类限制:

保护解决的问题
重复调用检测相同工具和参数反复出现,结果却没有变化
工具次数与费用预算循环没有卡死,但调用成本已经超出任务价值
总超时和取消信号用户取消或请求已经超过响应时限
结果完整性检查模型证据不足却提前生成最终答案
写操作审批和幂等键重试导致重复扣款、重复发送或重复创建记录

重复调用不能只比较原始 JSON 字符串,因为字段顺序可能不同。可以先把参数解析并规范化,再用“工具名 + 规范化参数”生成指纹。只读查询偶尔重试没有问题;写操作在超时后应先查询业务状态,不能直接再执行一次。

循环停止时也要区分“任务完成”和“受限制中止”。达到轮数上限、预算耗尽或被用户取消,都不应该伪装成成功答案。

7. Tool Loop、Function Calling 和 ReAct ​

这三个词经常出现在同一套 Agent 代码中,但它们解决的不是同一件事:

概念所在层次负责什么
Function Calling接口格式模型怎样提出结构化工具请求
Tool Loop应用运行时怎样请求模型、执行工具、回传结果并重复
ReAct模型决策模式怎样根据 Observation 选择下一步 Action

可以把 Tool Loop 看成正在运转的循环,ReAct 是循环中的一种决策节奏。上面的订单案例具有 ReAct 特征:模型看到订单工具返回的物流单号后,才决定查询物流。

但 Tool Loop 不一定使用 ReAct。固定 Workflow 可以由代码指定下一步工具;Planner 可以先生成计划,再让执行器逐项调用;规则路由也可以在已知条件下直接选工具。反过来,现代 ReAct 通常要落在 Tool Loop 上,才能把 Action 真正执行并把 Observation 送回模型。更完整的区别见 ReAct 模式。

持续学习,持续实践。