教程Claude Code系列第 11 / 21 篇

Claude API 工具调用入门:定义工具、处理调用、自己写循环

让 Claude 调你自己的函数:怎么定义工具、怎么把结果送回去、一次调多个工具怎么处理、出错怎么告诉它,以及 strict、Tool Runner 和流式参数。例子都在服务器上通过中转站跑过,并列出和官方文档不一样的地方。

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

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

本页目录(15 节)
  1. 一、工具调用是一份约定
  2. 二、一个来回:定义、调用、送回结果
  3. 三、把工具定义写好
  4. 四、tool_choice:让不让它调、调哪个
  5. 五、一次回复里调好几个工具
  6. 六、工具出错了怎么告诉它
  7. 七、完整的循环:查订单、退款
  8. 八、不想自己写循环:SDK 的 Tool Runner
  9. 九、strict:保证参数符合 schema
  10. 十、流式输出时的工具参数
  11. 十一、排错速查
  12. 十二、通过中转站实测到的出入
  13. 和 Claude Code、Agent SDK 的关系
  14. 花费
  15. 这次实测没有覆盖到的

这篇教程解决一个问题:自己写的程序想让 Claude 查数据库、调内部接口、改文件,该怎么接?

答案是工具调用(tool use,也叫 function calling)。Claude Code 每一次读文件、跑命令,底下用的都是这套机制。这篇讲自己直接调接口时怎么用它。

实测环境
日期 2026-10-13
方式 Python:anthropic SDK 1.13.0,部分实验直接发 HTTP 请求
模型 Sonnet 5.5 为主,Opus 5.5 和 Opus 4.7 做对比
连接方式 通过酷站AI 中转站,不是直连官方

先说实测的结论:

  • 定义工具、送回结果、并行调用、strict、Tool Runner、流式参数,通过这家中转站都能用。
  • Sonnet 5.5 和 Opus 5.5 不接受强制调用工具,这和官方文档一致。
  • 消息格式写错的几种情况,官方文档说会报 400,通过中转站没有报错。照官方的规则写,不要依赖这一点。详见第十二节。

一、工具调用是一份约定

你告诉 Claude 有哪些操作可用、每个操作的参数长什么样;Claude 决定什么时候调、用什么参数调。模型自己不执行任何东西,它只输出一个结构化的请求,由你的代码(或 Anthropic 的服务器)去执行,再把结果放回对话。

工具按“代码在哪里跑”分三类:

类别 谁写定义 谁执行 例子
自己定义的工具 你 你 查订单、调内部接口
Anthropic 定义、你来执行的工具 Anthropic 你 bash、文本编辑器、记忆、电脑和浏览器操作
服务器端工具 Anthropic Anthropic 联网搜索、抓取网页、代码执行

这一篇只讲第一类。后两类各有一篇,见文末。

什么时候该用工具,官方给了一个判断:如果你在写正则,想从模型的回答里抠出一个决定,那这个决定本来就该是一次工具调用。 反过来,模型凭已有知识就能答的(总结、翻译、常识问答),不需要工具,多一个来回只会更慢。

二、一个来回:定义、调用、送回结果

下面这段代码在服务器上原样跑过:

import anthropic

client = anthropic.Anthropic()

tools = [{
    "name": "get_weather",
    "description": "查询一个城市当前的天气。用户问到某个地方现在的天气、气温、"
                   "要不要带伞时使用。只返回当前实况,不返回预报。",
    "input_schema": {
        "type": "object",
        "properties": {
            "location": {"type": "string", "description": "城市名,例如“杭州”"},
            "unit": {"type": "string", "enum": ["celsius", "fahrenheit"],
                     "description": "温度单位,默认 celsius"},
        },
        "required": ["location"],
    },
}]
messages = [{"role": "user", "content": "杭州现在天气怎么样?"}]

# 第一次请求:Claude 决定要不要调工具
first = client.messages.create(model="claude-sonnet-5-5", max_tokens=1024,
                               tools=tools, messages=messages)
