1. 项目概述:从“单打独斗”到“团队协作”的智能体进化
如果你最近在折腾大语言模型应用,尤其是想让它不只是个“聊天高手”,而是能真正帮你干点实事——比如查查天气、订个餐、分析下数据,那你肯定绕不开两个词:ReAct和Function Calling。这俩不是什么新框架,而是两种让大模型(LLM)学会“动手”的核心范式。你可以把它们理解成给一个超级聪明但“四肢不勤”的大脑配上的两套不同的“手脚”和“工具使用说明书”。
简单来说,ReAct更像是一个“思考型助手”。它让模型在面对问题时,先“想一想”(Reason),再“动动手”(Act),调用外部工具(比如搜索引擎、计算器、数据库API)去获取信息,然后根据新信息继续思考,如此循环,直到解决问题。整个过程是透明的,你能看到它的思考链,就像看一个人写解题步骤。而Function Calling则更像一个“指令接收器”。你提前定义好一堆工具(函数),比如get_weather(city),然后直接问模型“北京天气怎么样?”。模型不会输出大段思考,而是直接返回一个结构化的调用请求:{“name”: “get_weather”, “arguments”: {“city”: “北京”}}。你的程序拿到这个请求,就去执行真正的函数,并把结果返回给用户或模型进行下一步处理。
为什么这很重要?因为纯聊天模型的知识是静态的、可能过时的,也没有执行能力。ReAct和Function Calling打破了这堵墙,让LLM能够与实时、动态的外部世界互动,成为真正能处理复杂任务的智能体(Agent)。当前火热的AI Agent开发,无论是基于LangChain、LangGraph还是其他框架,其核心的“工具调用”能力,都建立在这两种范式之上。理解它们,就是理解了智能体为何能“自主”工作的第一性原理。
2. 核心范式深度对比:ReAct vs. Function Calling
虽然目标一致——让LLM使用工具,但ReAct和Function Calling在实现哲学、流程和适用场景上有着根本性的不同。选择哪一种,往往决定了你Agent的“性格”和“能力边界”。
2.1 ReAct范式:深思熟虑的“规划-执行”循环
ReAct的核心思想是“链式思考与行动”。它要求模型将推理(Reasoning)和行动(Acting)交织在一起。
2.1.1 工作流程拆解
一个标准的ReAct循环通常如下:
- 思考(Thought):模型分析当前状态(包括用户问题、已有的观察结果),规划下一步该做什么。例如:“用户想知道珠穆朗玛峰的高度。我需要查找一个可靠的数据源,比如维基百科API。”
- 行动(Action):模型根据上一步的思考,决定调用哪个工具,并生成符合工具输入格式的调用指令。例如:
Search: 珠穆朗玛峰 海拔 - 观察(Observation):系统执行工具调用(如执行搜索),并将返回的结果(如“海拔8848.86米”)作为观察反馈给模型。
- 循环:模型基于新的观察,再次进入“思考”阶段,判断信息是否足够回答用户,若不够,则继续下一轮“行动”。最终,模型汇总所有观察,生成给用户的最终答案。
2.1.2 优势与适用场景
- 透明度高:完整的“Thought-Action-Observation”轨迹就像程序的日志,非常适合调试和解释Agent的决策过程。你可以清楚地看到它为什么失败(比如思考方向错了)。
- 动态规划能力强:由于每一步行动都基于上一步的观察和新的思考,ReAct Agent非常适合处理需要多步、非线性规划的复杂任务。例如,“帮我比较一下Python和Go在Web后端开发上的优劣,并给出学习建议。” 这种任务可能需要先搜索各自特点,再查找性能基准,最后结合社区活跃度综合判断,路径不是固定的。
- 对工具描述依赖相对较低:模型通过“思考”来理解何时使用工具,有时即使工具描述不那么精确,模型也能通过上下文推断。
2.1.3 劣势与挑战
- 延迟高:每一步都需要模型生成一次文本(Thought和Action),导致调用次数多、总体响应慢。
- 成本高:更多的LLM调用意味着更高的API费用。
- 稳定性挑战:模型生成的“Action”格式必须严格匹配工具定义的输入格式,否则解析会失败。需要精心设计提示词(Prompt)来约束输出格式,或使用输出解析器。
- 可能陷入循环:如果模型思考陷入死胡同,可能会在“思考-行动”中无限循环,需要设置最大步数限制。
2.2 Function Calling范式:精准高效的“函数调度”
Function Calling(或Tool Calling)采取了不同的策略。它把“思考”过程隐藏起来,让模型直接输出一个结构化的函数调用请求。
2.2.1 工作流程拆解
- 定义工具(Functions):开发者预先定义好一系列工具,每个工具都是一个函数,有明确的名称、描述和参数(参数类型、是否必需等)。例如:
{ “name”: “get_current_weather”, “description”: “获取指定城市的当前天气”, “parameters”: { “type”: “object”, “properties”: { “location”: { “type”: “string”, “description”: “城市名,如‘北京’” }, “unit”: { “type”: “string”, “enum”: [“celsius”, “fahrenheit”], “default”: “celsius” } }, “required”: [“location”] } } - 请求与响应:将用户查询和定义好的工具列表一起发送给LLM。LLM不会生成自然语言中间步骤,而是直接返回一个或多个结构化的函数调用请求。
- 用户输入:“旧金山今天天气如何?”
- LLM响应:
{“name”: “get_current_weather”, “arguments”: {“location”: “San Francisco”, “unit”: “celsius”}}
- 执行与回复:你的程序解析这个JSON,调用本地或远程的
get_current_weather函数,获取真实天气数据。然后,你可以选择:- 直接将结果返回给用户。
- 将结果作为新的上下文,再次调用LLM,让其生成一个友好、归纳性的回答(“旧金山今天晴,气温22摄氏度……”)。
2.2.2 优势与适用场景
- 高效、低延迟:一次LLM调用就能决定要使用的工具及其参数,减少了交互轮次,速度更快。
- 成本更低:调用次数少,自然费用更低。
- 稳定可靠:输出是结构化JSON,易于程序解析,几乎不会出现格式错误。主流API(如OpenAI, Anthropic)都原生支持。
- 适合明确、单一的任务:对于“查天气”、“订日历”、“计算器”这类目标明确、工具匹配直接的任务,它是最高效的选择。
2.2.3 劣势与挑战
- 黑盒性:你失去了模型内部的推理过程,只知道它决定调用哪个函数,但不知道“为什么”选这个。调试时,如果函数调用不合理,追溯原因更困难。
- 复杂任务规划能力弱:对于需要多个工具动态组合、且有复杂依赖关系的任务,单次Function Calling可能不够。虽然可以设计“规划函数”或依赖框架进行多轮,但其本质仍是“一次规划,多次执行”,灵活性不如ReAct的逐步推理。
- 严重依赖工具描述:模型完全依靠你提供的函数名称和描述来决定是否调用。描述不准确或不清晰,会导致模型无法正确匹配用户意图。
2.3 范式选择决策指南
如何选择?可以遵循这个简单的决策树:
- 任务是否步骤清晰、工具明确?如果是,优先考虑Function Calling。例如:数据查询、单位转换、发送邮件。
- 任务是否需要复杂推理、探索或动态规划?如果是,ReAct更合适。例如:研究分析、复杂问题排查、创意性任务规划。
- 是否需要完整的可解释性?如果需要审计或理解Agent的每一步决策,选ReAct。
- 是否对延迟和成本极度敏感?如果是,选Function Calling。
- 是否可以兼得?当然可以!现代Agent框架(如LangGraph)允许你混合使用。你可以用Function Calling快速处理标准化子任务,而在顶层的决策和规划层采用ReAct式的推理。这就像团队中既有高效执行指令的专员,也有负责整体策划的经理。
注意:这两种范式并非互斥。在实际的复杂Agent系统中,它们常常协同工作。例如,一个基于ReAct的Agent,其“Action”步骤中调用的具体工具,其接口可能就是通过Function Calling方式与LLM交互来确定的。
3. 核心实现与框架应用实战
理解了理论,我们来看看如何落地。这里不会只讲概念,我会结合主流框架,分享具体的实现套路和踩坑经验。
3.1 基于LangChain实现ReAct Agent
LangChain的Agent模块封装了ReAct范式。最经典的是使用create_react_agent函数。
3.1.1 基础搭建步骤
from langchain import hub from langchain.agents import create_react_agent, AgentExecutor from langchain_community.tools import Tool from langchain_openai import ChatOpenAI # 1. 定义工具 def search_api(query: str) -> str: # 模拟一个搜索工具 return f“根据查询‘{query}’找到的结果是:XXX。” search_tool = Tool( name=“Search”, func=search_api, description=“用于回答关于当前事件或特定信息的问题。输入应该是一个搜索查询词。” ) def calculator(expression: str) -> str: # 极度简化,实际应用请使用安全评估 try: return str(eval(expression)) except: return “计算错误” calc_tool = Tool( name=“Calculator”, func=calculator, description=“用于执行数学计算。输入是一个数学表达式,如‘3 * 5 + 2’。” ) tools = [search_tool, calc_tool] # 2. 获取ReAct提示词模板(LangChain Hub上有官方版本) prompt = hub.pull(“hwchase17/react”) # 3. 初始化LLM llm = ChatOpenAI(model=“gpt-4”, temperature=0) # 4. 创建Agent和Executor agent = create_react_agent(llm, tools, prompt) agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True, handle_parsing_errors=True) # 5. 运行 result = agent_executor.invoke({“input”: “特斯拉当前股价是多少?如果是100股,总价值多少美元?”}) print(result[“output”])3.1.2 关键配置与避坑指南
- 提示词(Prompt)是灵魂:
hwchase17/react这个模板经过了大量优化,定义了严格的Thought/Action/Action Input/Observation格式。不要轻易大幅修改其结构,否则容易破坏输出解析。 handle_parsing_errors=True:这个参数至关重要。当模型输出格式不符合预期时,它会尝试修复而不是直接崩溃。在实际生产中,你还需要更健壮的异常处理。verbose=True:调试时打开,可以像看“直播”一样看到Agent的思考链,对于理解其行为和调试Prompt无比有用。- 工具描述(Description)要精准:这是模型选择工具的主要依据。描述应清晰说明工具的用途、输入格式和输出示例。例如,“计算器”的描述比“进行数学运算”要好得多。
- 控制循环与超时:
AgentExecutor有max_iterations和max_execution_time参数,务必设置,防止无限循环或长时间运行。
3.2 利用原生API实现Function Calling
以OpenAI API为例,Function Calling是其原生特性,无需额外框架。
3.2.1 基础调用示例
import openai from typing import List, Dict, Any import json # 1. 定义函数(工具)列表 functions = [ { “name”: “get_current_weather”, “description”: “获取当前天气”, “parameters”: { “type”: “object”, “properties”: { “location”: {“type”: “string”, “description”: “城市或地区,如‘北京’或‘San Francisco, CA’”}, “unit”: {“type”: “string”, “enum”: [“celsius”, “fahrenheit”], “default”: “celsius”} }, “required”: [“location”] } }, { “name”: “currency_converter”, “description”: “货币转换”, “parameters”: { “type”: “object”, “properties”: { “amount”: {“type”: “number”, “description”: “金额”}, “from_currency”: {“type”: “string”, “description”: “源货币代码,如USD”}, “to_currency”: {“type”: “string”, “description”: “目标货币代码,如CNY”} }, “required”: [“amount”, “from_currency”, “to_currency”] } } ] # 2. 模拟的工具执行函数 def execute_function(function_name: str, arguments: Dict) -> str: if function_name == “get_current_weather”: # 模拟调用真实天气API return json.dumps({“location”: arguments[“location”], “temperature”: “22”, “unit”: arguments.get(“unit”, “celsius”), “condition”: “晴朗”}) elif function_name == “currency_converter”: # 模拟调用汇率API return json.dumps({“converted_amount”: arguments[“amount”] * 7.2, “currency”: arguments[“to_currency”]}) else: return json.dumps({“error”: “未知函数”}) # 3. 主循环 def run_conversation(user_input: str): messages = [{“role”: “user”, “content”: user_input}] # 第一轮调用,让模型决定是否调用函数、调用哪个 response = openai.chat.completions.create( model=“gpt-4”, messages=messages, functions=functions, function_call=“auto”, # “auto”让模型决定, “none”不调用, 或指定{“name”: “xxx”}强制调用 ) response_message = response.choices[0].message # 检查模型是否想调用函数 if response_message.function_call: # 4. 解析并执行函数 function_name = response_message.function_call.name function_args = json.loads(response_message.function_call.arguments) print(f“模型决定调用函数: {function_name}, 参数: {function_args}”) function_response = execute_function(function_name, function_args) # 5. 将函数执行结果作为新的上下文消息发送给模型,让它生成面向用户的回答 messages.append(response_message) # 添加模型的上一条消息(包含函数调用) messages.append({ “role”: “function”, “name”: function_name, “content”: function_response, }) second_response = openai.chat.completions.create( model=“gpt-4”, messages=messages, ) return second_response.choices[0].message.content else: # 模型认为无需调用函数,直接回答 return response_message.content # 测试 print(run_conversation(“波士顿现在多少度?”)) print(run_conversation(“100美元换成人民币是多少?”)) print(run_conversation(“讲个笑话”)) # 此问题无需调用函数3.2.2 实战技巧与深度解析
function_call: “auto”:这是最常用的设置。模型会根据对话上下文和函数描述,自主决定是否需要调用函数。你也可以强制指定某个函数({“name”: “get_weather”})或禁止调用(“none”)。- 函数描述是命门:
description和parameters里的description必须清晰、无歧义。模型完全依赖这些描述来做匹配。用自然语言描述清楚输入是什么、输出大概是什么。 - “function”角色消息:这是OpenAI定义的特定角色,用于向模型传递函数执行结果。必须在消息列表中准确添加,否则模型会丢失这部分关键上下文。
- 处理多个函数调用:一次响应中,模型可能会决定并行调用多个函数(
parallel_function_calls)。你的代码需要能处理一个响应中包含多个function_call对象的情况。 - 错误处理:函数执行可能失败(网络超时、参数无效)。最佳实践是将错误信息也通过
“function”角色消息返回给模型,让它有机会向用户解释或尝试其他方案,而不是直接向用户抛出一个技术错误。
3.3 进阶架构:使用LangGraph构建可控工作流
当你的Agent需要处理包含分支、循环、状态管理的复杂工作流时,基础的AgentExecutor可能显得力不从心。这时,LangGraph就派上用场了。它用“图”的概念来定义Agent的执行流程,节点(Node)是处理步骤,边(Edge)决定下一步走向。
3.3.1 为什么需要LangGraph?
想象一个客服Agent:用户说“我想订机票”。这个任务可能包含:1)询问目的地/时间(条件分支);2)查询航班(工具调用);3)询问舱位选择(分支);4)确认订单(工具调用);5)如果支付失败,重试或取消(循环)。用ReAct硬编码提示词来管理这个状态机非常痛苦。LangGraph让你能可视化、可编程地控制这个流程。
3.3.2 构建一个简单的决策Agent
from typing import TypedDict, Annotated, List from langgraph.graph import StateGraph, END from langchain_core.messages import HumanMessage from langchain_openai import ChatOpenAI import operator # 1. 定义状态(State)。这是在整个图中传递的数据结构。 class AgentState(TypedDict): messages: Annotated[List, operator.add] # 消息列表,会自动追加 needs_clarification: bool # 是否需要澄清 flight_info: dict # 存储查询到的航班信息 # 2. 定义各个节点(函数) def receive_input(state: AgentState): """接收用户输入""" # 这里可以从state[‘messages’]中取出最新用户消息 # 简单起见,我们假设输入已存在 print(“节点:收到用户输入”) return {“needs_clarification”: False} # 初始化状态 def decide_action(state: AgentState): """决策节点:判断是否需要澄清信息""" llm = ChatOpenAI(model=“gpt-4”, temperature=0) # 基于对话历史,让LLM判断信息是否完整 prompt = f“”” 当前对话: {state[‘messages’]} 用户想查询航班。请判断信息是否完整(需要目的地、出发地、时间)。 如果完整,回复‘proceed’;如果不完整,回复‘clarify’。 “”” decision = llm.invoke(prompt).content.strip().lower() if “clarify” in decision: return {“needs_clarification”: True} else: return {“needs_clarification”: False} def clarify_details(state: AgentState): """澄清节点:询问缺失信息""" llm = ChatOpenAI(model=“gpt-4”, temperature=0) # 生成一个澄清问题 clarification_msg = llm.invoke(“生成一个礼貌的问题,询问用户航班的目的地、出发地和日期。”).content # 将问题添加到消息历史 new_msg = HumanMessage(content=clarification_msg) return {“messages”: [new_msg]} def search_flights(state: AgentState): """搜索节点:调用航班搜索工具""" # 模拟工具调用 print(“节点:正在搜索航班...”) # 这里应解析state[‘messages’]中的关键信息,调用真实API flight_data = {“flight”: “CA1234”, “price”: 1500} # 模拟数据 return {“flight_info”: flight_data} def finalize_response(state: AgentState): """最终响应节点:整理信息并回复""" info = state.get(“flight_info”, {}) response = f“为您找到航班:{info.get(‘flight’, ‘N/A’)}, 价格{info.get(‘price’, ‘N/A’)}元。” # 将最终回复加入消息历史 final_msg = HumanMessage(content=response) return {“messages”: [final_msg]} # 3. 构建图 workflow = StateGraph(AgentState) # 添加节点 workflow.add_node(“receive”, receive_input) workflow.add_node(“decide”, decide_action) workflow.add_node(“clarify”, clarify_details) workflow.add_node(“search”, search_flights) workflow.add_node(“finalize”, finalize_response) # 设置入口点 workflow.set_entry_point(“receive”) # 添加边(定义流程逻辑) workflow.add_edge(“receive”, “decide”) # 根据决策节点的结果,决定下一步走向 workflow.add_conditional_edges( “decide”, # 这个函数根据state返回下一个节点名 lambda x: “clarify” if x[“needs_clarification”] else “search”, {“clarify”: “clarify”, “search”: “search”} ) workflow.add_edge(“clarify”, “decide”) # 澄清后,再次回到决策节点判断 workflow.add_edge(“search”, “finalize”) workflow.add_edge(“finalize”, END) # 结束 # 编译图 app = workflow.compile() # 4. 运行 initial_state = {“messages”: [HumanMessage(content=“我想订去上海的机票”)], “needs_clarification”: False, “flight_info”: {}} result = app.invoke(initial_state) print(“最终消息:”, result[“messages”][-1].content)3.3.3 LangGraph核心优势与心法
- 显式状态管理:所有数据都在
State对象中流动,清晰可控,避免了全局变量或复杂闭包。 - 灵活的路由控制:
add_conditional_edges让你能基于LLM的判断或业务逻辑轻松实现分支、循环,这是构建复杂Agent的核心。 - 可调试性与可观测性:图的每个节点都是独立的函数,易于单元测试。你可以记录每个节点的输入输出,对整个工作流进行监控和调试。
- 与ReAct/Function Calling结合:图中的任何一个节点,都可以是一个完整的ReAct Agent或一次Function Calling。例如,
search_flights节点内部可以封装一个使用Function Calling调用航班API的LLM交互。
实操心得:对于简单、线性的任务,直接用LangChain AgentExecutor更快捷。但当你的业务逻辑涉及“如果...那么...”、“重复直到...”时,尽早引入LangGraph这类工作流引擎。先在白板上画出状态图,再编码,会事半功倍。另外,将复杂的工具调用(如需要多步ReAct)封装成一个独立的“工具节点”,可以保持图的清晰度。
4. 避坑指南与性能优化实战
纸上得来终觉浅,绝知此事要躬行。下面这些坑,都是我趟过雷的。
4.1 提示词工程:不只是写描述
- 工具描述的黄金法则:为Function Calling定义工具时,描述要采用“动词开头+宾语+输入输出说明”的格式。例如:
- 差:
“天气工具” - 中:
“获取天气” - 优:
“获取指定城市当前的天气情况。输入参数‘location’应为城市名称字符串,例如‘北京’或‘New York’。返回该城市的温度、天气状况和湿度。”好的描述能极大提升模型匹配的准确率。
- 差:
- 为ReAct设定明确边界:在ReAct的Prompt中,一定要明确告诉模型可用的工具列表、每个工具的精确使用格式,以及最重要的停止条件。例如:“当你拥有足够信息给出最终答案时,你必须以 ‘Final Answer:‘ 开头输出答案,并停止。” 防止它无休止地思考下去。
- 少样本示例(Few-Shot)威力巨大:在Prompt中提供1-2个完整的、正确的ReAct轨迹示例(Thought/Action/Observation/Final Answer),对于引导模型遵循格式和理解任务意图,效果比任何抽象描述都好。
4.2 错误处理与稳定性
- 解析失败是常态:无论是ReAct的输出格式解析,还是Function Calling的JSON解析,都可能失败。必须在代码中包裹
try...except。对于ReAct,可以设置handle_parsing_errors=True并准备一个降级策略(例如,提示模型重试或转为简单对话)。 - 工具调用超时与降级:外部API可能失败。为每个工具调用设置超时(如
timeout=10s),并准备默认返回值或友好的错误信息,通过观察(Observation)或函数响应(Function Response)反馈给模型,让它决定下一步。 - 验证LLM输出:对于关键操作(如确认支付、删除数据),不要完全信任LLM的输出。应该在执行前,增加一层业务逻辑验证,或者采用“确认-执行”两步走策略(先让LLM生成待执行命令,经用户或系统确认后再执行)。
4.3 性能与成本优化
- 缓存是利器:对于频繁查询且结果变化不快的工具(如某些百科查询、汇率缓存),引入缓存机制(如Redis)。可以将“用户问题+工具参数”哈希后作为键,缓存工具返回结果。这能显著减少对外部API的调用和LLM的重复推理。
- 精简上下文:ReAct的每一步都会将完整的思考轨迹追加到上下文。随着步数增加,令牌数会爆炸。需要定期清理或总结过长的历史。对于Function Calling,也要注意每次传入的对话历史长度。
- 模型选型:对于工具调用中的“路由”或“规划”任务(决定用什么工具),使用速度快、成本低的模型(如
gpt-3.5-turbo)可能就足够了。对于需要深度推理或生成最终复杂答案的任务,再用更强大的模型(如gpt-4)。这种混合模型策略能有效平衡效果和成本。 - 设置预算与熔断:为Agent的运行设置最大迭代次数(
max_iterations)和最大令牌消耗预算。防止在极端情况下因逻辑错误导致无限循环,产生天价账单。
4.4 安全性与可靠性
- 工具权限隔离:不是所有工具都应该被所有问题触发。实现一个工具权限过滤器。根据用户身份、会话上下文或问题内容,动态过滤掉当前可用的工具列表。例如,普通用户不能调用“删除数据库”工具。
- 防范Prompt注入:用户输入可能包含恶意指令,试图让模型绕过你的系统提示词。对用户输入进行基本的清洗和检查,避免其直接拼接进系统指令的关键部分。在关键工具调用前,可以增加一个“安全审查”节点,用另一个LLM调用或规则引擎来判断该操作是否安全。
- 人工审核环(Human-in-the-loop):对于高风险操作(如发送邮件、生成重要内容),设计流程让Agent生成待执行动作后暂停,等待用户明确确认后再执行。这在LangGraph中很容易实现,只需增加一个等待用户输入的节点即可。
构建一个健壮、高效的Agent系统,远不止是拼接LLM和工具。它涉及到软件工程、提示词工程、运维监控的方方面面。从ReAct和Function Calling这两个核心范式出发,理解其优劣,并在合适的场景选用或混合它们,是你迈向高级Agent开发者的坚实第一步。记住,没有银弹,只有对问题和工具的深刻理解,才能设计出真正解决问题的智能体。