Day 13 / 共 20 天 · 阶段4 工具与 Agent

Tool 抽象:@tool 一贴,普通函数就有了模型能读懂的"说明书"

进入阶段4。RAG 给模型"喂材料",工具给模型"发扳手"——让它能查天气、算数、调 API。今天读 libs/core/langchain_core/tools/BaseTool 抽象:一个工具 = 名字 + 说明书 + 参数 schema + 执行体;②@tool 装饰器怎么把普通 Python 函数自动变成工具;③StructuredTool 怎么从函数签名和 docstring 里"抄"出 args_schema。今天只讲"工具长什么样、怎么造",明天 Day14 讲模型怎么"下单"用它。

📍 你在 20 天里的位置(阶段4:工具与 Agent · D13-16)
S1 LCEL S2 模型·消息·提示 S3 数据与 RAG D13 Tool 抽象 D14 工具调用 D15 Agent D16 记忆 S5 进阶收官
💡 先用两个类比兜住今天 类比一:Tool 像挂在货架上的电动工具,必须配"产品标签"。模型是个聪明但两手空空的师傅——它自己拧不了螺丝(查不了实时天气、连不了数据库)。你把工具挂上货架给它挑,每件工具必须贴三样东西:品名(name:get_weather)、用途说明(description:"查询指定城市当前天气")、插口规格(args_schema:需要传 city,字符串)。师傅只看标签决定用哪件、怎么插电——标签写不清,师傅就用错或不用。类比二:@tool 装饰器像"自动贴标机"——你写一个普通函数,它把函数名当品名、docstring 当用途说明、类型注解当插口规格,唰一下标签全贴好。今天的源码就是拆这台贴标机。
L01

痛点:模型知识有截止日期、双手够不着外界

🤔 痛点用户问"上海现在多少度?"——模型的训练数据停在过去,它要么拒答要么编一个。问"订单 #8817 发货了吗?"——数据在你家数据库里,模型根本看不见。解决思路大家都知道:让模型调用外部函数。但马上遇到工程问题:函数是 Python 写的,模型只认文本——怎么把"这里有个函数、它叫什么、干什么用、要什么参数"讲给模型听?又怎么把模型"我要调它,参数是 XX"的意图变成一次真实的函数执行?
💡 本质:工具 = 可执行体 + 机器可读的自我描述LangChain 的答案分两半。描述侧:每个工具携带 name/description/args_schema(一个 Pydantic 模型),可以序列化成 JSON Schema 发给模型——这就是"说明书"。执行侧:工具继承 Runnable,有标准的 invoke/run 执行入口和回调埋点。而 @tool 装饰器的全部魔法,就是利用 Python 的反射(inspect 签名 + 类型注解 + docstring),把你"本来就写了"的信息自动搬进说明书——不用你手写一遍 JSON Schema。
L02

BaseTool:一个工具的法定构成

基类在 libs/core/langchain_core/tools/base.py:427。先看它"是什么"和"带什么":

# libs/core/langchain_core/tools/base.py:427(字段裁剪汇总)
class BaseTool(RunnableSerializable[str | dict[str, Any] | ToolCall, Any]):
    """Interface LangChain tools must implement."""

    name: str                                   # :468 品名:模型用它点名调用(唯一)
    description: str                            # :471 用途说明:模型靠它决定"何时用这件工具"
    args_schema: ArgsSchema | None = Field(     # :477 插口规格:参数的 Pydantic 模型
        default=None, description="The tool schema.")
    return_direct: bool = False                 # :489 结果直接返回用户,不再交回模型(Day15 会用到)
    response_format: Literal["content", "content_and_artifact"] = "content"  # :541

    @abstractmethod
    def _run(self, *args: Any, **kwargs: Any) -> Any:   # :877 ★子类唯一必须实现的:真干活
        ...
