「LangChain」LangChain 如何为 Agent 注册工具?

0 前言

本文内容整理自公众号「小林面试笔记」的文章 5. 在 LangChain 中,如何为 Agent 注册工具?,用于记录与总结 LangChain 框架相关的核心面试知识点。

在 LangChain 中,如何为 Agent 注册工具?」,可以这样作答:

注册 Tool 的本质是同时向模型提供一份工具说明(名称、用途、参数 Schema)和向运行时提供一个可执行函数:模型依据说明决定是否调用并生成参数,LangChain 负责执行函数、把结果作为 ToolMessage 回传,让模型继续判断。定义方式按复杂度从低到高有四条路径:名称、类型注解、docstring 说清楚的普通函数可以直接放进 tools;需要自定义名称、描述与参数 Schema 时用 @tool(大多数业务工具的首选);原函数不能修改、需要运行时组装同步与异步实现时用 StructuredTool;工具变成长期持有客户端、有生命周期的组件时才继承 BaseTool。参数要分类处理:城市、关键词、订单号等任务参数让模型填写,用户身份、租户、权限等可信参数必须经 ToolRuntime 从应用侧注入,绝不能暴露给模型。此外还要处理异步(原生 async defainvoke,异步性与底层客户端一致)、错误分类(参数问题交模型重填、业务拒绝换路径、临时故障限次重试、程序 Bug 不要统一吞掉)与幂等审批。

上一篇文章我们讲了构建 Agent 的七步——组装那一步里,create_agent(tools=[...]) 里的工具就是这道题的主角。如果说 create_agent 决定了 Agent 的「骨架」,Tools 就决定了它的「手脚」。这道题真正的考察点不是背诵 @toolStructuredToolBaseTool 这几个类名,而是是否理解工具协议,能否根据输入复杂度和工程要求选择合适的实现方式。这篇按「注册了什么 → 四种方式怎么选 → 参数怎么分流 → 异步与错误 → 怎么检查」把工具注册这件事讲透。


1 Tool 注册了什么

先说清楚一件事:模型永远看不到你的 Python 函数源码。注册工具时,LangChain 把函数翻译成模型能理解的说明——name(叫什么)、description(干什么用的)、args_schema(参数怎么填)。调用时模型先看名称和描述决定要不要调用,再按 Schema 生成满足类型与约束的参数;真正执行函数的是应用侧的 executor,执行结果作为 ToolMessage 回到消息状态,模型读完后决定继续调工具还是输出最终回答。

工具调用合同:模型根据名称、描述与参数 Schema 生成调用请求,运行时执行函数并把结果作为 ToolMessage 回传

descriptionargs_schema 在代码里看起来像注释、像格式声明,但它们不是——这是模型与业务代码之间的「调用合同」。合同写得含糊会怎样?描述模糊,模型就分不清该选哪个工具;参数缺约束,模型生成的参数可能根本无法执行。这和点菜是一个道理:模型手上只有一份「菜单」(工具名 + 简介 + 规格说明),菜名起得怪、规格写得乱,客人自然点错菜;而后厨(真正执行函数的地方)只按菜单接单,拦不住任何写错的规格。


2 四种定义方式怎么选

面试官连环追问里有个陷阱:「全部继承 BaseTool,这样最规范」——规范是规范了,查一次天气也要写一个类,全是样板代码。选择的第一步是反问自己:这个工具到底还是不是一个普通函数?

按输入复杂度和工程要求,有四条上升路径:

需求 方式
简单已有函数,名字、类型注解、docstring 说得清楚 直接放进 tools
要自定义名称、补参数描述、用 Pydantic 限枚举/范围 @tool
原函数不能改,但需要运行时组装同步/异步实现 StructuredTool
工具成为组件:长期持有客户端、维护资源、定制执行 继承 BaseTool

这四条是随复杂度上升的路径,不是互相竞争的关系——函数能说清楚就用函数,需要明确 Schema 就用 @tool,需要运行时组装再上 StructuredTool,出现组件生命周期才考虑 BaseTool。多数业务工具根本走不到后两级。

顺带一提:模型厂商的服务端工具(Web Search、代码执行器这类)有时用厂商约定的字典配置,那属于特定 Provider 的能力,按对应集成文档使用即可,不算通用 Python Tool 的主要定义方式。


3 为什么优先使用 @tool

@tool 的价值在「省事 + 可控」:装饰器自动从函数签名和 docstring 推导 Schema,大多数情况下零配置;参数变复杂时,又能显式塞一个 Pydantic 模型进去,把类型、枚举、范围、默认值都写清楚。看个完整的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from typing import Literal

from langchain.agents import create_agent
from langchain.tools import tool
from pydantic import BaseModel, Field


class OrderQuery(BaseModel):
# Field 描述和类型约束都会进入模型看到的工具 Schema
order_id: str = Field(description="要查询的订单号")
detail: Literal["summary", "full"] = Field(
default="summary",
description="返回摘要还是完整信息",
)


