工具调用循环(Tool Loop)—— Agent 的心跳
用户问:“订单 ORD-2026-001 到哪里了?”模型现在只有订单号,查不到物流。它得先调用订单工具拿到物流单号,再用物流单号查询运输进度,最后才能回答。
这里有两个前后依赖的工具调用。第二步需要第一步的结果,无法在第一次请求中提前完成。Tool Loop 要做的,就是让“模型提出调用、应用执行、模型读取结果”这组动作重复运行,直到模型不再需要工具。
1. 循环中谁负责什么
先把模型和应用的职责分开:
用户问题
↓
应用把 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. 跟着订单查询跑三轮
假设模型可以使用两个只读工具:
query_order(order_id) 查询订单状态和物流单号
query_logistics(tracking_no) 查询物流轨迹第 1 轮:先找物流单号
开始时,messages 里只有用户问题:
messages = [
{"role": "user", "content": "订单 ORD-2026-001 到哪里了?"}
]模型发现自己缺少订单信息,于是提出第一次调用:
{
"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。工具返回:
{
"role": "tool",
"tool_call_id": "call_order_1",
"content": "{\"status\":\"shipped\",\"tracking_no\":\"SF123456\"}"
}tool_call_id 必须和上面的 id 一致。它像取餐号码:即使一轮发出了多个调用,模型也能知道每份结果对应哪张单。
第 2 轮:用上一步的结果继续查
应用把刚才的 assistant 消息和 tool 消息都放进 messages,再次请求模型。模型从工具结果里读到 tracking_no,于是提出第二次调用:
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_logistics_1",
"type": "function",
"function": {
"name": "query_logistics",
"arguments": "{\"tracking_no\":\"SF123456\"}"
}
}
]
}应用执行物流查询,并把结果按相同方式追加回去:
{
"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:
{
"role": "assistant",
"content": "订单已经发货,最新进度是“杭州转运中心已发出”,更新时间为今天 09:20。"
}这才是一个真正的多轮循环:第二次工具调用依赖第一次的 Observation,而不是在同一轮里并行查两个互不相关的数据。
3. messages 为什么不能少一截
跑完上面的例子,消息顺序大致如下:
| 顺序 | role | 保存的内容 |
|---|---|---|
| 1 | user | 用户的订单问题 |
| 2 | assistant | query_order 调用及其 id |
| 3 | tool | 订单状态和物流单号 |
| 4 | assistant | query_logistics 调用及其 id |
| 5 | tool | 最新物流轨迹 |
| 6 | assistant | 最终回答 |
assistant 的工具调用消息不能只留下函数名,也不能被 tool 结果替换。下一轮模型需要同时看到“我请求了什么”和“应用返回了什么”。缺少其中一条,轻则重复调用,重则被 API 判定为消息格式错误。
在最小示例里,messages 承担了主要的会话状态,但它不是生产 Agent 的全部状态。运行中的最大轮数、累计费用、用户权限、审批结果、取消信号和 checkpoint 通常由应用另外保存。长任务也不会让消息无限增长,而会裁剪旧消息、压缩上下文,或把大结果放到外部存储中。相关方法见记忆机制(Memory)和记忆压缩(Memory Compaction)。
4. 一步步搭出循环
下面的代码使用 Chat Completions 风格字段演示结构。不同 SDK 的对象类型会有差异,先抓住每一步的职责。
第一步:只完成一次模型请求
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。明确列出允许调用的处理函数:
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。工具白名单只能防止调用未知函数,不能代替业务鉴权。
第三步:把请求、执行和回传连起来
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 模式。