泛型入参三选一类签名说输入可以是 str | dict | ToolCall:字符串(单参数工具的便捷写法)、字典(多参数)、或者模型返回的 ToolCall 对象——第三种是明天闭环的关键伏笔。
name + description这两个字段是给模型看的,不是给人看的。模型在几件工具里挑哪件,全凭 description 的文字描述——所以它是"提示词的一部分",写得含糊模型就乱用。
args_schema一个 Pydantic BaseModel 子类(或 JSON Schema dict)。三重用途:①序列化成 JSON Schema 发给模型;②模型传参回来时做校验;③tool.args 属性(base.py:602)暴露给你调试看。
_run 抽象方法又见模板方法!和 Day12 的 Retriever 一样:公开入口 run(L05)管回调/校验/异常,_run 只管业务逻辑。继承 RunnableSerializable 则让每件工具都是标准 Runnable,能单独 invoke、能进链。
大白话BaseTool 读成一张"上架审核表":没有品名不能上架(name 必填)、没有用途说明不能上架(description 必填)、要么声明插口规格要么走默认、必须真的能干活(_run 必须实现)。凡是过了审的,货架(明天的 bind_tools)照单全收。
L03

@tool 装饰器:自动贴标机的传送带

手写 BaseTool 子类太啰嗦,99% 的场景用 @tool。实现在 libs/core/langchain_core/tools/convert.py:77(前面 :18/:32/:48/:63 是四个 @overload 类型重载,真身是第五个):

# libs/core/langchain_core/tools/convert.py:77
def tool(
    name_or_callable=None,          # 既支持 @tool 也支持 @tool("自定义名")
    runnable=None, *args,
    description=None,               # 不给就用函数 docstring
    return_direct=False,
    args_schema=None,               # 不给就从函数签名推断
    infer_schema=True,
    response_format="content",
    parse_docstring=False, ...
) -> BaseTool | Callable[..., BaseTool]:

它的核心分拣逻辑(convert.py:297-319,裁剪)——判断你给的是同步函数、异步函数还是 Runnable,最后统一送进 StructuredTool.from_function

# libs/core/langchain_core/tools/convert.py:297(裁剪)
elif inspect.iscoroutinefunction(dec_func):
    coroutine = dec_func            # async def → 装进 coroutine 槽
    func = None
    schema = args_schema
else:
    coroutine = None
    func = dec_func                 # 普通 def → 装进 func 槽
    schema = args_schema

if infer_schema or args_schema is not None:
    return StructuredTool.from_function(     # ★绝大多数工具的最终归宿
        func, coroutine,
        name=tool_name,                      # 默认 = 函数名
        description=tool_description,        # 默认 = docstring
        args_schema=schema,
        infer_schema=infer_schema, ...)
四个 @overload只为类型提示服务:让 @tool@tool("名字")@tool(parse_docstring=True) 各种用法在 IDE 里都能推断出正确返回类型。运行时逻辑全在 :77 的真身里。
iscoroutinefunction 分拣同步函数进 func 槽、异步进 coroutine 槽——StructuredTool 两个槽都有(L04),调 invoke 走 func、ainvoke 走 coroutine,一件工具同时支持同步异步。
Runnable 也能变工具:280 附近还有一段:传入 Runnable 时自动包一层 invoke_wrapper,schema 直接用 runnable.input_schema——你 Day03 学的任何链都能一键上货架。
description 的优先级docstring 写在 :118-121:显式 description 参数 > 函数 docstring > args_schema 的描述。所以给工具函数写好 docstring 不是代码洁癖,是在写提示词。
L04

StructuredTool:args_schema 是从函数签名"抄"来的

StructuredToollibs/core/langchain_core/tools/structured.py:40)是 @tool 产物的真身——"函数 + 说明书"的合体:

# libs/core/langchain_core/tools/structured.py:40
class StructuredTool(BaseTool):
    """Tool that can operate on any number of inputs."""

    description: str = ""
    args_schema: Annotated[ArgsSchema, SkipValidation()] = Field(
        ..., description="The tool schema.")     # 这里变成必填(基类里可为 None)
    func: Callable[..., Any] | None = None       # 同步执行体
    coroutine: Callable[..., Awaitable[Any]] | None = None   # 异步执行体

    def _run(self, *args, config, run_manager=None, **kwargs):   # :74
        if self.func:
            ...
            return self.func(*args, **kwargs)    # ★_run 就是转调你的原函数

