Go AI Agent 实战 19|工具定义、参数 Schema 与工具注册表

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

当前进度:阶段三 Tool Calling · 第 2 课(19)

上一篇:Tool Calling 的基本原理与完整执行流程

上一课用 switch 完成了最简单的工具分派,也明确了"模型提出请求,Go 程序负责执行"的边界。

工具从一个增加到十几个以后,继续堆 switch 会让参数解析、权限检查、日志和错误处理重复出现。这一课把工具描述、参数 Schema 和执行器封装成统一对象,再通过工具注册表按名称查找。

一、本课学习目标

学完本课,你应该能够:

  1. 理解工具定义与工具实现的职责区别。
  2. 设计统一的 Go Tool 接口。
  3. 使用 JSON Schema 描述工具参数。
  4. 实现并发安全的工具注册表。
  5. 拒绝重复注册、未知工具、null 参数和未知字段。
  6. 为下一课接入原生 Tool Calling 准备稳定接口。

二、为什么需要工具注册表

最初的分派方式可能是:

go 复制代码
switch call.Name {
case "get_weather":
	// 执行天气工具
case "get_current_time":
	// 执行时间工具
default:
	// 拒绝未知工具
}

只有两三个工具时这很直观。但工具增加后会出现几个问题:

  • 名称、描述、参数 Schema 和执行逻辑散落在不同位置。
  • 每个分支都要重复参数解析与错误处理。
  • 新增工具需要修改核心分派代码。
  • 不清楚哪些工具真正暴露给了模型。

注册表把关系变成:

text 复制代码
工具名称
   ↓
工具定义 + 参数 Schema + 执行器

模型只能看到允许注册的工具;执行时也只能通过名称查找已经注册的实例。它和 HTTP 路由表有相似之处,但处理的是模型提出的结构化调用请求。

三、定义统一 Tool 接口

气温、时间和文章搜索工具功能不同,却拥有共同能力:

  • 唯一名称。
  • 给模型阅读的描述。
  • 参数 JSON Schema。
  • 一个接收 Context 和原始参数的执行方法。

可以定义:

go 复制代码
type Tool interface {
	Name() string
	Description() string
	Parameters() json.RawMessage
	Execute(
		ctx context.Context,
		arguments json.RawMessage,
	) (any, error)
}

参数使用 json.RawMessage,而不是固定为某一种参数结构:

  • 天气工具接收 city。
  • 时间工具不需要业务参数。
  • 搜索工具接收 keyword 和 limit。

接口统一调用入口,具体工具负责解析自己的参数类型。返回值使用 any,以便不同工具返回不同结果;调用方再把结果序列化为 JSON 交给模型。

四、工具定义与执行器分离

模型只需要看到名称、描述和参数 Schema,不需要接触 Go 执行器。

go 复制代码
type ToolDefinition struct {
	Type     string             `json:"type"`
	Function FunctionDefinition `json:"function"`
}

type FunctionDefinition struct {
	Name        string          `json:"name"`
	Description string          `json:"description"`
	Parameters  json.RawMessage `json:"parameters"`
}

从内部 Tool 转换成外部 ToolDefinition:

text 复制代码
内部执行器 Tool
      ↓
可公开描述 ToolDefinition
      ↓
序列化为模型 API 的 tools 参数

这层分离能防止模型拿到 Go 对象,也能确保暴露的工具和实际可执行的工具来自同一注册表。

五、完整实现

下面的程序实现两个工具:模拟天气和服务器当前时间。

go 复制代码
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"sort"
	"strings"
	"sync"
	"time"
)

type Tool interface {
	Name() string
	Description() string
	Parameters() json.RawMessage
	Execute(context.Context, json.RawMessage) (any, error)
}

type ToolDefinition struct {
	Type     string             `json:"type"`
	Function FunctionDefinition `json:"function"`
}

type FunctionDefinition struct {
	Name        string          `json:"name"`
	Description string          `json:"description"`
	Parameters  json.RawMessage `json:"parameters"`
}

