在做 AI Agent 开发时,很多教程只讲到 LLM 输出
tool_call就戛然而止,真正关键的「如何执行工具并回传结果」却被一笔带过 。本文将补齐这块拼图,重点讲清楚call_id在多轮工具调用中的匹配机制。
一、ReAct 的本质是什么?
ReAct(Reasoning + Acting)不是某种特定算法,而是一种让大模型具备「动手能力的交互范式」。
其核心思想很简单:
- Thought(思考):LLM 分析用户意图,判断自己能否直接回答
- Action(行动):如果需要外部数据,就生成结构化的工具调用请求
- Observation(观察):工具执行完成后,把结果喂回给 LLM
- Answer(回答):LLM 基于观察结果给出最终答案
这个过程会循环往复,直到 LLM 认为不需要再调用工具为止。
二、完整的 ReAct 交互流程
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 用户输入 │────▶│ LLM 决策 │────▶│ tool_call? │
└─────────────┘ └─────────────┘ └──────┬──────┘
│
┌───────────────────────────┘
▼ 否
┌─────────────┐
│ 直接回答用户 │
└─────────────┘
▼ 是
┌─────────────┐
│ 解析工具调用 │
│ call_id + │
│ func + args │
└──────┬──────┘
▼
┌─────────────┐
│ 执行工具函数 │
└──────┬──────┘
▼
┌─────────────┐
│ 封装执行结果 │
│ 带上 call_id │
└──────┬──────┘
▼
┌─────────────┐
│ 回传给 LLM │
└──────┬──────┘
▼
┌─────────────┐
│ LLM 再次决策 │
│ 还有 tool_call?│
└──────┬──────┘
└─────────────────────┐
│
◀────────────────────┘
关键点 :LLM 和工具之间不是"一问一答"就结束了,而是一个可能多轮的循环 。每轮循环中,LLM 可能发出多个 tool_call。
三、代码实现:补齐那块缺失的拼图
以下是一个完整可运行的 ReAct Agent 实现,重点标注了 tool execution 环节:
python
import json
from typing import List, Dict, Any, Callable
class Tool:
"""工具基类"""
def __init__(self, name: str, description: str, func: Callable):
self.name = name
self.description = description
self.func = func
def execute(self, **kwargs) -> Any:
return self.func(**kwargs)
class ReActAgent:
def __init__(self, llm, tools: List[Tool], system_prompt: str):
"""
初始化 ReAct Agent
Args:
llm: 大模型接口,需实现 chat(messages) 方法
tools: 可用工具列表
system_prompt: 系统级提示词,定义 Agent 角色和工具使用规范
"""
self.llm = llm
self.tools = {tool.name: tool for tool in tools} # 工具注册表
self.system_prompt = system_prompt
self.messages = [{"role": "system", "content": system_prompt}]
def run(self, user_input: str) -> str:
"""
执行 ReAct 循环
Args:
user_input: 用户输入
Returns:
最终回答
"""
# ========== 第1步:接收用户输入 ==========
self.messages.append({"role": "user", "content": user_input})
# ========== 第2步:LLM 决策(是否需要调用工具)==========
response = self.llm.chat(self.messages)
self.messages.append(response)
# 提取 tool_calls(不同模型格式可能略有差异)
tool_calls = response.get("tool_calls", [])
# ========== 第3步:ReAct 循环 ==========
while tool_calls:
# 遍历本轮的所有工具调用
for tool_call in tool_calls:
# --- 解析 tool_call ---
call_id = tool_call["id"] # 唯一标识
func_name = tool_call["function"]["name"] # 函数名
arguments = json.loads(tool_call["function"]["arguments"]) # 参数
print(f"[Tool Call] ID={call_id}, Function={func_name}, Args={arguments}")
# ========== 第4步:执行工具(最容易被遗漏的部分!)==========
if func_name in self.tools:
tool = self.tools[func_name]
try:
result = tool.execute(**arguments) # 实际执行工具函数
result_content = json.dumps(result, ensure_ascii=False, default=str)
except Exception as e:
result_content = json.dumps({"error": str(e)}, ensure_ascii=False)
else:
result_content = json.dumps(
{"error": f"工具 '{func_name}' 未找到"},
ensure_ascii=False
)
# ========== 第5步:回传结果(必须带上 call_id!)==========
self.messages.append({
"role": "tool",
"tool_call_id": call_id, # ← 核心!让 LLM 知道这是哪个调用的结果
"content": result_content
})
# ========== 第6步:LLM 再次决策(开始新一轮循环)==========
response = self.llm.chat(self.messages)
self.messages.append(response)
tool_calls = response.get("tool_calls", [])
# ========== 第7步:没有 tool_calls,返回最终答案 ==========
return response.get("content", "")
四、为什么 call_id 是必须的?
这是很多初学者容易忽略的点。让我用一个具体场景说明:
场景:LLM 同时调用多个工具
用户问:"北京今天天气怎么样?上证指数多少点?"
LLM 分析后,一次输出两个 tool_call:
json
{
"tool_calls": [
{
"id": "call_001", // 第一个调用的 ID
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"北京\"}"
}
},
{
"id": "call_002", // 第二个调用的 ID
"function": {
"name": "get_stock_index",
"arguments": "{\"index\": \"上证指数\"}"
}
}
]
}
两个工具并行执行后,返回结果:
python
# 工具执行结果
weather_result = {"temperature": "25°C", "condition": "晴"}
stock_result = {"index": "3087.53", "change": "+0.32%"}
如果没有 call_id,回传结果会变成这样:
python
# ❌ 错误示例:LLM 无法知道哪个结果对应哪个调用
messages.append({"role": "tool", "content": str(weather_result)})
messages.append({"role": "tool", "content": str(stock_result)})
LLM 看到两条 role: "tool" 的消息,但无法区分哪个是天气、哪个是股票,回答时就容易"张冠李戴"。
正确的做法:
python
# ✅ 正确示例:每个结果都带上对应的 call_id
messages.append({
"role": "tool",
"tool_call_id": "call_001", # ← 对应 get_weather
"content": str(weather_result)
})
messages.append({
"role": "tool",
"tool_call_id": "call_002", # ← 对应 get_stock_index
"content": str(stock_result)
})
这样 LLM 就能精确匹配 ,知道 call_001 的结果是天气,call_002 的结果是股票。
五、完整示例:跑一个实际的 Agent
python
# 定义工具函数
def get_weather(city: str) -> dict:
"""模拟天气查询"""
mock_data = {
"北京": {"temp": "25°C", "weather": "晴"},
"上海": {"temp": "28°C", "weather": "多云"},
}
return mock_data.get(city, {"error": "未知城市"})
def calculate(expression: str) -> str:
"""计算表达式"""
try:
return str(eval(expression)) # 生产环境不要用 eval!
except:
return "计算错误"
# 创建工具实例
tools = [
Tool("get_weather", "查询指定城市的天气", get_weather),
Tool("calculate", "计算数学表达式", calculate)
]
# 系统提示词(关键!要告诉 LLM 有哪些工具、怎么用)
system_prompt = """你是一个智能助手,可以使用以下工具:
1. get_weather(city: str) - 查询城市天气
2. calculate(expression: str) - 计算数学表达式
如果需要使用工具,请输出 tool_call。如果可以直接回答,直接回答即可。"""
# 初始化 Agent(假设 llm 已实例化)
agent = ReActAgent(llm=your_llm_client, tools=tools, system_prompt=system_prompt)
# 运行
result = agent.run("北京今天多少度?顺便算一下 15 * 23")
print(result)
六、常见坑点汇总
| 坑点 | 现象 | 解决方案 |
|---|---|---|
忘记传 tool_call_id |
LLM 回答混乱,把工具 A 的结果当成工具 B 的 | 回传时务必带上 tool_call_id |
| 工具执行异常未捕获 | 整个 Agent 崩溃 | 用 try-except 包裹工具执行 |
arguments 未解析 |
报错说参数格式不对 | 用 json.loads() 解析 |
| 死循环 | LLM 反复调用同一个工具 | 设置最大轮次限制,或检查 context 是否有效更新 |
| 工具未注册 | 提示找不到工具 | 确保 tools 字典中包含了所有可用工具 |
七、总结
ReAct 的核心是一个**"决策 → 执行 → 观察 → 再决策"**的循环。
Tool Execution 是循环中最容易被忽略但最关键的环节。它不仅仅是"跑一个函数",还涉及到:
- 解析 :从
tool_call中提取call_id、function.name、arguments - 分发:根据函数名找到对应的工具并执行
- 回传 :把执行结果包装成
role: "tool"的消息,必须带上tool_call_id - 匹配 :LLM 通过
call_id将结果与请求一一对应,确保多工具调用时不混乱
理解了这个闭环,你就真正掌握了 ReAct Agent 的工作原理。
写作初衷 :做 Agent 开发时踩了不少坑,尤其是多工具并行调用时 LLM 回答混乱的问题,追溯到根源就是
call_id没传对。希望这篇文章能帮到你。如果对你有帮助,欢迎点赞收藏,有问题评论区见!
参考
- ReAct: Synergizing Reasoning and Acting in Language Models (Yao et al., 2022)
- OpenAI Function Calling 官方文档
- LangChain Agent 源码实现