Week 2 -- Day 5:Agent 系统(下)— Function Calling 与 create_agent
Day 5:Agent 系统(下)— Function Calling 与 create_agent
在 Day 4 的学习中,我们理解了 Agent 系统最核心的概念,工具(Tool)和 ReAct 推理循环。今天我们要进一步深入两个在实际开发中至关重要的主题,底层的 Function Calling 机制,以及 LangChain v1 中最强大的 Agent 构建入口 create_agent 和它背后的中间件(Middleware)系统。如果把 Day 4 的内容比作理解了汽车发动机的工作原理,那么今天我们要学习的是整车的电控系统和底盘架构,它们让 Agent 从"能跑"进化为"稳定、可控、可扩展"。
Function Calling:模型与工具的通信协议
bind_tools() 是连接大语言模型和工具世界的桥梁。从表面上看它的用法非常简单,把用 @tool 装饰器标记过的函数列表传给 bind_tools(),模型就获得了一种新能力,在生成文字回复的间隙,它可以决定暂停文字输出,转而发出一个结构化的工具调用请求。但实际上它的工作机制值得仔细拆解,因为理解了这个机制,你才能真正掌握 Agent 的行为逻辑。
@tool 装饰器的职责是把一个普通的 Python 函数转换成模型可以理解的结构化接口。它做了三件事,把函数的类型标注(type hints)自动转换为 JSON Schema 格式的输入参数定义,把函数的 docstring 提取为工具的自然语言用途描述,以及生成一个全局唯一的工具名称(默认使用函数名)。这个 JSON Schema 和描述文档会在每次模型调用时被注入到 API 请求中,成为模型决定"要不要用工具、用哪个工具、传什么参数"的唯一依据。值得注意的是,工具名应该使用 snake_case 命名(如 search_database 而不是 Search Database),因为部分模型提供商会拒绝包含空格或特殊字符的函数名,依据官方文档的建议,"Prefer snake_case for tool names; some model providers have issues with or reject names containing spaces or special characters." 我们来看一个最基本的工具定义,它展示了 @tool 装饰器如何把一个带有类型标注和 docstring 的普通函数转化为模型可以理解并调用的结构化接口:
from langchain.tools import tool
@tool
def search_database(query: str, limit: int = 10) -> str:
"""搜索客户数据库中的匹配记录。
Args:
query: 搜索关键词
limit: 最大返回结果数
"""
return f"在数据库中找到了 {limit} 条与 '{query}' 相关的结果。"
这段代码虽然简短,但包含了模型决策所需的全部信息,query: str 和 limit: int 告诉模型需要哪些参数及其类型,docstring 用自然语言解释了工具的用途,返回值则会在工具执行后重新流回模型。如果你需要更精细的控制,比如为参数添加自然语言描述、设置枚举值约束或定义复杂的嵌套结构——可以通过 args_schema 参数传入一个 Pydantic 模型。模型看到的不再是简单的类型标注,而是包含详细 Field(description=...) 的完整 JSON Schema,这在高风险、多工具场景下能显著提升调用准确率。下面的例子中,我们为天气查询工具定义了一个 Pydantic 输入模型,其中 Field(description=...) 是模型决定如何填充参数的关键依据:
from pydantic import BaseModel, Field
from typing import Literal
class WeatherInput(BaseModel):
"""天气查询的输入参数。"""
location: str = Field(description="城市名称或经纬度坐标")
units: Literal["celsius", "fahrenheit"] = Field(
default="celsius",
description="温度单位偏好"
)
include_forecast: bool = Field(
default=False,
description="是否包含5天预报"
)
@tool(args_schema=WeatherInput)
def get_weather(location: str, units: str = "celsius", include_forecast: bool = False) -> str:
"""获取指定地点的当前天气和可选预报。"""
temp = 22 if units == "celsius" else 72
result = f"{location}当前天气:晴,{temp}°{'C' if units == 'celsius' else 'F'}"
if include_forecast:
result += "\n未来5天:晴间多云"
return result
在这个例子中,WeatherInput 里的 Literal["celsius", "fahrenheit"] 将温度单位约束为两个有效选项,include_forecast 的布尔字段让模型知道它可以选择性地请求天气预报,这些约束都直接来源于 Pydantic 模型,并被 @tool 自动转换为 JSON Schema,无需任何手动编写。
bind_tools() 在 ChatOpenAI 等聊天模型上的作用是把这个结构化的工具描述附加到模型实例上。当你对一个绑定了工具的模型发起调用时,请求中会额外携带一个 tools 字段,包含了所有工具的名称、描述和参数 JSON Schema。模型的响应也因此多了一种可能性,除了生成普通的文本消息,它还可以返回一个 tool_calls 数组,每个元素包含了要调用的工具名和一组符合该工具参数 Schema 的 JSON 参数。在 create_agent 内部,这个 tool_calls 会被 LangGraph 的状态图机制拦截和解析,随后实际执行对应的 Python 函数,再将执行结果包装成 ToolMessage 追加到对话历史中,供模型在下一轮推理中参考。
那为什么在 create_agent 的代码中我们看不到显式的 bind_tools() 调用呢?因为 create_agent 在内部帮你完成了这一步。当你把工具列表传给 create_agent(tools=[...]) 时,框架会自动检测这些工具并将它们绑定到模型上,同时构建一个完整的 LangGraph 状态图来管理 ReAct 循环的流转。这意味着你不必关心底层是 ReAct 还是 Function Calling,create_agent 统一了它们的调用方式,框架根据当前模型的能力自动选择最优的执行策略。正如官方文档所概括的 "Agent = Model + Harness",模型负责思考,而 harness(包括工具绑定、状态管理、中间件、循环控制)负责为模型提供正确的上下文和行动框架。
create_agent:LangChain v1 的 Agent 标准入口
如果说 2023 年的 LangChain Agent 开发体验是"在工具箱里挑扳手和螺丝刀然后自己组装",那么 create_agent 给开发者的感觉更接近"坐进驾驶位然后点火"。这个函数是 LangChain v1 构建 Agent 的标准方式,它整合了过去分散在 create_react_agent、AgentExecutor、AgentAction 等多个组件中的功能,把所有复杂性封装进一个由 LangGraph 驱动的高层抽象中。下面这段代码展示了一个完整的 Agent 从定义工具到调用执行的完整生命周期,只需寥寥二十行就能构建一个能够自主查询天气、执行计算和检索数据库的智能助手:
from langchain.agents import create_agent
from langchain.tools import tool
from langchain_openai import ChatOpenAI
from dotenv import load_dotenv
load_dotenv()
@tool
def get_weather(city: str) -> str:
"""获取指定城市的天气"""
return f"{city}:晴,25度"
@tool
def calculate(expression: str) -> str:
"""执行数学计算,输入一个算术表达式,返回计算结果"""
return str(eval(expression))
@tool
def search_database(query: str) -> str:
"""搜索内部数据库"""
return f"搜索结果:关于'{query}'的3条记录"
agent = create_agent(
model=ChatOpenAI(
model="Qwen/Qwen3.6-35B-A3B",
temperature=0,
base_url="https://api.siliconflow.cn/v1"
),
tools=[get_weather, calculate, search_database],
system_prompt="你是一个专业的AI助手,遇到不确定的信息时优先使用工具。"
)
result = agent.invoke(
{"messages": [{"role": "user", "content": "北京今天多少度?华氏度是多少?"}]}
)
print(result["messages"][-1].content)
当你运行这段代码时,Agent 在后台做的事情远比表面上复杂。模型首先收到用户的问题,意识到需要先调用 get_weather 获取北京的温度,工具执行结果返回后,模型再次推理判断还需要调用 calculate 做华氏度换算,直到信息完备才输出最终答案。整个过程对调用者来说只表现为一次 agent.invoke(),但内部经历了多轮模型推理和工具执行,这正是 create_agent 所封装的 LangGraph 状态图在发挥作用。
create_agent 的核心参数体现了它的设计哲学。model 参数接收一个模型标识字符串(如 "Qwen/Qwen3.6-35B-A3B")或者一个已初始化的聊天模型实例,框架自动适配不同提供商的调用差异。tools 参数接受工具列表,框架在内部完成工具到模型的绑定以及工具执行节点的注册。system_prompt 参数塑造 Agent 的行为基调,可以传入一个字符串或一个 SystemMessage 对象,对于需要在运行时动态生成系统提示词的场景,可以通过 middleware 来实现而非直接传入静态字符串。response_format 参数允许你传入一个 Pydantic 模型来约束 Agent 的最终输出格式,框架会在 Agent 停止工具调用后自动进行结构化输出解析,将散落在多轮对话中的信息凝聚为结构化的数据对象。
checkpointer 参数是另一个在实际项目中极其重要的配置项。当你为 Agent 提供一个 checkpointer(本地开发时用 InMemorySaver(),生产环境用 PostgresSaver 等持久化实现),并在 invoke 时传入一个 thread_id,Agent 就能在多次独立的调用之间保持对话连续性。同一个 thread_id 下的所有消息会自动累积,模型能"记住"之前的对话内容,而不同 thread_id 之间的对话则是完全隔离的。如果需要为每次调用传递不可变的上下文数据(如用户 ID、权限标记或功能开关),则通过 context_schema 定义数据类并在 invoke 时传入 context 实例,工具和 middleware 就能通过 ToolRuntime 访问这些信息而无需依赖全局状态。下面的示例将 checkpointer 和 context_schema 组合在一起,展示了如何在本地开发中实现带用户上下文的持久化对话,同一个 thread_id 下的多轮对话会自动累积历史,而 context 则让工具能够感知当前是哪个用户在提问:
from dataclasses import dataclass
from dotenv import load_dotenv
from langchain.tools import tool
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver
load_dotenv()
@tool
def get_weather(city: str) -> str:
"""获取指定城市的天气"""
return f"{city}:晴,25度"
@tool
def calculate(expression: str) -> str:
"""执行数学计算,输入一个算术表达式,返回计算结果"""
return str(eval(expression))
@dataclass
class UserContext:
user_id: str
tier: str # "free" or "premium"
agent = create_agent(
model=ChatOpenAI(
model="Qwen/Qwen3.6-35B-A3B",
temperature=0,
base_url="https://api.siliconflow.cn/v1"
),
tools=[get_weather, calculate],
context_schema=UserContext,
checkpointer=InMemorySaver(),
)
result_1 = agent.invoke(
{"messages": [
{"role": "user", "content": "北京天气怎么样?"}
]},
config={
"configurable": {
"thread_id": "conversation-001",
}},
context=UserContext(user_id="user-123", tier="premium")
)
print(result_1["messages"][-1].content)
# 再次询问,Agent 能够记住之前的对话历史并正确响应
result_2 = agent.invoke(
{"messages": [
{"role": "user", "content": "我刚才问了什么?"}
]},
config={
"configurable": {
"thread_id": "conversation-001",
}},
context=UserContext(user_id="user-123", tier="premium")
)
print(result_2["messages"][-1].content)
正因为有了 checkpointer 和 thread_id,后续再次调用 agent.invoke 并传入相同的 thread_id 时,Agent 会带着之前的完整对话历史进入新一轮推理,而不需要用户把前面说过的话再重复一遍。
当你调用 agent.invoke(...) 时,create_agent 返回的实际上是一个编译好的 LangGraph 图对象(CompiledGraph),它的内部节点编排是经过精密设计的,模型调用节点负责生成思考或工具选择,工具执行节点负责实际运行工具函数并捕获结果,条件路由边根据模型响应中是否包含 tool_calls 来决定是进入工具执行分支还是结束循环返回最终答案。这个编排过程对开发者完全透明,但你可以通过 agent.stream(..., stream_mode="values") 逐块获取中间状态,观察每一步的工具调用和返回结果,就像在旧版 API 中开启 verbose=True 一样,只是方式更现代也更灵活。
Middleware:Agent 的可插拔扩展层
create_agent 最令人惊艳的设计是它的中间件(Middleware)系统。在旧版 API 中,如果你想要实现"工具执行失败后自动重试"或者"超过一定轮次后强制终止",你只能在 AgentExecutor 的参数里寻找有没有对应的开关,或者手动在外层包装 try-catch。而在 create_agent 中,所有这些横切关注点都被抽象为了可组合、可排序、可自定义的中间件,每个中间件只做一件事并且把它做到极致。官方文档对此的定位非常明确 "Middleware is the primitive for customization: each piece handles one concern, hooks into the agent loop at the right moment, and composes freely with any other."
中间件的本质是 Agent 执行生命周期中的一系列钩子(Hooks),分为节点式和包裹式两种风格。节点式钩子在 Agent 流程的特定时间点顺序运行,before_agent 在整个 Agent 调用开始前执行一次(适合做全局的状态初始化),before_model 在每一次模型调用前触发(适合注入动态上下文或做调用计数),after_model 在每一次模型响应后触发(适合做日志记录或响应校验),after_agent 在整个 Agent 调用结束后执行一次(适合做清理或汇总)。包裹式钩子则环绕在每次调用周围,允许你控制被包裹的操作被调用零次、一次还是多次,wrap_model_call 环绕每次模型调用(典型用途包括重试、缓存、动态切换模型),wrap_tool_call 环绕每次工具调用(可以用来做错误转换、日志记录和限流)。
一个典型的自定义中间件场景是在每次模型调用前向系统提示词动态注入当前时间戳,让 Agent 获得时间感知能力。这可以通过 @before_model 装饰器实现:
from datetime import datetime
from dotenv import load_dotenv
from langchain.agents.middleware import before_model, AgentState
from langchain.messages import HumanMessage
from langgraph.runtime import Runtime
from typing import Any
from langchain.tools import tool
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
load_dotenv()
@tool
def get_weather(city: str, date: str) -> str:
"""获取指定城市的天气"""
return f"{date} {city}:晴,25度"
@tool
def calculate(expression: str) -> str:
"""执行数学计算,输入一个算术表达式,返回计算结果"""
return str(eval(expression))
@before_model
def add_timestamp(state: AgentState, runtime: Runtime):
"""在每次模型调用前注入当前时间戳"""
now = datetime.now().strftime("%Y年%m月%d日 %H:%M:%S")
state["messages"].append(HumanMessage(content=f"[系统信息] 当前时间:{now}"))
return None # 返回 None 表示不额外更新 state,已在原地修改
agent = create_agent(
model=ChatOpenAI(
model="Qwen/Qwen3.6-35B-A3B",
base_url="https://api.siliconflow.cn/v1"
),
tools=[get_weather, calculate],
system_prompt="你是一个智能助手,注意利用系统时间信息回答时间敏感问题。",
middleware=[add_timestamp]
)
result = agent.invoke(
{"messages": [{"role": "user", "content": "现在北京的天气怎么样?"}]}
)
print(result["messages"][-1].content)
这段代码的效果是,每当 Agent 准备调用模型进行下一轮推理时,add_timestamp 钩子会抢在模型调用前将当前时间作为一条系统消息插入对话上下文,模型因此获得了"现在是什么时候"的准确认知——这对于需要回答时间敏感问题(如"今天的截止日期过了没有")的 Agent 来说尤为实用,因为它不再需要凭空猜测或编造一个时间。
更贴近生产需求的是 @wrap_tool_call 中间件,它像一个透明的拦截器包裹在每一次工具调用之外。最典型的用法是异常转换:在 try 块中调用 handler(request) 执行真实的工具,在 except 块中捕获异常并返回一个 ToolMessage,将异常信息转化为模型可以理解和响应的文本。这确保了任何工具层面的故障都不会导致整个 Agent 崩溃,Agent 收到的是一条"工具返回了错误信息"的观测结果,它可以根据这条信息自主决定下一步行动。下面是一个完整的异常转换中间件实现,当任何工具因为参数错误、网络超时或业务逻辑异常而抛出 Exception 时,它不会让整个 Agent 崩溃,而是把异常包装成一条结构化的 ToolMessage 返回给模型,让模型像处理正常的工具返回结果一样去理解和响应:
from langchain.agents.middleware import wrap_tool_call
from langchain.tools.tool_node import ToolCallRequest
from langchain.messages import ToolMessage
from langchain.agents import create_agent
from langchain.tools import tool
from langchain_openai import ChatOpenAI
from collections.abc import Callable
from dotenv import load_dotenv
from agent import search_database
from agent import search_database
load_dotenv()
@tool
def get_weather(city: str) -> str:
"""获取指定城市的天气"""
return f"{city}:晴,25度"
@tool
def calculate(expression: str) -> str:
"""执行数学计算,输入一个算术表达式,返回计算结果"""
return str(eval(expression))
@wrap_tool_call
def handle_tool_errors(
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage]
) -> ToolMessage:
"""将工具异常转换为模型可理解的 ToolMessage"""
try:
return handler(request)
except Exception as e:
return ToolMessage(
content=f"工具执行出错,请检查输入参数后重试。错误详情:{e}",
tool_call_id=request.tool_call["id"],
)
agent = create_agent(
model=ChatOpenAI(
model="gpt-4o",
base_url="https://api.siliconflow.cn/v1"
),
tools=[get_weather, calculate, search_database],
middleware=[handle_tool_errors],
system_prompt="你是一个智能助手。"
)
这个中间件的精妙之处在于它完全遵循了 ReAct 的核心理念,把一切都转化为可推理的信息。工具出错不再是一个需要外部干预的异常事件,而是被转化为一条"工具返回了错误"的观测结果,模型在下一轮思考中自然会据此做出调整,比如尝试换一种参数重试、换个工具完成同样的任务,或者实事求是地告知用户当前无法完成请求。
LangChain 还提供了一套丰富的官方预置中间件,覆盖了从容错到安全的完整场景。ModelRetryMiddleware 在模型调用因网络波动失败时自动重试(支持指数退避策略),ToolRetryMiddleware 对工具调用执行同样的容错重试,ModelFallbackMiddleware 在主力模型宕机时自动切换到备用模型,ModelCallLimitMiddleware 通过限制单次执行的模型调用次数来防止成本失控,ToolCallLimitMiddleware 可以从全局或按工具粒度限制工具调用次数,PIIMiddleware 在输入或输出中检测和脱敏邮箱、信用卡号等敏感信息,HumanInTheLoopMiddleware 在特定工具被调用前暂停 Agent 执行等待人工审批,SummarizationMiddleware 在对话历史逼近 token 上限时自动生成历史摘要以压缩上下文,TodoListMiddleware 为 Agent 配备任务规划和追踪能力让复杂多步任务更可控,LLMToolSelectorMiddleware 在大规模工具集中自动筛选当前问题最相关的工具子集以减少 token 消耗和模型决策负担。这些预置中间件每一个都经过了生产环境的验证,你只需要按需挑选并添加到 middleware 列表即可,无需从零实现。
不过,当你同时使用多个中间件时,它们的执行顺序就变得至关重要。好在这套顺序遵循一套清晰而直观的规则,理解这套规则对于设计复杂的中间件组合至关重要。before_* 钩子按中间件列表的顺序从前到后执行,after_* 钩子按相反的顺序从后到前执行(类似洋葱模型的 return 阶段),wrap_* 钩子形成嵌套结构,列表中最前面的中间件包裹在最外层。这意味着如果你同时使用了日志记录、错误处理和限流三个中间件,限流包裹着错误处理,错误处理包裹着日志记录,而 before 钩子按日志→错误→限流的顺序触发,after 钩子按限流→错误→日志的顺序触发。官方文档推荐将关键的中间件放在列表前面,因为它们会获得最外层的控制权。
从 ReAct 到 Function Calling 的范式演进
回到最根本的问题:ReAct 和 Function Calling 到底有什么区别?为什么有了 Function Calling 之后我们还需要关心 ReAct?
ReAct(Reasoning + Acting)是一种提示词工程范式,它通过在系统提示词中嵌入固定的格式指令来诱导模型按"Thought → Action → Observation"的模板输出文本,然后由程序用正则或解析器从文本中提取 Action 块并执行工具。Function Calling 则是模型提供商在模型推理层面原生支持的能力,模型不再输出"带格式的文本",而是输出一个结构化的 JSON 对象(tool_calls 数组),精确地指出了要调用哪个函数以及传入什么参数。从工程角度看,Function Calling 的解析是确定性的,JSON 解析不会像正则匹配那样因为模型输出格式的微小偏差而失败。从效果角度看,Function Calling 的调用准确率通常高于 ReAct 文本解析,因为它是模型训练时就内置的能力而非通过提示词强行约束出来的行为。
两者的执行流程在概念上是对齐的,用户提问 → 模型分析是否需要工具 → 如果是则发出工具调用 → 执行工具获取结果 → 将结果注入对话 → 模型再次评估 → 循环直到给出最终答案。区别在于中间"发出工具调用"这一步的实现方式,ReAct 依赖文本格式约定,Function Calling 依赖原生的结构化输出。这意味着在面对复杂的嵌套参数或多步骤并行调用时,Function Calling 通常更稳定和高效。而在没有原生 Function Calling 支持的模型上(或需要追溯模型每一步"思考过程"的学术场景中),ReAct 仍然有它独特的价值。
在 LangChain v1 的世界里,这个选择已经被 create_agent 帮你做好了。如果你绑定了工具并且使用的模型原生支持 tool calling,框架会自动走 Function Calling 路径,如果你的模型不支持 tool calling,框架会退回到传统的文本推理模式。重要的是你不需要改变 Agent 的构建代码,同样的 create_agent 调用、同样的 invoke 接口、同样的 middleware 体系——底层协议的选择只影响执行效率而不影响功能接口。这种抽象层的统一是 LangChain v1 相比旧版本最重要的架构升级之一。
练习任务
- 用 create_agent() 重写 Day4 的 ReAct Agent
- 编写一个 before_model middleware
- 对比传统 ReAct 与 Function Calling 的差异
考核点 ✅
- create_agent 迁移:提交用
create_agent()重写的 Agent 代码,功能与 Day4 等价 - Middleware 实战:提交一个自定义 middleware 的
create_agent,说明其触发时机和作用 - 机制对比:用同一任务运行 ReAct 和 Function Calling 版本,输出对比报告(含异同点)
- 工具绑定理解:口头解释
bind_tools()的工作原理及与@tool装饰器的配合关系

浙公网安备 33010602011771号