print(first.stop_reason)                      # tool_use
call = next(b for b in first.content if b.type == "tool_use")
print(call.name, call.input)                  # get_weather {'location': '杭州'}

# 你的代码执行工具(这里用一个写死的结果代替)
result = "多云,18°C,东北风 3 级,湿度 71%"

# 第二次请求:把 Claude 的整条回复和工具结果都放回去
messages.append({"role": "assistant", "content": first.content})
messages.append({"role": "user", "content": [
    {"type": "tool_result", "tool_use_id": call.id, "content": result},
]})
second = client.messages.create(model="claude-sonnet-5-5", max_tokens=1024,
                                tools=tools, messages=messages)
print(second.stop_reason)                     # end_turn
print(second.content[-1].text)

输出:

tool_use
get_weather {'location': '杭州'}
end_turn
杭州现在是多云,气温 18°C,东北风 3 级,湿度 71%。

目前没有降雨,不过湿度偏高,体感可能有点潮凉。出门可以带一件薄外套,一般不用带伞。

第一次响应里的工具调用块,原样是这样的(id 截短了):

{"type": "tool_use", "id": "toolu_01NQfC6i…", "name": "get_weather",
 "input": {"location": "杭州"}, "caller": {"type": "direct"}}

几件要记住的事:

  • stop_reason 是 tool_use,表示它在等你执行工具。
  • id 用来对号。你回的 tool_result 里,tool_use_id 要填它。
  • 把助手的整条 content 原样放回去,不要只取文字,里面可能还有思考块。
  • 没有 tool 这种角色。 工具结果放在 user 消息里,这一点和 OpenAI 的接口不一样。
  • caller 是 direct,表示这是模型直接调的。另一种取值在讲服务器端工具的那篇里。
  • tool_result 的 content 可以是字符串,也可以是内容块的数组(文字、图片、文档)。字符串和文字块数组这两种写法实测过,结果一样。

带上工具要多花多少输入 token? 同一个问题,带这一个工具比不带多了 471 个输入 token。官方文档的说法是:只要请求里有工具,接口就会自动加一段让模型会用工具的系统提示词,Opus 5.5、Sonnet 5.5 和 Haiku 5.5 上是 286 个 token,其余是你的工具定义本身。老模型这段更长,Opus 4.7 是 675 个。

三、把工具定义写好

工具定义有三个必填的部分:

字段 要求
name 只能用字母、数字、下划线和连字符,最长 128 个字符
description 一段话,说清它做什么、什么时候用
input_schema 一份 JSON Schema,描述参数

官方文档说,说明写得够不够详细,是影响工具表现最大的一个因素。它给的做法:

  • 每个工具至少写三四句。 讲清楚它做什么、什么时候该用和不该用、每个参数的含义、它不返回什么。
  • 把相关的操作合并成一个工具。 与其做 create_pr、review_pr、merge_pr 三个,不如做一个,带一个 action 参数。工具少了,模型更不容易选错。
  • 工具多了就加前缀。 例如 github_list_prs、slack_send_message。
  • 返回的内容只留有用的。 给稳定、有意义的标识符,只带模型下一步推理用得着的字段。
  • 不要设一个让模型写“推理过程”的参数。 想知道它为什么这么调,就要一句简短的解释。要它一步一步的思考过程,可能会被拒答。

工具名出现重复时,请求会被拒绝。实测到的报错是 tools: Tool names must be unique.。

给几个输入的例子

参数嵌套比较深、或者格式有讲究的工具,可以在定义里加 input_examples,放几组合法的输入:

{
  "name": "search_flights",
  "description": "按目的地、日期、人数和舱位搜索航班。",
  "input_schema": { "…": "…" },
  "input_examples": [
    {"destination": "成都", "date": "2026-11-03", "passengers": 2, "cabin": "economy"},
    {"destination": "东京", "date": "2026-12-24", "passengers": 1, "cabin": "business"}
  ]
}

每个例子都必须符合 input_schema,否则请求报 400。实测故意放了一个不合法的例子,拿到的报错直接指出了哪里不对:

