1. 本章目标 #
第 2 章已经用普通函数当过工具;第 4 章手写过 bind_tools + ToolMessage 循环。
那些示例能跑,但业务工具一多就会碰到:
- 工具名、描述、参数说明写糊了,模型选错或乱填参
- 复杂入参需要枚举、默认值、嵌套字段,单靠
city: str不够 - 工具内部抛异常时,Agent 直接崩掉,而不是让模型改参重试
- 同一套工具既要给
create_agent用,也要能单独测、单独绑到模型上
可以把问题分成两半:
| 问题侧 | 典型症状 | 本章对应节 |
|---|---|---|
| 定义侧 | 选型错、参数飘 | §3~5 |
| 挂载 / 执行侧 | 调了没执行、执行崩了 | §6~9 |
本章把「工具」本身讲透:
用
@tool定义可复用业务工具:写清 schema、学会bind_tools、处理好错误,并组装成业务工具包。
学完你应能:
- 用
@tool定义工具(名称、描述、返回值) - 用类型注解 / Pydantic
args_schema约束参数 - 说清模型到底「看到」了工具的哪几样东西,以及 docstring 里的
Args:段默认并不会变成参数说明 - 用
bind_tools让模型发起工具调用(并理解与 Agent 的分工) - 处理工具执行失败(防御性返回 /
wrap_tool_call),知道不处理时整个 Agent 会直接崩 - 产出一套可挂到 Agent 上的业务工具包
本章有几条实测结论会让人意外,先摆在这里,读到对应小节时会有完整证据:
| 你可能以为 | 实际情况 | 见 |
|---|---|---|
docstring 里写了 Args:,模型就能看到每个参数的说明 |
默认整段 docstring 塞进描述,参数一个 description 都没有 |
§3.3 |
| 工具名随便起,中文也行 | 必须匹配 ^[a-zA-Z0-9_-]+$,中文名会被 API 用 400 拒掉 |
§4.2 |
args_schema 字段名和函数参数名不一致会报错 |
构造时不报,等到真的调用才 TypeError |
§5.4 |
| 工具抛异常,Agent 会告诉用户「查询失败」 | 不做处理时 agent.invoke() 直接把异常抛给你,进程崩 |
§9.1 |
return_direct=True 后最后一条消息还是 AIMessage |
是 ToolMessage |
§8.1 |
参考文档:
- Tools
- Models · Tool calling
- Agents
- Middleware(错误处理进阶)
2. 工具在系统里的位置 #
工具 = 模型可请求、由你的代码真正执行的外部能力。
模型负责「要不要调、调哪个、参数是什么」;工具函数负责「查库 / 调 API / 算数 / 读配置」。
容易混的一点:模型发出 tool_calls 不等于已经查到天气或订单——那只是「调用意图」。真正执行发生在你的函数或驾驭层(create_agent)里,结果再以 ToolMessage 回灌。
打个比方:模型是个不能离开座位的顾问。它手里有一本工具目录(名字、说明、要填哪些字段),能做的只是填一张申请单递出来——「我要调 get_weather,参数是 {"city": "北京"}」。真正跑腿的是你的代码;跑完把结果写在纸上递回去(ToolMessage),它才知道北京到底什么天气。
这个比方能解释很多现象:
- 工具描述写糊了 → 目录写得不清楚,顾问选错工具
- 参数 schema 没写
description→ 申请单上字段没标注,顾问乱填 - 忘了回灌
ToolMessage→ 递出申请单后没人回话,顾问只能瞎猜 - 工具抛异常没兜住 → 跑腿的人当场晕倒,整件事直接中断(§9.1 会看到真实崩溃)
用户问题
↓
模型(看到工具 schema)──► 发出 tool_calls
↓
执行工具函数 ──► ToolMessage(结果回灌)
↓
模型组织最终回答再补一个初学常忽略的事实:一条 AIMessage 里可以同时带多个 tool_calls。用户一句话里问了三件事,模型往往会一次提出三个调用,接着你会看到连续三条 ToolMessage。§10.2 有真实轨迹。所以「执行工具」这一步天然是个循环,不是单次。
三条边界记牢:
| 角色 | 做什么 | 不做什么 |
|---|---|---|
| 模型 | 选型、填参、组织最终回答 | 真的访问数据库 / HTTP |
| 工具函数 | 执行业务逻辑、返回观察结果 | 代替模型做「该不该调」的决策 |
| 驾驭层 / 你的循环 | 调度执行、对齐 tool_call_id、维护 messages |
替你写业务规则 |
两条常见挂载路径:
| 路径 | 写法 | 谁执行工具 | 何时用 |
|---|---|---|---|
| Agent(推荐日常) | create_agent(..., tools=[...]) |
驾驭层(create_agent)自动执行并回灌 |
业务默认 |
| 模型绑定(细控) | model.bind_tools([...]) |
你自己执行并构造 ToolMessage |
教学、调试、特殊编排 |
第 2 章走的是 Agent 路径;第 4 章演示过手工路径。本章两边都会写:先把工具定义好,再谈怎么挂。定义质量差,两条路径都会一起翻车。
3. 用 @tool 定义工具 #
最简方式:给函数加 @tool。装饰之后,它不再只是普通 callable,而是带有 name / description / args 等元数据的工具对象,可直接交给模型或 Agent。
默认规则:
- 工具名 ← 函数名(建议
snake_case) - 工具描述 ← 函数 docstring(模型靠它选型)
- 参数 schema ← 类型注解(必填;没有注解就很难生成可靠 schema)
模型「看见」的主要是这三样,而不是函数体里的实现细节。所以:说明书(名 + 描述 + schema)比实现注释更影响行为。
3.1. 1.basic_tool.py #
先学会「定义 → 打印元数据 → 本地 invoke」,再接模型。
# 从 langchain.tools 导入 tool 装饰器
from langchain.tools import tool
# @tool 把普通函数包装成工具对象(BaseTool 的子类实例)
@tool
# query 是必填参数(无默认值);limit 有默认值 10,因此模型可以不填
def search_database(query: str, limit: int = 10) -> str:
# 这段 docstring 会被整段(含下面的 Args 部分)当成工具描述
"""在客户库中按关键词检索记录。
Args:
query: 要搜索的关键词
limit: 最多返回多少条
"""
# 模拟检索结果(真实项目里这里换成查数据库的代码)
return f"找到与「{query}」相关的 {limit} 条记录。"
# 工具名:默认取函数名
print("name:", search_database.name)
# 工具描述:默认取整段 docstring
print("description:", search_database.description)
# 参数 schema:由类型注解推断出来的字典
print("args:", search_database.args)
# 不经过模型,直接本地调用工具——这是给工具做单元测试的标准方式
print("invoke:", search_database.invoke({"query": "张三", "limit": 3}))输出:
name: search_database
description: 在客户库中按关键词检索记录。
Args:
query: 要搜索的关键词
limit: 最多返回多少条
args: {'query': {'title': 'Query', 'type': 'string'}, 'limit': {'default': 10, 'title': 'Limit', 'type': 'integer'}}
invoke: 找到与「张三」相关的 3 条记录。先看懂这段输出——下一小节的「意外」就藏在这里:
name是函数名,符合预期description把Args:那几行连缩进一起塞进来了args里query和limit各自只有title和type,没有description
换句话说,你在 docstring 里辛苦写的「要搜索的关键词」,并没有落到 query 这个字段上。
要点:
- 先本地
tool.invoke({...})测通,再交给模型——把「业务逻辑错」和「模型选型错」分开。 - docstring 写清「干什么、何时用」;参数名用业务语义(
order_id比x好)。 invoke入参用字典(键=参数名)。传裸字符串也能用,但有条件——见下面的补充。
关于
invoke传裸字符串:search_database.invoke("张三")其实能跑,会被当成{"query": "张三"}(第一个参数),limit用默认值 10。但这只在只有一个必填参数时成立。如果有两个必填参数,就会报ValidationError: Field required。类型也会自动转换,int_tool.invoke("5")能得到5。结论:统一用字典。裸字符串的便利只在最简单的情况下有效,而且读代码时看不出参数名是什么。
3.2. 模型到底收到了什么 #
args 是 LangChain 给你看的简化视图。想知道发给 API 的原始 JSON 长什么样,用 convert_to_openai_tool 转一下:
# 导入 json 用于格式化打印
import json
# 这个函数把 LangChain 工具转成 OpenAI 风格的工具定义(各家 API 的通用格式)
from langchain_core.utils.function_calling import convert_to_openai_tool
# 导入 tool 装饰器
from langchain.tools import tool
# 定义一个工具用于观察
@tool
# 和 §3.1 完全相同的工具
def search_database(query: str, limit: int = 10) -> str:
# 整段 docstring 会成为工具描述
"""在客户库中按关键词检索记录。
Args:
query: 要搜索的关键词
limit: 最多返回多少条
"""
# 返回模拟结果
return f"找到与「{query}」相关的 {limit} 条记录。"
# 转换并打印:ensure_ascii=False 保证中文正常显示,indent=2 便于阅读
print(json.dumps(convert_to_openai_tool(search_database), ensure_ascii=False, indent=2))输出:
{
"type": "function",
"function": {
"name": "search_database",
"description": "在客户库中按关键词检索记录。\n\nArgs:\n query: 要搜索的关键词\n limit: 最多返回多少条",
"parameters": {
"properties": {
"query": {
"type": "string"
},
"limit": {
"default": 10,
"type": "integer"
}
},
"required": [
"query"
],
"type": "object"
}
}
}这就是模型侧「工具目录」的原文。三件事值得注意:
description是一整块纯文本,Args:和换行符都在里面properties里两个参数都只有类型,没有说明required只有query——limit因为有默认值而变成可选
第 6 章讲结构化输出时看过类似的 JSON Schema,机制同一套:Pydantic/类型注解 → JSON Schema → 随请求发给模型。
3.3. 坑:Args: 段默认不会变成参数说明 #
在 docstring 里写 Google 风格的 Args: 段,容易以为模型能读到每个参数的说明。默认并不会。 LangChain 只是把整段 docstring 原样当作工具描述,参数的 description 是空的。
后果是:参数说明混在一大段描述里,模型得自己猜哪句对应哪个字段。参数少时通常还能猜对;字段一多(尤其是几个含义相近的字符串字段)就开始飘。
想让 Args: 真正生效,加 parse_docstring=True:
# 导入 json 用于格式化打印
import json
# 导入 tool 装饰器
from langchain.tools import tool
# parse_docstring=True 让 LangChain 解析 Google 风格 docstring
@tool(parse_docstring=True)
# 参数签名不变
def search_v2(query: str, limit: int = 10) -> str:
# 第一行摘要会成为工具描述;Args 段会被拆到各个参数上
"""在客户库中按关键词检索记录。
Args:
query: 要搜索的关键词
limit: 最多返回多少条
"""
# 返回模拟结果
return f"找到与「{query}」相关的 {limit} 条记录。"
# 描述里只剩摘要,Args 段已被剥离
print("description:", repr(search_v2.description))
# 每个参数上都多了 description
print("args:", json.dumps(search_v2.args, ensure_ascii=False, indent=2))输出:
description: '在客户库中按关键词检索记录。'
args: {
"query": {
"description": "要搜索的关键词",
"title": "Query",
"type": "string"
},
"limit": {
"default": 10,
"description": "最多返回多少条",
"title": "Limit",
"type": "integer"
}
}差别一目了然:
| 默认 | parse_docstring=True |
|
|---|---|---|
description |
整段 docstring(含 Args: 与缩进) |
只有摘要行 |
参数的 description |
无 | 逐个填好 |
两条路都能走通,选一条并保持一致:
| 做法 | 适合 |
|---|---|
@tool(parse_docstring=True) + Google 风格 docstring |
参数不多、想少写代码 |
Pydantic args_schema + Field(description=...)(§5.2) |
业务工具、需要枚举/约束/默认值 |
最差的是这种中间状态:写了 Args: 却没加 parse_docstring=True,自以为模型看到了参数说明,其实没有。
用 parse_docstring=True 时,下面两种情况会报错(实测确认过):
| 情况 | 结果 |
|---|---|
docstring 只有一句话,没有 Args: 段 |
ValueError: Found invalid Google-Style docstring. |
Args: 里写了函数签名中不存在的参数 |
ValueError: Arg nonexistent in docstring not found in function signature. |
Args: 只标注了部分参数 |
正常,未标注的参数就是没有 description |
第一条最容易撞上:给原本只有单行 docstring 的老工具加上 parse_docstring=True,会直接报错。要么补齐 Args: 段,要么别加这个参数。
好消息:这两个失败都发生在装饰器执行时——导入模块的瞬间就炸,不会拖到线上,属于「响亮的失败」。
3.4. 两个边界情况 #
不写类型注解:不报错,但 schema 会静默降级。
# 导入 tool 装饰器
from langchain.tools import tool
# 注册工具
@tool
# 注意 query 没有类型注解
def no_annotation(query) -> str:
# 描述照常从 docstring 取
"""没有类型注解的工具。"""
# 返回固定结果
return "ok"
# 观察生成的 schema
print(no_annotation.args)输出:
{'query': {'title': 'Query'}}type 字段整个消失了。模型只知道有个叫 query 的参数,却不知道该填字符串、数字还是数组。这不会抛异常,只会让填参质量悄悄变差——典型的静默失败。所以「类型注解必写」不是风格建议,而是功能要求。
完全不写 docstring:直接报错。
# 导入 tool 装饰器
from langchain.tools import tool
# 用 try 捕获构造期的异常
try:
# 注册一个既没有 docstring 也没有 description 的工具
@tool
# 函数签名正常,唯一的问题是没有说明
def no_doc(query: str) -> str:
# 函数体里没有 docstring,直接就是返回语句
return "ok"
# 捕获并打印错误
except ValueError as e:
# 打印异常类型与信息
print(f"{type(e).__name__}: {e}")输出:
ValueError: Function must have a docstring if description not provided.这个失败同样「响亮」:装饰器执行时就炸——比静默降级友好得多。LangChain 强制你至少给模型留一句说明:要么写 docstring,要么在 @tool(description=...) 里显式传入。
4. 自定义名称与描述 #
默认用函数名 / docstring 往往够用。需要覆盖时常见两种动机:
- 函数名偏实现(如
search),对外希望更语义化(web_search) - docstring 偏实现说明,但模型需要更强的选型指令(「算术必须调用,禁止心算」)
可在 @tool(...) 上显式传入名称与 description。好处是:对外「说明书」和内部实现命名解耦了。函数叫什么随团队习惯,模型看到的名字可以单独优化。
4.1. 2.custom_name_desc.py #
# 从 langchain.tools 导入 tool 装饰器
from langchain.tools import tool
# @tool 的第一个位置参数就是工具名,这里覆盖为 web_search
@tool("web_search")
# 函数本身仍可叫 search,两者互不影响
def search(query: str) -> str:
# 没有传 description 时,描述仍取自 docstring
"""在互联网上搜索信息。"""
# 返回模拟搜索结果
return f"关于「{query}」的搜索结果。"
# 同时覆盖名称和描述
@tool(
# 第一个位置参数:对外工具名
"calculator",
# description 会覆盖 docstring,这里写成带指令性的选型说明
description="执行四则运算。遇到任何算术问题都请调用本工具,不要心算。",
)
# 内部函数名仍可用简写
def calc(expression: str) -> str:
# 传了 description 后,这句 docstring 对模型不再可见
"""计算数学表达式。"""
# 白名单:只允许数字、四则运算符、括号、小数点和空格
allowed = set("0123456789+-*/(). ")
# set(expression) <= allowed 判断表达式的字符是否都在白名单内
if not set(expression) <= allowed:
# 不合法时返回错误说明,让模型知道该改写表达式
return "错误:表达式包含不允许的字符。"
# 用空的 __builtins__ 执行,屏蔽内置函数,降低(但不能消除)风险
return str(eval(expression, {"__builtins__": {}}, {}))
# 打印两个工具对外暴露的名字
print(search.name, calc.name)
# 打印 calc 的描述,确认是 description 而不是 docstring
print(calc.description)输出:
web_search calculator
执行四则运算。遇到任何算术问题都请调用本工具,不要心算。description 是写给模型的「使用说明书」,比写给人类的代码注释更重要。多工具时,描述要能互斥:
- 好:「查天气用 get_weather;算术用 calculate」
- 差:两个工具都写「获取信息」「帮助用户」——模型无法区分
4.2. 工具名不能随便起:^[a-zA-Z0-9_-]+$ #
前面说「工具名建议 snake_case」,但没说清违反了会怎样。实测结论比「建议」严厉得多。
LangChain 完全不校验工具名,什么都能构造成功:
# 导入 tool 装饰器
from langchain.tools import tool
# 工具名里带空格
@tool("web search")
# 函数本身写法完全正常
def f1(query: str) -> str:
# 描述取自 docstring
"""搜索信息。"""
# 返回占位结果
return "ok"
# 工具名用中文
@tool("查询天气")
# 函数本身写法也完全正常
def f2(city: str) -> str:
# 描述取自 docstring
"""查询城市天气。"""
# 返回占位结果
return "ok"
# 两个都能顺利构造出来,name 原样保留
print(repr(f1.name), repr(f2.name))输出:
'web search' '查询天气'但一旦绑到模型上发请求,API 会拒绝:
# 导入 dotenv 用于加载 API key
from dotenv import load_dotenv
# 导入统一模型初始化函数
from langchain.chat_models import init_chat_model
# 导入 tool 装饰器
from langchain.tools import tool
# 加载 .env 中的密钥
load_dotenv(override=True)
# 工具名带空格
@tool("web search")
# 入参为检索词
def bad_name(query: str) -> str:
# 描述取自 docstring
"""搜索信息。"""
# 返回模拟结果
return f"关于 {query} 的结果"
# 初始化模型
model = init_chat_model("deepseek:deepseek-v4-flash", temperature=0)
# 绑定并调用,捕获 API 返回的错误
try:
# bind_tools 本身不报错,真正发请求时才失败
model.bind_tools([bad_name]).invoke("搜索一下 LangChain")
# 打印错误类型与信息
except Exception as e:
# 输出 BadRequestError 及其详细说明
print(f"{type(e).__name__}: {e}")输出:
BadRequestError: Error code: 400 - {'error': {'message': "Invalid 'tools[0].function.name': string does not match pattern. Expected a string that matches the pattern '^[a-zA-Z0-9_-]+$'.", ...}}规则很明确:只允许字母、数字、下划线、连字符。所以:
| 工具名 | 能构造 | API 接受 |
|---|---|---|
get_weather |
√ | √ |
get-weather |
√ | √ |
web search(空格) |
√ | × 400 |
web.search(点) |
√ | × 400 |
查询天气(中文) |
√ | × 400 |
中文工具名会被拒,这一条要特别记住——做中文业务时很容易顺手把工具名写成中文,而错误要等到真正发请求才出现;报错信息还是英文、指向 tools[0].function.name,第一次遇到不容易想到是工具名的问题。
结论:工具名一律用英文 snake_case;中文只放在 description 里。描述用中文完全没问题(前面例子里的中文描述都正常工作)。
4.3. 关于示例里的 eval #
上面 calc 的白名单看起来严密,其实挡不住所有麻烦:
# 白名单字符集合
allowed = set("0123456789+-*/(). ")
# 逐个测试几种表达式能否通过白名单
for expr in ["1+1", "9**9", "9**9**9", "(1).__class__"]:
# set(expr) <= allowed 判断字符是否全在白名单里
print(f"{expr!r:18s} 通过白名单: {set(expr) <= allowed}")输出:
'1+1' 通过白名单: True
'9**9' 通过白名单: True
'9**9**9' 通过白名单: True
'(1).__class__' 通过白名单: False访问属性那种注入被挡住了,但 ** 是两个 * 拼起来的,白名单管不了——9**9**9 是个天文数字,会让进程算到内存耗尽。这类问题叫资源耗尽型攻击,光靠字符白名单防不住。
所以本章的 calc 只用来演示「怎么覆盖名称和描述」,不要照搬到生产。真正要给模型算术能力,用 §10 那种把运算符做成 Literal["+", "-", "*", "/"] 枚举的写法:模型只能在四个符号里选一个,** 根本表达不出来,从结构上消掉问题。
更普适的原则:能用枚举 + 结构化参数表达的能力,就不要让模型自由拼字符串。字符串是最难校验的输入形式。第 10 章讲 middleware 与 HITL 时会继续这个话题。
5. 参数 Schema #
模型填参质量,很大程度上取决于 schema 是否清晰。
可以把 schema 想成「给模型填的表单」:字段名、类型、说明、可选/必填、枚举值,都会影响它怎么填。
由简到繁三档:
| 档位 | 做法 | 适用 | 能否约束取值 |
|---|---|---|---|
| 基础 | 类型注解 + parse_docstring=True |
参数少、类型简单 | 只能靠描述「建议」 |
| 进阶 | Pydantic args_schema + Field(description=...) |
枚举、默认值、约束、多字段 | 能,且本地会校验 |
| 兼容 | JSON Schema 字典 | 动态生成 / 与外部 schema 对齐 | 取决于你写的 schema |
业务工具建议尽早升到「进阶」:多花几行 Field,能少掉大量「参数抽成整句问话」的翻车。最后一列是关键区别——基础档只能在描述里写「天数 1~7」这种软约束,模型想填 100 也拦不住;进阶档用 Literal 或 Field(ge=1, le=7) 能真正锁死。
5.1. 类型注解够用时 #
两个参数、类型直观时,注解 + parse_docstring=True 就够:
# 从 langchain.tools 导入 tool 装饰器
from langchain.tools import tool
# 加上 parse_docstring=True,Args 段才会落到各参数的 description 上(见 §3.3)
@tool(parse_docstring=True)
# city 无默认值所以必填;days 有默认值所以可选
def get_forecast(city: str, days: int = 3) -> str:
# 首行摘要成为工具描述,Args 段成为参数说明
"""查询城市未来几天的天气预报。
Args:
city: 城市名,如「北京」
days: 预报天数,1~7
"""
# 返回模拟预报
return f"{city} 未来 {days} 天:晴转多云。"
# 打印工具名
print("name:", get_forecast.name)
# 打印描述(只有摘要行)
print("description:", get_forecast.description)
# 打印参数结构(带上了 description)
print("args:", get_forecast.args)输出:
name: get_forecast
description: 查询城市未来几天的天气预报。
args: {'city': {'description': '城市名,如「北京」', 'title': 'City', 'type': 'string'}, 'days': {'default': 3, 'description': '预报天数,1~7', 'title': 'Days', 'type': 'integer'}}注意 days: int = 3 这个默认值做了两件事:本地调用时可以省略;同时在 schema 里表现为「不在 required 里」,等于告诉模型「这个可以不填」。
不过这里也暴露了类型注解方案的天花板:docstring 里写「预报天数,1~7」只是一句给模型看的建议,模型完全可以填 days=100,int 类型拦不住。想真正约束取值范围,得升级到 Pydantic。
5.2. 3.pydantic_schema.py(推荐业务工具) #
字段一多,或需要 Literal / 取值约束时,用 Pydantic 显式声明 args_schema。
这和第 6 章「用 schema 约束模型输出」是同一思路,只是这里约束的是工具入参。
# 从 typing 导入 Literal,用于把取值限制在固定几个选项内
from typing import Literal
# 从 pydantic 导入 BaseModel 与 Field
from pydantic import BaseModel, Field
# 从 langchain.tools 导入 tool 装饰器
from langchain.tools import tool
# 继承 BaseModel 定义入参模型
class WeatherInput(BaseModel):
# 类的 docstring 会成为 schema 的顶层 description
"""天气查询入参。"""
# 必填字段:没有 default,所以会进入 required 列表
location: str = Field(description="城市名或坐标,如「上海」")
# Literal 会在 JSON Schema 里变成 enum,模型只能二选一
units: Literal["C", "F"] = Field(
# 有默认值 → 可选字段
default="C",
# 描述帮助模型理解该填什么
description="温度单位:C 或 F",
)
# 布尔开关字段
include_forecast: bool = Field(
# 默认关闭
default=False,
# 说明这个开关的作用
description="是否包含未来 5 日预报",
)
# args_schema 指定入参模型,此时函数自己的类型注解不再用于生成 schema
@tool(args_schema=WeatherInput)
# 函数参数名必须和 schema 字段名逐一对应(否则调用时才报错,见 §5.4)
def get_weather(
# 对应 schema 的 location
location: str,
# 对应 schema 的 units
units: str = "C",
# 对应 schema 的 include_forecast
include_forecast: bool = False,
) -> str:
# 这段 docstring 成为工具描述(说明何时该调用)
"""查询当前天气;需要事实天气时必须调用,不要编造。"""
# 根据单位给出模拟温度
temp = 22 if units == "C" else 72
# 拼装基础返回文本
result = f"{location} 当前 {temp}°({units}),晴。"
# 只有模型明确要求预报时才追加这一段
if include_forecast:
result += " 未来 5 日:多云。"
# 返回字符串,它将成为 ToolMessage 的内容
return result
# 查看最终的参数结构
print(get_weather.args)
# 本地测一条,确认业务逻辑正确
print(get_weather.invoke({"location": "上海", "units": "C"}))
# 确认描述来自函数 docstring,而不是 WeatherInput 的 docstring
print(repr(get_weather.description))输出(args 已格式化便于阅读):
{'location': {'description': '城市名或坐标,如「上海」',
'title': 'Location',
'type': 'string'},
'units': {'default': 'C',
'description': '温度单位:C 或 F',
'enum': ['C', 'F'],
'title': 'Units',
'type': 'string'},
'include_forecast': {'default': False,
'description': '是否包含未来 5 日预报',
'title': 'Include Forecast',
'type': 'boolean'}}
上海 当前 22°(C),晴。
'查询当前天气;需要事实天气时必须调用,不要编造。'对照 §3.2 那份「只有 type、没有 description」的 schema,差距很直观:每个字段都有说明,units 还多了 enum 把取值锁死。模型填参质量的上限,由这份 schema 决定。
顺便说清一个容易混的点:用了 args_schema 之后,工具描述仍然取自函数的 docstring,WeatherInput 的 docstring 只是 schema 内部的顶层描述。所以「这个工具什么时候该调用」写在函数上,「每个字段是什么意思」写在 Field 里。
设计原则(与第 6 章结构化输出一致):
- 每个字段写
description:模型主要靠描述理解语义 - 能用枚举就别用自由字符串:
Literal["C", "F"]比units: str可靠得多 - 默认值表达常见路径:减少模型漏填
- 函数签名字段名与 schema 一致:见下一节,不一致的代价是延迟到调用时才暴露
5.3. 参数校验是真的在跑 #
args_schema 不只是给模型看的说明书,同时也是本地的运行时校验。填错了会在工具执行前就被 Pydantic 拦下来:
# 承接 §5.2 的 get_weather 工具
# 情况一:units 填了枚举之外的值
try:
# K 不在 Literal 的两个选项里
get_weather.invoke({"location": "上海", "units": "K"})
# Pydantic 会抛 ValidationError
except Exception as e:
# 打印异常类型与详细信息
print(f"{type(e).__name__}: {e}")
# 情况二:漏掉必填的 location
try:
# 只传了 units,没传必填的 location
get_weather.invoke({"units": "C"})
# 同样是 ValidationError,但原因不同
except Exception as e:
# 打印异常类型与详细信息
print(f"{type(e).__name__}: {e}")输出:
ValidationError: 1 validation error for WeatherInput
units
Input should be 'C' or 'F' [type=literal_error, input_value='K', input_type=str]
For further information visit https://errors.pydantic.dev/2.13/v/literal_error
ValidationError: 1 validation error for WeatherInput
location
Field required [type=missing, input_value={'units': 'C'}, input_type=dict]
For further information visit https://errors.pydantic.dev/2.13/v/missing这层校验的意义在于:模型偶尔还是会填错,而错误会在进入业务代码之前就被挡住,不会带着 units="K" 跑进函数体产生诡异结果。代价是会抛异常——所以 §9 的错误兜底才有必要,否则一次填错就能打崩整个 Agent。
5.4. 坑:args_schema 和函数签名不一致时,构造不报错 #
这是典型的「延迟失败」:schema 里叫 location,函数参数叫 city,装饰器不会有任何抱怨。
# 导入 Pydantic 组件
from pydantic import BaseModel, Field
# 导入 tool 装饰器
from langchain.tools import tool
# schema 里字段名叫 location
class MismatchInput(BaseModel):
# 类 docstring 成为 schema 顶层描述
"""入参。"""
# 唯一的字段名是 location
location: str = Field(description="城市名")
# 挂上 schema
@tool(args_schema=MismatchInput)
# 但函数参数名叫 city,和 schema 不一致
def weather_bad(city: str) -> str:
# 描述取自 docstring
"""查天气。"""
# 函数体正常使用 city
return f"{city} 晴"
# 构造阶段完全正常,schema 用的是 location
print("构造成功,args:", weather_bad.args)
# 按 schema 的字段名调用,问题才暴露
try:
# 校验能通过,但传给函数时参数名对不上
weather_bad.invoke({"location": "上海"})
# 捕获 TypeError
except TypeError as e:
# 打印异常类型与信息
print(f"{type(e).__name__}: {e}")输出:
构造成功,args: {'location': {'description': '城市名', 'title': 'Location', 'type': 'string'}}
TypeError: weather_bad() got an unexpected keyword argument 'location'机制是:schema 负责校验并生成模型可见的定义;通过之后,LangChain 把校验后的字段按关键字参数传给函数。字段名对不上,函数自然收不到。
麻烦在于时机——如果这个工具只挂在 Agent 上、又只在某个少见分支才被调用,你可能到线上才第一次看到这个 TypeError。防御办法很简单:写完带 args_schema 的工具,立刻 tool.invoke({...}) 跑一次。这也是 §3.1 强调「先本地单测」的原因之一。
5.5. 两个不给模型看的特殊参数 #
有些参数是给你的代码用的,不该出现在模型的表单里——比如当前用户 ID、数据库连接、租户标识。让模型填这类参数不仅没意义,还有安全风险(模型可以填别人的 user_id)。
config: RunnableConfig 会被自动排除在模型可见 schema 之外:
# 导入 RunnableConfig 类型
from langchain_core.runnables import RunnableConfig
# 导入 tool 装饰器
from langchain.tools import tool
# 注册工具
@tool
# config 这个参数名有特殊含义,会被 LangChain 识别并注入
def with_config_param(query: str, config: RunnableConfig) -> str:
# 描述取自 docstring
"""搜索信息。"""
# 从 config 里读取调用方传进来的元数据
return f"query={query}, tags={config.get('tags')}"
# 模型可见的参数里没有 config
print("模型可见的 args:", with_config_param.args)
# 调用时 config 通过第二个参数传入,而不是放在字典里
print("invoke:", with_config_param.invoke({"query": "x"}, config={"tags": ["t1"]}))输出:
模型可见的 args: {'query': {'title': 'Query', 'type': 'string'}}
invoke: query=x, tags=['t1']InjectedToolArg 用于你自己定义的隐藏参数:
# 导入 Annotated 用于给类型附加元信息
from typing import Annotated
# InjectedToolArg 是一个标记,表示该参数由代码注入而非模型填写
from langchain_core.tools import InjectedToolArg
# 导入 tool 装饰器
from langchain.tools import tool
# 注册工具
@tool
# user_id 用 Annotated 标注成注入参数
def with_injected(query: str, user_id: Annotated[str, InjectedToolArg]) -> str:
# 描述取自 docstring
"""搜索信息。"""
# 函数体内正常使用这个参数
return f"query={query}, user_id={user_id}"
# 模型只看得到 query
print("模型可见:", list(with_injected.args.keys()))
# 但完整 schema 里两个字段都在(校验时仍需要它)
print("完整 schema:", list(with_injected.args_schema.model_json_schema()["properties"].keys()))
# 调用时必须由你显式提供 user_id
print("invoke:", with_injected.invoke({"query": "x", "user_id": "u001"}))输出:
模型可见: ['query']
完整 schema: ['query', 'user_id']
invoke: query=x, user_id=u001两者的分工:
| 参数 | 谁提供 | 典型用途 |
|---|---|---|
| 普通参数 | 模型填 | 城市名、订单号、检索词 |
config: RunnableConfig |
调用方通过 config= 传 |
tags、metadata、thread_id 等运行时上下文 |
Annotated[T, InjectedToolArg] |
你的代码在执行前注入 | 当前用户 ID、租户、权限令牌 |
安全意义值得强调:凡是「不该由用户输入决定」的参数,都该用注入,而不是让模型填。第 15 章讲 RAG 权限过滤时用的就是这个思路——把当前用户的部门 dept 由代码注入进检索工具,而不是指望模型老老实实填自己的身份。
6. bind_tools:让模型发起调用 #
bind_tools 把工具 schema 绑定到聊天模型:模型在回复里可以带上 tool_calls。
它不会自动执行工具——执行与回灌要你自己做,或交给 create_agent。
这是初学者最常见的误解:
× bind_tools = 模型会自己去查天气
√ bind_tools = 模型被允许「提出」查天气;谁执行另说bind_tools 的职责:告诉模型「你有这些工具可用」
create_agent 的职责:绑定 + 执行 + 回灌 + 循环直到最终回答手写循环的价值:把 Agent 黑盒拆成三步,调试「没调工具 / 参数错 / id 对不齐」时特别清楚。业务默认仍用 Agent。
6.1. 4.bind_tools_loop.py #
# 从 langchain.chat_models 导入统一初始化函数
from langchain.chat_models import init_chat_model
# HumanMessage 表示用户消息,ToolMessage 表示工具执行结果
from langchain.messages import HumanMessage, ToolMessage
# 从 langchain.tools 导入 tool 装饰器
from langchain.tools import tool
# 从 dotenv 导入环境变量加载函数
from dotenv import load_dotenv
# 加载 .env 中的 API key;override=True 表示覆盖已存在的同名环境变量
load_dotenv(override=True)
# 注册天气工具
@tool
# 只有一个入参 city
def get_weather(city: str) -> str:
# 描述告诉模型这个工具能干什么
"""获取指定城市的天气信息。"""
# 模拟返回(真实项目里换成调用天气 API)
return f"{city} 今天晴,气温 25°C。"
# 初始化模型;temperature=0 让选型行为更稳定可复现
model = init_chat_model("deepseek:deepseek-v4-flash", temperature=0)
# bind_tools 返回一个新对象,原 model 不受影响
model_with_tools = model.bind_tools([get_weather])
# 建立「工具名 → 工具对象」索引:模型只会返回名字,得靠它找到真正的函数
toolkit = {get_weather.name: get_weather}
# 手工维护消息列表,初始只有用户这一句
messages = [HumanMessage("北京今天天气怎么样?")]
# 第 1 步:模型推理,返回的 AIMessage 可能带 tool_calls
ai = model_with_tools.invoke(messages)
# 把这条 AIMessage 追加进轨迹(这一步不能省,否则下一轮模型看不到自己的决定)
messages.append(ai)
# 打印模型提出的调用意图
print("tool_calls:", ai.tool_calls)
# 第 2 步:遍历每一个调用意图(可能不止一个)
for call in ai.tool_calls:
# 按名字取出对应的工具对象
selected = toolkit[call["name"]]
# 用模型给的参数字典执行工具,拿到观察结果
observation = selected.invoke(call["args"])
# 把结果包成 ToolMessage 追加进轨迹
messages.append(
ToolMessage(
# 工具返回的内容
content=observation,
# 必须回填本次调用的 id,模型靠它把结果和请求配对
tool_call_id=call["id"],
)
)
# 第 3 步:带着工具结果再问一次模型,让它组织自然语言回答
final = model_with_tools.invoke(messages)
# 这一次 content 不再为空,tool_calls 也应为空
print(final.content)输出(最后一句是模型生成的自然语言,每次跑措辞会不同):
tool_calls: [{'name': 'get_weather', 'args': {'city': '北京'}, 'id': 'call_00_U7e6OQNcApDU457HaAPL5889', 'type': 'tool_call'}]
北京今天天气晴朗,气温 25°C,天气不错,适合外出活动。两个细节值得细看:
- 第 1 步返回的
AIMessage,content往往是空字符串——模型这一轮什么话都没说,只提交了一张申请单。初学者常在这里困惑「模型怎么不回答我」,其实它在等工具结果。 call["id"](形如call_00_xxx)是这次调用的唯一标识。第 2 步必须原样回填到tool_call_id。这个 id 对不上,API 会直接报 400,而不是给你一个含糊的错误答案。
三步对照:
| 步 | 你在干什么 | 轨迹上多了什么 |
|---|---|---|
| 1 | invoke 模型 |
AIMessage(常带 tool_calls) |
| 2 | tool.invoke + 追加 |
ToolMessage |
| 3 | 再 invoke 模型 |
最终 AIMessage.content |
对照记忆:
| API | 作用 |
|---|---|
bind_tools |
模型侧:允许发出 tool_calls |
tool.invoke(args) |
本地执行工具 |
ToolMessage |
把结果写回 messages(tool_call_id 必对齐) |
create_agent |
上面整段循环的自动化 |
排错对照表:
| 症状 | 大概率原因 |
|---|---|
ai.tool_calls 是空列表 |
描述没写清「何时用」;或系统提示没引导;或模型不支持 tool calling |
有 tool_calls 但最终回答胡编 |
忘了把 ToolMessage 追加进 messages |
| API 报 400,提示 tool message 缺失 | tool_call_id 没对齐,或有多个 tool_calls 只回了一条 |
报 KeyError 在 toolkit[call["name"]] |
模型报的工具名不在你的索引里(通常是绑定的工具和索引不一致) |
最后一条尤其要注意:ai.tool_calls 里有几个调用,就必须回几条 ToolMessage,一条都不能少。第 6 章讲结构化输出重试时踩过同一个坑——AIMessage 带了 tool_calls,后面却没有对应的 ToolMessage,API 就会拒绝整个请求。
7. 挂到 create_agent #
定义好工具后,挂到 Agent 只需放进 tools=[...]。驾驭层(create_agent)会完成执行与回灌——也就是 §6 那三步的自动化版。
和 bind_tools 的选用:
| 场景 | 更合适 |
|---|---|
| 业务助理、多步工具、要少写样板代码 | create_agent |
只想看模型会不会发出 tool_calls、或自定义循环 |
bind_tools |
| 既要 Agent 又要细调模型参数 | init_chat_model 得到实例再传给 create_agent(第 3 章) |
7.1. 5.agent_with_tools.py #
# 从 langchain.agents 导入 create_agent 工厂函数
from langchain.agents import create_agent
# 从 langchain.tools 导入 tool 装饰器
from langchain.tools import tool
# 从 dotenv 导入环境变量加载函数
from dotenv import load_dotenv
# 加载 .env 中的 API key
load_dotenv(override=True)
# 注册天气工具
@tool
# 单一入参 city
def get_weather(city: str) -> str:
# 描述里同时写清「能力」和「何时用」
"""获取指定城市的天气信息。需要天气事实时必须调用。"""
# 模拟返回
return f"{city} 今天晴,气温 25°C。"
# 创建 Agent,内部会自动完成 §6 那三步循环
agent = create_agent(
# 模型标识字符串,格式为「厂商:模型名」
model="deepseek:deepseek-v4-flash",
# 挂载工具列表,驾驭层(create_agent)负责执行与回灌
tools=[get_weather],
# 系统提示:约束角色与策略,禁止编造
system_prompt="你是中文助手。查天气必须调用 get_weather,不要编造。",
)
# 一次 invoke 内部可能包含多轮「模型→工具→模型」
result = agent.invoke(
# 输入格式是带 messages 键的字典
{"messages": [{"role": "user", "content": "深圳天气如何?"}]}
)
# messages 列表的最后一条就是最终回答
print(result["messages"][-1].content)输出:
深圳今天天气晴朗,气温为 25°C。天气不错,适合外出活动哦!和 §6 对比一下代码量:手写循环 40 多行,Agent 版十几行,而且自动处理了多轮、多工具、id 对齐。日常业务没有理由手写循环——手写的价值只在于理解机制和调试。
system_prompt 与工具 docstring 要同向,分工是:
| 写在哪 | 负责什么 | 例子 |
|---|---|---|
system_prompt |
策略层:角色、必须/禁止、语气 | 「查天气必须调用 get_weather,不要编造」 |
工具 description |
能力层:这个工具干什么 | 「获取指定城市的天气信息」 |
参数 description |
字段层:每个参数填什么 | 「城市名,如北京、上海」 |
三层任意一层缺失,选型和填参都更容易漂。反过来,遇到「模型该调工具却不调」时,按这三层逐一检查通常能定位。
调试时建议打印完整 messages(第 2 / 4 章的方法),不要只看最后一句——中间的 tool_calls 和 ToolMessage 才能告诉你模型到底选了什么、填了什么参数。
8. 工具返回值怎么选 #
工具返回值会变成模型下一轮看到的「观察」。返回形态不同,后续行为也不同。
| 返回类型 | 模型看到什么 | 适用 |
|---|---|---|
str |
一段可读文本 | 大多数业务工具(默认首选) |
dict / 对象 |
序列化后的结构化字段 | 下游还要按字段推理 |
| 内容块列表 | 多模态 ToolMessage |
截图、图片等(需模型支持) |
return_direct=True |
不再经模型润色,直接把工具输出当最终结果 | 订单状态等「查完即返」 |
入门阶段优先返回简洁字符串:字段名清晰,别塞整页 JSON 噪音。需要结构化给下游程序用时,再考虑 dict 或第 6 章的结构化输出。
顺便提一个小坑。如果参数声明成 a: float, b: float,那么「128 + 256」的结果会是 384.0 而不是 384:
ToolMessage: '384.0'模型通常能在最终回答里正确写成「384」,但观察值里那个 .0 会一路带到日志和轨迹里。整数场景要么用 int,要么在返回前格式化一下。返回值是给模型读的文本,值得像写给用户的文案一样收拾干净。
8.1. return_direct=True:查完即返 #
默认流程是:工具结果 → 再进模型 → 自然语言最终答。
打开 return_direct=True 后,Agent 拿到该工具结果就立刻结束,不再让模型润色。
适合:订单状态、验证码、固定格式回执——不需要模型再总结。
不适合:多工具拼接、需要对比/改写语气、还要继续决策的场景。
# 从 langchain.agents 导入 create_agent,用于创建智能体
from langchain.agents import create_agent
# 从 langchain.tools 导入 tool 装饰器
from langchain.tools import tool
# 从 dotenv 导入 load_dotenv,用于加载环境变量
from dotenv import load_dotenv
# 加载 .env 中的密钥;override=True 表示覆盖已存在的同名变量
load_dotenv(override=True)
# return_direct=True:Agent 拿到该工具结果后直接结束,不再经模型二次润色
@tool(return_direct=True)
# 入参为订单号
def fetch_order_status(order_id: str) -> str:
# 描述里写清何时调用
"""查询订单物流状态;用户问订单进度时调用。"""
# 返回固定格式的回执
return f"订单 {order_id} 已发货,预计 2 天后送达。"
# 创建 Agent,挂载查单工具
agent = create_agent(
# 本文默认 DeepSeek 模型
model="deepseek:deepseek-v4-flash",
# 注册工具
tools=[fetch_order_status],
# 系统提示:引导模型在查订单时调用工具
system_prompt="你是订单助手。用户询问订单进度时必须调用 fetch_order_status。",
)
# 调用 Agent,询问订单状态
result = agent.invoke(
{
# 输入是带 messages 键的字典
"messages": [
{
# 角色为 user
"role": "user",
# 用户的问题
"content": "帮我查一下订单 A1001 的物流进度。",
}
]
}
)
# 遍历完整消息轨迹,用 enumerate 拿到序号便于对照
for i, msg in enumerate(result["messages"]):
# 打印分隔线与消息类型
print(f"\n===== [{i}] {type(msg).__name__} =====")
# 用 getattr 安全取 content,不同消息类型都有这个属性
content = getattr(msg, "content", None)
# 只有非空时才打印,避免刷屏
if content:
print("content:", content)
# tool_calls 只有 AIMessage 才有,用 getattr 兼容其他类型
tool_calls = getattr(msg, "tool_calls", None)
# 有调用意图时打印出来
if tool_calls:
print("tool_calls:", tool_calls)
# 打印最终对外结果,注意此时它来自 ToolMessage
print("\n最终回复:", result["messages"][-1].content)输出(省略了分隔线):
[0] HumanMessage content: 帮我查一下订单 A1001 的物流进度。
[1] AIMessage content: 我来帮您查询订单 A1001 的物流进度。
tool_calls: [{'name': 'fetch_order_status', 'args': {'order_id': 'A1001'}, ...}]
[2] ToolMessage content: 订单 A1001 已发货,预计 2 天后送达。
最终回复: 订单 A1001 已发货,预计 2 天后送达。轨迹只有 3 条,最后一条是 ToolMessage。 这是本节最该记住的事实。
把同一个工具去掉 return_direct=True 再跑一次,对照非常清楚:
[0] HumanMessage content: 帮我查一下订单 A1001 的物流进度。
[1] AIMessage content: '' ← 空,只提交了调用
[2] ToolMessage content: 订单 A1001 已发货,预计 2 天后送达。
[3] AIMessage content: 订单 A1001 已发货,预计 2 天后送达。请问还有其他需要帮助的吗?汇总成表:
return_direct=True |
默认 | |
|---|---|---|
| 消息条数 | 3 | 4 |
| 最后一条的类型 | ToolMessage |
AIMessage |
| 最终内容 | 工具返回的原文 | 模型改写过的版本 |
| 模型调用次数 | 1 | 2 |
有三个实际影响:
- 省一次模型调用,延迟和成本都更低。查单这类高频场景很可观。
- 输出完全可控,工具返回什么,用户就看到什么,不会被模型加上「请问还有其他需要帮助的吗」这类尾巴。
result["messages"][-1]的类型变了。如果你的代码里写了isinstance(last, AIMessage)之类的判断,或依赖last.tool_calls属性,在return_direct下会走到意外分支。取.content是安全的,两种消息都有。
另外注意上面轨迹里 [1] 的 AIMessage 是有内容的(「我来帮您查询…」),而默认版本那条是空的。这只是模型的随机行为,不由 return_direct 决定——它可能在提交调用的同时说句话,也可能什么都不说。不要依赖这条消息的 content 有值。
9. 错误处理 #
工具失败时,理想行为是:把可理解的错误变成 ToolMessage,让模型改参或换策略,而不是整个进程堆栈炸掉。
为什么「抛异常就结束」不好?因为模型常见错误是参数抽歪(订单号多了空格、城市名带了「市」)。错误能回灌,它往往能自纠;进程直接挂,用户只看到 500。
常见三层:
| 层 | 做法 | 何时用 |
|---|---|---|
| 工具内防御 | try/except 后 return "错误:..." |
可预期的业务错误(单号不存在等) |
| Agent 中间件 | @wrap_tool_call 捕获异常并转 ToolMessage |
统一兜底、避免未捕获异常打崩循环 |
| 可观测 | LangSmith 看失败轨迹 | 定位偶发 / 参数问题 |
9.1. 先看不处理会怎样:整个 Agent 崩掉 #
抽象地说「进程会崩」没有说服力,直接跑一次。下面这个工具用 ORDERS[key] 直接取值,查不到就会抛 KeyError——这是最常见的疏忽写法:
# 从 dotenv 导入环境变量加载函数
from dotenv import load_dotenv
# 从 langchain.agents 导入 create_agent
from langchain.agents import create_agent
# 从 langchain.tools 导入 tool 装饰器
from langchain.tools import tool
# 加载 .env 中的 API key
load_dotenv(override=True)
# 模拟订单库,只有两条数据
ORDERS = {"A1001": "已发货", "A1002": "运输中"}
# 注册查单工具
@tool
# 入参为订单号
def lookup_order(order_id: str) -> str:
# 描述取自 docstring
"""按订单号查询物流状态。订单号形如 A1001。"""
# 规范化:去空格并转大写
key = order_id.strip().upper()
# 直接用 [] 取值,查不到会抛 KeyError —— 这里故意不做防御
return f"订单 {key} 状态:{ORDERS[key]}。"
# 创建 Agent,不加任何错误处理
agent = create_agent(
# 模型标识
model="deepseek:deepseek-v4-flash",
# 挂载会抛异常的工具
tools=[lookup_order],
# 引导模型调用工具
system_prompt="你是订单助手。查订单必须调用 lookup_order。",
)
# 查一个不存在的订单号,用 try 观察异常是否会传到最外层
try:
# A9999 不在 ORDERS 里
result = agent.invoke({"messages": [{"role": "user", "content": "帮我查订单 A9999"}]})
# 如果没抛异常,打印最终回答
print(result["messages"][-1].content)
# 捕获任何异常并打印类型与信息
except Exception as e:
# 实测这里会打印 KeyError: 'A9999'
print(f"整个 Agent 崩了!{type(e).__name__}: {e}")输出:
整个 Agent 崩了!KeyError: 'A9999'注意这里发生了什么:异常穿透了整个 Agent,从 agent.invoke() 直接抛到你的代码里。 没有 ToolMessage,没有降级回答,模型完全没有机会向用户解释。如果这是 Web 服务,用户看到的就是 500。
这就是本节存在的理由。下面两节是两种修法。
顺便纠正一个容易产生的误解:这个演示之所以用「订单查不到」而不是「10 除以 0」,是因为除零这类错误模型自己就会规避——你问它「10 除以 0 等于多少」,它会推理出除数不能为 0,然后干脆不调用工具,直接给你解释。工具里的
raise一次都不会执行,也就演示不出兜底效果。想验证错误处理,得挑一个模型无法预判的失败:订单号不存在、网络超时、第三方返回 500。
9.2. 修法一:工具内返回错误字符串 #
这是第一道防线,也是优先选择:业务上「查不到」根本不是系统崩溃,而是一条正常的观察结果。
# 从 langchain.tools 导入 tool 装饰器
from langchain.tools import tool
# 模拟订单库
ORDERS = {"A1001": "已发货", "A1002": "运输中"}
# 注册工具
@tool
# 入参为订单号
def lookup_order(order_id: str) -> str:
# 描述里说明订单号格式,帮助模型正确填参
"""按订单号查询状态。order_id 形如 A1001。"""
# 规范化输入:去掉首尾空格并转成大写,容忍模型的格式偏差
key = order_id.strip().upper()
# 用 not in 判断而不是直接取值,避免 KeyError
if key not in ORDERS:
# 返回错误说明字符串,而不是 raise
return f"错误:未找到订单 {order_id}。可用示例:A1001、A1002。"
# 查到则返回正常状态
return f"订单 {key} 状态:{ORDERS[key]}。"把它挂到 Agent 上,查 A9999,轨迹变成:
[0] HumanMessage '帮我查订单 A9999'
[1] AIMessage ''
[2] ToolMessage '错误:未找到订单 A9999。可用示例:A1001、A1002。'
[3] AIMessage '抱歉,未查询到订单 A9999 的物流信息。系统中没有找到该订单。
可用示例订单号有:A1001、A1002。请问您是否需要查询这两个订单中的某一个呢?'进程活着,模型看到了错误,还把「可用示例」转达给了用户。对比 §9.1 的 KeyError,同样的用户输入,体验天差地别。
那句 key = order_id.strip().upper() 也不是摆设。实测让模型查「订单 a1001(小写)」,它老实地填了 {'order_id': 'a1001'},靠工具内的规范化才查到:
[1] AIMessage tool_calls args=[{'order_id': 'a1001'}]
[2] ToolMessage '订单 A1001 状态:已发货。'
[3] AIMessage '查询结果:订单 A1001 状态为 已发货。'模型不会帮你规范化输入,这活儿得工具自己干。 大小写、首尾空格、中文「市」字后缀、全角字符,都属于工具该容忍的范围。
错误文案的写法建议:
- 以「错误:」开头,方便你在日志里 grep,也让模型容易识别这是失败
- 告诉模型下一步能怎么做(「请确认订单号」「可用示例:A1001」)——这直接决定它能否自纠
- 不要返回空字符串或含糊的
"fail":模型看不懂,只能瞎猜或编造
9.3. 修法二:6.wrap_tool_errors.py(Agent 统一兜底) #
工具内防御能覆盖你想到了的错误。想不到的呢——第三方超时、空指针、依赖库内部异常?用 middleware 兜一层。
官方推荐用 wrap_tool_call 把未预期异常转成模型可读的 ToolMessage。第 10 章会系统讲 middleware;这里先掌握「给工具调用外包一层 try/except」这一招。
# 从 collections.abc 导入 Callable 用于类型标注
from collections.abc import Callable
# 从 dotenv 导入环境变量加载函数
from dotenv import load_dotenv
# 从 langchain.agents 导入 create_agent
from langchain.agents import create_agent
# wrap_tool_call 是把函数注册成「工具调用拦截器」的装饰器
from langchain.agents.middleware import wrap_tool_call
# ToolMessage 用于构造工具结果消息
from langchain.messages import ToolMessage
# ToolCallRequest 描述一次待执行的工具调用
from langchain.tools.tool_node import ToolCallRequest
# 从 langchain.tools 导入 tool 装饰器
from langchain.tools import tool
# 加载 .env 中的 API key
load_dotenv(override=True)
# 模拟订单库
ORDERS = {"A1001": "已发货", "A1002": "运输中"}
# 注册工具,这里故意保留会抛 KeyError 的写法
@tool
# 入参为订单号
def lookup_order(order_id: str) -> str:
# 描述取自 docstring
"""按订单号查询物流状态。订单号形如 A1001。"""
# 规范化输入
key = order_id.strip().upper()
# 直接取值,查不到就抛 KeyError,用来验证中间件能否兜住
return f"订单 {key} 状态:{ORDERS[key]}。"
# 用 wrap_tool_call 装饰后,每次工具调用都会先经过这个函数
@wrap_tool_call
# 函数名随意,签名必须是 (request, handler)
def handle_tool_errors(
# 本次调用请求,含工具名、参数、调用 id
request: ToolCallRequest,
# handler 是「真正执行这次调用」的默认逻辑
handler: Callable[[ToolCallRequest], ToolMessage],
) -> ToolMessage:
# 拦截器自己的说明
"""把工具异常转换成模型可消费的 ToolMessage。"""
# 正常路径:调用 handler 执行原工具
try:
# 成功时原样返回 handler 产出的 ToolMessage
return handler(request)
# 失败路径:捕获所有异常,不让它继续往上抛
except Exception as e:
# 构造一条错误 ToolMessage 顶替原本的结果
return ToolMessage(
# 把异常类型和信息都告诉模型,并提示下一步动作
content=f"工具错误:{type(e).__name__} - {e}。请检查参数后重试。",
# 必须回填调用 id,否则消息无法与请求配对
tool_call_id=request.tool_call["id"],
)
# 创建 Agent,通过 middleware 参数挂上兜底逻辑
agent = create_agent(
# 模型标识
model="deepseek:deepseek-v4-flash",
# 仍然是那个会抛异常的工具
tools=[lookup_order],
# middleware 接收列表,可以叠多个
middleware=[handle_tool_errors],
# 系统提示
system_prompt="你是订单助手。查订单必须调用 lookup_order。",
)
# 同样查不存在的订单号
result = agent.invoke({"messages": [{"role": "user", "content": "帮我查订单 A9999"}]})
# 打印完整轨迹,观察异常是否被转成了 ToolMessage
for i, msg in enumerate(result["messages"]):
# 打印序号与消息类型
print(f"[{i}] {type(msg).__name__}: {str(getattr(msg, 'content', ''))[:130]!r}")输出:
[0] HumanMessage: '帮我查订单 A9999'
[1] AIMessage: ''
[2] ToolMessage: "工具错误:KeyError - 'A9999'。请检查参数后重试。"
[3] AIMessage: '抱歉,我查询订单 A9999 时没有找到对应的物流信息,提示订单号不存在。请您确认一下订单号是否正确,或者提供其他信息,我再帮您查询。'同样是抛 KeyError 的工具,加了中间件之后进程就不崩了,异常变成一条 ToolMessage,模型据此给出得体回答。和 §9.1 的崩溃对照,中间件的价值一目了然。
wrap_tool_call 的执行位置值得理解一下:
模型发出 tool_calls
↓
handle_tool_errors(request, handler) ← 你的拦截器在这里
↓ try: handler(request)
实际执行 lookup_order
↓ except: 换成错误 ToolMessage
ToolMessage 回灌给模型它包在每一次工具执行的外面。所以除了兜异常,同样的位置还能做超时控制、调用计时、参数审计、敏感操作拦截——第 10 章会用它做安全护栏。
一个实现细节:ToolCallRequest 从 langchain.tools.tool_node 导入,实际类型是 langgraph.prebuilt.tool_node.ToolCallRequest(LangChain 做了转发)。两条路径都能用,本文统一用前者。
9.4. 两层怎么配合 #
| 错误类型 | 优先做法 |
|---|---|
| 单号不存在、权限不足、参数校验失败 | 工具内 return "错误:..." |
| 空指针、超时、第三方 500、依赖库异常 | wrap_tool_call 兜底 |
为什么不能只留中间件、省掉工具内防御?因为两者给模型的信息质量差很多:
| 工具内返回 | 中间件兜底 | |
|---|---|---|
| 模型看到 | 错误:未找到订单 A9999。可用示例:A1001、A1002。 |
工具错误:KeyError - 'A9999'。请检查参数后重试。 |
| 模型能否自纠 | 能,知道该试哪些值 | 难,KeyError 对它没有业务含义 |
| 泄露实现细节 | 不会 | 会(异常类型、内部键名) |
最后一行还是个安全问题:把原始异常文本回灌给模型,等于可能把内部表名、文件路径、SQL 片段送进对话,而对话内容通常会展示给用户。生产环境建议中间件只回一句笼统的「工具执行失败,请稍后重试」,详细异常写进日志。
原则:
可预期的业务错误 → 工具内返回说明(信息友好);未预期异常 → middleware 兜底(保证不崩);两者都要让模型「看得到」,但兜底那层别把内部细节抖出去。
10. 业务工具包 #
前面各节是零件;本节把它们收成可复用模块——本章实战。
设计目标:
- 多工具:天气 / 计算 / 查单 / 制度,覆盖选型与互斥描述
- 有 schema:天气用 Pydantic;计算用
Literal运算符(比裸eval更安全) - 有错误回灌:查单、制度未命中时返回「错误:...」
- 可导出:
BUSINESS_TOOLS+build_office_agent(),第 9 章可继续叠策略
数据仍用模拟字典,方便无外部依赖跑通;接真实 API 时只换函数体即可。
10.1. business_toolkit.py #
# 从 typing 导入 Literal,用于把参数取值限制在固定选项内
from typing import Literal
# 从 pydantic 导入 BaseModel 与 Field,用于声明入参 schema
from pydantic import BaseModel, Field
# 从 langchain.agents 导入 create_agent 工厂函数
from langchain.agents import create_agent
# 从 langchain.tools 导入 tool 装饰器
from langchain.tools import tool
# 从 dotenv 导入环境变量加载函数
from dotenv import load_dotenv
# 加载 .env 中的 API key
load_dotenv(override=True)
# ---------- 模拟数据 ----------
# 订单库:键为订单号,值含状态与预计天数
ORDERS = {
# 已发货订单
"A1001": {"status": "已发货", "eta_days": 2},
# 运输中订单
"A1002": {"status": "运输中", "eta_days": 1},
# 已签收订单
"A1003": {"status": "已签收", "eta_days": 0},
}
# 制度库:键为主题关键词,值为条文摘要
POLICIES = {
# 报销制度
"报销": "差旅报销需在返程 7 日内提交,单笔超 1000 元需主管审批。",
# 请假制度
"请假": "年假提前 3 天申请;病假需当日同步直属上级。",
# 加班制度
"加班": "加班需事先在系统提交,月末统一调休或结算。",
}
# ---------- 工具定义 ----------
# 用 Pydantic 声明天气工具的入参(对应 §5.2 的做法)
class WeatherInput(BaseModel):
# 类 docstring 成为 schema 的顶层描述
"""天气查询入参。"""
# 必填字段:城市名,description 帮助模型正确填写
city: str = Field(description="城市名,如北京、上海")
# 枚举字段:只能填两种单位之一,默认摄氏
units: Literal["C", "F"] = Field(
# 有默认值 → 模型可以不填
default="C",
# 字段说明
description="温度单位",
)
# 挂上 Pydantic schema
@tool(args_schema=WeatherInput)
# 参数名必须与 WeatherInput 的字段一一对应
def get_weather(city: str, units: str = "C") -> str:
# docstring 成为工具描述,写明「何时必须调用」
"""查询城市当前天气。用户问天气时必须调用,不要编造。"""
# 根据单位选择模拟温度值
temp = 26 if units == "C" else 79
# 返回简洁字符串
return f"{city} 当前约 {temp}°({units}),晴间多云。"
# 用 description 覆盖描述,写成带指令性的选型说明
@tool(
description="计算两个数的加减乘除。任何算术都请调用,不要心算。",
)
# op 用 Literal 限定为四个运算符,从结构上避免了 §4.3 的 eval 隐患
def calculate(a: float, b: float, op: Literal["+", "-", "*", "/"]) -> str:
# 已传 description,这句 docstring 对模型不可见
"""对 a、b 做四则运算。"""
# 加法
if op == "+":
# 结果转字符串返回
return str(a + b)
# 减法
if op == "-":
# 结果转字符串返回
return str(a - b)
# 乘法
if op == "*":
# 结果转字符串返回
return str(a * b)
# 走到这里说明是除法,先挡住除零,返回错误说明而不是 raise
if b == 0:
# 错误文案让模型知道该换参数
return "错误:除数不能为 0。"
# 除法
return str(a / b)
# 查单工具
@tool
# 入参为订单号
def lookup_order(order_id: str) -> str:
# 描述里说明订单号格式
"""按订单号查询物流状态。订单号形如 A1001。"""
# 规范化输入:容忍空格和小写(§9.2 验证过模型真会填小写)
key = order_id.strip().upper()
# 用 .get() 而不是 [],查不到返回 None 而不抛 KeyError
row = ORDERS.get(key)
# 未命中时返回错误说明,并给出可用示例便于模型自纠
if not row:
# 错误文案带上可用订单号,模型能据此追问用户
return f"错误:未找到订单 {order_id}。可用示例:A1001、A1002、A1003。"
# 命中时拼装状态文本
return f"订单 {key}:{row['status']},预计 {row['eta_days']} 天后相关节点完成。"
# 制度查询工具
@tool
# 入参为制度主题
def lookup_policy(topic: str) -> str:
# 描述里列出可选主题,帮助模型填参
"""查询公司制度摘要。topic 可为:报销、请假、加班。"""
# 遍历制度库,用「关键词包含」做模糊匹配
for key, text in POLICIES.items():
# 只要主题里含有关键词就算命中,能容忍「报销流程」这类问法
if key in topic:
# 命中即返回条文
return text
# 全部未命中时返回错误说明与可选项
return "错误:未匹配到制度。请尝试:报销 / 请假 / 加班。"
# 导出工具包列表,供 Agent 或其他模块复用
BUSINESS_TOOLS = [get_weather, calculate, lookup_order, lookup_policy]
# 用函数封装 Agent 构造,方便复用与测试
def build_office_agent():
# 函数说明
"""组装多工具办公助理。"""
# 返回配置好的 Agent
return create_agent(
# 模型标识
model="deepseek:deepseek-v4-flash",
# 一次挂载全部四个工具
tools=BUSINESS_TOOLS,
# 系统提示里逐一点明「什么问题用哪个工具」,帮助选型
system_prompt=(
"你是公司办公助手,回答简洁可靠。"
"查天气用 get_weather;算术用 calculate;"
"查订单用 lookup_order;问制度用 lookup_policy。"
"禁止编造订单状态与制度条文。"
),
)
# 只有直接运行本文件时才执行下面的演示代码
if __name__ == "__main__":
# 构造 Agent
agent = build_office_agent()
# 一句话里塞三个不同类型的问题,观察模型如何选型
result = agent.invoke(
{
# 输入是带 messages 键的字典
"messages": [
{
# 角色为用户
"role": "user",
# 同时涉及天气、订单、算术
"content": "上海天气怎么样?订单 A1001 到哪了?另外 128+256 等于多少?",
}
]
}
)
# 打印最终回答
print(result["messages"][-1].content)10.2. 真实运行结果 #
先各自单测(对应下面自测清单的第 1 步):
上海 当前约 26°(celsius),晴间多云。
384.0
订单 A1001:已发货,预计 2 天后相关节点完成。
错误:未找到订单 A9999。可用示例:A1001、A1002、A1003。
差旅报销需在返程 7 日内提交,单笔超 1000 元需主管审批。再跑组合问题,完整轨迹如下:
[0] HumanMessage: '上海天气怎么样?订单 A1001 到哪了?另外 128+256 等于多少?'
[1] AIMessage: ''
-> get_weather({'city': '上海'})
-> lookup_order({'order_id': 'A1001'})
-> calculate({'a': 128, 'b': 256, 'op': '+'})
[2] ToolMessage: '上海 当前约 26°(celsius),晴间多云。'
[3] ToolMessage: '订单 A1001:已发货,预计 2 天后相关节点完成。'
[4] ToolMessage: '384.0'
[5] AIMessage: '三件事都查到了:...'最终回答:
三件事都查到了:
1. **上海天气**:当前约 26°C,晴间多云。
2. **订单 A1001**:已发货,预计 2 天后完成相关节点。
3. **128+256**:等于 384。
还有其他需要帮忙的吗?这段轨迹有三点值得注意:
- 一条
AIMessage里带了 3 个tool_calls(§2 预告过的并行调用)。模型没有一个一个来,而是一次把三个申请单全递出来,接着是 3 条ToolMessage。整个过程只用了 2 次模型调用,不是 4 次——这对延迟和成本影响很大。 - 选型全对:天气→
get_weather、订单→lookup_order、算术→calculate,参数也都填对了(包括op: '+'这个枚举)。功劳属于「系统提示逐一点明用途」+「描述互斥」+「枚举锁死取值」这三件事的叠加。 calculate返回了384.0(§8 提到的 float 问题)。模型在最终回答里正确写成了「384」,但观察值里的.0会留在日志中。想干净的话,把a: float, b: float换成int,或在返回前判断一下是否为整数。
建议自测顺序:
- 对每个工具单独
tool.invoke({...})(先保证业务逻辑对) - 再
build_office_agent()问组合问题(看选型与协作) - 用第 2 / 4 章方法打印完整
messages,确认调了哪些工具、参数是否合理 - 故意问不存在的订单号,确认走「错误:...」而不是编造
- 故意问「深圳的社保政策」这类工具覆盖不到的问题,看它是否老实说不知道
这套工具包就是本章实战;第 9 章会在此基础上讲多工具协作与动态策略。
11. 实用约定与坑 #
下面几条能避开大多数初学翻车。带 ⚠ 的是静默失败——不报错,只是悄悄变差,最难发现:
定义侧
- ⚠ 类型注解必写:不写不报错,但 schema 里
type字段会整个消失(§3.4) - ⚠ 写了
Args:就要加parse_docstring=True:否则参数说明并没有生效(§3.3) - 加
parse_docstring=True前先确认 docstring 有Args:段:没有会直接ValueError(§3.3) tool.invoke()统一传字典:裸字符串只在「仅一个必填参数」时可用(§3.1)- docstring / description 写「何时用」:比只写「做什么」更利于选型
- 多工具描述要互斥:避免两个工具都叫「获取信息」
- 工具名只能用
[a-zA-Z0-9_-]:中文名和空格会被 API 用 400 拒掉(§4.2) - 能枚举就别用自由字符串:
Literal[...]既提升填参准确率,也避免eval类隐患(§4.3) - ⚠
args_schema字段名要和函数参数名一致:不一致时构造不报错,等真调用才TypeError(§5.4) - 不该由模型决定的参数用注入:
config: RunnableConfig或Annotated[T, InjectedToolArg](§5.5)
执行侧
- 先
tool.invoke单测:再绑模型 / Agent,把业务逻辑错和模型选型错分开 bind_tools≠ 自动执行:要么自己写循环,要么用create_agenttool_call_id必须对齐,且有几个调用回几条ToolMessage:少一条就是 400- 错误要回得去模型:不做处理时抛异常的工具会让整个
agent.invoke()崩掉(§9.1) - 工具自己负责规范化输入:模型真的会填
a1001这种小写值(§9.2) - ⚠
return_direct=True会让最后一条消息变成ToolMessage:依赖AIMessage类型的代码会走错分支(§8.1) - 返回值收拾干净:
float参数会让「128+256」变成384.0 - 危险操作(删库、转账)先别直接给模型;第 10 章用 middleware / HITL 再放开
口诀:
说明写清,schema 写严;先单测工具,再交给模型;错误要回灌,不要只崩进程。
12. 练习 #
每题做完后,尽量打印完整 messages 或对工具做 invoke 单测,而不是只看最终一句。
- 看清模型收到了什么:用
convert_to_openai_tool打印business_toolkit.py里四个工具的定义,确认每个参数都有description。找出哪个工具的参数说明最弱并补上。 - 对比
parse_docstring:写一个三参数工具,分别在加与不加parse_docstring=True的情况下打印args,把差异记下来。 - 复现延迟失败:故意让
args_schema的字段名和函数参数名不一致,确认构造阶段没有任何报错,只有invoke时才TypeError。 - 复现崩溃再修好:把
lookup_order改成ORDERS[key]直接取值,查一个不存在的订单号,确认agent.invoke()抛KeyError;然后分别用「工具内返回错误」和wrap_tool_call修好,对比模型看到的两种错误文本。 - 加工具:在业务工具包中增加
get_employee_email(name: str) -> str(可用字典模拟),并更新system_prompt。注意查不到时要返回「错误:...」。 - Pydantic 入参:给
lookup_order加上args_schema,增加可选字段verbose: bool = False(为 True 时多返回一行温馨提示)。 - 手工环:不用
create_agent,只用bind_tools+ToolMessage完成一次「查 A1002 订单」。 return_direct对照:给fetch_order_status开关return_direct=True,分别打印len(result["messages"])和type(result["messages"][-1]).__name__,确认是 3/ToolMessage与 4/AIMessage。- 注入参数:给
lookup_order加一个user_id: Annotated[str, InjectedToolArg],确认它不在模型可见的args里,并在工具内用它做一次简单的权限判断。
13. 本章小结 #
- 工具是模型可请求、由代码执行的外部能力;日常用
create_agent(tools=...),细控用bind_tools。模型只是「填申请单」,跑腿的永远是你的代码。 - 模型能看到的只有三样:工具名、描述、参数 schema。用
convert_to_openai_tool可以看到发给 API 的原文。 - docstring 里的
Args:段默认不会变成参数说明,要么加parse_docstring=True,要么改用 Pydanticargs_schema+Field(description=...)。后者更适合业务工具。 - 工具名必须匹配
^[a-zA-Z0-9_-]+$,中文名会被 API 拒绝;中文放描述里没问题。 args_schema同时是模型的说明书和本地的运行时校验;但字段名与函数参数名不一致属于延迟失败,写完立刻单测。bind_tools只产生tool_calls;执行与ToolMessage回灌要自己做或交给 Agent。一条AIMessage可以带多个调用,回灌时一条都不能少。- 返回值默认用简洁
str;return_direct=True省一次模型调用,但会让最后一条消息变成ToolMessage。 - 错误处理是两层:工具内
return "错误:..."给模型友好且可自纠的信息,wrap_tool_call兜住想不到的异常保证不崩。完全不处理时,一次KeyError就能打崩整个 Agent。 - 本章产出:业务工具包(天气 / 计算 / 查单 / 制度),为第 9 章多工具办公助理打底。
工具如果已经跑在别的进程里,或要同时给多个宿主用,不要把源码贴进 Agent,改走 第 17 章 MCP。
下一章:create_agent 深入——系统提示策略、多工具协作、动态选模型 / 工具,做出更完整的多工具办公助理。