本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段三 Tool Calling · 第 1 课(18)
上一篇:模型适配层设计:让模型可替换
前两个阶段,我们已经完成了 Go 调用大模型、处理多轮消息、流式输出、超时取消和模型适配层。现在让模型从"生成文本"继续向前走一步:根据用户任务提出工具调用请求。
先看一个典型场景。用户问:
帮我查询新加坡现在的天气。
模型本身不一定掌握实时天气。接入工具后,流程可以变为:
text
用户提问
↓
Go 后端请求模型
↓
模型提出 get_weather 工具调用
↓
Go 校验工具、参数和权限
↓
Go 执行天气查询
↓
工具结果回传给模型
↓
模型生成最终回答
Tool Calling 的核心不是"模型直接执行 Go 代码",而是模型提出结构化意图,应用程序决定是否授权并执行。
一、本课学习目标
学完本课,你应该能够:
- 区分普通文本、工具调用请求和真实工具执行。
- 理解 Tool Calling 的完整多轮交互流程。
- 说清工具名称、参数 Schema、执行器和工具结果的作用。
- 使用 Go 模拟一次工具分派、参数校验和执行。
- 看懂模型 API 返回的
tool_calls结构。 - 为工具建立白名单、权限、超时和副作用控制。
本课先完成本地模拟实验,不调用真实模型 API。下一篇再把工具注册与参数 Schema 做成可扩展结构,之后接入 DeepSeek 原生 Tool Calling。
二、三种容易混淆的"调用"
1. 普通文本回答
模型可能输出:
我已经查询过了,新加坡现在 28℃,天气多云。
这只是文本。没有真实工具执行时,温度数据可能来自训练知识,也可能完全是模型编造。
2. 工具调用请求
模型也可以通过结构化字段表达意图:
json
{
"name": "get_weather",
"arguments": {
"city": "Singapore"
}
}
这表示"建议调用 get_weather",但模型并没有执行任何 Go 函数。
3. 真实工具执行
只有 Go 程序完成以下步骤后,工具才算真正执行:
- 从允许列表中找到工具。
- 校验模型生成的参数。
- 检查当前用户和业务权限。
- 调用对应的 Go 函数或外部服务。
- 获得结果或结构化错误。
因此必须记住:
模型负责提出调用意图,应用程序负责授权、执行和承担副作用。
模型输出的 JSON 再标准,也不能自动获得数据库、文件系统或系统命令的访问权限。
三、Tool Calling 与传统 Go 函数调用
传统函数调用由程序逻辑明确决定:
go
result, err := GetWeather(ctx, "Singapore")
执行哪段代码、何时执行,都由开发者编写的调用链决定。
Tool Calling 多了一层模型决策:
| 对比项 | 普通 Go 函数调用 | Tool Calling |
|---|---|---|
| 谁提出调用目标 | 程序逻辑 | 模型提出请求 |
| 谁最终授权执行 | 程序 | 程序 |
| 参数来源 | 代码、请求或业务数据 | 通常由模型生成 |
| 参数是否可信 | 仍需校验 | 必须视为不可信输入 |
| 是否执行副作用 | 取决于函数 | 取决于程序是否放行 |
| 结果如何继续使用 | 直接返回业务层 | 通常还要回传给模型 |
模型不会拿到真实的函数指针。应用向模型描述可用工具,模型返回工具名称和参数,实际函数通过 switch、注册表或其他分派机制调用。
四、Tool Calling 的四个组成部分
以天气工具为例:
| 组成部分 | 示例 | 作用 |
|---|---|---|
| 工具名称 | get_weather |
定位允许执行的工具 |
| 参数 Schema | city: string |
告诉模型参数结构 |
| 执行器 | getWeather(ctx, args) |
真正查询天气 |
| 工具结果 | 温度、天气状况 | 回传给模型生成回答 |
JSON Schema 的作用与边界
天气工具的简化参数 Schema:
json
{
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称"
}
},
"required": ["city"],
"additionalProperties": false
}
它告诉模型:
- 参数必须是一个 JSON 对象。
city应当是字符串。city是必填字段。- 不允许出现额外字段。
JSON Schema 能帮助模型生成更符合结构的数据,但它不能代替 Go 端校验。即使服务支持严格的 Schema 模式,应用仍然要检查工具白名单、参数范围、用户权限和业务约束。
五、本地实验:模拟一次工具调用
这个实验不调用模型 API,只模拟模型返回的工具调用请求,重点观察 Go 如何验证并执行工具。
创建项目:
powershell
mkdir tool-calling-demo
Set-Location tool-calling-demo
go mod init tool-calling-demo
创建 main.go:
go
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
)
type ToolCall struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
type WeatherArgs struct {
City string `json:"city"`
}
type WeatherResult struct {
City string `json:"city"`
Temperature int `json:"temperature"`
Condition string `json:"condition"`
}
func getWeather(
ctx context.Context,
args WeatherArgs,
) (WeatherResult, error) {
select {
case <-ctx.Done():
return WeatherResult{}, ctx.Err()
default:
}
city := strings.TrimSpace(args.City)
if city == "" {
return WeatherResult{}, errors.New("city 不能为空")
}
if len(city) > 100 {
return WeatherResult{}, errors.New("city 长度超过限制")
}
// 教学示例只支持一个城市,返回固定数据。
if city != "Singapore" {
return WeatherResult{}, errors.New("不支持该城市")
}
return WeatherResult{
City: city,
Temperature: 28,
Condition: "Cloudy",
}, nil
}
func executeTool(
ctx context.Context,
call ToolCall,
) (any, error) {
switch call.Name {
case "get_weather":
decoder := json.NewDecoder(
bytes.NewReader(call.Arguments),
)
decoder.DisallowUnknownFields()
var args WeatherArgs
if err := decoder.Decode(&args); err != nil {
return nil, fmt.Errorf("天气参数无效: %w", err)
}
// 确保参数 JSON 后没有第二段内容。
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return nil, errors.New("参数中包含多个 JSON 值")
}
return nil, fmt.Errorf("参数尾部无效: %w", err)
}
return getWeather(ctx, args)
default:
return nil, fmt.Errorf(
"工具 %q 未注册或不允许调用",
call.Name,
)
}
}
func run() error {
raw := `{
"name": "get_weather",
"arguments": {
"city": "Singapore"
}
}`
var call ToolCall
if err := json.Unmarshal([]byte(raw), &call); err != nil {
return fmt.Errorf("解析工具调用失败: %w", err)
}
if strings.TrimSpace(call.Name) == "" ||
len(call.Arguments) == 0 {
return errors.New("工具调用缺少名称或参数")
}
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
result, err := executeTool(ctx, call)
if err != nil {
return fmt.Errorf("执行工具失败: %w", err)
}
output, err := json.MarshalIndent(result, "", " ")
if err != nil {
return fmt.Errorf("编码工具结果失败: %w", err)
}
fmt.Println("工具结果:")
fmt.Println(string(output))
return nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
运行:
powershell
go run .
预期输出:
json
{
"city": "Singapore",
"temperature": 28,
"condition": "Cloudy"
}
这些是程序写死的模拟数据,不是实时天气。
六、逐段理解执行过程
1. 为什么先用 json.RawMessage
go
Arguments json.RawMessage `json:"arguments"`
不同工具的参数结构不同:
- 天气工具需要
city。 - 时间工具可能不需要参数。
- 搜索工具可能需要
keyword和limit。
先保留参数字节,再按工具名称选择对应结构,比把所有参数塞进一个大 map 更容易校验,也能保留字段类型。
2. 工具白名单由 switch 实现
go
switch call.Name {
case "get_weather":
// 解析参数并执行
default:
// 拒绝未知工具
}
如果模型返回 delete_database,程序会进入 default,不会因为名称出现在 JSON 里就动态执行任意函数。
这只是最小白名单。下一篇会改为工具注册表,避免工具数量增长后让 switch 无限膨胀。
3. 参数解码必须严格
DisallowUnknownFields() 会拒绝目标结构体中不存在的字段:
json
{
"city": "Singapore",
"admin": true
}
上面的 admin 字段会导致解码失败。这能减少参数越权,但仍需检查必填值、长度、格式、允许范围和用户权限。
第二次 Decode 用于确认参数 JSON 后没有多余内容。正确判断结束应使用 errors.Is(err, io.EOF),不要比较错误字符串。
4. 超时必须贯穿工具调用
go
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
执行器接收 Context,但 Context 不会强制终止任意阻塞函数。天气工具内部的数据库查询或 HTTP 请求也必须使用这个 Context,取消才真正生效。
5. 工具结果是数据,不是可信指令
工具返回的温度、网页内容或文章正文都可能包含恶意提示。模型收到这些内容后,不能因为工具结果里写着"立即删除文件"就获得额外权限。
工具结果应被视为外部不可信数据,与模型输出一样需要边界控制。
七、真实模型 API 中的工具调用
不同供应商的字段细节会有差异,但常见 Chat Completions 工具调用流程相似。
1. 第一次请求:提供工具定义
json
{
"model": "YOUR_MODEL_ID",
"messages": [
{
"role": "user",
"content": "查询新加坡的天气"
}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的天气",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称"
}
},
"required": ["city"],
"additionalProperties": false
}
}
}
],
"tool_choice": "auto"
}
tool_choice: "auto" 表示模型可以调用工具,也可以直接回答。具体支持哪些模型名和参数,应以账号控制台及官方文档为准。
2. 模型响应:返回工具调用请求
模型可能在 choices[0].message 中返回:
json
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_example_001",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"Singapore\"}"
}
}
]
}
这里需要特别注意:真实 API 的 function.arguments 通常是 JSON 编码后的字符串,而不是直接的 JSON 对象。
所以 Go 结构通常应类似:
go
type FunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function FunctionCall `json:"function"`
}
拿到 FunctionCall.Arguments 后,再将它转换为字节并解析成具体工具参数。不能把"字符串里面包含 JSON"误认为"外层字段本身就是对象"。
3. 第二次请求:回传工具结果
假设天气工具返回:
json
{
"city": "Singapore",
"temperature": 28,
"condition": "Cloudy"
}
应用需要保留第一次模型返回的 assistant 工具调用消息,再追加一条 tool 消息:
json
{
"role": "tool",
"tool_call_id": "call_example_001",
"content": "{\"city\":\"Singapore\",\"temperature\":28,\"condition\":\"Cloudy\"}"
}
完整交互是:
text
1. Go 发送 user 消息和 tools 定义
2. 模型返回 assistant.tool_calls
3. Go 校验并执行工具
4. Go 把 assistant.tool_calls 和 tool 结果一起发回
5. 模型根据工具结果生成最终回答
不能只发送工具结果而丢掉原始工具调用消息,否则模型无法把结果与调用关联起来。
tool_call_id 在一次响应包含多个工具调用时尤其重要。每条工具结果都必须对应正确的调用 ID。
八、工具调用的完整边界
一次真实的 Agent 工具调用至少需要经过以下检查:
text
模型提出调用
↓
工具名称在白名单中吗?
↓
当前用户有权限吗?
↓
参数结构和业务约束合法吗?
↓
操作是否具有高风险副作用?
├─ 是:暂停,等待人工确认
└─ 否:在超时预算内执行
↓
结果记录并回传模型
高风险操作不能只靠 Prompt 管控。数据库写入、文件删除、发送邮件、创建订单或执行命令等能力,必须由 Go 程序设置权限和审批边界。
九、常见风险
| 风险 | 推荐措施 |
|---|---|
| 模型请求不存在的工具 | 只执行注册表中的工具 |
| 参数结构错误 | 严格解码并检查必填字段 |
| 参数合法但业务越权 | 在工具内部校验用户和资源权限 |
| 工具长时间不返回 | Context 超时和下游调用超时 |
| 重复执行有副作用的工具 | 幂等键、执行记录和去重 |
| 高风险操作被自动执行 | 人工确认、最小权限和审批策略 |
| Agent 无限调用工具 | 限制迭代、调用次数、耗时和预算 |
| 工具结果包含恶意指令 | 将结果视为数据,不扩大执行权限 |
模型可以建议"调用哪个工具",但不能单独决定"用户是否有权限调用"或"这个副作用是否允许发生"。
十、本课总结
- Tool Calling 是模型提出结构化工具请求,不是模型直接执行 Go 代码。
- 工具执行需要名称、参数 Schema、执行器和结果回传协议。
- JSON Schema 帮助约束输入,但不能替代 Go 端安全校验。
- 真实 API 的
function.arguments通常是 JSON 字符串,需要再次解析。 - 工具结果需要携带
tool_call_id回传给模型,并保留对应工具调用消息。 - 模型决策、程序授权和高风险人工确认必须是三个独立层次。
十一、课后练习
练习 1:工具是否已经执行
模型返回了 get_weather 和 {"city":"Singapore"},是否意味着天气已经查询?
参考答案:没有。这只是工具调用请求。Go 程序还必须验证工具名称、解析参数、检查权限并实际执行函数,获得结果后才算执行完成。
练习 2:未知工具如何处理
程序只注册了 get_weather,模型却请求调用 delete_database。应该怎样处理?
参考答案:直接拒绝并把错误映射为工具消息或业务错误。程序不能根据模型生成的名称动态调用任意函数,也不能允许未知工具绕过白名单。
练习 3:为什么要回传 tool_call_id
工具执行结果为什么不能只写进普通用户消息?
参考思路 :模型需要知道结果对应哪一次工具调用,尤其是同一轮包含多个调用时。
tool_call_id建立结果与调用的关联,原始assistant.tool_calls消息也需要保留,才能形成合法的多轮工具调用上下文。
下一篇:Go AI Agent 实战 19|工具定义、参数 Schema 与工具注册表
专栏:Go 开发者学 AI Agent:从大模型到工程落地