第 3 篇拆 with_structured_output 时留了条线头:function_calling 方法借的正是各家的工具调用通道——OpenAI 叫 tool calling,Anthropic 叫 tool use,本篇把这条通道正面拆开。结构化输出是"借工具的壳要数据",工具调用才是工具的本职:模型在对话中声明"我要调用某个函数、参数是什么",由你的程序执行并把结果送回,模型再基于结果继续回答。本篇是《LangChain 从入门到实战》第 4 篇:先看框架的工具抽象抹掉了什么重复劳动,再用 @tool 定义工具、用 bind_tools 发起调用,重点拆透一个手动执行循环——它是后面一切 agent 的骨架。
1、从手写 Schema 到工具抽象
裸 SDK 定义工具的套路各家不同,但都绕不开"一个函数 + 一份手写 JSON Schema"。以 OpenAI 的格式为例,给模型声明一个天气查询工具长这样:
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的实时天气",
"parameters": { # 手写 JSON Schema
"type": "object",
"properties": {
"city": {"type": "string",
"description": "城市中文名"},
"unit": {"type": "string",
"enum": ["摄氏", "华氏"]},
},
"required": ["city"],
},
},
}]
这份 schema 的每一行都在重复函数签名里已有的信息:参数名、类型、枚举取值,函数之外又写了一遍。问题不在行数,在双份事实:函数签名改了 schema 忘了跟,错误要等到模型据此生成非法参数才在运行时暴露;description 写什么全凭自觉,而它恰恰是模型决定"何时调用"的唯一依据。换厂商还要再交一遍学费——OpenAI 的 tools 参数与 Anthropic 的 tools 块结构并不相同,细节见 OpenAI function calling 指南。
LangChain 的工具抽象把两份事实合成一份:@tool 装饰器把函数变成工具,参数 schema 从类型注解自动生成,函数签名就是唯一事实来源。这与第 2 篇"把消息结构声明成数据"、第 3 篇"Pydantic 模型即 schema"一脉相承——让机器从单一来源生成协议细节,人只维护业务本身。
值得先看清这条路通向哪里。模型的知识有截止日期、算术会出错、更碰不到你的数据库与内部系统;工具把这些缺口一块块补上——查实时天气、检索内部文档、执行精确计算、调用业务接口。第 1 篇路线图里"agent"那一格的全部想象空间,地基都是本篇的工具调用。
2、tool 装饰器基础
先定口径:@tool 由 langchain-core 提供(当前 1.6.x,随主包安装),官方工具指南的推荐导入是 from langchain_core.tools import tool。最小用法如下:
老教程里常见的
from langchain.tools import tool在 1.x 仍能用——主包对 core 做了再导出;新代码建议直接从langchain_core.tools导入,与官方文档口径一致。
from langchain_core.tools import tool
@tool
def get_weather(city: str, unit: str = "摄氏") -> str:
"""查询指定城市的实时天气。"""
return f"{city}:晴,26 {unit}" # 实际项目里这里是 API 调用
print(get_weather.name) # get_weather
print(get_weather.args) # 自动生成的参数 schema(dict)
get_weather.invoke({"city": "北京"}) # 工具也可以直接执行
三点解读。其一,docstring 即工具描述:装饰器把它原样交给模型,模型看不到函数体,选不选这个工具、传什么参数,全部依据这几行文字——它就是工具的提示词。其二,类型注解即 schema:str 映射为 string,默认值 unit: str = "摄氏" 映射为带默认值的可选参数,上一节那份手写 schema 一行都不用写了。其三,装饰后的函数变成了 StructuredTool 对象:invoke 可以直接执行,.name、.description、.args 随时可查——一个对象同时是"给模型看的说明书"和"给程序用的函数"。
3、args_schema 精控参数
docstring 只能覆盖工具级的整体描述;参数要写清取值含义、枚举与格式时,交给 args_schema。它接收一个 Pydantic 模型,这套语法第 3 篇已经全部学过:
from typing import Literal
from pydantic import BaseModel, Field
from langchain_core.tools import tool
class WeatherInput(BaseModel):
city: str = Field(description="城市正式中文名,如“杭州”")
unit: Literal["摄氏", "华氏"] = Field(
default="摄氏", description="温度单位,默认摄氏")
@tool(args_schema=WeatherInput)
def get_weather(city: str, unit: str = "摄氏") -> str:
"""查询指定城市的实时天气,返回天气现象与温度。"""
return f"{city}:晴,26 {unit}"
解读:args_schema 接管参数 schema 的生成,docstring 仍作为工具描述保留;Field(description=...) 逐字段写给模型看,Literal 枚举与 default 同时是校验规则——第 3 篇的口诀"描述写给模型看,约束留给校验器"在工具上原样成立。另有一条进路:参数描述也可以写进 Google 风格 docstring 并开 parse_docstring=True,但当前版本默认不解析参数段注释,显式 args_schema 不依赖解析行为,团队协作下更稳。
4、bind_tools 发起调用
工具定义好后,bind_tools 把工具列表挂到模型上。它不改动原模型,而是返回一个绑定了工具声明的新调用单元,并负责把声明翻译成目标厂商的协议格式——OpenAI 翻成 tools 参数,Anthropic 翻成 tools 块,各集成包自行实现,见模型文档。发起与解析的代码如下:
from langchain.chat_models import init_chat_model
model = init_chat_model("openai:gpt-6.1-terra", temperature=0)
llm = model.bind_tools([get_weather]) # 返回绑定工具的新模型
ai = llm.invoke("杭州今天多少度?")
print(ai.content) # 此刻通常为空或一句过渡语
for tc in ai.tool_calls: # 模型提出的调用请求清单
print(tc["name"], tc["args"], tc["id"])
一次典型运行的输出如下:
get_weather {'city': '杭州', 'unit': '摄氏'} call_a1b2c3
tool_calls 是 AIMessage 上的标准字段,各集成包都翻译成同一形状,每个元素的字段含义如下:
| 字段 | 含义 |
|---|---|
| name | 工具名,对应装饰后的函数名 |
| args | 参数字典,键与 schema 字段一致 |
| id | 本次调用的唯一标识,回填结果时配对用 |
三点值得注意。模型只是提出调用而不是执行——tool_calls 是一份请求清单,跑不跑、怎么跑由你的程序决定。ai.content 此时通常是空字符串或一句过渡语,最终回答要等工具结果回填后才会产生。id 不能丢:一次回答可能同时发起多个调用,回填时全靠它配对。
顺带补一个参数形态:bind_tools 除了接 @tool 产出的工具对象,也接受函数名列表或裸 Pydantic 类——后者只声明 schema 让模型填充,适合"要结构化输入但没有可执行函数"的场景,第 3 篇的 function_calling 方法底下走的就是这条路。
5、手动执行循环
把"提出、执行、回填、再调用"串成闭环,就是工具调用的完整流程:
图里最关键的转折是右下那条回流边:工具结果不是给调用方看的,而是作为新一轮输入送回模型。对应的最小实现是一个 while 循环,封装成可复用的入口(model 沿用上一节):
from langchain_core.messages import HumanMessage, ToolMessage
def run(question: str, tools: list) -> str:
tool_map = {t.name: t for t in tools} # 按工具名建索引
llm = model.bind_tools(tools)
messages = [HumanMessage(question)]
while True:
ai = llm.invoke(messages)
messages.append(ai)
if not ai.tool_calls: # 无调用即最终回答
return ai.content
for tc in ai.tool_calls: # 逐个执行并回填
result = tool_map[tc["name"]].invoke(tc["args"])
messages.append(ToolMessage(
content=str(result), tool_call_id=tc["id"]))
五点解读。tool_map 按名索引——tool_calls 里的 name 就是查表的键。执行用 tool_map[tc["name"]].invoke(tc["args"]):args 本身就是符合 schema 的字典,直接喂回工具即可。回填必须用 ToolMessage 且携带 tool_call_id,它标记"这条结果对应哪次调用",漏了或错了模型会直接报请求格式错误,细节见 ToolMessage 参考。消息列表全程累积,模型第二轮看到的是完整上下文:问题、它的调用请求、工具结果一个不少。终止条件是 tool_calls 为空——模型认为信息够了,content 里就是最终回答。
这个循环揭示了 agent 的本质:模型提议、程序执行、结果回流,如此往复。信息不够时模型会自己发起第二次调用,第 7 节能看到两步工具链的实例。"执行权在你手里"同时也是安全边界:真实项目的工具接的是数据库、支付接口、发信通道,批准谁执行、记录审计日志、限制单轮调用次数,全都发生在这个循环里——框架不替你做这些决定,正是把控制权留给了你。
也要看到它的朴素处:没有最大轮数限制(模型固执时会无限循环)、没有并行执行、没有状态持久化,这些工程化能力属于 LangGraph 与 create_agent,是第 6 篇的主场。
6、描述写法决定调用质量
工具定义的语法十分钟就能学会,真正的功夫在描述。模型路由工具的全部依据只有 name 与 description——函数体、异常处理、性能它一概看不见。同功能工具的两种写法对照如下:
| 坏写法 | 问题 | 好写法 |
|---|---|---|
| 查询数据 | 名字空泛,任何问题都可能命中 | 按城市中文名查询实时天气 |
| 获取时间 | 没说时区与格式 | 返回北京时间,格式如 2026-10-31 14:00 |
| 全网搜索 | 什么都能搜等于什么都搜 | 只搜公司内部文档,外部常识直接回答 |
坏写法的共同点是只写了"是什么",没写"什么时候用"。写好描述有三条要领:工具名动词开头、说清动作与对象,get_weather 好过 weather_api;描述覆盖"做什么、输入什么、返回什么",再补一句边界——"不支持的城市返回提示,不要重试";参数描述给出取值示例与格式。工具多于三个时再加一条:点名与近似工具的分工,"查实时天气用本工具,查历史气候用另一个工具"。这套功夫与第 3 篇"字段描述即提示"同源,schema 全文都会进入模型上下文——写得好是路由,写得差是噪声。
7、实战天气与计算器
组合两个工具跑通一个跨工具问题:先查杭州天气,再把温度换算成华氏。工具定义与调用如下:
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""查询指定城市实时天气,返回天气现象与摄氏温度。"""
data = {"杭州": "晴,22 度", "北京": "多云,18 度"}
return data.get(city, "暂无该城市数据")
@tool
def calculator(expression: str) -> str:
"""计算四则运算表达式,支持加减乘除与括号。"""
return str(round(eval(expression), 2)) # 演示用,生产禁用 eval
print(run("杭州今天天气如何?换算成华氏是多少度?",
[get_weather, calculator]))
一次典型运行的输出如下:
杭州今天晴,气温 22 摄氏度,换算成华氏约 71.6 度。
解读这段输出背后的过程:模型第一步调用 get_weather 拿到"晴,22 度",发现要换算华氏还缺一步计算,自动发起第二次调用 calculator,表达式 22 * 9 / 5 + 32 也是模型自己写的——两步工具链没有任何硬编码,全部由模型在循环里自主决策,这正是第 5 节那条回流边在工作。再把 init_chat_model 的字符串换成 anthropic:claude-sonnet-4-5,整段代码不改一行照跑:工具声明、调用解析、结果回填都被抽象层接走了。
与裸 SDK 实现同一功能的对照如下:
| 维度 | 裸 SDK 写法 | LangChain 写法 |
|---|---|---|
| 工具定义 | 两份手写 JSON Schema | 两个装饰器函数 |
| 事实来源 | 函数与 schema 各自维护 | 函数即 schema |
| 调用解析 | 手剥厂商响应结构 | 统一 tool_calls 字段 |
| 结果回填 | 手拼 role 与 id 的字典 | ToolMessage 对象 |
| 整体行数 | 约 55 行 | 约 30 行 |
诚实地说,省下的二十多行代码不是重点——裸 SDK 多写的那些行,每一行都是你与某个厂商协议的私人约定,换模型、加工具、多人协作时全部要重读重改。行数差距是表象,单一事实来源与厂商无关才是抽象收益的本体。
8、总结与练习
回顾全文:@tool 把函数变工具,docstring 即描述、类型注解即 schema,args_schema 用 Pydantic 逐字段精控;bind_tools 挂载工具并翻译成厂商协议,模型以 AIMessage.tool_calls 提出调用请求;手动循环执行工具、以 ToolMessage 回填,直到模型不再发起调用。这个循环是 agent 的骨架,但每个应用都重抄一遍并不体面——第 6 篇的 create_agent 会把它收编成一行:内部用模型节点加预置的工具执行节点自动跑完整个循环,你只管递工具列表。在那之前,第 5 篇先讲 LCEL 链式编排,把模板、模型、解析器组装成可测试的单元。对于手头的项目,建议先把第 5 节的 run 函数抄下来接一个真实 API 跑通,再等第 6 篇对比 create_agent 版本,抽象收益立刻可感。
练习两道:
- 给第 7 节加上第三个工具
get_air_quality,先写清晰描述看模型如何在三个工具间路由,再把描述改含混,观察误调用并修正 - 给 while 循环加最大轮数限制(如 3 轮),超限时要求模型基于已有信息作答,体会工程化补丁的必要性

