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}

这段里真正决定模型行为的是三件事:

  1. Name:模型后续会在 tool_calls[].function.name 里原样回填这个名字。
  2. Description:模型判断“什么时候应该调这个工具”的主要依据。
  3. Parameters:参数形状约束。你要求 city/date 必填,模型就会倾向于补齐这两个字段。

常见误区:把 Description 写成“获取信息”。这等于没写,模型会犹豫甚至误调。

四、Step 2:第一次请求——把“问题+工具列表”发给模型

这一步和普通聊天请求唯一核心差异:多了 ToolsToolChoice

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 注入诱导模型填危险参数。
  • 真正承担责任的是你的服务,不是模型。

建议至少做四类校验:

  1. 结构校验:能不能反序列化。
  2. 业务校验:参数是否满足业务约束(城市白名单、日期范围)。
  3. 权限校验:当前用户是否允许调用该工具。
  4. 风控校验:频率限制、敏感词、风险动作拦截。

七、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

老墨总结

你真正要记住的,不是步骤编号,而是这个闭环的机械结构:

  1. 第一次请求带上 tools。
  2. 收到 tool_calls。
  3. 解析参数并校验。
  4. 执行本地工具。
  5. 绑定 tool_call_id 回传 tool 消息。
  6. 第二次请求拿最终答复。

这几件事做对了,Function Calling 才不会停留在“偶尔能跑”的演示代码。

下一步建议很明确:在你现有项目里,先挑一个最小高价值动作(查天气、查订单、查知识片段)把这条链路跑稳,再考虑并行调用和 Agent 编排。

完整示例代码见 weather.go


关注公众号:极客老墨

更多 AI 应用开发、工程实践和效率工具分享,欢迎扫码关注。

极客老墨微信公众号二维码

相关阅读