框架与编排 / LangChain 生态 / Agent 构建 / Tool 注册与工具契约
本文转载自 Awesome AI Roadmap,原作者 Polo Li,依 CC BY 4.0 协议共享。

第五章:Tool 注册与工具契约

5.1 Tool 注册的是什么

模型看不到 Python 函数的源码,因此注册工具时,LangChain 会先把函数转换成一份模型能够理解的说明:

部分 谁看 作用
name + description 模型 判断工具叫什么、什么情况下该用
args_schema 模型 生成满足类型和约束的参数
executor(函数/协程) 应用侧 按这些参数执行实际操作
flowchart LR
    M["模型生成调用请求
工具名 + 参数"] --> R["运行时执行函数"] R --> T["结果作为 ToolMessage 返回"] T --> M2["模型决定继续调用
还是生成最终回答"] style M fill:#e8f0fe

工具描述和 Schema 不是普通注释,而是模型与业务代码之间的调用合同。

描述过于模糊,模型可能选错工具;参数缺少约束,模型可能生成无法执行的数据。

5.2 四种定义方式怎么选

不必一开始就继承最底层的类。选择时先判断:这个工具是否仍然只是一个普通函数?

flowchart TB
    Q1{"已有函数的名称、类型注解
docstring 能说清用途吗?"} Q1 -->|能| A["直接放入 tools
(普通函数)"] Q1 -->|不能:要改工具名
补参数描述
限制枚举和范围| Q2{"原函数可以修改吗?
需要运行时动态组装
同步与异步实现吗?"} Q2 -->|可以修改,只是要补契约| B["@tool
大多数业务工具的自然选择"] Q2 -->|原函数不能改
或需要动态组装| C["StructuredTool.from_function"] C --> Q3{"工具要长期持有客户端
维护资源、定制完整执行过程?"} B --> Q3 Q3 -->|是,它已经是一个组件| D["继承 BaseTool"] style B fill:#e6f4ea style D fill:#fff3cd

这四种方式对应的是复杂度逐步上升的实现路径:先让函数把用途说清楚,再补充工具契约,接着处理动态组装,最后才管理组件生命周期。

5.2.1 一个例外

模型厂商提供的 Web Search、代码执行器等服务端工具,有时会使用厂商约定的字典配置。

这类工具属于特定 Provider 的能力。使用时应单独查看对应集成文档,不要把它当作通用 Python Tool 的主要定义方式。

5.3 优先使用 @tool 的原因

@tool 能从函数签名和 docstring 自动推导 Schema,也允许通过 Pydantic 显式描述复杂参数。

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}"

agent = create_agent(
    model="<provider>:<your-model-id>",
    tools=[query_order],
)

Pydantic 的字段描述和枚举限制会进入工具 Schema,一举两得:

  • 帮助模型正确填写参数;
  • 在执行前拦截非法输入。

简单函数也可以不加装饰器直接传给 tools,但函数必须有清楚的名称、类型注解和 docstring,否则自动生成的说明很难指导模型正确调用。

5.4 何时使用高级定义

5.4.1 StructuredTool.from_function

适合「原函数不能修改,但需要改变它对模型的呈现方式」:

  • 同一个业务函数需要注册成不同名称;
  • 要把同步函数和异步协程组合成一个工具对象。

5.4.2 BaseTool

当工具不再只是一个函数,而是要:

  • 长期持有数据库或第三方客户端;
  • 同时管理同步、异步、tags、metadata 和回调。

它已经变成了一个有生命周期的组件,此时才值得承担更多样板代码。

5.5 可信参数如何注入

假设「查询我的账户余额」工具需要用户 ID。

如果把 user_id 放进模型可见的 Schema:

  • 模型可能填错用户;
  • 也可能被恶意提示诱导查询其他账户。

5.5.1 两类参数必须分开

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

5.5.2 ToolRuntime 的三个作用域与完整接线

来源 存放
runtime.context 用户身份、租户、依赖——本次调用上下文
runtime.state 当前会话消息和短期状态
runtime.store 跨会话仍要保留的长期数据
from dataclasses import dataclass

from langchain.agents import create_agent
from langchain.tools import ToolRuntime, tool
from langgraph.store.memory import InMemoryStore

@dataclass
class UserContext:
    """仅由已认证的应用边界创建,绝不从聊天文本或 Tool 参数解析。"""
    user_id: str
    tenant_id: str
    permissions: frozenset[str]

@tool
def save_locale(
    locale: str,
    runtime: ToolRuntime[UserContext],
) -> str:
    """保存当前登录用户的界面语言,如 zh-CN 或 en-US。"""
    if locale not in {"zh-CN", "en-US"}:
        return "不支持的语言"
    if "profile:write" not in runtime.context.permissions:
        return "当前用户无权修改偏好"

    # namespace 必须包含租户和用户,避免跨租户/跨用户读取长期数据。
    namespace = ("profile", runtime.context.tenant_id, runtime.context.user_id)
    runtime.store.put(namespace, "locale", {"value": locale})
    return f"已保存语言偏好:{locale}"

@tool
def get_locale(runtime: ToolRuntime[UserContext]) -> str:
    """读取当前登录用户已保存的界面语言。"""
    namespace = ("profile", runtime.context.tenant_id, runtime.context.user_id)
    item = runtime.store.get(namespace, "locale")
    return item.value["value"] if item else "zh-CN"

store = InMemoryStore()  # 仅为可运行示例;生产环境换成持久化 Store。
agent = create_agent(
    model="<provider>:<your-model-id>",
    tools=[save_locale, get_locale],
    context_schema=UserContext,
    store=store,
)

# Web/API 层先验证 session/JWT,再由服务端构造 Context;不要接受客户端声称的 user_id。
authenticated_context = UserContext(
    user_id="u_123",
    tenant_id="tenant_acme",
    permissions=frozenset({"profile:write"}),
)
result = agent.invoke(
    {"messages": [{"role": "user", "content": "把我的语言设为 en-US"}]},
    context=authenticated_context,
)

context_schema=UserContext 是让框架知道 Context 类型的接线;没有它,ToolRuntime[UserContext] 只是工具函数的类型标注。模型只能看到 locale,看不到 runtime、身份或 Store namespace。

可信身份边界在 Agent 外部:认证层验证凭证、查出服务端权限后才构造 UserContext。不要从用户消息、模型输出、工具参数或浏览器传入的 user_id 创建它;工具服务还应对该身份重新执行授权。runtime.state 是当前线程的业务状态,不应用来伪造身份;runtime.store 也不是访问控制系统。

5.6 异步工具怎么处理

搜索、数据库和远程 API 通常是 I/O 密集型。底层客户端支持异步时,工具也应使用原生 async def,并通过 Agent 的 ainvoke 或异步流式接口调用。

最常见的假异步:只把函数声明成 async def,内部却继续调用阻塞式 HTTP 客户端——这种写法不会自动提高并发能力。

工具是否异步,应该和底层客户端以及整条 Agent 调用链保持一致。

5.7 错误应该怎么分类

工具调用失败时,不应先把所有情况都归为可重试错误,因为不同失败意味着完全不同的下一步。

失败类型 例子 正确处理
参数错误 日期格式错误、缺必填字段 先让 Schema 拦截,再把可修正信息交给模型重填
业务结果 库存不足、无权限、订单不存在 不是故障,工具说清原因,让 Agent 换路径或告知用户
临时故障 网络超时、限流、服务不可用 有上限的重试 + 退避 + 总超时
真实缺陷 程序 Bug、数据损坏、权限配置错误 不应统一转成「调用失败」后继续执行,否则掩盖真正的问题

对于付款、发邮件、创建订单等有副作用的工具,还必须设计幂等键和人工审批,避免重试造成重复执行。

5.8 注册后还要检查什么

一个能被 Agent 调用的函数,并不等于一个可以安全上线的工具。 沿着一次真实调用往下走:

flowchart TB
    P1["① 模型准备调用前
名称/描述会不会与其他工具混淆
Schema 有没有限制枚举、范围、必填"] P2["② 进入执行阶段
身份和权限来自可信 Runtime 而非模型参数
远程调用有超时、重试上限、并发限制
会改外部状态就补幂等、审批、审计"] P3["③ 调用结束后
日志与 Trace 要能排查错误
但不能记录密钥、完整身份凭证或不必要的敏感数据"] P1 --> P2 --> P3 style P2 fill:#fff3cd

5.8.1 工具数量不是越多越好

一次性向模型暴露大量相似工具,会增加选择错误和参数混淆的概率。更合理的做法是根据用户权限和当前任务动态缩小工具集合(可用 Middleware 实现,见 第四章)。

5.9 常见错误

5.9.1 把工具描述当成普通注释

它是模型与业务代码之间的调用合同,描述模糊模型就会选错。

5.9.2 一上来就继承 BaseTool

四种方式是随复杂度上升的路径,绝大多数业务工具 @tool 就够了。

5.9.3 把 user_id 放进模型可见 Schema

模型可能填错,也可能被恶意提示诱导——可信参数必须从 Runtime 注入。

5.9.4 混淆 context / state / store

分别对应本次调用上下文、会话短期状态、跨会话长期数据。

5.9.5 假异步

async def 里包阻塞客户端,并发能力不会凭空提高。

5.9.6 把所有失败统一重试

业务结果不该重试,真实缺陷更不该被掩盖成「调用失败」。

5.9.7 重试没有退避和总超时

Agent 只会在一个坏掉的服务前反复等待。

5.9.8 有副作用的工具没有幂等键

一次重试就可能变成重复扣款、重复发信。

5.9.9 一次性暴露几十个相似工具

选择错误和参数混淆的概率随之上升,应按权限和任务动态裁剪。

5.9.10 日志里记录敏感数据

Trace 要能排查问题,但不能落密钥和完整身份凭证。

5.10 本章总结

  1. Tool = 模型可见的调用合同 + 运行时可执行函数,name / description / args_schema 给模型看,executor 给应用跑;
  2. 四种定义方式是一条复杂度递增的路径:普通函数 → @tool → StructuredTool → BaseTool;
  3. @tool 是大多数业务工具的首选,Pydantic 字段描述与枚举既指导模型也拦截非法输入;
  4. StructuredTool 解决运行时组装(改名、同步异步合一),BaseTool 解决组件生命周期;
  5. 参数必须二分:任务参数由模型生成,可信参数由运行时注入;
  6. ToolRuntime 三作用域:context_schema 定义并接入可信的调用上下文,context(调用上下文)、state(会话状态)、store(跨会话长期)各司其职;
  7. 异步要真异步,底层客户端、工具、调用链三者一致;
  8. 错误分四类:参数错误、业务结果、临时故障、真实缺陷——处理方式完全不同;
  9. 有副作用的工具必须幂等 + 审批 + 审计;
  10. 上线检查沿一次真实调用走:选择前看契约、执行时看权限与限流、结束后看日志脱敏;
  11. 工具数量要治理,按权限和任务动态缩小可见集合。

注册工具时,更关键的是把「模型看得见的契约」和「服务端执行的安全边界」彻底分开:前者决定模型能否选对,后者决定系统是否安全。

参考资料