Go AI Agent 实战 18|Tool Calling 的基本原理与完整执行流程

本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。

当前进度:阶段三 Tool Calling · 第 1 课(18)

上一篇:模型适配层设计:让模型可替换

前两个阶段,我们已经完成了 Go 调用大模型、处理多轮消息、流式输出、超时取消和模型适配层。现在让模型从"生成文本"继续向前走一步:根据用户任务提出工具调用请求。

先看一个典型场景。用户问:

帮我查询新加坡现在的天气。

模型本身不一定掌握实时天气。接入工具后,流程可以变为:

text 复制代码
用户提问
   ↓
Go 后端请求模型
   ↓
模型提出 get_weather 工具调用
   ↓
Go 校验工具、参数和权限
   ↓
Go 执行天气查询
   ↓
工具结果回传给模型
   ↓
模型生成最终回答

Tool Calling 的核心不是"模型直接执行 Go 代码",而是模型提出结构化意图,应用程序决定是否授权并执行。

一、本课学习目标

学完本课,你应该能够:

  1. 区分普通文本、工具调用请求和真实工具执行。
  2. 理解 Tool Calling 的完整多轮交互流程。
  3. 说清工具名称、参数 Schema、执行器和工具结果的作用。
  4. 使用 Go 模拟一次工具分派、参数校验和执行。
  5. 看懂模型 API 返回的 tool_calls 结构。
  6. 为工具建立白名单、权限、超时和副作用控制。

本课先完成本地模拟实验,不调用真实模型 API。下一篇再把工具注册与参数 Schema 做成可扩展结构,之后接入 DeepSeek 原生 Tool Calling。

二、三种容易混淆的"调用"

1. 普通文本回答

模型可能输出:

我已经查询过了,新加坡现在 28℃,天气多云。

这只是文本。没有真实工具执行时,温度数据可能来自训练知识,也可能完全是模型编造。

2. 工具调用请求

模型也可以通过结构化字段表达意图:

json 复制代码
{
  "name": "get_weather",
  "arguments": {
    "city": "Singapore"
  }
}

这表示"建议调用 get_weather",但模型并没有执行任何 Go 函数。

3. 真实工具执行

只有 Go 程序完成以下步骤后,工具才算真正执行:

  1. 从允许列表中找到工具。
  2. 校验模型生成的参数。
  3. 检查当前用户和业务权限。
  4. 调用对应的 Go 函数或外部服务。
  5. 获得结果或结构化错误。

因此必须记住:

模型负责提出调用意图,应用程序负责授权、执行和承担副作用。

模型输出的 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 无限调用工具 限制迭代、调用次数、耗时和预算
工具结果包含恶意指令 将结果视为数据,不扩大执行权限

模型可以建议"调用哪个工具",但不能单独决定"用户是否有权限调用"或"这个副作用是否允许发生"。

十、本课总结

  1. Tool Calling 是模型提出结构化工具请求,不是模型直接执行 Go 代码。
  2. 工具执行需要名称、参数 Schema、执行器和结果回传协议。
  3. JSON Schema 帮助约束输入,但不能替代 Go 端安全校验。
  4. 真实 API 的 function.arguments 通常是 JSON 字符串,需要再次解析。
  5. 工具结果需要携带 tool_call_id 回传给模型,并保留对应工具调用消息。
  6. 模型决策、程序授权和高风险人工确认必须是三个独立层次。

十一、课后练习

练习 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:从大模型到工程落地

相关推荐
captain3761 小时前
Spring Web MVC(2)
java·前端·spring boot·后端·spring·mvc
紧跟先前的步伐1 小时前
Go AI Agent 实战 17|模型适配层设计:让模型可替换
开发语言·人工智能·后端·ai·golang
lucas_AI1 小时前
别再迷信 OCR 专用小模型:8 个 7B 以下 VLM 实测,抽取 JSON 结构还得靠通用选手
人工智能
神一样的老师1 小时前
#移远产品体验官 Quectel Pi M2 实战:完全离线的中文语音助手
人工智能
能源探索家1 小时前
本周能源政策密集落子:电力现货规则修订、碳市场扩围至石化化工、储能升格新兴支柱产业
能源·1024程序员节·新型电力系统·十五五规划·能源信息化·能源资讯
女神下凡1 小时前
芯参谋(71):全桥电机驱动电路设计指南
arm开发·stm32·单片机·嵌入式硬件·硬件工程·设计规范·1024程序员节
EnCi Zheng1 小时前
AI Agent工具误调用怎么解决-从参数校验到高危二次确认的四层工程防线
大数据·数据库·人工智能
一级新生1 小时前
多智能体协作:常用协作模式详解
人工智能·状态模式·agent·多智能体·智能体