给 Claude Agent SDK 的智能体加自己的工具:让它查你的数据库、调你的接口
内置工具只会读写文件和跑命令。要让智能体查订单、办退款,得把你自己的函数做成工具。本站用 Python 实测了一个只用自定义工具的客服助手:三个工具、七组运行,包括订单号不存在、工具内部抛异常、退款没被批准。两点要留意:没接住的异常会把内部信息带到回答里;去掉内置工具后一次运行只要两分钱。
本站已实测,测试日期:2026-10-12。
这篇教程解决一个问题:上一篇的智能体只会读写文件和跑命令,怎么让它用上你自己的业务功能,比如查订单、办退款。
做法是把你的函数包成工具。Claude 看得到每个工具的名字、说明和参数,自己判断什么时候调哪个;你的函数负责真正去查库、调接口,把结果交回去。
本站写了一个网店的客服助手,三个工具,背后是一个 SQLite 订单库。它一个内置工具都不带,能做的事完全由这三个工具决定。
| 实测环境 | |
|---|---|
| 日期 | 2026-10-12 |
| 版本 | claude-agent-sdk 0.2.165,自带的 Claude Code 2.1.294 |
| 系统 | Ubuntu 24.04,Python 3.12.3 |
| 模型 | Opus 5.5 |
| 连接方式 | 通过酷站AI 中转站,不是直连官方 |
| 示例数据 | 一个有四张订单的 SQLite 库,自己建的 |
一个工具由四样东西组成
| 说明 | |
|---|---|
| 名字 | Claude 调用时用的标识 |
| 说明 | 这个工具做什么。Claude 靠这句话决定什么时候用它 |
| 参数的结构 | 接受哪些参数、各是什么类型 |
| 处理函数 | 被调用时真正执行的异步函数 |
在 Python 里用 @tool 装饰器把这四样写在一起:
from typing import Annotated, Any
from claude_agent_sdk import tool, ToolAnnotations
@tool(
"get_order",
"按订单号查一张订单的详情,包括物流单号。",
{"order_id": Annotated[str, "订单号,形如 A1001"]},
annotations=ToolAnnotations(readOnlyHint=True),
)
async def get_order(args: dict[str, Any]) -> dict[str, Any]:
found = rows("select * from orders where id = ?", (args["order_id"],))
if not found:
return text(f"没有订单号 {args['order_id']}。……", is_error=True)
return text(json.dumps(found[0], ensure_ascii=False))
其中 rows 是我们自己写的查库的小函数,text 把一段文字包成工具要求的返回格式:
def text(s, is_error=False):
out = {"content": [{"type": "text", "text": s}]}
if is_error:
out["is_error"] = True
return out
处理函数必须返回一个带 content 的字典,content 是一个列表,里面是一块块结果,最常用的就是文字块。
参数的两种写法
简单的,一个字典,键是参数名,值是类型。想给参数加说明,用 Annotated 包一下,Claude 看得到这句说明:
{"order_id": Annotated[str, "订单号,形如 A1001"]}
这种写法里每个参数都是必填的。
要可选参数、枚举、取值范围的,直接写完整的 JSON Schema:
@tool(
"find_orders",
"按客户姓名查订单,可以再按状态筛选。返回订单号、状态、金额(分)和下单日期。",
{
"type": "object",
"properties": {
"customer": {"type": "string", "description": "客户姓名"},
"status": {
"type": "string",
"enum": ["paid", "shipped", "delivered", "refunded"],
"description": "只看这个状态的订单,不传就是全部",
},
},
"required": ["customer"],
},
annotations=ToolAnnotations(readOnlyHint=True),
)
async def find_orders(args: dict[str, Any]) -> dict[str, Any]:
sql, params = "select id, status, amount_cents, created from orders where customer = ?", [args["customer"]]
if args.get("status"): # 可选参数用 .get() 读
sql += " and status = ?"
params.append(args["status"])
...
status 没有列在 required 里,所以 Claude 可以不传,函数里用 args.get() 读。
第三个工具是会改数据的:
@tool(
"refund_order",
"给一张订单办理退款。只有状态是 paid 或 shipped 的订单能退。",
{"order_id": Annotated[str, "订单号"], "reason": Annotated[str, "退款原因,写给财务看"]},
)
async def refund_order(args: dict[str, Any]) -> dict[str, Any]:
...
把工具交给智能体
工具要先装进一个“服务器”,再把服务器交给 query:
from claude_agent_sdk import create_sdk_mcp_server, query, ClaudeAgentOptions
shop = create_sdk_mcp_server(name="shop", version="1.0.0", tools=[find_orders, get_order, refund_order])
options = ClaudeAgentOptions(
mcp_servers={"shop": shop},
tools=[], # 内置工具一个都不要
allowed_tools=["mcp__shop__find_orders", "mcp__shop__get_order"],
permission_mode="dontAsk", # 没预先批准的一律拒绝
system_prompt="你是一家网店的客服助手。只根据工具查到的数据回答,查不到就直说,不要猜。回答用中文,简短。",
)
这个服务器用的是 MCP 协议,但它不是另起的一个进程,就跑在你的程序里面,所以处理函数可以直接用你程序里的数据库连接、配置和别的函数。
工具的全名是 mcp__服务器名__工具名,服务器名取的是 mcp_servers 字典里的键。这里就是 mcp__shop__get_order。在 allowed_tools 里要写这个全名,也可以用通配 mcp__shop__* 放行这个服务器上所有的工具。
启动之后,会话的初始化消息里能看到实际登记了什么:
[会话里的工具] ['mcp__shop__find_orders', 'mcp__shop__get_order', 'mcp__shop__refund_order']
[MCP 服务器] [{'name': 'shop', 'status': 'connected', 'source': 'sdk'}]
只有我们的三个工具,内置的一个都没有。
哪个选项管“看不看得见”,哪个管“让不让用”
这几个选项容易混,官方文档分了两层:
| 选项 | 管哪一层 | 效果 |
|---|---|---|
tools=["Read", "Grep"] |
看不看得见 | 只有列出的内置工具在。不影响 MCP 工具 |
tools=[] |
看不看得见 | 内置工具全部拿掉,只剩你的 MCP 工具 |
allowed_tools |
让不让用 | 列出的工具不用问直接运行。没列的仍然在,调用时走权限流程 |
disallowed_tools |
两层都管 | 写工具名(如 "Bash")是直接拿掉;写带范围的规则(如 "Bash(rm *)")是工具还在,只拒绝匹配的调用 |
客服助手用不着读文件、跑命令,所以直接 tools=[]。这比留着内置工具再一个个去拒绝要干净:它看不见的工具,根本不会去试。
跑起来
python support_agent.py "张伟最近一张订单到哪了?"
[调用] mcp__shop__find_orders {"customer": "张伟"}
[结果] [{"id": "A1002", "status": "shipped", "amount_cents": 45900, "created": "2026-10-08"},
{"id": "A1001", "status": "delivered", "amount_cents": 12900, "created": "2026-09-28"}]
[调用] mcp__shop__get_order {"order_id": "A1002"}
[结果] {"id": "A1002", "customer": "张伟", "status": "shipped", ..., "tracking": "YT9876543210"}
[回答] 张伟最近的订单是 A1002(10月8日下单,金额 ¥459.00),已经发货,物流单号是 YT9876543210。
我这边只能查到"已发货",查不到包裹现在具体在哪里。需要的话,可以拿物流单号去快递公司官网
或者物流查询平台查实时位置。
[结束] success 轮数=3 花费估算=0.0242794
没有人告诉它先查哪个、再查哪个。它先按姓名找出两张订单,挑了最近的一张,再去查详情拿物流单号,金额从“分”换算成了元。它也没有编造包裹的位置:工具里没有这个信息,它就直说查不到。
再来一个要自己算的:
$ python support_agent.py "张伟一共在我们这里花了多少钱?已经退款的不算。"
[调用] mcp__shop__find_orders {"customer": "张伟"}
[回答] 张伟一共花了 588.00 元,他名下没有已退款的订单。
……
A1002 已发货但还没签收。如果你只想算已签收的,就是 129.00 元。
一次调用就够了,它没有多查。
工具出错的时候
处理函数出了错,循环不会停。官方文档的说法是:SDK 会接住异常,把它变成一条报错的工具结果交给 Claude,Claude 可以重试、换一个工具,或者向用户解释。区别在于 Claude 读到的是什么,有两种情况。
你自己接住,写清楚
订单号不存在时,我们的函数返回一段写给 Claude 看的话,并标上 is_error:
return text(
f"没有订单号 {args['order_id']}。订单号是字母 A 加四位数字;"
"不确定订单号的话,可以用 find_orders 按客户姓名查。",
is_error=True,
)
$ python support_agent.py "帮我查一下订单 Z9999 的物流。"
[调用] mcp__shop__get_order {"order_id": "Z9999"}
[结果(报错)] 没有订单号 Z9999。订单号是字母 A 加四位数字;不确定订单号的话,可以用 find_orders 按客户姓名查。
[回答] 系统里没有订单 Z9999。我们的订单号是字母 A 加四位数字,比如 A1001,请再核对一下。
如果找不到订单号,告诉我下单人的姓名也可以,我帮您查出订单再看物流。
报错信息里写了“可以怎么办”,它就把这条出路转告给了用户。报错信息是写给模型看的提示,写得越具体,它接下来做得越对。
你没接住,它读到的是原始的异常
我们让 get_order 直接抛一个异常,模拟数据库连不上:
raise RuntimeError("connection to orders-db-2 timed out after 3000ms")
[调用] mcp__shop__get_order {"order_id": "A1002"}
[结果(报错)] connection to orders-db-2 timed out after 3000ms
[回答] Retrying once.
[调用] mcp__shop__get_order {"order_id": "A1002"}
[结果(报错)] connection to orders-db-2 timed out after 3000ms
[回答] A1002 的物流单号这次没查到。我连着查了两次,订单数据库(orders-db-2)都连接超时,
系统没有返回这张订单的任何信息。请稍后再试,到时我再帮您查一次。
程序没有崩,它还自己重试了一次,这是好的一面。不好的一面在最后那句回答里:内部数据库的主机名 orders-db-2 出现在了给客户的回答里。 异常的原文原样到了模型手上,模型又原样转述了出去。
真实系统里的异常信息常常带着主机名、表名、SQL、文件路径。所以处理函数里要自己接住异常,只把适合让用户知道的话交出去:
try:
found = rows("select * from orders where id = ?", (args["order_id"],))
except Exception:
logger.exception("get_order failed") # 细节记进你自己的日志
return text("订单系统暂时查不了,请稍后再试。", is_error=True)
这段改法是我们的建议,没有再跑一遍验证。
会改数据的工具
refund_order 会改库。我们故意没有把它放进 allowed_tools,权限模式是 dontAsk,也就是没预先批准的一律拒绝:
$ python support_agent.py "给订单 A1003 办退款,原因是客户拍错了。"
[调用] mcp__shop__refund_order {"order_id": "A1003", "reason": "客户拍错了"}
[结果(报错)] Permission to use mcp__shop__refund_order has been denied because Claude Code is
running in don't ask mode. ……
[调用] mcp__shop__get_order {"order_id": "A1003"}
[回答] A1003 的退款没办成。当前是"不询问"模式,退款工具的调用权限被系统拒绝了,订单没有任何改动。
我查了一下订单本身,是可以退的:客户李娜,已付款还没发货,88.00 元……
调用被拦在了函数外面,我们查了库,订单状态没有变。它转而去查了这张订单的情况,告诉我们这张单本身是可以退的。
把退款工具也加进 allowed_tools 再跑:
[调用] mcp__shop__refund_order {"order_id": "A1003", "reason": "客户拍错了"}
[结果] 订单 A1003 已退款。原因:客户拍错了
[回答] 订单 A1003 已经退款,退款原因写的是"客户拍错了"。
库里这张单变成了 refunded。
真正的把关在你的函数里
权限只决定工具能不能被调用。调用之后做什么、该不该做,由函数里的代码决定。让它给一张已经签收的订单退款:
$ python support_agent.py "给订单 A1001 办退款,原因是客户不想要了。"
[调用] mcp__shop__refund_order {"order_id": "A1001", "reason": "客户不想要了"}
[结果(报错)] 订单 A1001 现在的状态是 delivered,不能退款。已签收的订单要走售后流程。
[回答] A1001 的退款没办成。系统显示这张订单已经签收(delivered),只有已付款(paid)或已发货
(shipped)的订单才能直接退款。签收后的订单要走售后流程来处理……
拦下它的是函数里的那个状态判断,库没有被改。
工具的说明里也写了“只有 paid 或 shipped 的订单能退”,但不要指望说明来把关。说明是给模型的参考,模型可能看漏,也可能被用户的话带着走。业务规则要写在处理函数里,像对待任何一个外部请求那样校验参数。 谁在调用、有没有权限退这一单、金额对不对,都是函数的事。
需要人点头才能做的操作(比如大额退款),可以让调用停下来等你的程序批准,这是下一篇的内容。
工具的说明怎么写
这次三个工具它都用对了,没有调错,也没有多调。几条写说明时的做法,一部分来自官方文档,一部分是这次的体会:
- 说明里写清楚返回什么。
find_orders的说明里有“返回订单号、状态、金额(分)和下单日期”。它知道金额的单位是分,所以换算对了。 - 参数的说明写格式。 “订单号,形如 A1001”。
- 用枚举限制取值。 状态只有四种,就写成枚举,它不会传一个你没见过的值。
- 只读的工具标上
readOnlyHint。 文档说明这个标记决定工具能不能和别的只读工具同时执行,默认是一个个顺序执行。它只是一个标记,不会真的阻止函数写东西,所以要和函数实际做的事一致。
工具多了会占上下文吗?文档的说法是工具搜索默认开着:SDK 里的 MCP 工具,Claude 一开始只看到名字的清单,完整的参数结构用到时才加载。
花了多少:每次两分钱
七次运行,每次的估算在 0.018 到 0.024 美元之间,合计约 0.15 美元。
和上一篇对比很明显:那边一次最简单的运行也要 0.13 美元。差别来自两处,这个助手都做了:tools=[] 拿掉了全部内置工具的说明,system_prompt 用一句自己的话换掉了 Claude Code 那一大段默认的系统提示词。固定开销小了,每次请求自然便宜。
做一个只干特定事情的智能体,这两处是最先该做的。两处各占多少我们没有分开测。这些是 SDK 按官方价格估算的数字,不是中转站实际扣的金额。
接已有的 MCP 服务器
上面是在自己的程序里定义工具。如果要用的是一个现成的 MCP 服务器(独立的进程,或者一个网址),同样通过 mcp_servers 传进去,写法换成它的启动命令或者地址。工具的全名规则不变,还是 mcp__服务器名__工具名。这一种我们没有在 SDK 里实测,命令行里接 MCP 服务器的做法见接 MCP 服务器实战。
文档里还有一处 Python 特有的限制:@tool 装饰器只转发返回值里的 content 和 is_error。想返回机器可读的 structuredContent,要用独立的 MCP 服务器,不能用进程内的这种。
这次实测没有覆盖到的
- TypeScript 没有实测。 那边用
tool()函数和 Zod 来定义参数,可选参数是.optional()。 - 独立的 MCP 服务器、返回图片和文件、
structuredContent没有试。 - 只读工具的并行执行没有观察到,这次的问题都是先后依赖的调用。
- “自己接住异常”的改法没有重新跑。
- 固定开销降下来,
tools=[]和自定义系统提示词各占多少,没有分开测。 - 工具只有三个。 工具很多时它选得准不准、工具搜索表现如何,这次看不出来。
- 每个问题只跑了一次。
下一步
这个助手的权限是写死的:要么预先批准,要么一律拒绝。系列的下一篇讲中间的那种情况:调用停下来,由你的程序(或者屏幕前的人)决定批不批,以及用 hooks 在工具运行前后插入自己的检查。