第08篇-工具注册框架设计-可扩展的工具生态
【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 正确理解」的工具,需要描述清楚下面这些维度。先看一张知识结构图:
把这棵树翻译成 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(注册表)是工具生态的中枢。它负责管理工具从「注册 → 发现 → 调用 → 注销」的完整生命周期。
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 error(ErrToolNotFound),调用方可以用 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 过滤。
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)
}
完整生命周期一览
本篇小结
| 知识点 | 核心内容 |
|---|---|
| 元数据模型 | ToolMetadata:身份 + 描述 + 参数 schema + 行为 + 权限五维度 |
| Registry 模式 | Register/Get/List/Unregister/Execute,读写锁保证并发安全 |
| 自动发现 | 结构体标签 + 反射,把「加工具」成本从 3 段代码降到 1 个标签 |
| 分组与权限 | ListByGroup / ListForAgent,让不同 Agent 看到不同工具子集 |
| Schema 导出 | ExportOpenAITools(给 LLM)+ ExportMarkdown(给人)双输出 |
| 工程取舍 | 核心工具手动注册,插件用自动发现;危险工具靠 AllowedAgents 隔离 |
下篇预告
第 09 篇:常用内置工具集实现 — 搜索、计算、代码执行
框架搭好了,得有真工具用。下一篇我们实现 5 个生产级内置工具:Web Search、安全计算器、沙箱代码执行、文件读写、通用 HTTP 调用器。每个都包含安全考量(防注入、超时、目录限制),可以直接拿去用。
如果本篇内容对你有帮助,欢迎点赞收藏!有任何疑问,欢迎在评论区交流。
更多推荐



所有评论(0)