本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段三 Tool Calling · 第 2 课(19)
上一课用 switch 完成了最简单的工具分派,也明确了"模型提出请求,Go 程序负责执行"的边界。
工具从一个增加到十几个以后,继续堆 switch 会让参数解析、权限检查、日志和错误处理重复出现。这一课把工具描述、参数 Schema 和执行器封装成统一对象,再通过工具注册表按名称查找。
一、本课学习目标
学完本课,你应该能够:
- 理解工具定义与工具实现的职责区别。
- 设计统一的 Go
Tool接口。 - 使用 JSON Schema 描述工具参数。
- 实现并发安全的工具注册表。
- 拒绝重复注册、未知工具、
null参数和未知字段。 - 为下一课接入原生 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":""}
因此需要三层校验:
- JSON 格式与对象类型。
- 字段类型、必填字段和未知字段。
- 业务范围、用户权限和资源状态。
示例的 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 不应被当成合法参数对象")
}
}
单元测试不访问网络,也不消耗模型额度。
八、本课总结
- 工具注册表用名称映射到"定义 + 执行器"。
Tool接口统一调用入口,具体参数仍由各工具解析。- 暴露给模型的是
ToolDefinition,不是 Go 执行对象。 - Schema 指导模型生成参数,Go 端仍必须校验。
- 严格校验应拒绝非对象、未知字段、多个 JSON 值和非法业务值。
- 并发安全、稳定排序和重复注册检查能让工具系统更容易维护。
九、课后练习
练习 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:从大模型到工程落地