func definitionOf(t Tool) ToolDefinition {
	return ToolDefinition{
		Type: "function",
		Function: FunctionDefinition{
			Name:        t.Name(),
			Description: t.Description(),
			Parameters:  t.Parameters(),
		},
	}
}

type Registry struct {
	mu    sync.RWMutex
	tools map[string]Tool
}

func NewRegistry() *Registry {
	return &Registry{tools: make(map[string]Tool)}
}

func (r *Registry) Register(t Tool) error {
	if t == nil {
		return errors.New("tool 不能为 nil")
	}

	name := strings.TrimSpace(t.Name())
	if name == "" {
		return errors.New("tool 名称不能为空")
	}
	if !json.Valid(t.Parameters()) {
		return fmt.Errorf("tool %q 的参数 Schema 不是合法 JSON", name)
	}

	r.mu.Lock()
	defer r.mu.Unlock()

	if _, exists := r.tools[name]; exists {
		return fmt.Errorf("tool %q 已注册", name)
	}

	r.tools[name] = t
	return nil
}

func (r *Registry) Get(name string) (Tool, bool) {
	r.mu.RLock()
	defer r.mu.RUnlock()

	t, ok := r.tools[name]
	return t, ok
}

func (r *Registry) Definitions() []ToolDefinition {
	r.mu.RLock()
	defer r.mu.RUnlock()

	defs := make([]ToolDefinition, 0, len(r.tools))
	for _, t := range r.tools {
		defs = append(defs, definitionOf(t))
	}

	// 稳定顺序便于日志、测试和请求对比。
	sort.Slice(defs, func(i, j int) bool {
		return defs[i].Function.Name < defs[j].Function.Name
	})
	return defs
}

func (r *Registry) Execute(
	ctx context.Context,
	name string,
	args json.RawMessage,
) (any, error) {
	t, ok := r.Get(name)
	if !ok {
		return nil, fmt.Errorf("tool %q 未注册或不允许调用", name)
	}

	if err := ctx.Err(); err != nil {
		return nil, err
	}

	return t.Execute(ctx, args)
}

// decodeStrict 只接受一个 JSON 对象,并拒绝未知字段。
func decodeStrict(data json.RawMessage, dst any) error {
	trimmed := bytes.TrimSpace(data)
	if len(trimmed) == 0 ||
		trimmed[0] != '{' ||
		!json.Valid(trimmed) {
		return errors.New("arguments 必须是 JSON 对象")
	}

	decoder := json.NewDecoder(bytes.NewReader(trimmed))
	decoder.DisallowUnknownFields()

	if err := decoder.Decode(dst); err != nil {
		return fmt.Errorf("解析参数失败: %w", err)
	}

	var extra any
	if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
		if err == nil {
			return errors.New("arguments 包含多个 JSON 值")
		}
		return fmt.Errorf("arguments 尾部无效: %w", err)
	}

	return nil
}

type WeatherTool struct{}

func (WeatherTool) Name() string {
	return "get_weather"
}

func (WeatherTool) Description() string {
	return "查询支持城市的模拟天气数据"
}

func (WeatherTool) Parameters() json.RawMessage {
	return json.RawMessage(`{
		"type": "object",
		"properties": {
			"city": {
				"type": "string",
				"description": "城市名称"
			}
		},
		"required": ["city"],
		"additionalProperties": false
	}`)
}

type WeatherArgs struct {
	City string `json:"city"`
}

func (WeatherTool) Execute(
	ctx context.Context,
	raw json.RawMessage,
) (any, error) {
	var args WeatherArgs
	if err := decodeStrict(raw, &args); err != nil {
		return nil, err
	}

	city := strings.TrimSpace(args.City)
	if city == "" {
		return nil, errors.New("city 不能为空")
	}
	if err := ctx.Err(); err != nil {
		return nil, err
	}

	// 教学示例只返回固定模拟数据。
	if city != "Singapore" {
		return nil, errors.New("当前只支持 Singapore")
	}

	return map[string]any{
		"city":        city,
		"temperature": 28,
		"condition":   "Cloudy",
		"source":      "mock",
	}, nil
}

type CurrentTimeTool struct{}