tools.0.custom: Example at index 0 is invalid: "超级经济舱" is not one of
"economy", "business" or "first". Each example must match the tool's `input_schema`.

例子是要占输入 token 的。上面这两组例子,实测多了 299 个输入 token。

用户没给全参数时

官方文档说,缺了必填参数时,Opus 更可能反过来问你,Sonnet 可能会自己猜一个。本站用“现在天气怎么样?”(没说城市)各试了三次,Sonnet 5.5 和 Opus 5.5 这六次都是反问“你想查哪个城市”,没有猜。次数很少,只能说明这一代模型在这个简单例子上没有乱猜。

四、tool_choice:让不让它调、调哪个

tool_choice 意思
{"type": "auto"} 由 Claude 决定调不调。带了工具时的默认值
{"type": "none"} 这一次不许调工具
{"type": "any"} 必须调一个工具,调哪个它自己选
{"type": "tool", "name": "…"} 必须调指定的这个工具

后两种叫强制调用。Opus 5.5、Sonnet 5.5、Fable 5.1 和 Mythos 5.1 不支持强制调用,会返回 400。 实测结果:

模型 auto none any 指定工具
Sonnet 5.5 200 200 400 400
Opus 5.5 200 200 400 400
Opus 4.7 200 200 200,调了工具 200,调了工具

测试用的问题是“用一句话说说杭州这座城市”,和天气工具没有关系。auto 下三个模型都没有调工具,Opus 4.7 在强制下照样调了 get_weather。

在这几个新模型上,官方建议的替代办法:

  • 想让它一定调工具:用 auto,在提示词里说明。例如在用户消息末尾加一句“用 get_weather 工具回答”。
  • 想让参数一定合法:用 auto 加 strict,见第九节。
  • 想要固定结构的 JSON 回答:用结构化输出,不用拿工具来凑。

另外两点:

  • 在支持强制调用的模型上,强制调用会跳过思考,回复直接以工具调用开头,前面也不会有解释的话。
  • 用了提示词缓存时,改 tool_choice 会让消息部分的缓存失效,工具定义和系统提示词的缓存不受影响。

auto 下调得太少或太多,可以用系统提示词调。官方给的几句话,从轻到重:“先用工具查清楚再回答”“回答之前一定先调一次工具”;反方向是“自己判断该调工具还是直接回答”。

五、一次回复里调好几个工具

Claude 4 及之后的模型,默认会在一次回复里同时发出多个工具调用。实测问“杭州、东京、伦敦现在分别是什么天气、当地几点”,给了查天气和查时间两个工具,Sonnet 5.5 一次回复里发了 6 个 tool_use 块。

处理的规则有三条:

  1. 每个 tool_use 块都要回一个 tool_result,用 tool_use_id 对上。
  2. 所有结果放在同一条 user 消息里。
  3. 结果放在最前面,文字只能跟在后面。

这些调用按什么顺序执行、并不并发,由你决定,接口不管。互不相干的只读操作可以并发;有副作用或有先后依赖的,按顺序跑。某一个没有执行(比如前一个失败了,后面的你决定不跑),也要给它回一个带 is_error: true 的结果,说明原因。

想关掉并行,在 tool_choice 里加一个字段(它不是顶层参数):

"tool_choice": {"type": "auto", "disable_parallel_tool_use": true}

同一个问题,开和关的对比:

请求次数 输入 token 合计 输出 token 合计
默认(并行) 2 1,595 485
关掉并行 7 5,075 474

关掉以后每次只调一个,要来回七次,每次都把越来越长的历史重发一遍,所以输入是并行时的三倍多。除非你的执行环境一次只能跑一个操作,否则不要关。

如果它该并行的时候没有并行,官方文档说最常见的原因是历史里的结果格式不对:把每个结果各放了一条 user 消息。这样写不会报错,但等于在教它以后一个一个调。其次是提示词不够强,可以在系统提示词里明确要求“互不依赖的工具调用要同时发出”。

