🛠️ 工具体系
LC3 章我们用 bind_tools 把工具绑给模型,但当时用的是手写的 JSON Schema。本章学习 LangChain 的工具抽象——用 @tool 装饰器把普通 Python 函数一键变成工具,自动生成 schema、自动校验、自动执行。
本章目标
- 理解
BaseTool抽象(它本身也是 Runnable) - 熟练使用
@tool装饰器定义工具(最常用方式) - 掌握工具的
_run/invoke/tool_call_schema - 理解工具描述(docstring)为什么对 Agent 至关重要
工具是什么
回顾 F2 章:工具 = 名字 + 描述 + 参数 schema + 执行逻辑。模型看到 schema 决定何时调、传什么参数;应用层执行真正的逻辑。LangChain 的 BaseTool 把这四样打包成一个对象。
BaseTool:工具的根
class BaseTool(RunnableSerializable[str | dict | ToolCall, Any]):
"""所有工具的基类。
注意:它继承 RunnableSerializable —— 工具本身就是一个 Runnable!
输入可以是 str/dict/ToolCall,输出任意。
"""
name: str
"""工具名(模型在 tool_calls 里引用的就是它)"""
description: str
"""工具描述 —— 极其重要!模型靠它判断"什么时候该用这个工具"。
写得好坏直接决定 Agent 能不能正确选工具。"""
args_schema: type[BaseModel]
"""参数 schema(Pydantic 模型),自动转成 JSON Schema 给模型"""
@property
def tool_call_schema(self) -> ArgsSchema:
"""返回给模型看的参数 schema。"""
... # base.py:671
def _run(self, *args, **kwargs) -> Any:
"""真实执行逻辑 —— 子类必须实现(同步版)。"""
raise NotImplementedError
async def _arun(self, *args, **kwargs) -> Any:
"""异步版执行逻辑(可选,默认降级到 _run)。"""
def invoke(self, input, config=None, **kwargs):
"""统一调用入口(继承自 Runnable)。input 可以是 dict/ToolCall。
内部会校验参数,然后调 _run。"""
... # base.py:731
模型完全靠 description 决定何时调用你的工具。描述写得含糊,模型就会用错工具或不用。生产实践里,工具描述要写清:这个工具做什么、什么时候该用、什么时候不该用、输入是什么。OpenCode 每个工具都有专门的 .txt 描述文件(如 read.txt、edit.txt),就是这个道理(见 OC3)。
@tool 装饰器:最常用方式
直接继承 BaseTool 写类很繁琐。99% 的场景用 @tool 装饰器——它从函数签名和 docstring 自动推断 name / description / args_schema:
def tool(
*,
description: str | None = None,
return_direct: bool = False,
args_schema: ArgsSchema | None = None,
infer_schema: bool = True, # 自动从类型注解推断 schema
...
) -> Callable[..., BaseTool]:
"""把普通函数/Runnable 转成 BaseTool。"""
# ============ 用法 ============
from langchain_core.tools import tool
@tool
def search_weather(city: str) -> str:
"""查询指定城市的当前天气。
Args:
city: 城市名称,如"北京"、"上海"
"""
# 这里的 docstring 会变成工具的 description!
# city: str 的类型注解会变成 args_schema
# 实际查询逻辑(这里假装)
db = {"北京": "晴 25度", "上海": "多云 30度"}
return db.get(city, "未知城市")
# 现在 search_weather 是一个 BaseTool 对象
print(search_weather.name) # → "search_weather"
print(search_weather.description) # → docstring 内容
print(search_weather.args_schema.model_json_schema()) # → 参数的 JSON Schema
# 直接调用(也可以用 .invoke)
print(search_weather.invoke({"city": "北京"})) # → "晴 25度"
它做了三件事:① 函数名 → name;② docstring → description;③ 类型注解(city: str)+ Pydantic → args_schema。你只需写带类型注解和好 docstring 的普通函数,其余全自动。这就是"约定优于配置"。
用 Pydantic 增强参数校验
对于复杂参数,可以显式定义 Pydantic 模型,获得更严格的校验和更详细的 schema:
from langchain_core.tools import tool
from pydantic import BaseModel, Field
class SearchInput(BaseModel):
query: str = Field(description="搜索关键词")
max_results: int = Field(default=5, ge=1, le=20, description="最大返回数 1-20")
@tool(args_schema=SearchInput)
def web_search(query: str, max_results: int = 5) -> str:
"""在网络上搜索信息。当需要查找最新资讯或模型不知道的事实时使用。"""
return f"搜索 {query},返回 {max_results} 条结果"
# 模型会看到带约束的 schema:max_results 必须在 1-20 之间
print(web_search.args_schema.model_json_schema())
# 调用时若参数非法,会抛错(而不是传给函数)
返回附件(进阶)
有些工具除了文本结果,还要返回"附件"(如图片、文件路径)。用 response_format="content_and_artifact":
@tool(response_format="content_and_artifact")
def generate_chart(data: list[int]) -> tuple[str, bytes]:
"""生成柱状图。"""
# 返回 (给模型看的文本, 附件如图片字节)
return "图表已生成", b""
# invoke 时用 ToolMessage 取附件
result = generate_chart.invoke({"data": [1, 2, 3]})
# result.content = "图表已生成",result.artifact = b""
InjectedToolArg:注入隐藏参数
有些参数不该让模型填(如 session_id、用户身份),而是由应用层注入。用 InjectedToolArg 标注:
from typing import Annotated
from langchain_core.tools import tool, InjectedToolArg
@tool
def get_user_files(
pattern: str, # 模型填这个
user_id: Annotated[str, InjectedToolArg], # 这个对模型隐藏,应用层注入
) -> str:
"""根据模式列出用户文件。"""
return f"用户 {user_id} 的匹配 {pattern} 的文件"
# user_id 不会出现在给模型的 schema 里
# 但 invoke 时应用层必须提供
get_user_files.invoke({"pattern": "*.py", "user_id": "u123"})
把 @tool 工具绑给模型
现在把 LC3 的 bind_tools 和本章的 @tool 串起来——这才是完整的工作流:
# pip install langchain langchain-openai
# export OPENAI_API_KEY="sk-..."
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_openai import ChatOpenAI
# ① 定义工具(@tool 自动生成 schema)
@tool
def add(a: int, b: int) -> int:
"""计算两个整数的和。当用户要求做加法时使用。"""
return a + b
@tool
def multiply(a: int, b: int) -> int:
"""计算两个整数的乘积。当用户要求做乘法时使用。"""
return a * b
tools = [add, multiply]
# ② 绑定到模型(LC3 学过的 bind_tools)
model = ChatOpenAI(model="gpt-4o-mini")
model_with_tools = model.bind_tools(tools)
# ③ 模型决定调用工具
ai_msg = model_with_tools.invoke("3乘以4等于多少?")
print("模型要调用:", ai_msg.tool_calls)
# → [{'name': 'multiply', 'args': {'a': 3, 'b': 4}, 'id': 'call_xxx'}]
# ④ 应用层执行工具(关键!模型不会自己执行)
tc = ai_msg.tool_calls[0]
tool_to_call = {"add": add, "multiply": multiply}[tc["name"]]
result = tool_to_call.invoke(tc["args"])
print("工具结果:", result) # → 12
# ⑤ 把结果用 ToolMessage 喂回去(LC1 学过)
tool_msg = ToolMessage(content=str(result), tool_call_id=tc["id"])
final = model_with_tools.invoke([
HumanMessage(content="3乘以4等于多少?"),
ai_msg,
tool_msg,
])
print("最终回答:", final.content) # → "3乘以4等于12。"
第 ④⑤ 步——"执行工具 + 把结果喂回去"——是每次都要写的样板代码。这正是下一章 LC7(Agent Schema)和 LC8(AgentExecutor)要解决的核心问题:把这个循环自动化。AgentExecutor 会自动判断 tool_calls、自动执行、自动喂回、自动循环,直到模型不再调工具。
StructuredTool 与其他方式
| 方式 | 源码 | 何时用 |
|---|---|---|
@tool 装饰器 | tools/convert.py:18 | 首选,99% 场景 |
StructuredTool | tools/structured.py:40 | 需要更细粒度控制时 |
继承 BaseTool | tools/base.py:427 | 复杂工具、需要状态时 |
Tool(简单类) | tools/simple.py | 字符串入参的老式工具 |
与生产实践对照
| LangChain 概念 | OpenCode 对应 | |
|---|---|---|
| BaseTool / @tool | → | Tool.define(id, Effect.gen(...))(统一工厂函数) |
| description(docstring) | → | 每个工具的 .txt 文件(read.txt / edit.txt) |
| args_schema 参数校验 | → | tool.ts 的 wrap 做 Schema 校验 + InvalidArgumentsError |
| _run 执行逻辑 | → | Effect.gen 内的工具实现 |
| InjectedToolArg 隐藏参数 | → | Tool.Context(sessionID/agent/messages 自动注入) |
| return_direct | → | 无(OpenCode 工具结果总是回传模型) |
小结
BaseTool= name + description + args_schema +_run(),本身是 Runnable。@tool装饰器是首选——从函数签名和 docstring自动生成 schema。description决定模型能否正确选工具,要写得清晰具体。InjectedToolArg让某些参数对模型隐藏,由应用层注入。- 完整流程:定义
@tool→bind_tools→ 模型输出 tool_calls → 应用层执行 → ToolMessage 喂回。
你已经能定义工具、绑定工具、手动执行+喂回了。但这套循环写起来繁琐。下一章 LC7 · Agent 数据模型:看 LangChain 如何用 AgentAction / AgentFinish 抽象这个循环的本质——为 LC8 的全自动 AgentExecutor 做最后准备。