造它的工厂 from_functionstructured.py:133)里,最关键的是"推断 schema"这几行(structured.py:203):

# libs/core/langchain_core/tools/structured.py:202
name = name or source_function.__name__          # 品名 = 函数名
if args_schema is None and infer_schema:
    args_schema = create_schema_from_function(   # ★读函数签名,现场生成 Pydantic 模型
        name, source_function,
        parse_docstring=parse_docstring,         # True 时还会解析 Google 风格 docstring
        error_on_invalid_docstring=error_on_invalid_docstring,
        filter_args=_filter_schema_args(source_function))
...
if description is None and not parse_docstring:
    description_ = source_function.__doc__ or None    # 说明书 = docstring
create_schema_from_function★贴标机的心脏(定义在 tools/base.py:265 附近的辅助区):用 inspect 读函数签名——参数名、类型注解、默认值——动态构造出一个 Pydantic 模型类。def get_weather(city: str, unit: str = "c") 就变成"必填 city:str,选填 unit:str 默认 c"。
parse_docstring=True还能更进一步:解析 Google 风格 docstring 的 Args: 段,把每个参数的中文描述也塞进 schema——模型看到的参数说明更丰富,传参更准。
func / coroutine 双槽_run(:74)转调 self.func_arun(:101)转调 self.coroutine;只给了同步版时,ainvoke(:60)自动 run_in_executor 降级——和 Embeddings 的异步兜底同款套路,全框架一致。
📝 真实值:贴完标签的工具长这样 @tool
def get_weather(city: str, unit: str = "c") -> str:
    """查询指定城市当前天气。"""
    return f"{city} 26 度,晴"
造出来的是 StructuredTool 实例:get_weather.name == "get_weather"get_weather.description == "查询指定城市当前天气。"get_weather.args == {"city": {"title": "City", "type": "string"}, "unit": {"default": "c", "title": "Unit", "type": "string"}}。直接执行:get_weather.invoke({"city": "上海"})"上海 26 度,晴"。注意:它已经不是函数了,想本地直调要用 .invoke().func()
L05

run:执行一次工具,杂务全在壳里

执行入口 BaseTool.runlibs/core/langchain_core/tools/base.py:977)——又一次模板方法,骨架如下:

# libs/core/langchain_core/tools/base.py:977(裁剪骨架)
def run(self, tool_input, ..., tool_call_id=None, **kwargs) -> Any:
    callback_manager = CallbackManager.configure(...)        # ① 装回调
    run_manager = callback_manager.on_tool_start(            # ② 记"工具开始"(含入参)
        {"name": self.name, "description": self.description},
        tool_input_str, ..., tool_call_id=tool_call_id)
    try:
        tool_args, tool_kwargs = self._to_args_and_kwargs(   # ③ ★解析+schema 校验入参
            tool_input, tool_call_id)
        response = context.run(self._run, *tool_args, **tool_kwargs)  # ④ ★真干活
        if self.response_format == "content_and_artifact":   # ⑤ 双通道返回的拆包
            content, artifact = response
        ...
    # (异常处理 / ToolException 兜底 / on_tool_end 上报,略)
