第 21 篇把外部 MCP Server 接进了自己的 Agent 图,"会用工具"这条线至此闭环:第 5 篇在进程内定义工具,第 19 到 21 篇让工具跨进程、跨机器。但同一个模型、同一套框架,为什么有的 Agent 一问就对,有的连查个订单都要绕三圈?差距多半不在模型。工具是模型唯一的四肢,决定 Agent 上限的往往不是模型,而是工具设计——四肢没长好,大脑再聪明也是空转。
本篇是《AI Agent 开发》系列第 22 篇,MCP 模块收官篇。代码基于 langchain-core 1.6.x(@tool 与 ToolNode),示例不依赖具体数据库,可以直接抄进任何项目。全文按"粒度 → 描述 → 错误处理 → 护栏 → 返回值 → 动态生成 → 反模式"推进:第 9 篇埋的 handle_tool_errors 伏笔、第 20 篇留下的粒度与护栏两道题,这一篇全部回收。
1、工具粒度设计
粒度是工具设计的第一道分岔,两头都是坑。太粗的典型是一个万能工具:一个 manage_order 吞下查、改、取消、备注所有操作,参数表十几项,每个分支只用其中几项。巨型 schema 里模型要自己判断哪些字段当前有效,填错率随参数数量上升,而描述又写不下每个分支的行为——工具对模型半明半暗。太细的典型是订单场景拆出 20 个工具:get_order_by_id、get_order_by_user、list_recent_orders……列表一长,模型开始选错对象,把"按用户查"当成"按号查"。
两种极端对照如下:
# 反例:一个工具吞下所有订单操作
@tool
def manage_order(action: str, order_id: str = "", status: str = "",
note: str = "", days: int = 0) -> str:
"""订单管理工具。"""
# 正例:一个动词一个工具,各自收窄
@tool
def get_order(order_id: str) -> str:
"""按订单号查询订单详情。"""
@tool
def cancel_order(order_id: str, reason: str) -> str:
"""取消订单;已发货订单不可取消,改用 apply_refund。"""
反例的死因在 docstring 那五个字:action 有哪些合法取值、各分支做什么、何时不该调,全部没说。正例拆开后,每个工具名以动词开头、参数各自收窄、描述精确到单个行为。这就是一个动词一个工具的含义:工具名里的动词,就是函数体里唯一的主要动作;函数体一旦长出 if action == "cancel" 这类分支,就是该拆的信号。
但粒度不是越细越好。Anthropic 在工程博客里给过一组经验:用评测驱动工具合并,功能高度重叠的工具合成一个,误用率反而下降。注意合并的对象是"同构操作"——三个只差过滤条件的查询,合成一个带参数的查询;而不是"一切操作"——把查、改、取消塞进一个 action 参数,那是绕回反例。诊断当前粒度看失败轨迹就够:选错工具居多,说明列表太细或描述重叠;传错参数居多,说明单个工具太粗或描述含糊。第 20 篇 SQLite Server 的 list_tables → describe_table → query 三连,就是小工具组合成"先探索再行动"路径的样板。
2、描述即 API 文档
模型选工具时能看到的信息只有三样:工具名、参数 schema、描述。它不读你的源码,不看 README——描述就是写给模型读的 API 文档。而现实里大多数工具的描述,信息量还不如变量名。人类 API 文档的全部写法在这里适用:做什么、输入输出是什么、何时用;再加一条模型特有的:何时不该用。
正反例对照如下:
# 反例:五个字,模型只能靠猜
@tool
def search_orders(q: str) -> str:
"""搜索订单。"""
# 正例:写给模型读的 API 文档
@tool
def search_orders(query: str, status: str = "all") -> str:
"""按关键词搜索当前用户的历史订单。
何时用:用户问"我的订单""上周买过什么"等查单需求。
何时不该用:已知订单号时直接用 get_order;查物流进度
用 get_logistics,本工具不返回物流信息。
query 填商品名或店铺名片段;status 取 all/paid/shipped/refund。
返回前 10 条匹配,每条含订单号、金额与下单时间。
"""
反例的 q 没有语义没有格式,"搜索订单"没说搜的是谁的订单,模型只能猜,猜错就烧掉一轮调用。正例的 docstring 分四段:首行一句话功能;"何时用/何时不该用"划清与相邻工具的边界;参数取值;返回形态。其中最值钱的是反例条件那两句——直接消灭"该用 get_order 时调了搜索"这类最常见的误调用,成本只有二十几个 token。描述里再放一个调用示例同样划算,Anthropic 那篇文章的实测结论是富示例描述对选择准确率提升明显。
两条纪律收尾。其一,描述随 schema 每一轮都进上下文、按 token 计费,精确但不啰嗦;一段边界写五行写不完,八成是该拆工具了,回到第 1 节。其二,参数语义写进 Field 的 description,和约束待在同一层,别全堆在 docstring 里。
描述是工具唯一的话筒:模型听不见你的实现有多优雅,只听得见这五行字。
3、错误处理与自我修复
工具一定会失败:订单号不存在、网络超时、参数越界。关键不是避免失败,而是失败以什么形态回到模型手里——这决定模型能不能自己修复。第 9 篇手写工具节点时见过雏形,这里立成正式规矩。
3.1、抛异常还是回传错误
两种写法对照如下:
import json
@tool
def get_order(order_id: str) -> str:
"""按订单号查询订单详情,含金额、状态与物流。"""
order = DB.find(order_id)
if order is None:
# 反例:raise ValueError(f"订单 {order_id} 不存在")
return json.dumps({
"error": "not_found",
"message": f"订单 {order_id} 不存在",
"hint": "请先用 search_orders 确认订单号",
}, ensure_ascii=False)
return json.dumps(order, ensure_ascii=False)
raise 版本的行为取决于运行时:裸循环里整轮对话当场崩掉;prebuilt 图里被 ToolNode 捕获,回传的是 traceback 字符串——模型看得出"出错了",看不出"错在哪、怎么改"。结构化版本把 not_found 变成一条 JSON:error 给机器可判别的类别,message 给人话,hint 给出下一步出路。错误信息要可读、可行动,模型下一轮通常就带着 hint 改道了。第 20 篇 MCP Server 的 ToolError 走的是同一方向:SDK 把异常转成 isError 结果回传,模型自愈的前提同样是错误文本本身的质量。
模型不需要工具永远成功;它需要的是失败时拿到一条能指导下一步的错误消息。
3.2、ToolNode 兜底机制
第 9 篇的伏笔到这里回收:prebuilt ToolNode 的错误兜底就是 handle_tool_errors,四种取值覆盖四种策略。想接管兜底格式,传一个 callable:
from langgraph.prebuilt import ToolNode
def _format_error(exc: Exception) -> str:
return json.dumps({
"error": type(exc).__name__,
"message": str(exc),
"hint": "请检查参数后重试,或改用其他工具",
}, ensure_ascii=False)
tool_node = ToolNode(
tools,
handle_tool_errors=_format_error, # 换成 False 则异常上抛
)
四种形态各自的行为:True(默认)捕获全部异常,转成带 status="error" 的 ToolMessage 回传,图不崩、模型看到错误继续推理;False 关闭兜底,异常原样上抛,适合要在图外层接人工处理的场景;异常类型元组只兜指定类型,其余照抛;callable 拿到异常自己格式化——上面这段让兜底错误也带上结构化字段。位置要摆正:兜底是最后防线,不是主策略。ToolNode 只保证图不崩,不保证错误信息能指导纠错;3.1 节的主动结构化永远在前,兜底只接住你没料到的异常。参数细节见 ToolNode 参考文档。
4、参数护栏与幂等
第 20 篇立过规矩:凡拼接进 SQL 的输入必须先过校验。现在把护栏展开成三层。第一层,把约束写进 schema:Pydantic 的 Field 约束会原样进入参数 schema,模型生成参数的那一刻就看得见边界,越界值在进函数体之前就被 ValidationError 拦下。能进 schema 的校验,不要留给运行时 if——省掉一整轮"调用、报错、重试"的往返。第 2 节 search_orders 里那个自由字符串 status,在这里收成枚举:
from typing import Literal
from pydantic import BaseModel, Field
class OrderFilter(BaseModel):
status: Literal["paid", "shipped", "refunding"] = Field(
description="订单状态,仅这三种可查")
days: int = Field(default=7, ge=1, le=90, description="回看天数")
region: str = Field(default="cn-BJ",
pattern=r"^[a-z]{2}-[A-Z]{2}$",
description="区域码,如 cn-BJ")
@tool
def list_orders(f: OrderFilter) -> str:
"""按状态与时间窗口列出订单,每页 10 条。"""
return json.dumps(DB.list(**f.model_dump()), ensure_ascii=False)
Literal 枚举让模型不再发明 status="已完成";ge=1, le=90 把回看天数钳进范围;pattern 用正则约束区域码格式。全部是声明式护栏:一次声明,schema 层生效,函数体一行校验代码都不用写。约束字段的完整清单见 Pydantic Field 文档。
第二层是危险参数白名单。凡是会拼进 SQL、shell 命令、文件路径的字符串参数,必须先过白名单校验再进执行层——第 20 篇 describe_table 的表名 isalnum() 校验、query 的开头白名单加正文黑名单双正则,就是完整范例。默认不信任 LLM 生成的任何拼接输入,这条没有例外。
第三层是幂等。Agent 场景里重试是常态:模型上一轮失败后重试、网络超时后框架重试、用户不满意再问一遍。读操作天然幂等,写操作必须设计成可安全重试——upsert 语义、幂等键、先查后建,三选一即可:
@tool
def create_ticket(order_id: str, draft_id: str) -> str:
"""为订单创建售后工单;draft_id 是草稿 ID,
同一 draft_id 重试不会重复建单。"""
ticket = DB.upsert_ticket(draft_id, order_id) # 幂等写
return json.dumps({
"ticket_id": ticket.id,
"created": ticket.is_new, # 告诉模型这次是否真的新建
}, ensure_ascii=False)
create_ticket 以 draft_id 为幂等键做 upsert,同一草稿重试 N 次也只有一张工单;created 字段还告诉模型这次是不是真的新建,避免它向用户复述两遍"已创建"。重试安全了,第 3 节鼓励的"模型自愈重试"才真的安全。
5、返回值设计
返回值不影响"能不能跑",只影响跑得省不省、准不准,所以最容易被忽视。第一条:小而结构化。返回 JSON 字符串而不是自然语言段落——模型从结构化字段里取数的可靠性,远高于从长句子里捞数字,字段名本身就是语义。MCP Server 返回 dict 时 SDK 自动生成 structured_content(第 20 篇);进程内工具则自己 json.dumps,别依赖框架对 dict 的默认字符串化,那个格式不受你控制。
第二条:大结果不要一次给完。列表类工具遵守"摘要 + 分页 + 下钻"三件套——默认一页摘要,配一个按 id 取全量的工具,翻不翻页让模型自己决定:
@tool
def search_orders(query: str, page: int = 1) -> str:
"""按关键词搜索订单,每页 10 条摘要。"""
hits, total = DB.search(query, page=page, size=10)
return json.dumps({
"total": total,
"page": page,
"items": [{"order_id": o.id, "summary": o.brief()}
for o in hits],
}, ensure_ascii=False)
@tool
def get_order_detail(order_id: str) -> str:
"""查订单全量字段:明细、物流与操作记录。"""
return json.dumps(DB.find(order_id), ensure_ascii=False)
search_orders 每页 10 条、每条一行摘要加订单号,total 告诉模型还有多少没看;get_order_detail 才给全量字段。绝大多数提问在摘要层就能收口,上下文只花在必要的地方——这和第 20 篇 query 强制 LIMIT 是同一件事的两面:上下文预算是工具的硬约束,不是免费资源。
第三条:二进制内容不进文本。图片 base64 塞进返回值,token 账单直接击穿上下文窗口。MCP 侧用 Image 类型包成 image content block(第 20 篇的 trend_chart),支持图片的 Host 直接渲染;进程内工具返回 URL 或文件路径,让前端或多模态模型按需取。文本管道里只传引用,不传字节。
6、动态工具生成
前面的技巧都假设工具是写死的,还有一类场景工具面本身随数据变化:给 Agent"查任意数据库"的能力,表结构要到运行时才知道。这时可以按 schema 现场生成工具,但第 1、2 节的粒度与描述纪律一条不能少。骨架如下:
from langchain_core.tools import StructuredTool
def _make_table_tool(conn, table: str, cols: list[str]) -> StructuredTool:
def query(sql: str) -> str:
"""占位 docstring,实际描述走 description 参数。"""
return run_ro_query(conn, table, sql) # 第 20 篇的三层护栏
return StructuredTool.from_function(
func=query,
name=f"query_{table}",
description=f"查询 {table} 表(列:{', '.join(cols)}),"
"仅支持单条 SELECT,最多返回 50 行 JSON。",
)
def build_table_tools(conn) -> list:
names = (r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"))
tools = []
for t in names:
cols = [r[1] for r in conn.execute(f"PRAGMA table_info({t})")]
tools.append(_make_table_tool(conn, t, cols))
return tools
两个关键点。工厂函数 _make_table_tool 不是摆设:循环里直接写 lambda 会踩闭包晚绑定的坑——所有工具共享循环结束后的最后一个表名;工厂函数一表一闭包,稳稳隔开。StructuredTool.from_function 的 description 参数由表名与列名现场拼出,动态生成不豁免描述义务,反而要把列语义写全,因为模型对这批工具没有任何先验。另外三件事别忘:
- 控制数量:几十张表就是几十个工具,第 1 节的粒度问题原样回归,必要时按业务域分组生成
- 缓存与失效:schema 变更后要重新生成,别让工具面和库结构脱节
- 护栏照旧:动态拼 SQL 的白名单一条不能少,
run_ro_query内部仍是第 20 篇那三层
StructuredTool 的完整参数见 LangChain 工具文档。
7、反模式清单
把全文散落的坑收成一张速查表,评审工具清单时逐行对照:
| 反模式 | 典型症状 | 修正方向 |
|---|---|---|
| 隐式状态依赖 | B 必须在 A 之后调用,顺序只写在实现里 | A 显式返回 B 所需 ID,或 B 自取完整参数 |
| 副作用不声明 | get_report 顺手发邮件、改状态 |
名字如实反映行为,写操作用动词命名 |
| 返回渲染文本 | 返回整段 markdown 报告 | 返回结构化数据,渲染交给前端 |
| 异常当控制流 | 用 raise 表达"没查到" | 结构化 not_found 结果(第 3 节) |
| 万能字符串参数 | query: str 里塞 JSON、塞时间范围 |
类型化参数加 schema 约束(第 4 节) |
| 关键词堆砌描述 | 描述塞满同义词诱使模型调用 | 精确边界加反例条件(第 2 节) |
六条的共同根源,是把工具当成"函数"而不是"给模型的接口"。函数的调用方是懂上下文的程序员,工具的调用方是只看名字、描述和 schema 的模型。给模型用的接口,一切信息必须显式可见——顺序依赖、副作用、错误原因、结果边界,实现里藏一行,模型就瞎一轮。
8、总结
这一篇把"会用工具"推进到"会设计工具":粒度上一个动词一个工具,用失败轨迹判断该合还是该拆;描述是写给模型读的 API 文档,反例条件性价比最高;错误处理把异常换成可读可行动的结构化消息,handle_tool_errors 是兜底不是主策略;护栏三层递进——约束进 schema、危险参数过白名单、写操作幂等;返回值小而结构化,大结果靠摘要、分页加下钻,二进制只传引用;动态生成把工具面交给运行时,描述与护栏一样不能省。
练习:挑一个手头真实的订单工具做重构。它的起点多半长这样——描述一句"查订单",函数体里 raise ValueError,返回全量字段一整页。目标形态:描述补齐何时用、何时不该用、参数取值;查不到时返回 not_found 结构化 JSON 加 hint;列表默认每页 10 条摘要,另拆 get_order_detail 下钻;status 改成 Literal 枚举。验收也简单:故意用一个不存在的订单号和一组越界参数各调一次,看模型能不能一轮自我修复。
MCP 模块到此收官:协议(第 19 篇)、Server(第 20 篇)、接入(第 21 篇)、设计(本篇)四块拼齐,Agent 的工具层就完整了。对于正在搭 Agent 的团队,建议把第 7 节那张表直接加进工具评审清单,新工具合入前逐行过一遍——模型不会抱怨烂工具,它只会安静地选错。下一篇进入模块五:Agent 的记忆系统。


