【AI Agent 与 Super Agent 构建实战】第 08 篇:工具注册框架设计 — 可扩展的工具生态

本系列定位:用 Go 语言从零构建各类 AI Agent,覆盖 ReAct、Plan-and-Execute、Multi-Agent、Super Agent 等核心设计模式的完整工程实现。

承上启下:第 07 篇我们让 ReAct 引擎跑起来了,但工具是 map[string]Tool 这种最朴素的存法——加一个工具要手写一段注册代码,改个描述要翻三处文件。当工具数量从 3 个涨到 30 个、再到 300 个(想象接 MCP 之后),这种手工作坊式管理会彻底失控。本篇的目标:给 AgentForge 造一个工业级的工具注册框架,让工具像插件一样即插即用。


本篇你将学到

  • 工具元数据模型:一个工具到底需要描述清楚哪些维度
  • Tool Registry 模式:注册、发现、注销的完整生命周期
  • 结构体标签 + 反射实现工具自动发现(告别手写注册代码)
  • 工具分组与权限控制:让不同 Agent 看到不同的工具子集
  • 从 Go 类型自动生成 JSON Schema 工具描述

一句话总结:好的工具系统不是「能存工具」,而是「能让 Agent 看懂工具、让开发者零成本加工具、让管理员能管控工具」。


一、为什么 map[string]Tool 不够用

第 07 篇的 ReAct 引擎里,我们用了这样的结构:

tools map[string]Tool

三个工具时没问题,三十个工具时就开始痛了:

痛点 表现 后果
元数据散落 描述、参数 schema、示例分散在不同地方 LLM 看到的工具描述不一致,选错率上升
无分组 所有工具一锅端给 Agent 工具越多,Agent 选择困难症越严重(第 10 篇详解)
无权限 任何 Agent 都能调任何工具 危险工具(删文件、执行代码)无法隔离
无生命周期 注册了就不能注销,运行时不能热插拔 无法支持插件化、MCP 动态加载
注册靠手写 每加一个工具写三行注册代码 容易漏注册、漏描述

工业级框架要解决的就是这五件事。我们一步步来。


二、工具元数据模型设计

一个「能被 LLM 正确理解」的工具,需要描述清楚下面这些维度。先看一张知识结构图:

ToolMetadata

身份

Name 唯一标识

Version 版本号

Group 分组

描述

Description 给 LLM 看的一句话

LongDescription 详细说明

Examples 调用示例

参数

Parameters JSON Schema

Required 必填项

Defaults 默认值

行为

Execute 执行函数

Timeout 超时

Dangerous 是否危险

权限

AllowedAgents 允许哪些 Agent

RequiredScopes 需要的权限域

把这棵树翻译成 Go 结构体:

// internal/registry/metadata.go
package registry

// ToolMetadata 工具的完整元数据
type ToolMetadata struct {
	Name            string            `json:"name"`              // 唯一标识,如 "search_web"
	Group           string            `json:"group"`             // 分组,如 "search" / "math" / "fs"
	Description     string            `json:"description"`       // 给 LLM 的一句话描述(最关键)
	LongDescription string            `json:"long_desc,omitempty"` // 详细说明(可选)
	Examples        []string          `json:"examples,omitempty"` // 调用示例(可选,提升选择准确率)
	Parameters      map[string]ParamSpec `json:"parameters"`     // 参数 schema
	Required        []string          `json:"required"`          // 必填参数名
	Timeout         int               `json:"timeout,omitempty"` // 超时秒数,0 表示用默认
	Dangerous       bool              `json:"dangerous"`         // 是否高危(代码执行、文件删除等)
	AllowedAgents   []string          `json:"allowed_agents,omitempty"` // 权限:允许的 Agent 名,空=全部
	Version         string            `json:"version"`           // 版本
}

// ParamSpec 单个参数的规格
type ParamSpec struct {
	Type        string   `json:"type"`        // string/number/boolean/array/object
	Description string   `json:"description"`
	Enum        []string `json:"enum,omitempty"`
	Default     any      `json:"default,omitempty"`
}

为什么 Description 是最关键的字段? 因为 LLM 选工具时,本质上是在做语义匹配——把用户意图和每个工具的描述做相似度判断(哪怕是符号匹配)。描述写得含糊(比如 "执行计算"),模型就分不清它和别的计算类工具的区别。第 10 篇会专门讲描述优化,这里先记住:Description 决定了一个工具被选中的概率

2.1 工具接口:元数据 + 执行器分离

把「描述」和「执行」解耦,让同一份元数据可以被不同执行器复用:

// internal/registry/tool.go
package registry