# args_schema 显式指定工具参数的校验模型
@tool(args_schema=OrderQuery)
def query_order(order_id: str, detail: str = "summary") -> str:
"""查询订单状态。用户询问某个订单时调用。"""
return f"订单 {order_id} 的状态为已发货,返回模式:{detail}"


# 注册时把 Tool 放进 create_agent 的 tools 列表
agent = create_agent(
model="openai:gpt-5.4-mini",
tools=[query_order],
)

在这个例子里,模型只需要决定两件事:order_id 填什么、detailsummary 还是 full。字段描述帮模型正确填参,枚举限制在参数进函数前就拦住非法输入——这就是 Schema 的「双向作用」:一面指导模型,一面拦截错误:

Schema 清晰度对比:结构化的参数定义帮助模型正确填参,并在执行前拦截非法输入

反过来也提醒一句:已有的简单函数不加装饰器直接传也是合法的,前提是名称清楚、类型注解完整、docstring 说人话——否则自动生成的工具说明很难指导模型正确调用。


4 何时使用高级定义

StructuredTool.from_function 解决的是**「组装」问题**:原函数不能改,但你可以改它面向模型的呈现方式。比如同一个业务函数要注册成两个不同名称、各有各的面向用户的描述;或者想把同步实现和异步协程组合进同一个工具对象——这些用 StructuredTool 拼装即可。

BaseTool 则是另一回事,工具不再只是函数,而是有生命周期的组件:长期持有数据库连接或第三方客户端(连接要在工具生命周期里初始化与释放)、同时管理同步/异步两种执行路径、挂载 tags 与 metadata、定制回调。这时候把模板代码写全才有价值——相当于你的工具从「一道菜」变成了「一家后厨」,值得为它做整套基建。

四种工具定义层级:普通函数、@tool、StructuredTool 与 BaseTool 随复杂度逐级上升

记住这个判断顺序就够:函数能说清楚 → 补工具契约(@tool)→ 运行时组装(StructuredTool)→ 组件生命周期(BaseTool)


5 可信参数如何注入

假设有个工具叫「查询我的账户余额」,它必然需要 user_id。能不能把 user_id 放进模型可见的 Schema,让模型生成?不能,这是信任边界问题:模型可能填错用户(幻觉一个 ID),更危险的是可以被恶意提示诱导——「帮我查一下 admin 的余额」,模型照填,越权查询就发生了。

参数必须分成两类看待:

参数类型 示例 来源
任务参数 城市、关键词、订单号 模型根据用户问题生成
可信参数 用户 ID、租户、权限、当前状态 应用运行时注入

LangChain 的 ToolRuntime 就是注入可信参数的通道——模型看到的 Schema 里只有任务参数,运行时注入的上下文它碰不到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from dataclasses import dataclass

from langchain.tools import ToolRuntime, tool


@dataclass
class UserContext:
# 这些字段由应用运行时提供,不让模型生成
user_id: str
role: str


@tool
def get_balance(
account_type: str,
runtime: ToolRuntime[UserContext],
) -> str:
"""查询当前登录用户的账户余额。"""
# 先使用可信 Context 做权限检查
if runtime.context.role not in {"user", "finance_admin"}:
return "当前用户无权查询余额"

# 用户 ID 来自 Runtime,而不是模型参数
user_id = runtime.context.user_id
return f"用户 {user_id}{account_type} 账户余额为 100 元"

模型能看见并填写的是 account_typeruntime 对它是隐形的:

ToolRuntime 信任边界:任务参数由模型填写,可信参数经运行时注入,模型不可见

这条边界是工具权限控制的地基:可信身份出自应用,不产自模型——就像身份证号不该由你自己现场报给柜台,而是由系统从可信库存里调出来。


6 异步工具怎么处理

搜索、数据库、远程 API 都是 I/O 密集型操作。当底层客户端支持异步时,工具应该写成原生 async def,再经 Agent 的 ainvoke 或异步流式接口调用——整条调用链的异步性保持一致。

这里有一个高频反模式:方法只声明了 async def,内部却调用阻塞式 HTTP 客户端。这不会自动提高并发——事件循环被阻塞调用卡住,异步就名存实亡。工具的异步性不取决于 async 关键字,而取决于底层客户端和整条 Agent 调用链是否真的是异步的。


7 错误应该怎么处理

工具会失败,但不同的失败对应不同的下一步,别上来就统一重试。先把失败分个类:

  • 模型参数问题(日期格式错、必填字段缺失)——这不是工具坏了,是模型没填对。先靠 Schema 拦截一部分,对没拦住的可修正错误,把错误信息交回给模型重新填写;
  • 正常业务结果(库存不足、无权限、订单不存在)——参数完全合法,是业务上拒绝了。工具说清原因,让 Agent 换条路径处理或直接告知用户,重试没有意义;
  • 可恢复的临时故障(网络超时、限流、服务暂不可用)——这才是重试的对象:有上限的重试 + 退避 + 总超时,否则会在一个坏掉的服务前面反复空等;
  • 程序 Bug、数据损坏、权限配置错误——不应统一转成「调用失败」然后继续跑,那会掩盖真问题,让线上的错悄无声息。

