Function Calling的工作流程:从定义工具到结果回传

第 12 篇已经把 Function Calling 的概念和基本例子讲过了。真正开始写代码时,我更容易卡在另一层:
为什么工具明明定义了,模型却没有调用?或者调用了,第二轮却接不上?
这篇继续沿用第 12 篇的 get_weather,把一次调用从请求、响应、参数校验到结果回传走一遍。重点不在记住步骤编号,而是知道每条消息为什么要这样拼。
一、先统一上下文:我们沿用第 12 篇的同一个例子
继续先前的场景:
- 模型:通过
DEEPSEEK_MODEL指定的当前可用模型(OpenAI 兼容接口) - 工具名:
get_weather - 用户问题:
帮我查一下上海明天的天气,然后给出穿衣建议。 - 执行策略:
tool_choice = auto
为什么必须固定上下文?因为你调试 Function Calling 时,任何一个变量变了(模型、prompt、tool schema),行为都会变。我们要先做“可复现”,再做“可扩展”。
老墨说: 调工具链路像查线上事故,第一原则不是“聪明”,是“变量收敛”。
二、先看一眼完整链路
1sequenceDiagram
2 participant U as 用户
3 participant APP as 你的Go服务
4 participant LLM as 模型API
5 participant TOOL as 本地工具函数
6
7 U->>APP: 问天气+穿衣建议
8 APP->>LLM: 第一次请求(messages+tools)
9 LLM-->>APP: assistant返回tool_calls
10 APP->>APP: 解析arguments并校验
11 APP->>TOOL: 执行get_weather(city,date)
12 TOOL-->>APP: 返回结构化天气结果
13 APP->>LLM: 第二次请求(追加tool消息)
14 LLM-->>APP: 最终自然语言回答
你把它记成两次请求就行:
- 请求1:让模型决定要不要调用工具
- 请求2:把工具结果喂回去,让模型完成人话回答
三、Step 1:定义工具(不是“声明一个函数名”这么简单)
第 12 篇里的工具定义是这样的(我保留同样字段):
1// 关键步骤1:定义工具,告诉模型“你可以调用什么能力”
2tools := []openai.Tool{
3 {
4 Type: openai.ToolTypeFunction,
5 Function: &openai.FunctionDefinition{
6 Name: "get_weather",
7 Description: "查询城市某天的天气",
8 Parameters: json.RawMessage(`{
9 "type":"object",
10 "properties":{
11 "city":{"type":"string","description":"城市名"},
12 "date":{"type":"string","description":"日期,如今天/明天"}
13 },
14 "required":["city","date"]
15 }`),
16 },
17 },
18}
这段里真正决定模型行为的是三件事:
Name:模型后续会在tool_calls[].function.name里原样回填这个名字。Description:模型判断“什么时候应该调这个工具”的主要依据。Parameters:参数形状约束。你要求city/date必填,模型就会倾向于补齐这两个字段。
常见误区:把 Description 写成“获取信息”。这等于没写,模型会犹豫甚至误调。
四、Step 2:第一次请求——把“问题+工具列表”发给模型
这一步和普通聊天请求唯一核心差异:多了 Tools 和 ToolChoice。
1// 关键步骤2:第一次请求,让模型决定是否触发工具调用
2firstResp, err := client.CreateChatCompletion(context.Background(), openai.ChatCompletionRequest{
3 Model: model,
4 Messages: msgs,
5 Tools: tools, // 关键:把可用工具列表传给模型
6 ToolChoice: "auto", // 关键:允许模型自主判断是否调用工具
7})
你可以把这次请求体抽象成:
1{
2 "model": "${DEEPSEEK_MODEL}",
3 "messages": [
4 {"role":"system","content":"你是一个天气助手,需要时必须调用工具。"},
5 {"role":"user","content":"帮我查一下上海明天的天气,然后给出穿衣建议。"}
6 ],
7 "tools": ["...get_weather schema..."],
8 "tool_choice": "auto"
9}
这里有个细节非常关键:
- 你在 system 里写了“需要时必须调用工具”,这是行为偏置。
- 但是否调用仍由模型结合问题判断。
tool_choice在本文只讲“够用语义”:auto是让模型自行判断;想强制调用再考虑required/指定工具(不同兼容实现字段名可能有差异,以服务商文档为准)。
关于 tool_choice 的模式差异、参数对象写法和并行调用约束,第 14、15 篇会分别展开;这里先把最小闭环跑通。
五、Step 3:第一次响应——识别 tool_calls,别急着执行
第一次响应回来后,重点看 assistant 消息里有没有 tool_calls。
第 12 篇代码里的关键处理:
1assistantMsg := firstResp.Choices[0].Message
2if len(assistantMsg.ToolCalls) == 0 {
3 log.Fatal("模型未触发工具调用,请检查模型、提示词和工具定义")
4}
5msgs = append(msgs, assistantMsg) // 关键:必须把assistant这条原样放回上下文
6
7for _, tc := range assistantMsg.ToolCalls {
8 if tc.Function.Name != "get_weather" {
9 continue
10 }
11 // 后面再解析参数
12}
典型响应格式(简化):
1{
2 "role": "assistant",
3 "content": "",
4 "tool_calls": [
5 {
6 "id": "call_xxx",
7 "type": "function",
8 "function": {
9 "name": "get_weather",
10 "arguments": "{\"city\":\"上海\",\"date\":\"明天\"}"
11 }
12 }
13 ]
14}
请注意两个关键字段:
tool_calls[i].id:后面回传工具结果必须绑定这个 ID。tool_calls[i].function.arguments:通常是字符串化 JSON,不是直接结构体。
六、Step 4:解析并校验参数——这一步是安全闸门
建议对参数进行二次校验:
1var args WeatherArgs
2if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
3 log.Fatalf("arguments parse error: %v", err)
4}
5
6// 关键步骤:服务端二次校验
7if args.City == "" || args.Date == "" {
8 log.Fatal("工具参数缺失:city/date 不能为空")
9}
为什么必须“二次校验”?
- 模型输出再像 JSON,也可能字段缺失、类型不稳。
- 攻击者可能通过 prompt 注入诱导模型填危险参数。
- 真正承担责任的是你的服务,不是模型。
建议至少做四类校验:
- 结构校验:能不能反序列化。
- 业务校验:参数是否满足业务约束(城市白名单、日期范围)。
- 权限校验:当前用户是否允许调用该工具。
- 风控校验:频率限制、敏感词、风险动作拦截。
七、Step 5:执行本地工具函数——模型到这里就“停手”了
1func getWeather(city, date string) string {
2 // 关键步骤4:这里是“真实执行层”,生产环境应替换为真实天气API
3 if city == "上海" && date == "明天" {
4 return `{"city":"上海","date":"明天","weather":"多云转小雨","temp":"18~24C"}`
5 }
6 return fmt.Sprintf(`{"city":"%s","date":"%s","weather":"晴","temp":"20~28C"}`, city, date)
7}
这里建议你做一件常被忽略的事:统一工具返回格式。哪怕是字符串,也尽量稳定成 JSON 对象,后续模型总结会更稳。
八、Step 6:拼接 tool 消息回传——ToolCallID 是生命线
第 12 篇最关键的一段就在这里:
1msgs = append(msgs, openai.ChatCompletionMessage{
2 Role: openai.ChatMessageRoleTool,
3 ToolCallID: tc.ID, // 关键:必须与 tool_calls[i].id 对齐
4 Name: tc.Function.Name,
5 Content: toolResult,
6})
这条消息的意义:
role=tool:告诉模型“这是工具执行结果”。tool_call_id=tc.ID:告诉模型“这份结果对应哪次调用”。content=toolResult:给模型可推理的原始结果。
如果你做多工具并行调用,ToolCallID 对不齐会直接“串台”。
九、Step 7:第二次请求——让模型把工具结果转成最终回答
最后一步很简单,但常被漏掉:再次调用模型。
1// 关键步骤5:第二次请求,模型基于工具结果生成最终答复
2finalResp, err := client.CreateChatCompletion(context.Background(), openai.ChatCompletionRequest{
3 Model: model,
4 Messages: msgs, // 这里包含:system + user + assistant(tool_calls) + tool(result)
5})
6if err != nil {
7 log.Fatalf("final completion error: %v", err)
8}
9if len(finalResp.Choices) == 0 {
10 log.Fatal("模型第二轮没有返回 choices")
11}
12
13fmt.Println(finalResp.Choices[0].Message.Content)
这个时候模型才会把“冷数据”翻译成“用户可读答案”,例如:
- “上海明天多云转小雨,18~24C,建议薄外套+折叠伞。”
至此,闭环完成。
十、把流程串起来:一份可直接对照日志的最小闭环代码
下面是和第 12 篇一致、但加了更明确流程注释的版本:
1package main
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "log"
8 "os"
9
10 openai "github.com/sashabaranov/go-openai"
11)
12
13type WeatherArgs struct {
14 City string `json:"city"`
15 Date string `json:"date"`
16}
17
18func getWeather(city, date string) string {
19 // 关键流程:真实项目请改成调用天气API,这里用固定结果演示调用链路
20 if city == "上海" && date == "明天" {
21 return `{"city":"上海","date":"明天","weather":"多云转小雨","temp":"18~24C"}`
22 }
23 return fmt.Sprintf(`{"city":"%s","date":"%s","weather":"晴","temp":"20~28C"}`, city, date)
24}
25
26func main() {
27 // Step 0: 初始化客户端
28 apiKey := os.Getenv("DEEPSEEK_API_KEY")
29 model := os.Getenv("DEEPSEEK_MODEL")
30 baseURL := "https://api.deepseek.com/v1"
31 if apiKey == "" {
32 log.Fatal("请先设置 DEEPSEEK_API_KEY")
33 }
34 if model == "" {
35 log.Fatal("请先设置 DEEPSEEK_MODEL,值以 DeepSeek 当前模型文档为准")
36 }
37
38 cfg := openai.DefaultConfig(apiKey)
39 cfg.BaseURL = baseURL
40 client := openai.NewClientWithConfig(cfg)
41
42 // Step 1: 定义工具
43 tools := []openai.Tool{
44 {
45 Type: openai.ToolTypeFunction,
46 Function: &openai.FunctionDefinition{
47 Name: "get_weather",
48 Description: "查询城市某天的天气",
49 Parameters: json.RawMessage(`{
50 "type":"object",
51 "properties":{
52 "city":{"type":"string","description":"城市名"},
53 "date":{"type":"string","description":"日期,如今天/明天"}
54 },
55 "required":["city","date"]
56 }`),
57 },
58 },
59 }
60
61 msgs := []openai.ChatCompletionMessage{
62 {Role: openai.ChatMessageRoleSystem, Content: "你是一个天气助手,需要时必须调用工具。"},
63 {Role: openai.ChatMessageRoleUser, Content: "帮我查一下上海明天的天气,然后给出穿衣建议。"},
64 }
65
66 // Step 2: 第一次请求(让模型决定是否调用工具)
67 firstResp, err := client.CreateChatCompletion(context.Background(), openai.ChatCompletionRequest{
68 Model: model,
69 Messages: msgs,
70 Tools: tools,
71 ToolChoice: "auto",
72 })
73 if err != nil {
74 log.Fatalf("first completion error: %v", err)
75 }
76
77 if len(firstResp.Choices) == 0 {
78 log.Fatal("模型没有返回 choices")
79 }
80
81 // Step 3: 接收 tool_calls 并把 assistant 原样入历史
82 assistantMsg := firstResp.Choices[0].Message
83 if len(assistantMsg.ToolCalls) == 0 {
84 log.Fatal("模型未触发工具调用,请检查模型、提示词和工具定义")
85 }
86 msgs = append(msgs, assistantMsg)
87
88 // Step 4/5: 解析参数 -> 执行工具
89 for _, tc := range assistantMsg.ToolCalls {
90 if tc.Function.Name != "get_weather" {
91 continue
92 }
93
94 var args WeatherArgs
95 if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
96 log.Fatalf("arguments parse error: %v", err)
97 }
98 if args.City == "" || args.Date == "" {
99 log.Fatal("工具参数缺失:city/date 不能为空")
100 }
101
102 toolResult := getWeather(args.City, args.Date)
103
104 // Step 6: 回传 tool 消息(必须绑定 tool_call_id)
105 msgs = append(msgs, openai.ChatCompletionMessage{
106 Role: openai.ChatMessageRoleTool,
107 ToolCallID: tc.ID,
108 Name: tc.Function.Name,
109 Content: toolResult,
110 })
111 }
112
113 // Step 7: 第二次请求(生成最终用户答案)
114 finalResp, err := client.CreateChatCompletion(context.Background(), openai.ChatCompletionRequest{
115 Model: model,
116 Messages: msgs,
117 })
118 if err != nil {
119 log.Fatalf("final completion error: %v", err)
120 }
121 if len(finalResp.Choices) == 0 {
122 log.Fatal("模型第二轮没有返回 choices")
123 }
124
125 fmt.Println("最终回答:")
126 fmt.Println(finalResp.Choices[0].Message.Content)
127}
本地运行:
1go mod init function-calling-workflow
2go get github.com/sashabaranov/go-openai
3export DEEPSEEK_API_KEY="你的Key"
4export DEEPSEEK_MODEL="当前可用的模型 ID"
5go run main.go
十一、为什么你的链路常断?4 个高频故障点
1)忘了把 assistant(tool_calls) 原样放回 messages
症状:第二次请求模型“看不懂你在回什么”。
2)tool_call_id 没对齐
症状:多工具时回答串台,单工具时偶发不稳定。
3)arguments 反序列化失败但你没打日志
症状:看起来“模型没调成功”,其实是你本地解析失败。
4)工具返回是不可解析的自由文本
症状:模型总结质量忽高忽低。
建议把工具返回标准化成 JSON(哪怕字段很少),例如:city/date/weather/temp/error_code。
老墨总结
你真正要记住的,不是步骤编号,而是这个闭环的机械结构:
- 第一次请求带上 tools。
- 收到 tool_calls。
- 解析参数并校验。
- 执行本地工具。
- 绑定
tool_call_id回传 tool 消息。 - 第二次请求拿最终答复。
这几件事做对了,Function Calling 才不会停留在“偶尔能跑”的演示代码。
下一步建议很明确:在你现有项目里,先挑一个最小高价值动作(查天气、查订单、查知识片段)把这条链路跑稳,再考虑并行调用和 Agent 编排。
完整示例代码见 weather.go。
关注公众号:极客老墨
更多 AI 应用开发、工程实践和效率工具分享,欢迎扫码关注。