_to_args_and_kwargs③ 里干了两件事:_parse_inputbase.py:752)用 args_schema 校验入参——模型传错类型、漏必填参数,在这一步被拦下来抛清晰错误,而不是让你的函数收到脏数据;再把 dict 拆成位置参数/关键字参数喂 _run
on_tool_start/end工具也有专属回调事件——LangSmith 里每次工具调用是独立节点:用什么参数调的、返回了什么、耗时多少。排查"Agent 为什么抽风"全靠它。
content_and_artifact双通道返回:content 是给模型看的简短文本,artifact 是给程序用的原始大对象(图片、DataFrame)。避免把 10MB 的原始数据塞进对话历史。
tool_call_id 参数注意 run 签名里有个 tool_call_id——它是明天的主角:模型下的"工单号"。今天先记住:run 会把它一路带着,最后用来生成带回执号的 ToolMessage
@tool 贴标机:普通函数 → 带说明书的 StructuredTool 普通 Python 函数 def get_weather(city: str) """查询城市天气""" return ... @tool StructuredTool name = "get_weather" description = docstring args_schema = 签名推断 func = 原函数 (本身也是 Runnable) 说明书 → JSON Schema 发给模型看(Day14) invoke/run → 校验+执行 真正干活(本地)
图注:一件工具两副面孔——对模型是"可序列化的说明书",对运行时是"带校验和回调的可执行体"。
⚠️ 坑:没有类型注解 = 说明书残缺def f(city) 不写 : str,schema 推断只能猜;docstring 不写,description 就是空的——模型面对一件"三无产品",要么不用它,要么乱传参。@tool 的自动化质量 = 你函数签名和 docstring 的质量。另外工具名会被点名调用,别用 lambda(没有名字)也别重名。
L06

串起来 + 今日小结

角色位置一句话
BaseTooltools/base.py:427工具的法定构成:name/description/args_schema + 抽象 _run
@tooltools/convert.py:77自动贴标机:分拣同步/异步/Runnable → 送去 from_function
StructuredTooltools/structured.py:40@tool 的产物:func/coroutine 双槽 + 必有 args_schema
from_functiontools/structured.py:133用 create_schema_from_function 从签名/docstring 抄出说明书
runtools/base.py:977执行壳:回调 + schema 校验 + 调 _run + 异常兜底

👶 小白:description 随便写写不行吗?反正真正干活的是代码。

👨‍🏫 老师:恰恰相反——description 是工具最重要的部分。代码只决定"调用之后对不对",description 决定"模型会不会调、什么时候调"。模型选工具的过程就是读一遍所有工具的 name+description+参数说明,然后凭语义判断。写"查天气"太干,写"查询指定城市的当前实时天气(温度/天气状况),city 传中文城市名",命中率天差地别。把 description 当成写给模型的提示词来打磨,是工具工程的第一课。

🧠 今天你应该能回答

  • 一个 Tool 的法定构成?(name 品名 + description 用途说明 + args_schema 参数规格 + _run 执行体)
  • @tool 装饰器从函数身上"抄"了哪三样?(函数名→name、docstring→description、签名注解→args_schema)
  • @tool 产出的具体类型是什么?(StructuredTool,func/coroutine 双槽)
  • args_schema 的三重用途?(发给模型的 JSON Schema / 入参校验 / .args 调试查看)
  • run 和 _run 的分工?(模板方法:run 管回调+校验+异常,_run 只管业务)
  • 为什么 docstring 和类型注解必须认真写?(它们就是模型看到的说明书,决定工具会不会被正确使用)

✋ 10 分钟动手

cd /Users/bitmart/work/codes/github/AI_WORK/langchain

# 1. BaseTool 的字段们
sed -n '427,480p' libs/core/langchain_core/tools/base.py
sed -n '875,880p' libs/core/langchain_core/tools/base.py    # 抽象 _run

# 2. @tool 真身与分拣逻辑
sed -n '77,95p'   libs/core/langchain_core/tools/convert.py
sed -n '297,320p' libs/core/langchain_core/tools/convert.py

# 3. StructuredTool:schema 推断
sed -n '40,60p'   libs/core/langchain_core/tools/structured.py
sed -n '200,215p' libs/core/langchain_core/tools/structured.py
明日预告 · Day 14:工具造好了,怎么让模型用起来?明天走完整个闭环:bind_tools 把说明书发给模型 → 模型回一条带 tool_calls 的 AIMessage(开工单)→ 你执行工具 → 用 ToolMessage(带工单号回执)贴回对话 → 模型看着结果说人话。四种消息缺一环都闭不了环。
← Day 12 Retriever 与 RAG 链 Day 14 · 工具调用闭环 →