自定义 Tool:从函数封装到带状态写回的执行接口
好工具不是“能被模型调用”就够了,真正决定效果的是接口边界、参数约束、错误处理,以及它能否和运行时上下文、安全策略、状态更新配合工作。
01.Tool 的关键不在“怎么写”,而在“怎么约束”
很多人第一次做 Agent 工具,会把重点放在代码形式上:
- •是不是要用装饰器
- •要不要写 Pydantic schema
- •是不是要接
ToolNode
这些都重要,但它们还不是核心。真正决定工具是否好用的,是下面四件事:
- •这个工具职责是否单一
- •参数是否稳定、可校验
- •失败时是否能被系统理解和处理
- •是否能接入运行时上下文、记忆和状态更新
如果这些边界没设计好,再多工具也只是让模型更容易选错、系统更难排障。
所以我更倾向于把 Tool 看成“面向模型的业务接口”,而不是“给模型调用的 Python 函数”。
02.最简单的工具:`@tool` 已经够很多场景起步
LangChain 当前官方文档里,最基础的工具定义方式仍然是 @tool。它适合做职责清晰、参数不复杂的动作。
from langchain.tools import tool
@tool
def get_weather(city: str) -> str:
"""查询指定城市天气。"""
return f"{city} 当前天气晴。"这个写法的优点是直接:
- •docstring 会成为工具描述
- •参数签名会自动转成工具 schema
- •很适合起一个最小 demo
但如果你准备把它用进真实系统,下一步通常不是“加更多工具”,而是先把工具描述和参数边界写清楚。
03.当参数变复杂时,要尽早结构化
一旦工具开始接收多个字段、可选项或枚举值,就不该继续依赖模糊的自由文本参数了。更稳妥的方式是把输入 schema 明确下来。
from typing import Literal
from pydantic import BaseModel, Field
from langchain.tools import tool
class SearchTicketsInput(BaseModel):
query: str = Field(description="要搜索的问题描述")
limit: int = Field(default=5, ge=1, le=20, description="返回数量")
source: Literal["incident", "knowledge_base"] = Field(
default="incident",
description="查询来源"
)
@tool(args_schema=SearchTicketsInput)
def search_tickets(query: str, limit: int = 5, source: str = "incident") -> str:
"""搜索工单或知识库中的相关记录。"""
return f"从 {source} 中找到 {limit} 条与 {query} 相关的记录"这里最有价值的不是“写法更高级”,而是:
- •模型更容易知道每个字段该填什么
- •校验失败能更早暴露
- •工具契约更适合长期维护
经验上,只要工具开始进入真实业务链路,结构化输入几乎总是值得的。
04.不要把环境依赖硬编码进工具
工具真正进入系统后,通常需要一些运行时依赖,例如:
- •当前用户 ID
- •数据库连接
- •环境配置
- •长期记忆 store
LangChain 当前官方 runtime 文档推荐的做法,是通过 ToolRuntime 注入这些信息,而不是写全局变量或从环境里到处读。
from dataclasses import dataclass
from langchain.tools import tool, ToolRuntime
@dataclass
class Context:
user_id: str
@tool
def fetch_user_preferences(runtime: ToolRuntime[Context]) -> str:
"""读取当前用户偏好。"""
user_id = runtime.context.user_id
if runtime.store:
memory = runtime.store.get(("users",), user_id)
if memory:
return memory.value["preferences"]
return "用户未设置偏好"这个模式的好处非常实际:
- •工具更容易测试
- •不依赖全局状态
- •同一个工具可以服务不同用户或不同环境
- •后续接入长期记忆会更自然
对工程项目来说,这类“可注入上下文”往往比多一个工具功能更重要。
05.工具不只会“返回字符串”,也可以参与状态更新
很多入门示例都默认工具返回一段字符串,然后交给模型继续读。但在 LangGraph 场景里,工具还有另一类更值得关注的能力:直接影响图状态。
当前官方 tools 文档提到,工具返回值可以是:
- •纯字符串
- •结构化对象
- •
Command
当你只是想给模型看结果时,字符串就够了;当你希望工具直接修改状态或触发流程变化时,Command 会更合适。
from langgraph.types import Command
from langchain.tools import tool
@tool
def classify_priority(score: int) -> Command:
"""根据分数设置工单优先级。"""
priority = "high" if score >= 80 else "normal"
return Command(update={"ticket_priority": priority})这个思路对复杂工作流尤其重要,因为它意味着工具不再只是“供模型阅读的结果”,而是可以成为状态迁移的一部分。
06.在 LangGraph 里,工具执行最好交给 `ToolNode`
如果你的系统已经进入 LangGraph 编排阶段,通常不需要自己再写一层通用工具执行器。官方 tools 文档推荐直接使用 ToolNode。
它的价值主要在于:
- •自动执行模型发出的工具调用
- •支持并行工具执行
- •统一错误处理
- •自动注入状态和运行时上下文
一个最小骨架如下:
from langchain.tools import tool
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.graph import StateGraph, MessagesState, START
@tool
def search(query: str) -> str:
"""搜索资料。"""
return f"Results for: {query}"
tool_node = ToolNode([search], handle_tool_errors=True)
def call_llm(state: MessagesState):
...
builder = StateGraph(MessagesState)
builder.add_node("llm", call_llm)
builder.add_node("tools", tool_node)
builder.add_edge(START, "llm")
builder.add_conditional_edges("llm", tools_condition)
builder.add_edge("tools", "llm")
graph = builder.compile()如果你需要高度定制的执行逻辑,当然也可以自己写节点。但在绝大多数常规 Agent 工作流里,ToolNode 已经是一个更稳的默认起点。
07.自定义 Tool 最容易踩的几个坑
1. 工具职责太大
例如一个工具同时负责搜索、过滤、写库、发通知。模型很难选对,也很难在失败后恢复。
2. 参数自由度过高
工具表面上越“通用”,实际越容易被模型误用。能用枚举、默认值和 schema 收紧,就尽量收紧。
3. 直接暴露高风险副作用
删除数据、发正式通知、改生产配置这类操作,不能因为“工具能写出来”就直接开放给模型。必须配合权限、确认和审计。
4. 把错误处理留给模型猜
错误信息至少要做到:
- •能区分输入错误还是执行错误
- •能让上层工作流判断是否需要重试
- •不把内部异常原样暴露给最终用户
这一步做不好,工具一多,系统稳定性会明显下降。
08.一个更可靠的设计顺序
如果你正在给 Agent 系统加自定义工具,我更建议按这个顺序来:
- •先定义业务动作,而不是先想函数名
- •再收敛参数 schema
- •再决定是否需要运行时上下文或 store
- •最后再接入
ToolNode或 agent
这样做的结果通常是:工具数量少一些,但每个都更稳定、更容易解释。
09.总结
Tool 的本质不是“让模型多一个能力点”,而是给系统增加一个新的执行接口。对长期维护的 Agent 项目来说,真正重要的是:
- •接口边界清晰
- •输入可校验
- •错误可处理
- •上下文和状态可注入
只有把这些底层约束做好,工具系统才会成为 Agent 的能力扩展器,而不是新的不稳定来源。