func (CurrentTimeTool) Name() string {
	return "get_current_time"
}

func (CurrentTimeTool) Description() string {
	return "获取服务器当前时间"
}

func (CurrentTimeTool) Parameters() json.RawMessage {
	return json.RawMessage(`{
		"type": "object",
		"properties": {},
		"additionalProperties": false
	}`)
}

func (CurrentTimeTool) Execute(
	ctx context.Context,
	raw json.RawMessage,
) (any, error) {
	var args struct{}
	if err := decodeStrict(raw, &args); err != nil {
		return nil, err
	}
	if err := ctx.Err(); err != nil {
		return nil, err
	}

	return map[string]string{
		"time": time.Now().Format(time.RFC3339),
	}, nil
}

func run() error {
	registry := NewRegistry()

	if err := registry.Register(WeatherTool{}); err != nil {
		return err
	}
	if err := registry.Register(CurrentTimeTool{}); err != nil {
		return err
	}

	definitions, err := json.MarshalIndent(
		registry.Definitions(),
		"",
		"  ",
	)
	if err != nil {
		return fmt.Errorf("编码工具定义失败: %w", err)
	}

	fmt.Println("注册工具:")
	fmt.Println(string(definitions))

	ctx, cancel := context.WithTimeout(
		context.Background(),
		2*time.Second,
	)
	defer cancel()

	result, err := registry.Execute(
		ctx,
		"get_weather",
		json.RawMessage(`{"city":"Singapore"}`),
	)
	if err != nil {
		return fmt.Errorf("执行天气工具失败: %w", err)
	}

	output, err := json.MarshalIndent(result, "", "  ")
	if err != nil {
		return fmt.Errorf("编码工具结果失败: %w", err)
	}

	fmt.Println("\n工具结果:")
	fmt.Println(string(output))
	return nil
}

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

运行:

powershell 复制代码
go mod init tool-registry-demo
go run .

程序不需要 API Key。天气结果是固定模拟数据,时间工具返回本机服务器时间。

六、为什么这样设计

1. Schema 和 Go 参数校验是两层

Schema 告诉模型参数应该如何生成:

json 复制代码
{
  "type": "object",
  "properties": {
    "city": {"type": "string"}
  },
  "required": ["city"],
  "additionalProperties": false
}

但模型输出必须按不可信输入处理。下面的参数结构正确,业务上却不可用:

json 复制代码
{"city":""}

因此需要三层校验:

  1. JSON 格式与对象类型。
  2. 字段类型、必填字段和未知字段。
  3. 业务范围、用户权限和资源状态。

示例的 decodeStrict 不是通用 JSON Schema 校验器。它只保证输入是单个 JSON 对象,并通过 Go 结构体检查字段。复杂 Schema 若确实需要,应使用经过验证的校验库。

2. 为什么显式拒绝 null

JSON 的 null 是合法值,但工具期望的是参数对象。如果直接把 null 解码到空结构体,某些情况下可能不报错,导致"没有传对象"被误认为"参数合法"。