import "context"

// Tool 工具接口:元数据 + 执行能力
type Tool interface {
	Metadata() ToolMetadata
	Execute(ctx context.Context, args map[string]interface{}) (Result, error)
}

// Result 工具执行结果(比纯 string 富信息)
type Result struct {
	Content  string `json:"content"`            // 文本结果(喂给 LLM)
	MimeType string `json:"mime_type,omitempty"` // text/plain, application/json, image/png...
	Metadata map[string]interface{} `json:"metadata,omitempty"` // 额外元信息(耗时、来源 URL 等)
}

对比第 07 篇的 Execute(args) (string, error),这里升级为返回 Result 结构体——这样工具可以返回图片、结构化数据、以及执行耗时等元信息,而不仅仅是纯文本。


三、Tool Registry 模式:完整的注册生命周期

Registry(注册表)是工具生态的中枢。它负责管理工具从「注册 → 发现 → 调用 → 注销」的完整生命周期。

注销

执行阶段

发现阶段

Registry 核心

注册阶段

Register

AutoRegister

Lookup + Execute

开发者定义 Tool

ToolRegistry
name→entry

反射自动发现

工具存储

按名称查找

按分组过滤

按权限过滤

导出 Schema 给 LLM

Agent

Unregister

3.1 Registry 核心实现

// internal/registry/registry.go
package registry

import (
	"context"
	"errors"
	"fmt"
	"sort"
	"sync"
)

// ErrToolNotFound 工具未注册
var ErrToolNotFound = errors.New("tool not found")

// ErrToolExists 工具已存在(重复注册)
var ErrToolExists = errors.New("tool already registered")

// entry 一个注册条目
type entry struct {
	tool   Tool
	loadedAt int64 // 注册时间戳
}

// ToolRegistry 工具注册表
type ToolRegistry struct {
	mu     sync.RWMutex
	tools  map[string]entry
}

// NewToolRegistry 创建空注册表
func NewToolRegistry() *ToolRegistry {
	return &ToolRegistry{tools: make(map[string]entry)}
}

// Register 注册一个工具(若同名已存在则报错)
func (r *ToolRegistry) Register(t Tool) error {
	r.mu.Lock()
	defer r.mu.Unlock()

	meta := t.Metadata()
	if meta.Name == "" {
		return fmt.Errorf("工具 Name 不能为空")
	}
	if _, exists := r.tools[meta.Name]; exists {
		return fmt.Errorf("%w: %s", ErrToolExists, meta.Name)
	}
	r.tools[meta.Name] = entry{tool: t}
	return nil
}

// MustRegister 注册失败则 panic(用于启动期初始化)
func (r *ToolRegistry) MustRegister(t Tool) {
	if err := r.Register(t); err != nil {
		panic(err)
	}
}

// Unregister 注销一个工具
func (r *ToolRegistry) Unregister(name string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, exists := r.tools[name]; !exists {
		return fmt.Errorf("%w: %s", ErrToolNotFound, name)
	}
	delete(r.tools, name)
	return nil
}

// Get 按名查找(发现)
func (r *ToolRegistry) Get(name string) (Tool, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	e, ok := r.tools[name]
	if !ok {
		return nil, fmt.Errorf("%w: %s", ErrToolNotFound, name)
	}
	return e.tool, nil
}

// List 列出所有工具名(按字母序)
func (r *ToolRegistry) List() []string {
	r.mu.RLock()
	defer r.mu.RUnlock()
	names := make([]string, 0, len(r.tools))
	for n := range r.tools {
		names = append(names, n)
	}
	sort.Strings(names)
	return names
}

// Execute 便捷方法:查找 + 执行
func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]interface{}) (Result, error) {
	t, err := r.Get(name)
	if err != nil {
		return Result{}, err
	}
	return t.Execute(ctx, args)
}

注意三点工程细节:sync.RWMutex 保证并发安全(多个 Agent 可能同时查工具);MustRegister 用于启动期不可失败的注册,让配置错误尽早暴露;错误用 sentinel errorErrToolNotFound),调用方可以用 errors.Is 精确判断。

3.2 按分组和权限过滤

这是治理工具生态的关键。当工具变多时,不能把全部工具都塞给 LLM(第 10 篇会讲「工具选择困难症」)。

// ListByGroup 列出某分组的工具
func (r *ToolRegistry) ListByGroup(group string) []string {
	r.mu.RLock()
	defer r.mu.RUnlock()
	var names []string
	for n, e := range r.tools {
		if e.tool.Metadata().Group == group {
			names = append(names, n)
		}
	}
	sort.Strings(names)
	return names
}

