教程Claude Code系列第 2 / 8 篇

给 Claude Agent SDK 的智能体加自己的工具:让它查你的数据库、调你的接口

内置工具只会读写文件和跑命令。要让智能体查订单、办退款,得把你自己的函数做成工具。本站用 Python 实测了一个只用自定义工具的客服助手:三个工具、七组运行,包括订单号不存在、工具内部抛异常、退款没被批准。两点要留意:没接住的异常会把内部信息带到回答里;去掉内置工具后一次运行只要两分钱。

发布 · 约 11 分钟读完 · 经人工审核()· 本站的内容怎么来的

本站已实测,测试日期:2026-10-12。

本页目录(10 节)
  1. 一个工具由四样东西组成
  2. 把工具交给智能体
  3. 跑起来
  4. 工具出错的时候
  5. 会改数据的工具
  6. 工具的说明怎么写
  7. 花了多少:每次两分钱
  8. 接已有的 MCP 服务器
  9. 这次实测没有覆盖到的
  10. 下一步

这篇教程解决一个问题:上一篇的智能体只会读写文件和跑命令,怎么让它用上你自己的业务功能,比如查订单、办退款。

做法是把你的函数包成工具。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 在工具运行前后插入自己的检查。

来源

通过 API 中转站使用这些模型?到 Veridrop 免费检测中转站给你的是不是它声称的模型,或者看看中转站排行榜前 100 名。

相关文章