六、工具出错了怎么告诉它

执行失败时,照样回一个 tool_result,把错误信息放进 content,再加上 is_error: true:

{"type": "tool_result", "tool_use_id": "toolu_…", "is_error": true,
 "content": "天气服务超时(上游 504)。稍后可以重试一次。"}

实测在六个结果里把查伦敦时间的那个标成这样的错误,Sonnet 5.5 的下一条回复是一句“伦敦的时间查询超时了,我重试一次。”,后面跟着对同一个工具的再次调用。

错误信息怎么写,直接决定它接下来怎么做:

  • 写清楚发生了什么、下一步可以做什么。 只写一个 failed,它只能猜。
  • 有副作用的工具,要写明这次操作到底有没有生效。 下一节的退款例子里,报错写的是“这次退款没有执行,没有产生任何扣款或退款。可以原样重试一次”,模型才敢原样重试。如果只写“超时”,它不知道钱退了没有。
  • 不要在工具结果里放给模型的指令。 Claude 被训练成把工具结果里的指令当作可能不可信的第三方内容,会拒绝照做或者回头向用户确认。你自己的指令放在后面的 user 消息里,工具结果里只放数据。

反过来也一样:工具结果常常来自你控制不了的地方(网页、邮件、用户上传的文件),别人可以在里面埋指令。把这类内容留在 tool_result 里,不要拼进系统提示词。

模型调用工具时参数不对(比如缺了必填项),官方文档说把错误回给它,它一般会改正后重试两三次。想从根上避免,用 strict。

七、完整的循环:查订单、退款

真实的任务很少一个来回就结束。标准的写法是一个循环:只要 stop_reason 是 tool_use,就执行工具、送回结果、再发请求。

下面的例子用了一个内存里的小订单系统(shop.py,三个函数),其中 refund 第一次被调用时故意抛一个超时错误:

import json
import anthropic
import shop          # 一个内存里的小订单系统:list_orders、get_order、refund 三个函数

client = anthropic.Anthropic()


def tool(name, description, properties):
    return {"name": name, "description": description, "strict": True,
            "input_schema": {"type": "object", "properties": properties,
                             "required": list(properties), "additionalProperties": False}}


TOOLS = [
    tool("list_orders", "按客户邮箱列出这个客户的全部订单,返回订单号、日期、商品、金额和状态。"
                        "不返回扣款明细,要看扣款明细用 get_order。",
         {"email": {"type": "string", "description": "客户邮箱"}}),
    tool("get_order", "按订单号查一笔订单的详情,包括每一笔扣款(charges)、已退金额"
                      "和还能退多少(refundable)。",
         {"order_id": {"type": "string", "description": "订单号,形如 A1001"}}),
    tool("refund", "给一笔订单退款。会真的把钱退给客户,调用前先用 get_order 确认金额。"
                   "金额单位是元,不能超过这一单还能退的金额。",
         {"order_id": {"type": "string"},
          "amount": {"type": "number", "description": "退款金额,单位元"},
          "reason": {"type": "string", "description": "退款原因,一句话,会写进工单"}}),
]
FUNCS = {"list_orders": shop.list_orders, "get_order": shop.get_order, "refund": shop.refund}

messages = [{"role": "user", "content": "客户 lin@example.com 说国庆后买的那一单被扣了两次钱。"
                                        "查清楚,多扣的退掉,最后告诉我你做了什么。"}]

for step in range(10):                                   # 给循环设一个上限
    reply = client.messages.create(
        model="claude-sonnet-5-5", max_tokens=2048, tools=TOOLS, messages=messages,
        system="你是电商客服后台的助手,用工具查订单、处理退款。只在核实过扣款明细之后才退款。")
    if reply.stop_reason != "tool_use":
        break
    messages.append({"role": "assistant", "content": reply.content})   # 整条原样放回去
    results = []
    for block in reply.content:
        if block.type != "tool_use":
            continue
        try:
            output = FUNCS[block.name](**block.input)
            results.append({"type": "tool_result", "tool_use_id": block.id,
                            "content": json.dumps(output, ensure_ascii=False)})
        except Exception as e:                           # 出错也要回一个结果
            results.append({"type": "tool_result", "tool_use_id": block.id,
                            "content": f"{type(e).__name__}: {e}", "is_error": True})
        print(block.name, block.input, "→", results[-1]["content"][:60])
    messages.append({"role": "user", "content": results})              # 全部结果放同一条消息

