AI Agent 开发实战:从 ReAct 到工具调用
Agent 不是更智能的聊天机器人,而是一个能自主规划、反复尝试、使用工具完成多步任务的执行系统。理解 Agent 的核心循环和四个关键能力,是让它在生产环境可控运行的前提。
先想清楚:这个任务到底要不要上 Agent
见过不少团队一上来就写 ReAct 循环去做”从一段文本里提取三个字段”这种任务,结果模型偶尔多绕两圈去调用一个根本用不上的工具,延迟涨了、账单也涨了,输出还不如写死的流程稳。判断标准很简单:任务的步骤如果是固定的、你自己都能提前写出执行顺序,就别用 Agent,直接写一个函数链(先调 A、拿到结果传给 B、再传给 C)。这样跑得快、花钱少、结果可复现,出了问题也好定位是哪一步错了。
真正值得上 Agent 循环的场景,是步骤本身依赖中途结果——比如”先查一下这个店铺的评分,如果低于某个阈值再去查投诉记录,否则直接给结论”,下一步调用哪个工具、要不要再查一轮,只有模型看到上一步的 Observation 之后才能决定。这种”动态分支”是 Agent 循环存在的价值,而不是让它去干本来一行代码 if/else 就能解决的事。记住这个判断依据,后面看到 ReAct 循环时会更清楚它到底在解决什么问题。
ReAct 循环:Agent 的心跳
ReAct(Reasoning + Acting)是目前最主流的 Agent 架构:
输入任务
│
▼
Thought(思考):分析当前状态,决定下一步行动
│
▼
Action(行动):调用工具或直接输出
│
▼
Observation(观察):接收工具返回结果
│
▼
Thought(思考):基于观察,继续或结束
│
└─ 循环,直到任务完成或达到最大步数
│
▼
Final Answer(最终回答)
每次循环都会把完整的 Thought/Action/Observation 历史拼回 prompt,模型”看着自己的轨迹”做出下一步决策。这里有个容易被忽略的代价:第 5 轮的请求会带上前 4 轮的全部历史,token 消耗是累加的,不是每轮独立计费。跑一个 8 步的任务,实际花的 token 大致是”单步平均长度 × 步数的平方级增长”而不是线性——这也是后面 max_steps 和记忆压缩两个机制都要专门处理的原因,不是防呆,是真的会把账单跑起来。
工具调用(Tool Calling / Function Calling)
工具是 Agent 的手脚。OpenAI 兼容接口的 Function Calling 是标准做法:
tools = [
{
"type": "function",
"function": {
"name": "search_web",
"description": "搜索互联网获取最新信息。适合查找实时数据、新闻、价格等。",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "搜索关键词"}
},
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "run_python",
"description": "执行 Python 代码并返回输出。适合数据计算、格式转换。",
"parameters": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "要执行的 Python 代码"}
},
"required": ["code"]
}
}
}
]
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
tool_choice="auto" # 让模型自主决定是否调用工具
)
工具描述(description)是关键:写清楚”什么时候用”比”怎么用”更重要,模型靠描述决定是否选用该工具。
tool_choice 的三种模式,别只会用 auto
tool_choice 不是只有 "auto" 一种写法,实际有三种行为完全不同的用法:
| 取值 | 行为 | 什么时候用 |
|---|---|---|
"auto" | 模型自行判断要不要调用、调用哪个 | 常规场景,让模型自主决策 |
"required"(或旧接口的 "any") | 强制这一步必须调用某个工具,不许直接回文本 | 你确定这一步一定要走工具,比如强制先做一次检索 |
{"type": "function", "function": {"name": "xxx"}} | 强制调用指定的那一个工具 | 调试阶段验证某个工具本身有没有问题,排除模型选择的干扰 |
调试的时候遇到”模型死活不调用某个工具”,第一步不是去改 prompt 措辞,而是先用 {"type":"function","function":{"name":"xxx"}} 把选择权收回来,强制它调用一次,看工具本身返回是否正常。如果强制调用能跑通,说明问题出在”选择”这一步,多半是工具描述写得含糊,或者和另一个工具的功能描述有重叠——比如 search_web 和 search_docs 两个描述都写”搜索相关信息”,模型经常会选错,这时候把描述改具体(“搜索互联网上的实时信息,不包括本地文档”)比反复调参数管用得多。
工具调用循环实现
def run_agent(user_input: str, max_steps: int = 10) -> str:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_input}
]
for step in range(max_steps):
response = client.chat.completions.create(
model="gpt-4o", messages=messages, tools=tools
)
msg = response.choices[0].message
# 没有工具调用 → 任务完成
if not msg.tool_calls:
return msg.content
# 有工具调用 → 执行工具,把结果追加到 messages
messages.append(msg)
for tool_call in msg.tool_calls:
result = execute_tool(tool_call.function.name,
tool_call.function.arguments)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": str(result)
})
return "已达到最大步数,任务未完成"
这段代码跑起来之后,你应该在控制台或者日志里看到清晰的一问一答节奏:assistant 消息带 tool_calls,紧接着一条 role: tool 的消息塞进去,然后又发起一次请求——如果你打印 messages 发现这个规律没出现,说明中间某一步没把结果正确追加回去,模型下一轮拿到的历史是不完整的,接下来的推理基本会跑偏。
三个真实会踩到的坑
坑一:tool_call.function.arguments 不是合法 JSON。 多数时候模型给的参数是规整的 JSON 字符串,但在没开 strict mode、或者参数结构比较复杂(嵌套数组、长文本里带特殊字符)的情况下,偶尔会拿到一个解析不了的字符串,json.loads 直接抛 json.JSONDecodeError。上面 execute_tool 里那个 try/except 就是专门兜这个的——注意异常信息一定要把 type(e).__name__ 和内容都返回给模型,让它看到”参数格式错了”这个具体反馈,它大概率会在下一轮重新组织一次参数再调用,而不是卡死。
坑二:模型陷入死循环,反复用几乎相同的参数调用同一个工具。 这种情况在工具报错但错误信息不够明确时最常见,模型理解不了到底哪里错了,只会换一两个字重试。解决办法是自己加一层重复检测,而不是指望模型自己发现:
from collections import deque
recent_calls = deque(maxlen=3)
def check_repeat(name: str, args: str) -> bool:
key = (name, args)
recent_calls.append(key)
return list(recent_calls).count(key) >= 3 # 连续 3 次完全相同的调用
在 run_agent 的循环里,执行工具前先过一遍 check_repeat,命中了就不再执行,直接往 messages 里塞一条”你已经用相同参数连续调用 3 次,请换一种思路或直接结束任务”的提示,逼它跳出这个死胡同。
坑三:底层模型 API 本身限流或超时。 报错通常长这样:RateLimitError: Error code: 429 - {'error': {'message': 'Rate limit reached for gpt-4o in organization ...'}},或者纯粹是请求超时。这类错误不该原样透传给用户,也不该直接让整个任务失败,standard 做法是指数退避重试几次(比如等 1s、2s、4s 各重试一次),仍然失败再把”服务暂时繁忙”这种提示交给上层,而不是把一串堆栈甩给用户。
四个核心能力
1. 记忆(Memory)
| 类型 | 实现方式 | 适用 |
|---|---|---|
| 短期记忆 | messages 列表(滚动窗口) | 当前对话上下文 |
| 长期记忆 | 向量数据库存储历史摘要 | 跨会话用户偏好 |
| 工作记忆 | Scratch Pad(临时变量存在 prompt) | 复杂任务中间状态 |
| 外部存储 | 数据库/文件系统 | 持久化结构化数据 |
长上下文陷阱:对话轮次一多,messages 越来越长,成本和延迟跟着涨。建议超过 10 轮后做摘要压缩(Summarization)。
具体怎么压缩,别等到”快满了”才动手。多数主流模型的上下文窗口在几万到十几万 token 量级(具体数值以各家官方文档为准),如果等用到接近上限才处理,直接的后果是下一次请求报 context_length_exceeded,而这时候用户当前这句话已经发不出去了——体验上就是 Agent 突然”失忆”或者直接报错。更稳的做法是设一个提前量,比如 token 用量达到窗口的一半左右,就把”最近 N 轮”之外的历史摘要成一段话替换掉旧的 messages,只保留摘要 + 最近几轮的原文。摘要本身也调一次模型(用便宜的小模型做就够,这一步不需要强推理),成本比继续背着整个历史便宜得多。
工作记忆和长期记忆容易被搞混:工作记忆是这一次任务执行过程中的临时状态(比如”已经查过哪些工具、拿到了哪些中间结果”),任务结束就该丢;长期记忆是跨会话该留下来的东西(用户的偏好、历史决策),得单独存到向量库或者结构化的数据库里,不能和当前对话的 messages 混在一起,不然每次都要把所有历史用户偏好塞进 prompt,既贵又没必要。
2. 规划(Planning)
对于复杂任务,单次 ReAct 循环不够,需要显式规划:
- Plan-and-Execute:先让模型生成完整步骤列表,再逐步执行,适合任务结构固定的场景。
- Tree of Thought:生成多条规划路径,选择最优,适合有明确评估标准的问题。
- 反思(Reflexion):执行后评估结果,若失败则总结原因重试,适合有可验证输出的任务。
这三种怎么选,看下面这张表更直观:
| 模式 | 决策时机 | 适合什么任务 | 成本特点 |
|---|---|---|---|
| ReAct | 每一步都临时决定下一步 | 步骤不固定、依赖中途结果的探索型任务 | 步数不可预估,容易失控 |
| Plan-and-Execute | 一次性提前规划好全部步骤 | 步骤结构固定、只是参数不同的任务 | 规划阶段一次调用,执行阶段可以并行,成本更可控 |
| Reflexion | 执行完之后再评估一次 | 有明确对错标准、允许失败重试的任务(比如代码能不能跑通、格式对不对) | 比单纯 ReAct 多一轮评估调用,但能显著减少”看起来做完了其实错了”的情况 |
实际项目里这三种经常是组合用的:先用 Plan-and-Execute 把大任务拆成几个固定阶段,每个阶段内部再用 ReAct 处理该阶段里不确定的部分,最后对关键产出跑一次 Reflexion 校验,而不是三选一。
3. 工具管理
生产环境工具不宜过多(建议 ≤15 个),否则模型选择困难。分组策略:
- 按任务类型分 Agent(信息检索 Agent、代码执行 Agent),主 Agent 负责路由。
- 用 RAG 动态加载工具描述(而非把所有工具塞进 prompt)。
工具一多,模型选错的概率是肉眼可见往上走的,不是玄学。原因也简单:每个工具的 name + description + parameters schema 都要塞进 prompt,工具越多,模型要在越长的一段文字里做”选择题”,尤其是几个工具功能有重叠的时候,选错的概率明显更高。实操上除了前面说的分组路由,还有个更省事的办法:给工具描述加一个类别前缀,比如把 search_web、search_docs 写成”【网络检索】搜索互联网……”、“【内部知识库】搜索本地文档……”,让模型先按类别缩小范围,比单纯堆砌描述文字更容易选对。
4. 错误恢复
工具调用会失败,需要设计恢复机制:
def execute_tool(name: str, args: str) -> str:
try:
result = TOOL_MAP[name](**json.loads(args))
return json.dumps(result, ensure_ascii=False)
except Exception as e:
# 把错误信息返回给模型,让它自行决定重试或换方案
return f"工具执行失败: {type(e).__name__}: {str(e)}"
把错误信息返回给模型(而非直接抛出异常),允许模型自行纠错,是让 Agent 更健壮的关键技巧。
生产落地注意事项
| 问题 | 应对方案 |
|---|---|
| 无限循环 | 设 max_steps 硬上限,超限返回中间结果 |
| 工具调用幻觉(调用不存在的工具) | 严格校验 tool name,匹配失败返回错误 |
| 敏感操作(删除/支付) | 加”人工确认”步骤,Agent 提交,人审后执行 |
| 成本飙升 | 监控每次任务的总 token 消耗,设预算上限 |
| 调试困难 | 记录完整 Thought/Action/Observation 轨迹,用 LangSmith/Phoenix 可视化 |
| 工具返回结果过长 | 数据库查询、网页抓取这类工具容易一次返回几千字的原始内容,直接塞回 messages 会迅速把上下文吃满,应该在工具内部就做截断或摘要,只把关键字段返回给模型 |
| 多 Agent 之间互相调用形成死锁 | 主 Agent 路由到子 Agent 时,子 Agent 又反过来调用主 Agent 的场景要单独设”调用深度”上限,超过就强制返回,不要只靠单个 Agent 的 max_steps 兜底 |
常见问题
Agent 用什么模型最合适? 工具调用能力强的模型效果好:gpt-4o、claude-3.5-sonnet、deepseek-v3 均表现不错。对延迟敏感的简单任务可以试 gpt-4o-mini,复杂推理任务建议用更强的模型。
如何防止 Agent 做”危险操作”? 原则是”最小权限”:只给 Agent 完成任务所需的最小工具集;对不可逆操作(删除、发送邮件、支付)单独设计”确认工具”,要求模型先调用确认接口,收到人工授权信号才执行。
Agent 和 RAG 可以结合吗? 完全可以,且是常见架构:把向量检索封装成一个工具(search_knowledge_base),Agent 在需要查资料时自动调用。这比把所有文档塞进上下文更灵活,也更省 token。
除了限制工具数量,还有什么办法能把 Agent 的成本压下来? 一个常被忽略的手段是”级联”(cascade):不是每一步都用最贵的模型,而是先用一个便宜、快的小模型判断”这一步是否需要调用工具、需不需要复杂推理”,只有真正判断需要深入处理时才切到 gpt-4o 这类更强的模型。另外 max_steps 也别图省事全都设成一个统一的大数字——一个”提取字段”类的简单任务给 3-5 步的上限就够,给到 10 步只是让它在出错时多花几倍的钱才报出”任务未完成”。对高频重复出现的查询(比如同一个工具、同样的参数一天被调用几十次),加一层结果缓存也很值当,不用每次都真正打一次外部 API。
流式输出和工具调用能一起用吗?
能,但拼接方式要注意。开流式之后,模型决定调用工具时不是一次性给出完整的 tool_calls,而是分成多个 chunk 增量下发(delta.tool_calls 里 name 和 arguments 都是一点点拼出来的),你必须等这一轮流式结束、把所有分片拼接成完整字符串之后再交给 json.loads 解析。如果在流没结束的时候就拿一个不完整的片段去解析,会直接报 json.decoder.JSONDecodeError——这个坑很多人第一次接流式工具调用都会踩一次,原因不是模型出错,是自己的拼接逻辑提前了。
← 返回 应用模式总览:从 Prompt 到 Agent | 应用模式专题
相关阅读:RAG 怎么做:架构与落地 · 提示工程实用技巧
多工具 Agent 需要调用多家模型?力达云聚合 API 统一接口路由,无缝切换 GPT-4o / Claude / DeepSeek,降低供应商锁定风险。