// ListForAgent 根据调用方 Agent 过滤可见工具(权限控制)
func (r *ToolRegistry) ListForAgent(agentName string) []string {
	r.mu.RLock()
	defer r.mu.RUnlock()
	var names []string
	for n, e := range r.tools {
		meta := e.tool.Metadata()
		// AllowedAgents 为空表示对所有 Agent 开放
		if len(meta.AllowedAgents) == 0 {
			names = append(names, n)
			continue
		}
		for _, allowed := range meta.AllowedAgents {
			if allowed == agentName {
				names = append(names, n)
				break
			}
		}
	}
	sort.Strings(names)
	return names
}

用法:危险工具(如 execute_code)设 AllowedAgents: ["admin_agent"],普通对话 Agent 就看不到它。这比运行时拦截更安全——LLM 根本不知道这个工具存在,就不会尝试调用它


四、结构体标签 + 反射实现自动发现

前面 Register(t Tool) 还是要手动调用。如果工具很多,我们希望「定义好工具结构体就自动注册」。Go 的结构体标签(struct tag)+ 反射能优雅地做到。

4.1 思路:用标签声明工具元数据

我们定义一套自定义标签,开发者只要在结构体方法上打标签,框架就能自动提取元数据:

// 自动发现的工具示例
type CalculatorTool struct{}

// Execute 方法用标签声明工具元数据
// agent:tool 声明这是工具 + 工具名
// agent:desc 描述
// agent:group 分组
func (c *CalculatorTool) Execute(
	ctx context.Context,
	// agent:param 描述每个参数
	expression string `agent:"param,desc=数学表达式,required"`
) (string, error) {
	// ... 求值逻辑
	return expression + " = ...", nil
}

4.2 自动扫描器实现

// internal/registry/autodiscover.go
package registry

import (
	"context"
	"fmt"
	"reflect"
	"strings"
)

// AutoRegister 通过反射扫描一个结构体实例,自动注册其中的工具方法
// 约定:名为 Execute 的方法,且带有 agent:tool 标签,即为工具
func (r *ToolRegistry) AutoRegister(instance interface{}) error {
	v := reflect.ValueOf(instance)
	t := reflect.TypeOf(instance)

	// 必须是指针或结构体
	if t.Kind() == reflect.Ptr {
		t = t.Elem()
	}

	for i := 0; i < t.NumMethod(); i++ {
		method := t.Method(i)
		if method.Name != "Execute" {
			continue
		}

		// 读取方法上的标签(通过方法的输入参数类型)
		toolMeta, err := extractToolMeta(method)
		if err != nil {
			return fmt.Errorf("方法 %s.AutoRegister: %w", t.Name(), err)
		}
		if toolMeta == nil {
			continue // 不是工具方法
		}

		// 包装成 Tool 接口
		wrapped := &reflectedTool{
			instance: v,
			method:   method,
			meta:     *toolMeta,
		}
		if err := r.Register(wrapped); err != nil {
			return err
		}
	}
	return nil
}

// extractToolMeta 从方法签名和标签提取元数据
// 这里给出简化版:从方法名 + 第一个参数的 tag 推断
func extractToolMeta(method reflect.Method) (*ToolMetadata, error) {
	ft := method.Type
	if ft.NumIn() < 2 { // (receiver, ctx, ...) 至少 2 个 in
		return nil, nil
	}

	// 简化:工具名取 receiver 类型名转 snake_case
	recvType := method.Type.In(0)
	name := toSnakeCase(recvType.Elem().Name())
	name = strings.TrimSuffix(name, "_tool")

	meta := &ToolMetadata{
		Name:   name,
		Group:  "auto",
		Parameters: make(map[string]ParamSpec),
	}

	// 遍历参数(ctx 之后),每个参数根据类型生成 ParamSpec
	// 真实实现会读取结构体字段的 agent tag,这里展示原理
	for j := 2; j < ft.NumIn(); j++ {
		argType := ft.In(j)
		pname := fmt.Sprintf("arg%d", j-1)
		spec := ParamSpec{
			Type:        goTypeToJSONType(argType),
			Description: pname,
		}
		meta.Parameters[pname] = spec
	}

	return meta, nil
}

// goTypeToJSONType Go 类型映射到 JSON Schema 类型
func goTypeToJSONType(t reflect.Type) string {
	switch t.Kind() {
	case reflect.String:
		return "string"
	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
		reflect.Float32, reflect.Float64:
		return "number"
	case reflect.Bool:
		return "boolean"
	case reflect.Slice, reflect.Array:
		return "array"
	case reflect.Map, reflect.Struct:
		return "object"
	default:
		return "string"
	}
}