所以 decodeStrict 先检查第一个非空字符是否为 {。这比事后判断空 map 更可靠。

3. 为什么使用 sync.RWMutex

注册表在并发服务中可能同时被读取:

  • Register 修改 map,使用写锁。
  • Get 和 Definitions 读取 map,使用读锁。

锁只保护注册表,不会自动保证工具内部状态安全。若工具持有可变共享数据,仍需单独同步。

生产项目通常在启动阶段完成工具注册,运行期间只读。这比运行中动态增删工具更容易保证一致性。

4. 为什么对工具定义排序

Go map 的遍历顺序不稳定。直接序列化可能让每次请求中的工具顺序不同,给日志、测试快照和缓存带来噪声。

按工具名称排序后,输出稳定且便于对比。模型通常不依赖工具顺序,但稳定协议更容易排查。

七、测试注册表

至少在接入模型前验证这些场景:

场景 预期
注册合法工具 成功
重复注册同名工具 返回错误
查询已注册工具 成功
执行未知工具 拒绝
缺少 city 参数错误
city 为空 业务错误
参数出现未知字段 参数错误
参数为 null 或 [] 参数错误
Context 已取消 不开始工具执行

测试示例:

go 复制代码
func TestRegistryRejectsDuplicate(t *testing.T) {
	r := NewRegistry()
	if err := r.Register(WeatherTool{}); err != nil {
		t.Fatal(err)
	}
	if err := r.Register(WeatherTool{}); err == nil {
		t.Fatal("重复注册应当失败")
	}
}

func TestWeatherRejectsNull(t *testing.T) {
	_, err := (WeatherTool{}).Execute(
		context.Background(),
		json.RawMessage(`null`),
	)
	if err == nil {
		t.Fatal("null 不应被当成合法参数对象")
	}
}

单元测试不访问网络,也不消耗模型额度。

八、本课总结

  1. 工具注册表用名称映射到"定义 + 执行器"。
  2. Tool 接口统一调用入口,具体参数仍由各工具解析。
  3. 暴露给模型的是 ToolDefinition,不是 Go 执行对象。
  4. Schema 指导模型生成参数,Go 端仍必须校验。
  5. 严格校验应拒绝非对象、未知字段、多个 JSON 值和非法业务值。
  6. 并发安全、稳定排序和重复注册检查能让工具系统更容易维护。

九、课后练习

练习 1:新增搜索工具

如果 search_articles 需要 keyword 和 limit,为什么不把这两个字段直接加到 Tool 接口?

参考答案 :Tool 是所有工具共享的抽象。固定业务参数会迫使天气工具和时间工具适配无意义的字段。应让搜索工具定义自己的参数结构,在 Execute 中解析和校验,接口只保留通用方法。

练习 2:找出注册逻辑的问题

go 复制代码
func (r *Registry) Register(t Tool) error {
	r.tools[t.Name()] = t
	return nil
}

这段代码缺少哪些保护?

参考思路 :没有检查 nil、空名称和非法 Schema;重复名称会静默覆盖;多个 goroutine 并发访问 map 时没有同步保护。

练习 3:Schema 能否替代范围校验

Schema 声明 limit 是整数,模型传入 10000 且工具上限为 20。只做 JSON 解码足够吗?

参考答案 :不够。类型正确不代表业务范围合法,Go 端还要检查 1 <= limit <= 20,并限制关键词长度、查询成本和返回数据量。


下一篇:Go AI Agent 实战 20|接入 DeepSeek 原生 Tool Calling

专栏:Go 开发者学 AI Agent:从大模型到工程落地

相关推荐
蓝悦无人机1 小时前
Ansys仿真常见错误及警告
人工智能·ansys仿真
skywalk81631 小时前
deepseek对skywalk163/hive 和hivecommons/hive 两个多机AI Agent任务调度项目的对比
人工智能·hive·agent
NineData1 小时前
NineData 社区版 V5.4.0 上线:715 条复制对比链路,AI Agent 新增表结构设计发布能力
大数据·数据库·人工智能·mysql·数据库管理工具·ninedata·数据库迁移工具
龍德明宇1 小时前
机器人的礼貌是标注出来的-龍德明宇
人工智能·机器人·大语言模型llm·负主体性·ai存在论
京存高性能存储1 小时前
POC 实测笔记:全闪存储大带宽压测,顺序读 19.2GiB/s、顺序写 10.8GiB/s,聊聊长尾延迟与持续吞吐问题
人工智能·笔记
vipxieliang1 小时前
神经形态计算芯片:忆阻器如何实现毫秒级脑动力学仿真
人工智能
4SAPI1 小时前
企业级大模型工作流基础设施选型指南:4SAPI、硅基流动与 OpenRouter 的架构权衡
运维·人工智能·架构·自动化
IHaveADiscovery1 小时前
AI代劳Word表格线条:先搞懂术语再动手
人工智能·word
3D阅阅三维模型轻量化及浏览平台1 小时前
机加工 / 模具 / 钣金现场审图痛点:不用 UG/SW,手机、网页直接打开 STEP、DXF 轻量化审图工具(3D 阅阅)
人工智能·3d·机加工·钣金加工·模具加工