LangChain 里的 Tool,是把我经常被问到的一个问题彻底解决掉的东西:大模型只能生成文本,那它怎么查天气、查订单、读写数据库、调内部接口?答案就是把模型之外的真实能力包装成一个一个可以被模型“点名调用”的函数,这个函数就是 Tool。模型负责判断该干什么,Tool 负责实际去干。
这一章我会按自己在项目中真正用过的路径来写:为什么需要 Tool、怎么创建 Tool、参数和描述怎么写模型才不会填错、怎么把 Tool 接入 Agent 跑起一个完整的“思考-调用-观察-回答”循环,最后把调试时踩过的坑整理成一份可以直接对着排查的清单。适合正在学 LangChain 的开发者、想给模型接业务能力的后端工程师,以及被“模型一本正经瞎编”折磨过的人。
1.1 先说清楚模型到底缺什么
大模型的强项是文本生成、知识压缩、逻辑推理,但它有几个天生短板。
第一,训练数据有截止时间,它不知道“今天”是哪天,更不知道“现在的天气”“此刻的股价”这类实时信息。你直接问它“韶光市今天适合穿什么”,它只能根据历史知识猜,猜错了就是幻觉。
第二,它访问不了外部系统。企业内部的数据在数据库里、在业务接口后面,模型本身没有凭据也没有通道去拿,它唯一能接触到的就是用户输入的那段文本。
第三,它在精确计算上不可靠。让它算“2718 乘以 3649 等于多少”,它可能给出一个看起来合理但结果错误的大数。不是模型笨,是它的计算方式和普通程序不一样。
Tool 的思路就是针对这三块短板做弥补:把“获取实时数据”“读写业务系统”“执行精确计算”这些能力封装成函数,由模型来调度。模型在回答过程中发现需要这些能力时,会输出一个结构化的调用请求,程序收到请求后执行对应函数,再把结果喂回给模型,最后模型基于真实结果组织回答。
1.2 Tool、工具调用与 Function Calling 是什么关系
刚开始接触时很容易被几个词绕晕:Tool、Function Calling、bind_tools。我说一下自己理解后的结论。
Function Calling 是模型服务商提供的一种底层能力,指的是模型在生成回答时多输出一段结构化 JSON,内容是“我想调用某个函数,函数名是什么,参数是什么”。它本质上是模型输出格式的一种扩展。
Tool 是 LangChain 做的一层抽象。你写一个 Python 函数,加上 @tool 装饰器,LangChain 会帮你把函数转换成模型服务商要求的 JSON Schema 描述,发请求时随 Prompt 一起发给模型;模型返回的调用请求,LangChain 又能帮你解析成 Python 字典,传给函数执行。
bind_tools 就是把工具列表绑定到某个模型对象上,绑定之后,这个模型在回答时才有权限“提案”调用工具。注意,只是提案。模型输出的是“我想调用”的意图,真正执行函数的是你的代码或者 Agent 循环。
提示:很多新手绑完工具就以为模型会自动执行函数,结果发现模型只是“说”要调用,并没有真的执行,于是怀疑框架有问题。实际上执行这一步必须由 Agent 或你自己的代码来完成,这是理解 Tool 整个机制的核心。
1.3 工具的描述就是模型的“说明书”
一个非常容易被低估的事情是:模型不会运行你的代码,它只能阅读工具的 name 和 description 来决定要不要调用、怎么填参数。所以 description 写得清不清楚,直接决定模型选工具的准确度。
我见过太多失败案例。工具 description 写的是“查询天气功能”,模型面对用户问“明天出门要不要带伞”,可能根本想不到调它。更好的写法是直接说明触发场景:“当用户询问任一城市天气、气温、降水、风力或出行建议时使用,输入城市中文名称。如果用户只说‘那边’‘这里’,先尝试从上下文推断城市,无法推断时向用户确认。”
这个道理跟给实习生写交接文档一样:文档只写“负责接待”是没用的,要写清楚“什么情况接待、接待时做什么、拿不准时问谁”。模型在信息不足时倾向于不调用工具,而不是冒险调用。所以把描述写成“触发条件 + 参数说明 + 边界处理”三段式,是性价比最高的优化。
2.1 最常用的方式:@tool 装饰器
最常用的创建方式是直接写一个带类型注解和 docstring 的函数,加上 @tool 装饰器。我平时八成以上的工具都是用这种方式创建的。
from langchain_core.tools import tool @tool def get_weather(city: str) -> str: """查询指定城市的实时天气。 Args: city: 城市中文名称,例如“韶光市”“星野市”。 """ # 这里替换成真实天气服务的调用逻辑 return f"{city} 今天多云转晴,气温 21~27 摄氏度。"这个装饰器会自动帮你完成三件事:函数名变成工具名,docstring 变成工具描述,类型注解生成参数 JSON Schema。返回值默认直接作为工具执行结果。
这里有个值得注意的细节:docstring 的第一段会成为工具描述的主体,如果需要写给参数看的说明,放在 Args 段落里,LangChain 会解析并关联到对应参数上。所以 docstring 一定不要写“这是一个函数”这种等于没说的话,每一句都要给模型的决策提供信息。
如果不想用装饰器,也可以用 Tool.from_function 这个类方法来创建,效果等价,适合那些不方便加装饰器、或者需要动态生成工具的场景。
2.2 需要精细控制时用 BaseTool 和 args_schema
@tool 能覆盖大部分场景,但当你需要精确控制参数校验、默认值、参数描述、异步实现时,自定义一个继承 BaseTool 的类更合适。
from langchain_core.tools import BaseTool from pydantic import BaseModel, Field class WeatherInput(BaseModel): city: str = Field(description="城市中文名称") days: int = Field(default=1, description="预报天数,取值 1~3") class WeatherTool(BaseTool): name: str = "get_weather" description: str = "查询城市未来几天的天气,适合回答与气温、降水、穿衣建议相关的问题" args_schema: type[BaseModel] = WeatherInput def _run(self, city: str, days: int = 1) -> str: # 同步执行逻辑 return f"{city} 未来 {days} 天以多云为主,最高气温 27 度。" async def _arun(self, city: str, days: int = 1) -> str: # 异步执行逻辑,用于 AsyncAgent 场景 return await self._async_query(city, days)args_schema 用 Pydantic 模型定义,好处很明显:参数类型校验发生在模型决定调用之后、函数执行之前,如果模型填了错误类型或者缺了必填字段,LangChain 会得到明确的校验错误并反馈给模型,模型有机会修正参数再调一次,而不是整个流程直接崩掉。
注意:BaseTool 子类里 _run 实现同步逻辑,_arun 实现异步逻辑。如果只实现了 _run,在异步 Agent 里调用会报 NotImplementedError。项目里如果用异步为主,两个方法都建议实现。
2.3 参数说明的实操写法:把每个参数当表单字段
我写参数描述时有个习惯:把每个参数都当作“需要用户照着填的表单字段”来设计。参数名要直观,Field 描述要写清楚格式、取值范围和缺失时的处理方式。
比如一个查询订单的工具,参数是 order_id,如果描述只写“订单号”,模型遇到用户说“查一下我上个订单”时就会很为难:随便编一个 ID 会出错,不调用又完不成任务。把描述改成“订单号,形如 ORD-2025-0018;如果用户没有提供完整订单号,先询问用户”,模型就知道该怎么处理了。
还有一个实测很有用的技巧:给可选参数设置合理的默认值,能显著减少“模型少传参数导致报错”的概率。比如天气工具的 days 默认是 1,用户没明确说要看几天时,模型不会为了凑参数硬编一个数字。
参数数量也值得注意。单个工具的参数尽量控制在 3 个以内,参数越多模型填错的概率越高。如果一个工具需要五六个参数,考虑拆成两个工具,或者用更明确的业务对象做参数。
3.1 多参数工具与 Pydantic 校验
单参数函数只是热身,真实业务里工具通常是多参数的。直接用类型注解也能生成 schema,但更可控的方式是显式传入一个 Pydantic 模型作为 args_schema,@tool 装饰器接受这个参数。
from pydantic import BaseModel, Field class QueryOrderInput(BaseModel): order_id: str = Field(description="订单号,格式 ORD-2025-0018;用户未提供时先询问") with_items: bool = Field(default=False, description="是否返回订单商品明细") @tool(args_schema=QueryOrderInput) def query_order(order_id: str, with_items: bool = False) -> str: """根据订单号查询订单状态、金额和预计送达时间。""" if with_items: return f"订单 {order_id} 状态:已发货,金额:¥1299,明细:保温杯 x1、数据线 x2" return f"订单 {order_id} 状态:已发货,金额:¥1299"这里有个必须注意的一致性要求:函数的参数名和 Pydantic 字段名必须完全一致,类型也要对得上,否则 LangChain 解析参数时会因为键名对不上而报错。我踩过这个坑,函数里写的是 orderId,Pydantic 里是 order_id,结果模型填了 orderId,解析时找不到对应字段。
3.2 返回“文本 + 数据”的双通道结果
工具的执行结果不一定非要是一段给人看的纯文本。在新版 LangChain 里,可以通过 response_format 把返回值拆成两部分:给模型看的文本内容和给业务系统用的结构化数据。
from langchain_core.tools import tool @tool(response_format="content_and_artifact") def get_sales_report(date: str) -> tuple[str, dict]: """获取指定日期的销售汇总数据。""" data = {"date": date, "revenue": 250000, "orders": 320, "avg_order": 781.25} text = f"{date} 销售额 25 万元,订单 320 单,客单价约 781 元。" return text, data第一部分是模型组织回答时使用的摘要文本,第二部分是结构化的原始数据。这个设计的价值在复杂业务里体现得很明显:模型只看到摘要,不会被一大段 JSON 干扰判断;下游系统或者后续步骤拿 artifact 继续做数据加工,不用再从文本里解析。
我建议所有工具都遵循这个思路——返回值保持精简,只给模型必要的结论和关键数字。至于完整明细,走 artifact 或者文件系统,不要让模型在一个上下文里塞进一整天的订单流水。
3.3 工具组合与 bind_tools 绑定
工具建好后,下一步是把它们绑定到模型上。LangChain 的模型对象提供了 bind_tools 方法。
from langchain_openai import ChatOpenAI llm = ChatOpenAI( model="your-default-model", temperature=0, ) tools = [get_weather, query_order, get_sales_report] llm_with_tools = llm.bind_tools(tools)这行绑定代码背后做的事情不少:LangChain 会读取每个工具的 name、description、args_schema,转换成模型服务商要求的 JSON Schema 列表,随着请求一起发给模型。模型看到这一组工具定义后,在回答时才有机会输出对应的调用请求。
特别提醒:temperature 在工具调用场景下建议设成 0 或者比较低的值。温度越高模型输出越随机,它可能在同一问题上每次调用不同的工具、填不同的参数,这对工具调用场景非常致命。
绑定之后,先单独跑一次模型,观察输出结构,确认工具定义被正确理解。
4.1 理解 tool_calls 输出结构
工具绑定成功后,先不要急着接 Agent,我建议直接 invoke 一次,看看模型返回了什么。
result = llm_with_tools.invoke("帮我查一下韶光市今天天气") print(result.tool_calls)正常情况下 result.tool_calls 是一个列表,里面是类似这样的结构:
[ { "name": "get_weather", "args": {"city": "韶光市"}, "id": "call_xxx", "type": "tool_call", } ]这说明模型已经完成了“判断用哪个工具、填什么参数”这一步。接下来需要你写代码:解析 tool_calls、执行 get_weather、把执行结果包装成 ToolMessage 再回传给模型,让模型基于工具结果生成最终回答。
这一来一回,就是最小可用的工具调用闭环。理解这个闭环后,再看 Agent 会轻松很多——Agent 的本质就是帮你把这个循环自动跑起来。
4.2 用 Agent 让循环自动跑
手写循环可以加深理解,但生产环境我更推荐直接用 LangChain 的 Agent 封装。目前稳定推荐的做法是 create_tool_calling_agent 加 AgentExecutor。
from langchain.agents import AgentExecutor, create_tool_calling_agent from langchain_core.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_messages([ ("system", "你是智能助手。回答需要实时数据时,必须调用工具获取,工具结果要如实转述。"), ("placeholder", "{chat_history}"), ("human", "{input}"), ("placeholder", "{agent_scratchpad}"), ]) agent = create_tool_calling_agent(llm, tools, prompt) executor = AgentExecutor(agent=agent, tools=tools)这个 Prompt 模板有几个占位符,缺一个都会报错。{input} 是用户输入,{chat_history} 是多轮对话的上下文,{agent_scratchpad} 是 Agent 循环里的“草稿纸”,专门存放中间的思考过程和工具调用记录。最后这个最容易漏,手写 Prompt 时经常忘记加。
调用方式和普通链一致:
response = executor.invoke({"input": "韶光市今天天气怎么样?适合穿外套吗?"}) print(response["output"])AgentExecutor 内部做了这样几件事:第一轮让模型出 tool_calls,模型说要调用 get_weather,它就执行该函数,把结果包装成 ToolMessage 追加到消息列表;第二轮再让模型基于工具结果生成回答。如果模型第一轮就没有输出 tool_calls,AgentExecutor 直接把回答返回,循环终止。
4.3 ReAct 循环、停止条件与开销控制
Agent 的循环本质上是一种“思考-行动-观察”结构,也就是 ReAct。模型先思考当前任务需要什么信息,决定调用哪个工具,拿到工具返回的观察结果后继续思考,直到它认为信息足够,不再请求调用工具,才生成最终回答。
有个初学者很容易困惑的点:为什么模型明明可以直接回答,还要先调一下工具?这不是多此一举。比如你问“韶光市适合穿外套吗”,模型判断这个问题依赖实时天气,所以先查天气再回答,把“瞎猜”变成“有依据的推断”。这正是 Tool 存在的核心理由。
为了控制循环次数和成本,AgentExecutor 提供了 max_iterations 参数,默认是 15。我一般会在业务场景里调小,比如 5 到 8,防止模型在一个问题上反复调用工具导致时间拉长、费用失控。如果观察到日志里有大量重复调用同一个工具的行为,基本可以断定是工具描述没写好,模型在拿不到有效信息时只能反复尝试。
5.1 load_tools 与内置工具
很多基础能力其实不用自己实现。LangChain 提供了 load_tools 接口,按名字加载一批预设工具。
from langchain.agents import load_tools tools = load_tools(["requests_all", "human"])requests_all 类工具适合让模型访问指定的 HTTP 接口;human 是个特殊工具,模型遇到不确定的事时可以反过来向人提问确认——这个工具在需要人工介入审核的业务里特别好用。
搜索类工具也有内置版本,但注意:这类工具背后往往绑定具体搜索服务商的账号和套餐。加载前一定要先确认环境变量配好、套餐计费方式能接受,否则调用时要么直接报鉴权错误,要么上线后每搜一次都在烧钱。我建议先看文档确认服务商,再决定用哪个加载名。
注意:load_tools 返回的是工具列表,本质和你手写的一样,只是省了定义过程。但你无法控制它们内部的实现细节,所以生产环境里涉及敏感数据或核心业务的,还是建议自己写工具,便于审计和加兜底逻辑。
5.2 Toolkit:为什么要按场景打包工具
Toolkit 是“工具包”,本质是围绕某个数据源或系统的一组工具的集合。比如操作数据库,会包含列出表结构、执行查询、查看表数据等一组工具;操作文件系统,会包含列目录、读文件、写文件等一组工具。
用 Toolkit 最大的收益是工具之间职责划分清晰。让模型查一个业务指标,它需要先调用“列出现有表”了解有哪些数据,再调用“执行查询”取数。如果只给一个巨大的全能工具,模型反而容易在参数上翻车——工具拆得越细,模型每个决策点的负担越轻。
我在项目里用 Toolkit 的典型场景有两个。一是接入企业已有系统时,把系统能力按“元数据查询”“实际操作”“权限校验”拆成多个工具;二是做复杂任务拆解,让模型能组合多个工具完成一个用户请求,而不是要求一个工具把所有事情做完。
不过也要提醒一句:工具数量并不是越多越好。工具数量一多,模型的选择空间变大,误调用的概率也上升。每增加一个工具,都应该有明确的触发场景,并且在测试集上跑一遍回归,看它会不会被错误触发。
5.3 缓存、错误处理与安全边界
工具如果很费时或者很贵(比如调用外部付费接口),建议做缓存保护。
from langchain_core.cache import InMemoryCache from langchain.globals import set_llm_cache set_llm_cache(InMemoryCache())要注意这里缓存的是模型层面对相同请求的计算结果。如果只想对某个昂贵工具的执行结果做缓存,更直接的方式是在函数内部自己做判断:以城市名加日期作为 key,命中就直接返回上一次的结果,没命中再发真实请求。
错误兜底也是必须考虑的。给工具加上 handle_tool_error 后,工具执行抛异常时,Agent 会把错误信息返回给模型,由模型决定怎么应对,而不是整个调用链直接崩溃。
@tool(handle_tool_error="工具执行失败,请将失败原因告知用户并停止后续操作") def get_weather(city: str) -> str: ...最后是安全边界。工具一旦接上 Agent,模型就能调度它,而模型可能被用户的极端输入引导去做意料之外的事。所以工具的入参必须做强校验:长度、格式、取值范围、权限都要在函数内部把关。永远不要写一个接受任意 SQL 或任意 URL 就执行的工具。这个边界意识应该和写工具本身同等重要。
6.1 模型一直不调用工具:先按顺序排查
这是问得最多的一个问题。遇到时别先怀疑框架,按顺序查:
第一,确认模型服务商支持工具调用能力,不是所有模型都原生支持 Function Calling。第二,确认工具列表真的传进了 bind_tools,并且返回对象被正确使用——忘记接收绑定结果是常见的低级失误。第三,把系统提示词里“你可以调用工具”的交代写具体,明确告诉模型什么场景下必须先调用工具再回答。第四,检查工具的 name 和 description,模型面对含糊描述时,会选择不调用而不是冒险调用。
我调试时遇到过一种很隐蔽的情况:工具列表里混入了一个参数描述极其复杂的工具,模型评估后认为调用风险高,于是一律不调用任何工具。把它从工具列表里临时移除后,其他工具恢复调用。这就是“一个坏工具拖垮所有工具”的真实案例,排查时可以试试二分法:把工具列表减半,看模型是否恢复调用行为,逐步定位问题工具。
6.2 参数校验与 JSON 解析问题
工具被调用却参数报错,通常报的是 Pydantic 校验错误,比如函数需要 str,模型却传了 int,或者传了未知参数名。原因多半是参数描述不够清楚,模型根据用户原话猜了类型。解决办法在前面已经反复强调:Field 描述里写清楚“传什么”“范围多少”“什么格式”。
函数签名与 args_schema 字段不一致也会报错。Pydantic 模型里定义了 city 和 days,函数却只声明了 city 一个参数,执行时 LangChain 会因为多余的参数键而抛错。保持两边字段名、类型、默认值完全一致,是最基本的检查项。
JSON 解析失败通常不出现在使用 AgentExecutor 的标准流程里,而是出现在你手动解析模型输出时。可以说,自己写正则去解析工具调用是风险最大的做法,我强烈建议直接用框架的 Agent 编排能力,它们内部处理了消息类型转换和解析,比自己维护一套解析逻辑稳健得多。
6.3 Agent 循环异常与上下文超限
循环不终止的典型表现是模型反复调用同一个工具,日志里能看到大量重复执行记录。首先把 max_iterations 调小,避免无限循环烧钱;然后重点检查工具的返回值是否真正回答清了模型的问题。很多时候模型重复调用,是因为工具返回的信息不全,它拿不到关键字段只能再试一次。
上下文超限则是工具返回太长的结果导致的。工具返回的一段完整数据表,会原封不动进入模型上下文,可能把窗口撑爆。解决方法是返回内容精简再精简,给模型的只要结论和关键数字,明细放 artifact 或者单独的文件交互。
处理工具结果还有一个不易察觉的点:工具结果会以 ToolMessage 形式重新进入模型上下文,所以返回文本的语气会影响模型最终回答的语气。我习惯让工具返回中性、客观的事实陈述,不带“亲,好消息”这类带情绪的文案,模型转述时会更可控。
6.4 排查速查表
| 现象 | 先查什么 | 大概率原因 | 处理方向 |
|---|---|---|---|
| 模型不调用工具 | bind_tools 是否生效、模型能力 | 工具描述不清晰或模型不支持工具调用 | 优化 description,换支持工具调用的模型 |
| 工具参数校验报错 | Pydantic 错误信息 | Field 描述不清导致模型填错类型 | 强化参数说明,控制参数数量 |
| 工具执行了但答案不对 | 工具返回内容 | 返回文本丢失关键信息 | 精简结构化返回,减少模型推理负担 |
| 循环反复调用同一工具 | Agent 日志 | 返回值没有满足模型的信息需求 | 补全返回内容,调低 max_iterations |
| 上下文超限 | 工具返回长度 | 返回了整段原始数据 | 只回传摘要,明细放 artifact |
| 异步调用报 NotImplementedError | 工具类实现 | 只写了 _run 没写 _arun | 补全异步方法 |
6.5 调试辅助技巧
调试 Agent 时,打开 verbose 模式是最快的做法:
executor = AgentExecutor( agent=agent, tools=tools, verbose=True, )verbose 会打印模型每一步的思考、工具调用请求、工具返回结果,一眼就能看到循环卡在哪一步。还可以从返回结果里拿中间步骤出来看:
result = executor.invoke({"input": "韶光市今天天气怎么样"}) for step in result.get("intermediate_steps", []): print(step)我现场排查时总是先开 verbose,然后判断是“模型选错工具”还是“工具本身报错”:模型选错工具,去改 description;工具本身报错,去改函数代码。这两类问题的处理方向完全不同,方向错了会浪费大量时间。
另外,拿到中间步骤后,可以打印每一步的消息类型和内容字段,确认是 AIMessage 带 tool_calls,还是 ToolMessage 带执行结果。类型对不上往往是版本差异或者消息被手动构造时漏了字段。
最后分享一点我的使用习惯。如果你是要把 Tool 用到真实业务里,建议先从单个工具跑通闭环,再逐步增加工具和工具包。工具数量一多,调试复杂度是上升的,因为模型的决策空间变大、误定位率也上升。每次新增工具后,至少用真实的用户问题做一轮回归,重点观察它会不会被错误触发。工具描述和参数设计值得像产品文案一样反复打磨——我见过不少团队花大量精力写复杂的工具基类,却不愿意花十分钟把 description 写清楚,最后模型选不准工具,反过来怀疑框架,方向其实从一开始就偏了。