// toSnakeCase CamelCase → snake_case
func toSnakeCase(s string) string {
	var b strings.Builder
	for i, r := range s {
		if i > 0 && r >= 'A' && r <= 'Z' {
			b.WriteByte('_')
		}
		if r >= 'A' && r <= 'Z' {
			r = r + 32
		}
		b.WriteRune(r)
	}
	return b.String()
}

4.3 反射工具的执行包装

把反射拿到的方法包装成 Tool 接口:

// reflectedTool 把反射扫描到的方法包装成 Tool
type reflectedTool struct {
	instance reflect.Value
	method   reflect.Method
	meta     ToolMetadata
}

func (rt *reflectedTool) Metadata() ToolMetadata {
	return rt.meta
}

func (rt *reflectedTool) Execute(ctx context.Context, args map[string]interface{}) (Result, error) {
	// 把 map[string]interface{} 参数按顺序拼成反射调用参数
	ft := rt.method.Type
	callArgs := make([]reflect.Value, 0, ft.NumIn())
	callArgs = append(callArgs, rt.instance)         // receiver
	callArgs = append(callArgs, reflect.ValueOf(ctx)) // ctx

	for j := 2; j < ft.NumIn(); j++ {
		pname := fmt.Sprintf("arg%d", j-1)
		raw, ok := args[pname]
		if !ok {
			return Result{}, fmt.Errorf("缺少参数 %s", pname)
		}
		val, err := coerceValue(raw, ft.In(j))
		if err != nil {
			return Result{}, fmt.Errorf("参数 %s 类型错误: %w", pname, err)
		}
		callArgs = append(callArgs, val)
	}

	out := rt.method.Func.Call(callArgs)
	// 约定返回 (string, error)
	resultStr := ""
	if len(out) > 0 && out[0].Kind() == reflect.String {
		resultStr = out[0].String()
	}
	if len(out) > 1 && !out[1].IsNil() {
		return Result{}, out[1].Interface().(error)
	}
	return Result{Content: resultStr}, nil
}

// coerceValue 把 interface{} 强转到目标 reflect.Type
func coerceValue(raw interface{}, target reflect.Type) (reflect.Value, error) {
	rv := reflect.ValueOf(raw)
	if rv.Type().ConvertibleTo(target) {
		return rv.Convert(target), nil
	}
	return reflect.Value{}, fmt.Errorf("无法把 %T 转为 %v", raw, target)
}

反射自动发现是「锦上添花」——它让加工具的成本从「写 3 段代码」降到「打 1 个标签」。但反射有运行时开销和可读性成本,团队规模小、工具少时手动注册也完全 OK。工程选择上,建议核心工具手动注册(清晰可控),可选插件用自动发现(开发效率高)。


五、工具描述自动生成(Schema 导出)

注册了一堆工具后,我们需要把它们导出成 LLM 能理解的格式。第 04 篇我们写过 ToOpenAITool(),那是一次一个工具;现在要支持批量导出,并能按 Agent 过滤。

输出

Schema 生成器

工具存储

search_web

calculate

execute_code
dangerous

权限/分组过滤

Metadata → OpenAI Schema

给普通 Agent:
search + calculate

给 admin Agent:
全部 3 个

5.1 批量导出 OpenAI Tool 格式

// internal/registry/export.go
package registry

import (
	"github.com/sashabaranov/go-openai"
)

// ExportOpenAITools 导出为 OpenAI SDK 的 Tool 列表
// agentName 用于权限过滤,传空串表示不过滤
func (r *ToolRegistry) ExportOpenAITools(agentName string) []openai.Tool {
	visible := r.ListForAgent(agentName)
	tools := make([]openai.Tool, 0, len(visible))

	for _, name := range visible {
		t, _ := r.Get(name)
		tools = append(tools, metadataToOpenAITool(t.Metadata()))
	}
	return tools
}

// metadataToOpenAITool 把 ToolMetadata 转成 openai.Tool
func metadataToOpenAITool(m ToolMetadata) openai.Tool {
	properties := make(map[string]interface{})
	for pname, spec := range m.Parameters {
		prop := map[string]interface{}{
			"type":        spec.Type,
			"description": spec.Description,
		}
		if len(spec.Enum) > 0 {
			prop["enum"] = spec.Enum
		}
		if spec.Default != nil {
			prop["default"] = spec.Default
		}
		properties[pname] = prop
	}

	return openai.Tool{
		Type: openai.ToolTypeFunction,
		Function: &openai.FunctionDefinition{
			Name:        m.Name,
			Description: m.Description,
			Parameters: map[string]interface{}{
				"type":       "object",
				"properties": properties,
				"required":   m.Required,
			},
		},
	}
}

