Claude的Tool Use API:把「工单」写对,模型才会真的动手

你如果把大模型想成「特别会聊天的顾问」,那 Tool Use 就是给他发一张标准化工单:工单上写清楚有哪些活可以派、每件活需要什么材料、以及这次到底必须派活还是看着办。
很多工程问题不是模型「不会用工具」,而是工单格式不对:tool_choice 写成了字符串但 API 要对象、或者 tool_result 的顺序踩了雷,直接被 400 打回。
读完这篇,你会把 Claude 侧的工具链路从「凭感觉调」升级到「按协议调」:tools 怎么组、tool_choice 四种模式各干什么、input_schema 和 OpenAI 的 parameters 有什么心态差异;最后有一段 Go 代码,用标准库 HTTP 把两轮对话跑通,方便你对照抓包、写单测、接网关。
官方总览见 Tool use with Claude;工具定义细则见 Define tools;回传结果见 Handle tool calls。Messages 请求体字段以 Messages API 为准。
本文可能需要你已经阅读了前几篇文章:
- relref 第 12 篇解决“Function Calling 是什么”
- 第 13 篇解决“一次调用流程怎么跑通”
这篇会默认你已经知道 tool_use -> tool_result 的基本闭环,不再重复流程入门,而是聚焦字段级细节。我们只做一件事:把 Claude 里最容易写错、也最影响稳定性的参数协议拆透。
一、Claude 的「工具」在协议里长什么样
和「单独搞一个 function 通道」不同,Claude 把工具调用嵌在消息的多段内容里:assistant 的消息里可以出现 tool_use 块;你执行完以后,用 user 消息里的 tool_result 块把结果贴回去。官方在 Handle tool calls 里把这种差异讲得很直白:没有单独的 tool 角色,全靠 content 数组里的类型字段协作。
12-什么是Function Calling:让大模型学会调用工具中我们已经知道一次完整的客户端工具调用流程,对于 Claude 的工具调用是相同的:
sequenceDiagram
participant App as 你的服务
participant API as Anthropic Messages
participant Model as Claude
App->>API: POST /v1/messages + tools + messages
API->>Model: 注入工具定义与对话
Model-->>API: content 含 tool_use
API-->>App: stop_reason = tool_use
App->>App: 本地执行 name + input
App->>API: 追加 assistant 原样 + user(tool_result)
API->>Model: 带上工具结果继续推理
Model-->>App: 自然语言最终答复客户端工具(自己实现逻辑)走上面这条回路;服务端工具(例如网页里提到的 web_search)由 Anthropic 执行,响应里往往已经内嵌结果,你不必自己拼 tool_result。两类工具可以混在同一个 tools 数组里,但心智模型要分开,详见 Tool use overview。
Tool Use 本质是「模型产出结构化工单(tool_use)→ 你在沙箱外干活 → 用 tool_result 交卷」。安全边界清晰,代价是你要严格遵守消息块顺序。
二、tools 参数:每一项都是一张「能力说明书」
请求体顶层的 tools 是一个数组,元素通常是客户端自定义工具,每个对象至少包含:
| 字段 | 作用 |
|---|---|
name | 模型在 tool_use.name 里会引用的标识符 |
description | 用自然语言告诉模型「这是什么、何时用、何时别用」——官方强调这是性能杠杆最大的字段 |
input_schema | 用 JSON Schema 描述 input 对象的结构,也就是模型生成参数时的「模具」 |
name 的合法性在文档里写死为正则 ^[a-zA-Z0-9_-]{1,64}$(见 Define tools)。别在这里玩花活:中文名、带点号的伪命名空间,都可能直接校验失败。
description 不要写成一句「查询数据库」就完事。官方建议写满「做什么、何时做、每个参数如何影响行为、有哪些限制」——你写得越像给人类工程师的交接文档,模型越不容易瞎调用。好坏对比在同一个文档里就有对照示例。
input_schema 在协议层面就是一块 JSON Schema(文档指向 draft/2020-12 系能力,见 Messages API 内 Tool 类型说明)。工程上最常见的骨架是:
1{
2 "type": "object",
3 "properties": {
4 "location": { "type": "string", "description": "城市,例如 上海" }
5 },
6 "required": ["location"]
7}
Anthropic 还在持续扩展工具定义能力,例如严格校验、缓存和延迟加载。它们的可用范围可能受模型、API 版本和工具类型影响,这篇不把动态字段抄成一张容易过期的清单。需要时直接查 Strict tool use、Tool reference 和官方 SDK 类型,再针对当前模型验证。
如果你习惯了 OpenAI 的 functions[].parameters,可以把 input_schema 当成同一角色的「Anthropic 方言」:名字不同,干的活一样——都是约束模型吐出来的 JSON 形状。
老墨说:
input_schema负责「格式」,description负责「语义与边界」;只写 schema 不写清楚说明,模型会合法地调用错工具。
三、tool_choice:四种模式,管的是「态度」不是「工具实现」
tool_choice 描述的是模型这一轮对工具的策略,常见四种(见 Messages API - ToolChoice 与 Cookbook Tool choice):
{"type":"auto"}(默认):模型自己决定要不要调用、调用几个。适合大多数对话式产品。{"type":"none"}:明确禁止工具。适合「这段链路绝对不能碰工具」的降级路径,或和「带 tools 定义但临时关掉」组合使用。{"type":"any"}:强制至少使用你提供的某一工具(具体哪个由模型选)。适合「必须把自然语言编译成结构化动作」的流水线。{"type":"tool","name":"某个工具名"}:强制使用指定工具。适合你已经在外层路由好了意图、只差填参数的场景。
这四种类型都还可以带一个布尔字段 disable_parallel_tool_use。把它设为 true 时,相当于告诉模型:「这一轮最多(或恰好)给我一张工单」——在 auto 下是「至多一个 tool_use」,在 any / tool 下是「恰好一个」。并行多工具时的行为细节见官方 Parallel tool use。
需要留意的限制:若你启用了 Extended thinking(扩展思考),tool_choice、工具调用和思考块的组合规则会比普通请求更严格。这里不要凭经验猜支持矩阵,直接以 Extended thinking 指南 和 Messages API 当前说明为准。工程上我一般会把「长链推理」和「强制指定工具」拆成两阶段:先让模型产出判断或计划,再在下一轮用 tool_choice 约束具体工具。
tool_choice 解决的是产品策略(要不要硬派活),不是替你执行工具;执行权始终在你手里。
四、tool_use 与 tool_result:两个最容易翻车的细节
当 stop_reason 为 tool_use 时,content 里会出现形如官方示例的块(摘自 Handle tool calls):
1{
2 "type": "tool_use",
3 "id": "toolu_01A09q90qw90lq917835lq9",
4 "name": "get_weather",
5 "input": { "location": "San Francisco, CA", "unit": "celsius" }
6}
你回传结果时,用 tool_result,其中 tool_use_id 必须对上上面的 id。content 可以是字符串,也可以是嵌套的 text / image / document 块数组。
翻车点一:顺序。 含 tool_result 的 user 消息里,所有 tool_result 必须排在前面,后面才能跟普通 text。文档里的反例一贴就懂:先写一句「这是结果:」再贴 tool_result,会直接 400。
翻车点二:历史拼接。 第二轮请求要把上一轮 assistant 的完整 content 原样塞回 messages,再追加你的 user(tool_result)。漏抄一段 tool_use,后面就对不上号。
老墨说: 把
tool_use.id当成分布式链路里的trace_id——丢了就没法对账。
五、完整示例:用 Go 标准库走通一轮工具闭环
下面这段代码刻意只用 net/http + encoding/json,不绑特定 SDK 版本,方便你对照官方 JSON。密钥和模型分别从 ANTHROPIC_API_KEY、ANTHROPIC_MODEL 读取,模型名从 Models 列表 选择,避免文章里的固定别名过期后误导读者。
工具实现为一个极简的 get_weather:根据 location 返回固定字符串,重点演示协议,不接真实气象站。
1package main
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "io"
8 "log"
9 "net/http"
10 "os"
11 "strings"
12)
13
14const anthropicAPI = "https://api.anthropic.com/v1/messages"
15
16// --- 请求体(只声明本示例用到的字段)---
17
18type messageCreateRequest struct {
19 Model string `json:"model"`
20 MaxTokens int `json:"max_tokens"`
21 Tools []toolDef `json:"tools,omitempty"`
22 ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
23 Messages []message `json:"messages"`
24 System string `json:"system,omitempty"`
25}
26
27type toolDef struct {
28 Name string `json:"name"`
29 Description string `json:"description"`
30 InputSchema map[string]any `json:"input_schema"`
31}
32
33type message struct {
34 Role string `json:"role"`
35 Content any `json:"content"` // string 或 []contentBlock
36}
37
38type contentBlock struct {
39 Type string `json:"type"`
40
41 // text
42 Text string `json:"text,omitempty"`
43
44 // tool_use
45 ID string `json:"id,omitempty"`
46 Name string `json:"name,omitempty"`
47 Input map[string]any `json:"input,omitempty"`
48
49 // tool_result
50 ToolUseID string `json:"tool_use_id,omitempty"`
51 Content any `json:"content,omitempty"` // string 或嵌套块
52 IsError bool `json:"is_error,omitempty"`
53}
54
55// --- 响应体(只解析本示例关心的字段)---
56
57type messageResponse struct {
58 ID string `json:"id"`
59 Role string `json:"role"`
60 Content []contentBlock `json:"content"`
61 StopReason string `json:"stop_reason"`
62 Usage json.RawMessage `json:"usage"`
63}
64
65func main() {
66 apiKey := strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY"))
67 if apiKey == "" {
68 log.Fatal("请设置环境变量 ANTHROPIC_API_KEY")
69 }
70 model := strings.TrimSpace(os.Getenv("ANTHROPIC_MODEL"))
71 if model == "" {
72 log.Fatal("请设置环境变量 ANTHROPIC_MODEL,值以 Anthropic 当前 Models 文档为准")
73 }
74
75 client := &http.Client{}
76
77 tools := []toolDef{
78 {
79 Name: "get_weather",
80 Description: "根据城市名返回一句模拟天气描述。仅用于演示 Tool Use;" +
81 "当用户询问某地天气、气温、是否下雨时使用;不要用于与天气无关的问题。",
82 InputSchema: map[string]any{
83 "type": "object",
84 "properties": map[string]any{
85 "location": map[string]any{
86 "type": "string",
87 "description": "城市或地区,例如 上海、San Francisco, CA",
88 },
89 "unit": map[string]any{
90 "type": "string",
91 "enum": []string{"celsius", "fahrenheit"},
92 },
93 },
94 "required": []string{"location"},
95 },
96 },
97 }
98
99 // 第一轮:教学示例用指定工具,避免 auto 模式下模型直接自然语言回答
100 toolChoiceWeather, _ := json.Marshal(map[string]any{
101 "type": "tool",
102 "name": "get_weather",
103 })
104
105 firstUser := message{
106 Role: "user",
107 Content: []contentBlock{
108 {Type: "text", Text: "帮我看看上海明天适合跑步吗?从天气角度简单说说。"},
109 },
110 }
111
112 firstReq := messageCreateRequest{
113 Model: model,
114 MaxTokens: 1024,
115 Tools: tools,
116 ToolChoice: toolChoiceWeather,
117 Messages: []message{firstUser},
118 }
119
120 firstResp, err := callMessages(client, apiKey, firstReq)
121 if err != nil {
122 log.Fatal(err)
123 }
124
125 log.Printf("第一轮 stop_reason=%s", firstResp.StopReason)
126
127 if firstResp.StopReason != "tool_use" {
128 log.Fatalf("预期 stop_reason=tool_use,实际为 %s;请检查提示语或模型行为", firstResp.StopReason)
129 }
130
131 // 解析 tool_use 块并执行本地工具
132 var toolUses []contentBlock
133 for _, b := range firstResp.Content {
134 if b.Type == "tool_use" {
135 toolUses = append(toolUses, b)
136 }
137 }
138 if len(toolUses) == 0 {
139 log.Fatal("响应中未找到 tool_use 块")
140 }
141
142 toolResults := make([]contentBlock, 0, len(toolUses))
143 for _, tu := range toolUses {
144 out, execErr := runTool(tu.Name, tu.Input)
145 tb := contentBlock{
146 Type: "tool_result",
147 ToolUseID: tu.ID,
148 }
149 if execErr != nil {
150 tb.Content = execErr.Error()
151 tb.IsError = true
152 } else {
153 tb.Content = out
154 }
155 toolResults = append(toolResults, tb)
156 }
157
158 // 第二轮:assistant 原样带回 + user 仅含 tool_result(本示例无额外 user 文本)
159 secondReq := messageCreateRequest{
160 Model: model,
161 MaxTokens: 1024,
162 Tools: tools,
163 Messages: []message{
164 firstUser,
165 {
166 Role: "assistant",
167 Content: firstResp.Content,
168 },
169 {
170 Role: "user",
171 Content: toolResults,
172 },
173 },
174 }
175
176 secondResp, err := callMessages(client, apiKey, secondReq)
177 if err != nil {
178 log.Fatal(err)
179 }
180
181 log.Printf("第二轮 stop_reason=%s", secondResp.StopReason)
182
183 for _, b := range secondResp.Content {
184 if b.Type == "text" {
185 fmt.Println("--- Claude 最终回复 ---")
186 fmt.Println(b.Text)
187 }
188 }
189}
190
191func callMessages(client *http.Client, apiKey string, body messageCreateRequest) (*messageResponse, error) {
192 payload, err := json.Marshal(body)
193 if err != nil {
194 return nil, err
195 }
196
197 req, err := http.NewRequest(http.MethodPost, anthropicAPI, bytes.NewReader(payload))
198 if err != nil {
199 return nil, err
200 }
201 req.Header.Set("x-api-key", apiKey)
202 req.Header.Set("anthropic-version", "2023-06-01")
203 req.Header.Set("content-type", "application/json")
204
205 resp, err := client.Do(req)
206 if err != nil {
207 return nil, err
208 }
209 defer resp.Body.Close()
210
211 raw, _ := io.ReadAll(resp.Body)
212 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
213 return nil, fmt.Errorf("API %s: %s", resp.Status, string(raw))
214 }
215
216 var out messageResponse
217 if err := json.Unmarshal(raw, &out); err != nil {
218 return nil, err
219 }
220 return &out, nil
221}
222
223func runTool(name string, input map[string]any) (string, error) {
224 switch name {
225 case "get_weather":
226 loc, _ := input["location"].(string)
227 if strings.TrimSpace(loc) == "" {
228 return "", fmt.Errorf("缺少 location")
229 }
230 unit, _ := input["unit"].(string)
231 if unit == "" {
232 unit = "celsius"
233 }
234 // 假数据,仅演示协议
235 return fmt.Sprintf("【模拟】%s:气温约 22%s,微风,降水概率低,适合户外慢跑。",
236 loc, unitLabel(unit)), nil
237 default:
238 return "", fmt.Errorf("未知工具: %s", name)
239 }
240}
241
242func unitLabel(unit string) string {
243 switch unit {
244 case "fahrenheit":
245 return "°F"
246 default:
247 return "°C"
248 }
249}
把上面保存为 main.go 后执行:
1export ANTHROPIC_API_KEY="你的密钥"
2export ANTHROPIC_MODEL="你当前可用的模型 ID"
3go run main.go
若你想把 tool_choice 换成其他策略,只需把 toolChoiceWeather 改成例如:
1toolChoiceAuto, _ := json.Marshal(map[string]any{"type": "auto"})
2toolChoiceAny, _ := json.Marshal(map[string]any{"type": "any"})
3toolChoiceNamed, _ := json.Marshal(map[string]any{"type": "tool", "name": "get_weather"})
并写回 messageCreateRequest.ToolChoice 即可,语义与官方 Cookbook Tool choice 一致。
生产环境更推荐使用官方维护的 anthropic-sdk-go,省掉手写结构体与枚举字段的心智负担;但当你要接公司统一网关、做审计日志、或在边缘环境裁剪依赖时,上面这种「裸 HTTP + 明确 JSON」仍然是最稳的对照系。
老墨总结
Claude 的 Tool Use 不是魔法,而是一套把自然语言编译成结构化动作的协议:tools 负责声明能力边界,tool_choice 负责声明调用策略,input_schema 负责把参数形状钉死。真正决定体验上限的,往往是你愿不愿意把 description 当成产品文档来写。
这套协议最适合已经想清楚「哪些事必须落库、哪些事只能沙箱外执行」的团队;若你还在探索期,先用 auto 把闭环跑通,再按需切到 any / tool 做硬编排。遇到 400,优先查两类问题:tool_result 是否排在文本前面、历史里的 tool_use 是否被完整回放。
严格模式、并行工具、服务端工具计费,都可以在你跑通最小闭环后再逐项加。第一版先把工具定义、调用策略、结果回传和错误处理测稳,再决定要不要打开这些进阶能力。
完整示例代码见 Github
关注公众号:极客老墨
更多 AI 应用开发、工程实践和效率工具分享,欢迎扫码关注。