一句话:别一疼就吃止痛药——参数错了让模型重开药方,业务拒绝了换一家,只有网络抖动才值得原地重试。另外,有副作用的工具(付款、发邮件、创建订单)必须设计幂等键和人工审批,否则重试机制本身就是事故源——重复扣款、重复发信就是这么来的。


8 注册后还要检查什么

工具放进 tools 不是终点。沿着一次真实调用检查,而不是背清单:

  • 调用前:名称和描述会不会和其他工具混淆?Schema 是否限制了枚举、范围、必填项?模型看到的菜单明确吗?
  • 执行中:用户身份和权限来自可信 Runtime 而不是模型参数?远程调用有没有超时、重试上限、并发限制?改变外部状态的动作补上幂等、审批与审计了吗?
  • 调用后:日志与 Trace 能帮忙定位,但不记录密钥、完整身份凭证和不必要的敏感数据——从模型选择、业务执行到事后追踪,闭环要完整。

最后一个治理原则:工具数量不是越多越好。一次性暴露一堆相似工具,只会增加模型选错的概率和参数混淆的风险;更合理的做法是按用户权限和当前任务动态缩小工具集合——就像餐厅不会把整本菜单都给到每个食客,按身份和场景只递上该看的那几页。


9 面试总结

回答这道题,最大的雷就是为了「规范」盲目继承 BaseTool,或者让模型生成可信身份参数。先避开几个误区:

误区 正解
全部继承 BaseTool 最规范 大多数业务工具 @tool 就够;BaseTool 是工具成为有生命周期组件(持客户端、管资源、定制执行)时才值得的样板代码
工具描述和 Schema 随便写写 它们是模型与业务代码之间的调用合同:描述模糊选错工具,参数缺约束生成无法执行的调用
让模型生成 user_id 等可信参数 可信身份必须由应用运行时经 ToolRuntime 注入;模型生成既可能填错,也可能被提示注入利用
工具失败就统一重试 分四类:参数问题交模型重填、业务拒绝换路径、临时故障限次重试 + 退避 + 总超时、程序 Bug 不要吞掉
工具注册得越多能力越强 相似工具越多选择错误率越高;按用户权限与当前任务动态缩小工具集合

再按这张框架组织答案:

环节 要点
注册的本质 模型可见的调用合同(name/description/args_schema)+ 运行时可执行函数;结果经 ToolMessage 回传模型继续判断
四层定义路径 普通函数(能说清直接传)→ @tool(补工具契约,Pydantic 限枚举范围)→ StructuredTool(运行时组装同步/异步)→ BaseTool(组件生命周期)
参数分流 任务参数(城市/关键词/订单号)由模型生成;可信参数(用户 ID/租户/权限/当前状态)经 ToolRuntime 注入,对模型不可见
异步处理 底层客户端支持就用原生 async def + ainvoke;阻塞客户端声明 async 不会自动并发,异步性要与调用链一致
错误分类 参数问题→Schema 拦截 + 模型重填;业务拒绝→说清原因换路径;临时故障→限次重试 + 退避 + 总超时;程序 Bug→暴露修复
检查闭环 调用前 Schema 是否限制枚举/必填;执行中身份来自可信 Runtime、有超时与并发限制、副作用幂等审计;调用后 Trace 排查但不记敏感数据;工具集合动态缩集

追问预案

  • 「已有的普通函数能不能直接当工具?」——能,但前提是名称清楚、类型注解完整、docstring 说人话,否则自动生成的工具说明没法指导模型;需要自定义契约时用 @tool,不能改原函数则用 StructuredTool.from_function。
  • 「什么时候值得写 BaseTool?」——当工具从「函数」变成「组件」:长期持有数据库或第三方客户端、需要管理资源生命周期、定制同步/异步执行并挂 tags/metadata/回调。在出现这些需求之前,写类只是样板代码。
  • 「为什么 user_id 不能放进参数 Schema?」——可信身份不能由模型生成:模型可能填错用户,也可能被提示注入诱导越权查询;必须由应用经 ToolRuntime 注入,模型只决定任务参数。
  • 「工具写成 async def 就自动并发了吗?」——不一定。内部调用阻塞式客户端时事件循环照样被卡住;异步性取决于底层客户端与整条调用链(应走 ainvoke),而不是一个 async 关键字。
  • 「工具报错要不要重试?」——先分类:参数问题重试无意义,应把可修正信息交给模型重填;业务拒绝该换路径或告知用户;只有网络超时、限流这类临时故障值得限次重试 + 退避;程序 Bug 要暴露出来修复,不能统一吞掉,副作用工具还要靠幂等键与人工审批兜底。

参考

本文图片均来源于公众号「小林面试笔记」,版权归原作者所有。


「LangChain」LangChain 如何为 Agent 注册工具?
https://marisamagic.github.io/2026/08/26/20260826_LangChain工具注册/
作者
MarisaMagic
发布于
2026年8月26日
许可协议