5.2 导出成 Markdown 文档(给人看)

除了给 LLM 看,注册表还能导出一份人类可读的工具清单,方便团队协作和 Code Review:

// ExportMarkdown 导出工具清单为 Markdown
func (r *ToolRegistry) ExportMarkdown(agentName string) string {
	visible := r.ListForAgent(agentName)
	var b strings.Builder
	b.WriteString("# 工具清单\n\n")
	for _, name := range visible {
		t, _ := r.Get(name)
		m := t.Metadata()
		fmt.Fprintf(&b, "## %s\n", m.Name)
		fmt.Fprintf(&b, "- **分组**: %s | **版本**: %s", m.Group, m.Version)
		if m.Dangerous {
			b.WriteString(" | ⚠️ **高危**")
		}
		b.WriteString("\n")
		fmt.Fprintf(&b, "- **描述**: %s\n", m.Description)
		if len(m.Parameters) > 0 {
			b.WriteString("- **参数**:\n")
			for pname, spec := range m.Parameters {
				req := ""
				for _, rr := range m.Required {
					if rr == pname {
						req = " (必填)"
						break
					}
				}
				fmt.Fprintf(&b, "  - `%s` (%s)%s: %s\n",
					pname, spec.Type, req, spec.Description)
			}
		}
		b.WriteString("\n")
	}
	return b.String()
}

六、AgentForge ToolRegistry 完整整合

把前面所有零件组装起来,看一个真实的使用场景:

// main.go (片段)
func main() {
	reg := registry.NewToolRegistry()

	// 方式 1:手动注册核心工具(清晰可控)
	reg.MustRegister(&searchtool.WebSearch{APIKey: cfg.SearchKey})
	reg.MustRegister(&mathtool.Calculator{})

	// 方式 2:自动发现插件工具(开发效率高)
	plugins := plugin.LoadDir("./plugins") // 假想的插件加载器
	for _, p := range plugins {
		if err := reg.AutoRegister(p); err != nil {
			log.Printf("插件 %T 注册失败: %v", p, err)
		}
	}

	// 导出给某个 Agent 用的工具 schema
	chatTools := reg.ExportOpenAITools("chat_agent")
	adminTools := reg.ExportOpenAITools("admin_agent")

	fmt.Printf("普通 Agent 可见工具: %d 个\n", len(chatTools))
	fmt.Printf("管理员 Agent 可见工具: %d 个\n", len(adminTools))

	// 执行一个工具
	res, err := reg.Execute(context.Background(), "calculate",
		map[string]interface{}{"expression": "2^10"})
	fmt.Println(res.Content, err)
}

完整生命周期一览

LLM Agent 工具实例 ToolRegistry 应用启动 LLM Agent 工具实例 ToolRegistry 应用启动 反射扫描,自动注册 运行时热插拔 NewToolRegistry() new CalculatorTool() Register(Calculator) ok AutoRegister(plugin) 创建 Agent,注入 Registry ExportOpenAITools("chat_agent") [search_web, calculate] (过滤掉危险工具) 用户提问,LLM 选择 calculate Execute("calculate", {expression:"2^10"}) Calculator.Execute(...) Result{Content:"2^10 = 1024"} Result "2 的 10 次方是 1024" Unregister("old_tool") Register(newTool)

本篇小结

知识点 核心内容
元数据模型 ToolMetadata:身份 + 描述 + 参数 schema + 行为 + 权限五维度
Registry 模式 Register/Get/List/Unregister/Execute,读写锁保证并发安全
自动发现 结构体标签 + 反射,把「加工具」成本从 3 段代码降到 1 个标签
分组与权限 ListByGroup / ListForAgent,让不同 Agent 看到不同工具子集
Schema 导出 ExportOpenAITools(给 LLM)+ ExportMarkdown(给人)双输出
工程取舍 核心工具手动注册,插件用自动发现;危险工具靠 AllowedAgents 隔离

下篇预告

第 09 篇:常用内置工具集实现 — 搜索、计算、代码执行
框架搭好了,得有真工具用。下一篇我们实现 5 个生产级内置工具:Web Search、安全计算器、沙箱代码执行、文件读写、通用 HTTP 调用器。每个都包含安全考量(防注入、超时、目录限制),可以直接拿去用。


如果本篇内容对你有帮助,欢迎点赞收藏!有任何疑问,欢迎在评论区交流。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