print(reply.stop_reason)
print(reply.content[-1].text)

实际的输出(最后的回答有删节):

list_orders {'email': 'lin@example.com'} → [{"order_id": "A1001", "date": "2026-09-21", "item": "机械键盘",
get_order {'order_id': 'A1002'} → {"order_id": "A1002", "date": "2026-10-06", "item": "显示器支架",
get_order {'order_id': 'A1003'} → {"order_id": "A1003", "date": "2026-10-09", "item": "USB-C 线
refund {'amount': 199, 'order_id': 'A1002', 'reason': '重复扣款(ch_03 与 ch_02 同时扣 199 元),退还多扣的一笔'} → TimeoutError: 支付网关超时,这次退款没有执行,没有产生任何扣款或退款。可以原样重试一次。
refund {'amount': 199, 'order_id': 'A1002', 'reason': '重复扣款(ch_03 与 ch_02 同时扣 199 元),退还多扣的一笔'} → {"refund_id": "rf_9001", "order_id": "A1002", "amount": 199,
end_turn
已经查清并退掉了多扣的钱。

- **查证过程:** 客户国庆后有两单,A1002 和 A1003。A1003 只扣了一次 39 元,没有问题。A1002 在 10 月 6 日 20:15 同一分钟里扣了两次 199 元,一共扣了 398 元,所以是重复扣款。
- **退款:** 我给 A1002 退了 199 元,退款单号是 rf_9001。第一次调用时支付网关超时,系统确认那次没有执行,我重试了一次。这笔退款只成功了一次,没有重复退。

一共五次请求:列订单,并行查两单的详情,退款失败,重试成功,最后总结。这个循环有几处是有意这样写的:

  • 循环有上限。 没有上限的循环,遇到模型反复调同一个工具时会一直烧钱。
  • stop_reason 不是 tool_use 就退出。 退出以后还要再看一眼是哪种:max_tokens 是被截断了,refusal 是拒答,pause_turn 是服务器端工具还没跑完。各自怎么处理见《报错、限流和停止原因》。
  • 有副作用的工具(这里的 refund)在真实系统里要加一道人工确认。 自己写循环的好处就在这里:执行之前你可以拦下来。

八、不想自己写循环:SDK 的 Tool Runner

官方七种语言的 SDK 都带一个 Tool Runner(目前是 beta),替你做循环、执行工具、把结果送回去。Python 里用 @beta_tool 装饰一个普通函数,SDK 从类型标注和文档字符串生成工具定义:

import json
import anthropic
from anthropic import beta_tool
import shop

client = anthropic.Anthropic()


@beta_tool
def refund(order_id: str, amount: float, reason: str) -> str:
    """给一笔订单退款。会真的把钱退给客户,调用前先用 get_order 确认金额。金额单位是元,不能超过这一单还能退的金额。

    Args:
        order_id: 订单号
        amount: 退款金额,单位元
        reason: 退款原因,一句话,会写进工单
    """
    return json.dumps(shop.refund(order_id, amount, reason), ensure_ascii=False)


# list_orders 和 get_order 的写法一样,这里略去

runner = client.beta.messages.tool_runner(
    model="claude-sonnet-5-5",
    max_tokens=2048,
    max_iterations=10,
    system="你是电商客服后台的助手,用工具查订单、处理退款。只在核实过扣款明细之后才退款。",
    tools=[list_orders, get_order, refund],
    messages=[{"role": "user", "content": "客户 lin@example.com 说国庆后买的那一单被扣了两次钱。"
                                          "查清楚,多扣的退掉,最后告诉我你做了什么。"}],
)
for message in runner:                       # 每轮给出 Claude 的一条回复
    for block in message.content:
        if block.type == "tool_use":
            print("调用", block.name, block.input)
print(message.content[-1].text)              # 循环结束时,最后一条就是最终回答

实测它走了和手写循环一样的路:列订单,查两单,退款失败,重试成功,总结。几个看得见的细节:

  • 函数抛异常时,Runner 自动回一个 is_error 的结果。 送给模型的内容是异常的类型和信息,实测是 TimeoutError('支付网关超时,…'),不带调用栈。
  • Python SDK 会把完整的调用栈打到日志里(标准的 logging),所以终端里能看到。别的语言的 SDK 不记这个,要自己在工具函数里记。
  • 工具函数返回字符串(或内容块)。要返回结构化的数据,先转成 JSON 字符串。
  • SDK 生成的定义里带 additionalProperties: false,但没有 strict。

常用的几个控制点:

想做的事 Python 里怎么做
限制最多循环几轮 max_iterations=10
不关心中间过程,只要最后的回答 runner.until_done()
送回去之前看一眼工具结果 循环体里调 runner.generate_tool_call_response()
改掉工具结果,或者插一条消息 再调 runner.append_messages(…),这一轮的历史由你接管
中途改请求参数(如 max_tokens) runner.set_messages_params(…)
流式 创建时传 stream=True,每轮拿到一个流

接管历史时要小心:一旦调了 append_messages,这一轮 Runner 就不再自动追加助手消息和工具结果,你得自己把它们放进去,否则对话往下走不动。

什么时候还是自己写循环:执行之前要人批准、要自己的日志和审计、要按条件决定执行不执行。官方文档也是这么分的。

其他语言的入口:TypeScript 用 betaZodTool()(配 Zod)或 betaTool()(配 JSON Schema);Go 用带 jsonschema 标签的结构体;Java 用带注解的类,并且默认给工具加 strict。本站只测了 Python。

九、strict:保证参数符合 schema

在工具定义里加 "strict": true,接口会在生成时就把模型的输出限制在你的 schema 之内。它保证两件事:input 一定符合 input_schema,name 一定是你给过的工具名。

代价是 schema 只能用 JSON Schema 的一个子集。下面是实测到的几条规则和对应的报错:

写法 结果
对象上没写 additionalProperties: false 400:'additionalProperties' must be explicitly set to false
整数加 minimum、maximum 400:properties maximum, minimum are not supported
pattern 里用了先行断言 (?=…) 400:Invalid regex in pattern field
pattern 用普通写法,如 ^\d{4}-\d{2}-\d{2}$ 通过
字符串加 format: date 通过
有不在 required 里的可选字段 通过

文档列的不支持项还有:字符串的 minLength 和 maxLength,数组除 minItems 为 0 或 1 以外的约束,递归的 schema,外部的 $ref,正则里的反向引用和 \b。

还有几个数量上的限制,一个请求里所有 strict 工具合起来算:

限制 上限
strict 工具的个数 20
可选参数的总数 24
用了 anyOf 或多类型的参数总数 16

超了会报 400。文档的建议是只给真正要紧的工具加 strict,并且尽量把参数都设成必填。

第一次用一份新的 schema 会慢一点。 接口要先把 schema 编译成语法,编译结果缓存 24 小时。实测不加 strict 的请求约 3 秒,同一个工具加了 strict 后,每换一种 schema 的第一次请求用了 5 到 8 秒。第七节那个循环第一次跑的时候,头一个请求用了 10 秒,后面几个是 3 到 5 秒。

它在这次测试里有没有带来差别?没有测出来。 本站用一句故意写得别扭的话(“数量‘十二’件,单价 ¥9.90,加急:是”)让模型填一个有整数、小数、布尔值和数组的订单,加和不加 strict 各跑五次,十次的类型全对。strict 的意义在于“保证”:不加的时候大多数时候也对,加了以后你可以不写校验和重试的代码。

十、流式输出时的工具参数

流式输出时,工具的参数是一段一段到的:

  1. content_block_start 事件里,tool_use 块的 input 是一个空对象 {},只是占位。
  2. 之后一连串 input_json_delta 事件,每个带一小段 partial_json 字符串。
  3. content_block_stop 之后,把这些片段拼起来,再解析成 JSON。

默认情况下,接口会把每个参数的值攒齐、校验过再发给你。参数很长时(比如让它写一个文件的内容),中间会有一段时间什么都收不到。给工具加上 eager_input_streaming: true,接口就不攒了,模型写多少发多少。

实测让 Sonnet 5.5 用一个 make_file 工具写一份 600 字的文档,各一次:

默认 eager_input_streaming: true
第一个片段到达 2.5 秒 2.2 秒
一半的片段到达 12.1 秒 6.2 秒
最后一个片段到达 12.3 秒 10.5 秒
片段的样子 每段固定 7 个字符 长短不一,最长 25 个字符

默认方式下,2.5 秒时到的只是文件名那个短参数,文件内容要等到 12 秒左右才一口气全到。开了以后内容是均匀到的。要在界面上实时显示它正在写的东西,就得开。

代价是:接口不再替你校验,拼出来的字符串可能不是合法的 JSON。 最常见的情况是被 max_tokens 截断。实测把 max_tokens 设成 200:

拼出来的内容 能否解析
默认 只有 {"filename": "notes.md",23 个字符 不能
开了 eager 226 个字符,停在文件内容的半中间 不能

两种方式都解析不了,所以不管开没开,解析都要包在 try 里,并且先看 stop_reason。下面这段跑过的代码演示了完整的写法:

import json
import anthropic

client = anthropic.Anthropic()
tools = [{
    "name": "make_file",
    "description": "把内容写进一个文件。",
    "eager_input_streaming": True,           # 去掉这一行就是默认的缓冲方式
    "input_schema": {"type": "object",
                     "properties": {"filename": {"type": "string"},
                                    "content": {"type": "string", "description": "文件的完整内容"}},
                     "required": ["filename", "content"]},
}]
buffers = {}                                  # 每个 tool_use 块一个字符串
with client.messages.stream(
    model="claude-sonnet-5-5", max_tokens=4096, tools=tools,
    messages=[{"role": "user", "content": "用 make_file 工具写一个 notes.md:一份 300 字左右的"
                                          "《Git 分支管理约定》。直接调用工具,不要先解释。"}],
) as stream:
    for event in stream:
        if event.type == "content_block_start" and event.content_block.type == "tool_use":
            buffers[event.index] = ""         # 这时 input 是 {},只是占位
        elif event.type == "content_block_delta" and event.delta.type == "input_json_delta":
            buffers[event.index] += event.delta.partial_json
            print(event.delta.partial_json, end="", flush=True)   # 片段一到就能显示
    final = stream.get_final_message()

print("\nstop_reason:", final.stop_reason)
for raw in buffers.values():
    try:
        args = json.loads(raw)                # 拼完再解析,而且要防着解析失败
        print("解析成功:", args["filename"], len(args["content"]), "字符")
    except json.JSONDecodeError:
        print("解析失败,按出错把原文送回去:",
              json.dumps({"INVALID_JSON": raw}, ensure_ascii=False)[:80])

解析失败时,官方建议把收到的原文包在一个 {"INVALID_JSON": "…"} 的对象里,转成字符串,作为带 is_error: true 的工具结果送回去,让模型知道它上一次的参数坏了。包这一层要用 JSON 库,不要自己拼字符串。

eager_input_streaming 只能用在自己定义的工具上。

十一、排错速查

下面是官方排错页的要点,按现象查:

现象 多半的原因 办法
调了 A 工具,你想要的是 B 两个工具的说明分不清 说明里写清“什么时候用”,不只写“做什么”
从来不调你的工具 工具重名,或 schema 太笼统 查重名;加 input_examples
参数类型不对,或出现 schema 里没有的参数 没开 strict 加 strict: true,或加 input_examples
该并行的时候一个一个调 历史里每个结果各占一条消息 所有结果放进同一条 user 消息
disable_parallel_tool_use 好像没生效 设得太晚 它要设在返回 tool_use 的那次请求上
每次请求都没命中缓存 tool_choice、思考设置或 effort 每次不一样 这几项在一段对话里保持不变
拒绝照工具结果里的话去做,或回头找用户确认 你把自己的指令放进了工具结果 指令放到后面的 user 消息里
对工具参数做字符串比较,换了模型就对不上 不同模型对 Unicode 和斜杠的转义不一样 先用 JSON 库解析,再比较

常见的 400 报错:

报错里的话 原因
tool_use ids were found without tool_result blocks immediately after 有的调用没有回结果,或者结果前面放了别的内容
thinking … blocks in the latest assistant message cannot be modified 把助手消息放回去之前动了里面的思考块。原样放回
All tools have defer_loading: true 所有工具都设成了延迟加载,模型一个也看不到

十二、通过中转站实测到的出入

项目 官方文档 通过中转站实测
Sonnet 5.5、Opus 5.5 强制调用工具 400 400,一致。报错文字是中转站自己写的中文
工具结果前面放一段文字 400 200,正常回答
六个调用只回了五个结果 400 200,模型把缺的那个重新调了一次
tool_use_id 写错一个 文档没有单独说明 200,同上
工具名带点号、用中文 不符合命名规则 被接受,调用时返回的也是原名
工具名 129 个字符 超过 128 的上限 被接受,但返回的工具名和请求里的不一样,参数里的一个整数成了字符串
strict、input_examples、工具重名的校验 400 400,一致,报错文字是英文的官方格式

中间几条对你的影响是:在这家中转站上能跑通的代码,换到官方接口可能直接报 400。 所以写代码的时候按官方的规则来:每个调用都回结果、结果放在最前面、工具名只用字母数字下划线和连字符且不超过 128 个字符。

少回一个结果的那种情况还有一个副作用:模型会把缺的工具再调一次。如果那是个有副作用的工具,就等于执行了两遍。

和 Claude Code、Agent SDK 的关系

  • Claude Code 的读文件、改文件、跑命令,都是它内置的工具,走的就是这套 tool_use 和 tool_result。工具怎么设计才好用,可以看《从 Claude Code 学工具设计》。
  • Agent SDK 已经替你写好了循环、权限和上下文管理,自定义工具的写法见《给智能体加自定义工具》。只想要一个现成的智能体,用它比自己写循环省事。
  • 自己直接调接口(这一篇)适合循环和执行环境都要自己掌握的场合,比如把工具调用嵌进已有的后端服务。

这个系列接下来两篇讲另外两类工具:Anthropic 定义、由你执行的 bash、文本编辑器、记忆等工具;以及联网搜索、代码执行这些在 Anthropic 服务器上执行的工具。

花费

这一篇一共发了约 100 次请求,大多是几百到一两千 token 的小请求,最长的是四次订单循环(每次合计七八千输入 token)。按官方标价粗算在 0.5 美元上下。

这次实测没有覆盖到的

  • 官方接口没有测。 第十二节里“官方文档”一栏是文档的说法,不是本站测到的。
  • 只测了 Python。 其他六种语言的 Tool Runner 是按文档整理的。
  • Tool Runner 只测了基本用法和工具抛异常的情况。接管历史、中途改参数、流式这几项没有试。
  • 强制调用只在 Opus 4.7 上各试了一次。 Fable 5.1、Mythos 5.1 没有测;这家中转站没有 Haiku 5.5。
  • strict 的数量上限没有试(20 个工具、24 个可选参数、16 个多类型参数)。
  • input_examples 对调用质量有没有帮助,没有量过,只确认了它被接受和校验。
  • 各项对比都只跑了一次到五次,时间和 token 数会有波动。
  • 只测了一家中转站、一天。

来源

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

相关文章