1. 什么是 Tools?为什么要使用它?
大模型本质上是一个被"封印"在训练数据里的推理大脑,它没有联网能力,也无法直接操作你的本地数据库或 API。 引入 Tools 的目的,就是给大模型装上"手和眼"。当用户提出需求时,大模型可以通过调用预先定义好的工具代码,去获取外部事实或执行真实动作。
2. 大模型调用工具的过程 (Message Flow)
在架构层面,大模型并不具备代码执行环境,其核心职责为"规划与决策"。完整的工具调用是一个标准的多轮对话流转闭环(Message Flow):
- 用户请求 (
HumanMessage):用户向系统发起自然语言查询(例:"北京今天天气如何?")。 - 模型决策与指令下发 (
AIMessage携带tool_calls):模型经推理判断需要借助外部工具获取信息,生成结构化的工具调用指令(如:调用get_weather函数,入参city="北京")。此时模型进入挂起等待状态。 - 应用层拦截与执行 (
ToolMessage):业务应用框架(如 LangChain)解析并拦截该指令,在服务端运行环境(Server/Runtime)中执行对应的 Python 实体函数。函数执行完毕后,将其返回值封装为ToolMessage追加至对话上下文中。- 关键约束:生成的
ToolMessage必须严格携带模型下发指令时的tool_call_id,以确保在并发调用场景下,执行结果与原始请求能够精确映射。
- 关键约束:生成的
- 模型推理与总结 (
AIMessage):大模型接收到包含执行结果的对话上下文后,恢复推理过程,结合获取到的事实数据,最终生成自然语言回复返回给终端用户。
代码级推演 (Message 流转示例):
python
from langchain.messages import HumanMessage
from langchain_core.tools import tool
# 定义工具并与模型绑定
@tool
def get_weather(city: str):
"""获取天气的工具"""
return f"{city}天气晴朗~"
model_with_tools = model.bind_tools([get_weather])
# 1. 用户请求
messages = [HumanMessage("今天北京天气如何")]
# 2. 模型决策并下发调用指令 (此时返回的 response 是 AIMessage,包含 tool_calls)
response = model_with_tools.invoke(messages)
messages.append(response) # 核心步骤:必须把"模型要调工具"这一想法也记录进上下文
tool_calls = response.tool_calls
if tool_calls:
for tool_call in tool_calls:
if tool_call["name"] == "get_weather":
# 3. 应用层拦截并在本地执行代码
# (注:直接传入 tool_call 字典,底层会自动执行函数并将返回值、tool_call_id 封装为 ToolMessage)
tool_response = get_weather.invoke(tool_call)
messages.append(tool_response) # 将包含真实结果的 ToolMessage 追加进上下文
# 4. 模型基于完整的上下文(问题 -> 决策 -> 事实结果)进行最后推理
final_response = model_with_tools.invoke(messages)
print(final_response.content) # 输出最终回答:北京天气晴朗~
2.1 进阶:多工具场景下的优雅调用方式 (工具路由)
在实际开发中,当我们的 Agent 拥有数十个工具时,如果依然使用 if tool_call["name"] == "xxx": 来逐一判断,会导致代码极度臃肿且难以扩展。 为了解决这个问题,我们可以采用 工具字典映射 (Tools Map) 的设计模式来实现自动化的动态分发(工具路由)。针对不同的工具定义方式,有两种优雅的写法:
写法一:针对使用 @tool 包装的工具 (自动封装)
如果你的工具使用了 @tool 装饰器,LangChain 的 invoke 方法会自动处理参数解包,并直接返回标准的 ToolMessage:
python
# 假设我们有多个工具
tools = [get_weather, search_web, get_time]
# 1. 启动时构建映射字典 (key为工具名,value为工具对象)
tools_map = {tool.name: tool for tool in tools}
# ... 获取 response 后遍历 tool_calls ...
for tool_call in response.tool_calls:
tool_name = tool_call["name"]
if tool_name in tools_map:
# 2. 动态取出对应的工具并直接 invoke,彻底消灭 if-else 嵌套
selected_tool = tools_map[tool_name]
tool_response = selected_tool.invoke(tool_call)
messages.append(tool_response)
写法二:针对未使用 @tool 的原生 Python 函数 (手动封装)
如果你绑定的是纯原生 Python 函数,由于它们没有被 LangChain 包装,它们无法直接接收 tool_call 字典对象,返回值也不会自动变成 ToolMessage。我们需要手动解包并组装消息:
python
# 假设这些都是纯原生 Python 函数,没有加 @tool
raw_tools = [get_weather, search_web, get_time]
# 1. 构建映射字典 (注意这里取的是原生的 func.__name__)
tools_map = {func.__name__: func for func in raw_tools}
for tool_call in response.tool_calls:
tool_name = tool_call["name"]
if tool_name in tools_map:
selected_func = tools_map[tool_name]
# 2. 手动解包 args 并传入原生函数执行
raw_result = selected_func(**tool_call["args"])
# 3. 极其重要:必须手动组装 ToolMessage,并将 call_id 严丝合缝地绑定进去!
tool_response = ToolMessage(
content=str(raw_result), # 函数返回的真实结果
name=tool_call["name"], # 被调用的工具名
tool_call_id=tool_call["id"] # 大模型下发的唯一 call_id
)
messages.append(tool_response)
写法三:使用 LangGraph 的 ToolNode (终极开箱即用方案)
随着生态的发展,官方在 LangGraph 库中提供了一个终极杀器 ------ ToolNode。如果你不想手写字典映射和 for 循环,你可以直接把工具列表丢给它,它会自动在底层完成所有的解析、调用、和 ToolMessage 封装!
python
from langgraph.prebuilt import ToolNode
# 假设我们有多个工具
tools = [get_weather, search_web, get_time]
# 1. 极其简单:直接用 ToolNode 包装整个工具列表
tool_node = ToolNode(tools)
# 2. 当模型返回了包含 tool_calls 的 response 时,直接将其装在消息列表里扔给 tool_node
# ToolNode 会自动提取内部的 tool_calls,去对应的工具里执行,并返回封装好的一批 ToolMessage!
tool_node_response = tool_node.invoke({"messages": [response]})
# 3. 将自动执行产生的所有 ToolMessage 追加到对话历史中
messages.extend(tool_node_response["messages"])
3. 工具的入参校验 (Parameter Validation)
虽然大模型可以调用外部工具,但大模型的本质是基于概率的"文字接龙",充满不确定性。在真实的业务落地中(如向数据库写入数据),大模型在调用工具时极有可能产生幻觉、传错参数类型、甚至凭空捏造不存在的参数 。 因此,为了保证执行的确定性和系统的安全性,我们必须对工具的"入参"进行严格管控。在 LangChain 中,入参校验可以分为三种方式:通过 Docstring (弱校验)、通过 Pydantic (强校验) 以及通过手写 JSON Schema (弱校验,极不推荐)。
💡 核心认知:这三种方式到底有什么本质区别?
- 去程(发送给大模型)完全一样 :无论你用上述哪种方式,LangChain 在底层最终都会把它们统统翻译成 JSON Schema 格式发给大模型。因为大模型的 API 只认识 JSON Schema,这是唯一的通信规范。它们在"去程"都起到了对大模型的约束和提示作用。
- 回程(收到大模型生成的参数后)才是真正的差距所在 :
- Docstring / 手写 JSON Schema(弱校验):大模型把生成的 JSON 参数发回来后,框架直接原封不动地放行了。没有任何强制的验证和拦截,哪怕大模型传错了数据类型或漏了字段,脏数据也会直接冲进你的业务代码导致崩溃。
- Pydantic(强校验) :大模型返回参数后,Pydantic 会充当铁面无私的"海关/质检员",严格校验字段是否完全合规。如果不合规,立刻在这里抛出异常进行硬拦截!并且,如果此时你是交由 Agent(智能体)来调用的,Agent 还会拿着这个 Pydantic 抛出的报错信息,自动要求大模型重试修正!
3.1 通过 Docstring 自动提取 (弱校验)
什么是弱校验? 大模型仅靠阅读代码里的类型提示和注释来"自觉"遵守传参规则。如果在执行时它依然传错了参数(比如该传数字传了字符串),程序不会在进入底层函数前进行友好的强制拦截,通常会导致业务代码抛出意外的运行时崩溃。
底层本质 :由于大模型底层只认 JSON Schema 格式,因此"通过 Docstring 提取"的本质,其实是 LangChain 框架在底层自动帮你把 Python 注释和类型提示转换成了 JSON Schema。这与后文提到的"手写 JSON Schema"在发给大模型的最终效果上是完全一致的,只不过这里是由框架全自动代劳了。
在弱校验下,定义工具有两种方式:
方式一:使用 @tool 装饰器
- 机制 :将原函数包装为
StructuredTool,自动提取注释作为说明书。 - 本地调用 :必须通过
.invoke({"city": "北京"})以字典形式传参。
python
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""获取指定城市的天气"""
return f"{city}今天晴天"
方式二:不使用 @tool 装饰器
- 机制:保留原生 Python 函数。绑定时底层自动解析(必须有类型提示)。
- 本地调用 :保持原生传参
get_weather("北京"),不会破坏旧代码调用。
python
def get_weather(city: str) -> str:
"""获取指定城市的天气"""
return f"{city}今天晴天"
🚨 核心避坑:两者的"本地调用"存在巨大差异!
无论你选择哪种方式,发给大模型的 Schema 是基本一样的,但在你本地用 Python 直接调用(或写单元测试)时,两者的传参方式有天壤之别,这里是新手的重灾区!
- 使用了
@tool时 (包含后文的 Pydantic 强校验) :你的函数已经被 LangChain 挟持并包装成了StructuredTool对象。- ❌ 错误写法:
get_weather("北京")(按原函数习惯传参会直接抛出TypeError崩溃!) - ✅ 正确写法:必须写成
get_weather.invoke({"city": "北京"}),即必须把参数包成字典,且必须用.invoke()方法触发。
- ❌ 错误写法:
- 不使用
@tool时 :它依然是一个纯粹的原生 Python 函数。- ✅ 正确写法:
get_weather("北京")(一切照旧,完全兼容你原本的业务系统直接调用)。
- ✅ 正确写法:
💡 补充技巧:如何查看底层自动生成的 JSON Schema?
前面提到,无论哪种方式,底层都会将其翻译成 JSON Schema。如果你想在本地亲眼看看这个"说明书"长什么样,可以使用以下方法:
python
# 方法 1: 直接查看 @tool 包装后的参数 Schema
print(get_weather.args_schema.schema())
# 方法 2: 使用 LangChain 的底层工具,查看最终发给 OpenAI 的完整格式
from langchain_core.utils.function_calling import convert_to_openai_tool
import json
openai_tool_schema = convert_to_openai_tool(get_weather)
print(json.dumps(openai_tool_schema, indent=2, ensure_ascii=False))
💡 决策总结:何时使用 @tool,何时不使用?
- 全新开发的大模型专属工具 ➡️ 必须使用
@tool。这能统一 LangChain 的组件规范,并且最关键的是,只有加了@tool装饰器,你才能接入后面要讲的 Pydanticargs_schema进行强校验。 - 直接复用已有的旧业务代码 ➡️ 不使用
@tool。直接把它喂给bind_tools即可。因为如果你给一个旧函数强行戴上@tool的帽子,它在本地就被转成了StructuredTool,别的业务代码必须改用字典.invoke({})传参,这会导致你原本正常的业务系统大面积报错瘫痪。
3.2 通过 Pydantic 定义 args_schema (强校验)
企业级应用开发的核心法则 是引入强校验:Docstring 专职负责工具的宏观功能描述,Pydantic 专职负责微观参数的强校验。(99% 的正规商业级场景都必须这么写)
为什么必须用 Pydantic 强校验?
- 前置安检拦截 (Hook):在原函数执行前加一层外壳,利用 Pydantic (底层为极速的 Rust 引擎) 严格审查大模型传来的参数类型和必填项。一旦出错,硬性拦截,绝不让脏数据流进你的底层真实业务代码。
- 自纠错重试闭环 (Retry Loop) ⚠️ 注意调用方式差异 :
- 何时【不会】自动重试 :如果你是**"手工直调模型"**(例如直接调用
model.invoke(),然后自己写代码去执行tool.invoke()),当遇到 Pydantic 拦截报错时,程序会直接抛出异常并终止,不会自动重试。 - 何时【会】自动重试 :只有当你把模型和工具交给**"智能体管家"(如
AgentExecutor)**接管时,重试机制才会生效。Agent 底层自带了异常捕获的循环逻辑,它会捕获 Pydantic 抛出的精准异常信息,将其作为"执行失败"的反馈重新发送给大模型,大模型据此进行"自我反思"并修正参数,实现全自动纠错。
- 何时【不会】自动重试 :如果你是**"手工直调模型"**(例如直接调用
- 处理复杂嵌套:面对极其复杂的结构化输出需求,Pydantic 的面向对象类定义比手捏 JSON Schema 更加清晰、易维护且支持多处复用。
强校验优雅范例
python
from pydantic import BaseModel, Field
from langchain_core.tools import tool
# 1. Pydantic 专职负责入参的强定义与强校验
class WeatherInput(BaseModel):
city: str = Field(description="具体的城市名称,如:北京")
unit: str = Field(description="温度单位", default="celsius")
# 2. Docstring 只管宏观介绍,args_schema 接入 Pydantic 安检门
@tool(args_schema=WeatherInput)
def get_weather(city: str, unit: str = "celsius"):
"""
根据城市名称获取当地的天气情况。
"""
return f"{city}的天气是晴天,温度单位:{unit}"
配合 Agent 实现自动重试的代码范例
为了让上述 Pydantic 的强校验真正发挥"自动重试"的威力,必须搭配 AgentExecutor 使用,而非纯手工调用 model.invoke():
python
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_core.prompts import ChatPromptTemplate
# 1. 准备工具列表
tools = [get_weather]
# 2. 定义提示词模板 (注意必须包含 agent_scratchpad 用来暂存工具调用的报错信息)
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个有用的助手,请利用工具回答用户问题。"),
("human", "{input}"),
("placeholder", "{agent_scratchpad}"),
])
# 3. 创建智能体
agent = create_tool_calling_agent(model, tools, prompt)
# 4. 创建智能体执行器 (也就是那个帮我们写好了 while 循环和 try...except 的"管家")
# max_iterations = 3 表示最多允许大模型犯错重试 3 次
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True, max_iterations=3)
# 5. 极简调用。遇到 Pydantic 报错时,管家会自动拦截并把错误信息塞回给大模型让其修正重做
response = agent_executor.invoke({"input": "今天北京天气如何"})
print(response["output"])
3.3 通过手写 JSON Schema (弱校验,极不推荐)
除了使用 Pydantic,你也可以选择直接手写标准的 JSON Schema 字典,并把它传给 @tool(args_schema=...) 或者在底层的 API 调用中直接指定。
机制:大模型底层 API(如 OpenAI)原生接收的就是 JSON Schema 格式的参数描述说明书。Pydantic 的底层机制,其实也是在运行时把类结构自动转换成了 JSON Schema 发给大模型。因此,手写 JSON Schema 相当于"越过 Pydantic 这个中间商,直接用底层协议对话"。
为什么在实战中极不推荐?
- 地狱级的编写与维护体验:手写深层嵌套的字典结构非常痛苦、极易少写括号、拼错字段名,且在 IDE 中毫无自动补全和代码重构支持。
- 致命缺陷------缺乏运行时强拦截:手写的 JSON Schema 仅仅是一份发给大模型的"静态说明书",它只能在生成前尽量规范大模型。但如果大模型依然产生了幻觉,传回了错乱的参数,由于缺少类似 Pydantic 这样真正的验证引擎把关,脏数据会直接长驱直入你的底层业务函数,导致意想不到的运行时崩溃。
(结论:在工程实战中,永远优先使用 3.2 的 Pydantic。把 JSON Schema 仅当作了解底层通信原理的知识储备即可,千万不要手写它。)
4. 核心控制开关:tool_choice 的使用
当我们将工具绑定给大模型后,可以通过 tool_choice 参数来强制管控大模型的工具使用行为:
tool_choice="auto"(默认状态:自由发挥)- 含义:工具交给你,用不用你自己根据用户问题智能判断。
- 场景:99% 的日常通用智能体开发。
tool_choice="none"(强制禁用)- 含义:严禁调用任何工具,哪怕你觉得需要用,也只能用纯文本回答。
- 场景:临时限制大模型权限,只让其做文本总结或闲聊,防止误触敏感操作。
tool_choice="required"/"any"(强制必用)- 含义:不管用户问什么,你今天必须给我挑一个工具调用,绝不准只回纯文本!
- 场景:定向的 API 路由节点或信息提取器,强迫大模型输出结构化的工具调用参数。
python
# 强制大模型必须调用工具
model_with_tools = model.bind_tools([get_weather], tool_choice="required")