🏠

Tool Registration and Resolution

概述:为什么需要这个模块

想象一个智能代理系统就像一个"数字管家"——当用户问"帮我查一下上周的销售数据并生成图表"时,代理需要理解这个请求,然后调用合适的工具:先查询数据库,再调用数据分析工具。但这里有个关键问题:LLM 只知道工具的"名字"和"用法说明",它不知道如何真正执行这些工具

tool_registration_and_resolution 模块解决的就是这个"名字到实现"的映射问题。它维护一个工具注册表,让代理引擎能够:

  1. 向 LLM 提供可用工具列表(函数定义)
  2. 当 LLM 返回工具调用请求时,根据工具名找到对应的实现并执行

这个设计看似简单,但有一个关键的安全考量:如果允许工具名称被随意覆盖,恶意代码可能通过注册同名工具来"劫持"工具执行。因此,本模块采用了"先注册者优先"(first-wins)的策略,确保工具一旦注册就不能被后续的同名注册所替换。这是对该模块 GHSA-67q9-58vj-32qx 安全公告的直接响应。

架构与数据流

graph TB subgraph Startup["启动阶段"] Tools[各种工具实现
KnowledgeSearchTool
WebSearchTool
DataAnalysisTool...] Registry[ToolRegistry] end subgraph Runtime["运行时"] AgentEngine[AgentEngine] LLM[外部 LLM 服务] EventBus[EventBus] end subgraph Execution["工具执行"] ToolResult[ToolResult] Observability[日志/追踪] end Tools -->|RegisterTool| Registry AgentEngine -->|GetFunctionDefinitions| Registry Registry -->|FunctionDefinition 列表 | AgentEngine AgentEngine -->|发送工具定义 | LLM LLM -->|返回 ToolCall| AgentEngine AgentEngine -->|ExecuteTool name, args| Registry Registry -->|tool.Execute| Tools Tools -->|ToolResult| Registry Registry -->|ToolResult| AgentEngine Registry -.->|日志记录 | Observability AgentEngine -.->|事件发布 | EventBus

核心组件角色

组件 职责 类比
ToolRegistry 工具的中央注册表和執行入口 电话总机——知道每个分机号对应谁,负责转接
types.Tool 所有工具必须实现的接口 电器插头标准——确保所有工具都能"插入"注册表
AgentEngine 注册表的主要使用者 总机操作员——决定何时拨打哪个分机
ToolResult 工具执行的统一返回格式 工单回执——无论什么工具,都返回相同格式的结果

数据流详解

启动阶段

  1. 系统初始化时,各种工具(KnowledgeSearchToolWebSearchToolDataAnalysisTool 等)被创建
  2. 每个工具调用 registry.RegisterTool(tool) 将自己注册到注册表
  3. 注册表以工具名为键,工具实例为值,存储在内部 map[string]types.Tool

请求处理阶段

  1. AgentEngine 调用 registry.GetFunctionDefinitions() 获取所有工具的元数据
  2. 这些函数定义被发送给 LLM,让 LLM 知道可以调用哪些工具
  3. LLM 返回 ToolCall,包含工具名和参数

执行阶段

  1. AgentEngine 调用 registry.ExecuteTool(ctx, name, args)
  2. 注册表根据名字查找工具,调用 tool.Execute(ctx, args)
  3. 执行过程中记录日志(开始、完成、错误)
  4. 返回 ToolResultAgentEngine,后者将结果发布到 EventBus

核心组件深度解析

ToolRegistry

设计意图:提供一个安全、可观测的工具执行入口点。

内部机制

type ToolRegistry struct {
    tools map[string]types.Tool  // 工具名 -> 工具实例的映射
}

注册表内部使用一个简单的 map,这带来了 O(1) 的查找性能,但也意味着:

  • 工具名称必须唯一
  • 没有内置的工具分组或分类机制
  • 遍历顺序不确定(Go map 的特性)

关键方法分析

RegisterTool(tool types.Tool) —— 安全优先的注册策略

func (r *ToolRegistry) RegisterTool(tool types.Tool) {
    name := tool.Name()
    if _, exists := r.tools[name]; exists {
        return  // 已存在则忽略新注册
    }
    r.tools[name] = tool
}

这个看似简单的方法隐藏了重要的安全决策:

设计选择 替代方案 为什么选择当前方案
先注册者优先 后注册者覆盖 防止恶意工具通过晚注册来劫持合法工具
静默忽略重复 抛出错误 启动时工具注册顺序可能不确定,静默忽略更容错
无版本管理 支持工具版本 简化设计,工具名本身就是契约

实际影响:如果你发现某个工具的行为和预期不符,检查是否有其他地方注册了同名工具,且注册顺序更早。

ExecuteTool(ctx, name, args) —— 带可观测性的执行入口

这个方法不仅仅是转发调用,它还承担了可观测性网关的角色:

func (r *ToolRegistry) ExecuteTool(ctx context.Context, name string, args json.RawMessage) (*types.ToolResult, error) {
    // 1. 记录执行开始
    common.PipelineInfo(ctx, "AgentTool", "execute_start", ...)
    
    // 2. 查找工具
    tool, err := r.GetTool(name)
    if err != nil {
        // 3a. 工具不存在时返回"失败"的 ToolResult
        return &types.ToolResult{Success: false, Error: err.Error()}, err
    }
    
    // 4. 执行工具
    result, execErr := tool.Execute(ctx, args)
    
    // 5. 根据结果类型记录不同级别的日志
    if execErr != nil {
        common.PipelineError(ctx, "AgentTool", "execute_done", ...)
    } else if result != nil && !result.Success {
        common.PipelineWarn(ctx, "AgentTool", "execute_done", ...)
    } else {
        common.PipelineInfo(ctx, "AgentTool", "execute_done", ...)
    }
    
    return result, execErr
}

设计洞察

  • 即使工具查找失败,也返回一个 ToolResultSuccess: false),这保证了调用方可以统一处理结果,而不需要区分"工具不存在"和"工具执行失败"
  • 日志分为三个级别:Error(panic/异常)、Warn(业务逻辑失败,如搜索无结果)、Info(成功),这有助于后续的问题排查

Cleanup(ctx) —— 特例化的资源清理

func (r *ToolRegistry) Cleanup(ctx context.Context) {
    if tool, exists := r.tools[ToolDataAnalysis]; exists {
        if dataAnalysisTool, ok := tool.(*DataAnalysisTool); ok {
            dataAnalysisTool.Cleanup(ctx)
        }
    }
}

这是一个实用主义胜过纯粹性的设计:

  • 只有 DataAnalysisTool 需要清理(它会创建临时 SQL 表)
  • 没有定义通用的 Cleanup() 接口,因为其他工具不需要
  • 使用类型断言而非接口,避免了为单一用例增加接口复杂度

权衡:这种硬编码的方式耦合了注册表和具体工具类型,但考虑到清理需求的特殊性,这是一个合理的妥协。

types.Tool 接口

所有工具必须实现的四项契约:

type Tool interface {
    Name() string                              // 唯一标识符
    Description() string                       // 给 LLM 看的说明
    Parameters() json.RawMessage               // JSON Schema 格式的参数定义
    Execute(ctx context.Context, args json.RawMessage) (*ToolResult, error)
}

为什么是这四个方法?

方法 服务对象 设计原因
Name() 注册表、LLM 工具调用的"函数名",必须唯一
Description() LLM 帮助 LLM 理解何时使用该工具
Parameters() LLM JSON Schema 让 LLM 知道如何构造参数
Execute() 注册表 实际执行逻辑

关键洞察:前三个方法本质上是"元数据",用于在启动时构建给 LLM 的工具列表;Execute() 是运行时方法。这种分离让注册表可以在不加载工具完整实现的情况下(理论上)获取元数据,尽管当前实现中工具都是 eagerly loaded。

ToolResult

type ToolResult struct {
    Success bool                   `json:"success"`
    Output  string                 `json:"output"`          // 给人看的
    Data    map[string]interface{} `json:"data,omitempty"`  // 给程序用的
    Error   string                 `json:"error,omitempty"`
}

双通道输出设计

  • Output:自然语言描述,用于展示给用户或放入 LLM 上下文
  • Data:结构化数据,用于后续工具调用或程序化处理

这种设计源于一个观察:工具的结果既需要被人理解,也需要被其他工具消费。例如,KnowledgeSearchToolOutput 可能是"找到 5 篇相关文档",而 Data 则包含具体的文档 ID 列表供 GetDocumentInfoTool 使用。

依赖关系分析

本模块调用的组件

依赖 用途 耦合程度
types.Tool 工具接口契约 紧耦合——注册表的核心抽象
types.ToolResult 执行结果格式 紧耦合——所有工具必须返回此格式
types.FunctionDefinition LLM 工具定义格式 中耦合——用于构建给 LLM 的元数据
common.PipelineInfo/Error/Warn 日志记录 松耦合——可替换为其他日志系统
DataAnalysisTool 特殊清理逻辑 紧耦合——硬编码的类型检查

调用本模块的组件

调用方 使用方式 期望行为
AgentEngine 获取函数定义、执行工具 工具名必须存在,执行必须有结果
各工具实现 注册自己 注册后能被正确调用

数据契约

注册表 → AgentEngine

[]types.FunctionDefinition  // 启动时提供工具元数据
*types.ToolResult           // 执行时返回结果

AgentEngine → 注册表

string                      // 工具名
json.RawMessage             // 序列化的参数

隐式契约

  1. 工具名在注册后不可变
  2. Execute() 方法必须是线程安全的(多个会话可能并发调用)
  3. 工具执行不应有副作用影响其他工具

设计决策与权衡

1. 先注册者优先 vs 后注册者覆盖

选择:先注册者优先

原因:安全。如果允许后注册的工具覆盖已有工具,攻击者可以:

  1. 等待合法工具注册
  2. 注册同名恶意工具
  3. 劫持后续所有对该工具的调用

代价

  • 测试时难以 mock 工具(需要先 unregister 或重启)
  • 工具加载顺序变得重要

2. 集中式执行 vs 分布式执行

选择:集中式执行(所有工具通过注册表调用)

原因

  • 统一的日志和监控点
  • 便于实现限流、熔断等横切关注点
  • 简化 AgentEngine 的实现(不需要知道每个工具的具体类型)

代价

  • 注册表成为性能瓶颈(虽然当前 map 查找足够快)
  • 所有工具必须实现统一的接口

3. 特例化清理 vs 通用清理接口

选择:特例化清理(硬编码 DataAnalysisTool

原因

  • 只有这一个工具需要清理
  • 定义通用接口会增加所有工具的复杂度
  • 类型断言虽然不优雅,但清晰表达了"这是特例"

代价

  • 如果未来有其他工具需要清理,需要修改注册表
  • 违反了开闭原则

4. 静默忽略重复注册 vs 抛出错误

选择:静默忽略

原因

  • 工具注册顺序可能因初始化顺序不确定
  • 重复注册通常是配置问题,而非运行时错误
  • 启动失败比运行时失败更难调试

代价

  • 配置错误可能被掩盖
  • 开发者可能不知道自己的工具没有被注册

使用指南

注册工具

// 在应用启动时
registry := tools.NewToolRegistry()

// 创建工具实例
knowledgeSearchTool := &KnowledgeSearchTool{
    knowledgeBaseService: kbService,
    knowledgeService:     knowledgeService,
    // ...
}

// 注册工具
registry.RegisterTool(knowledgeSearchTool)

// 将注册表传递给 AgentEngine
engine := &AgentEngine{
    toolRegistry: registry,
    // ...
}

获取工具定义(供 LLM 使用)

// AgentEngine 在构建系统提示时调用
definitions := registry.GetFunctionDefinitions()
// 输出示例:
// [
//   {
//     "name": "knowledge_search",
//     "description": "搜索知识库中的相关内容",
//     "parameters": {"type": "object", "properties": {...}}
//   },
//   ...
// ]

执行工具

// AgentEngine 收到 LLM 的 ToolCall 后调用
result, err := registry.ExecuteTool(ctx, "knowledge_search", args)
if err != nil {
    // 工具不存在或执行异常
    log.Error(err)
}
if !result.Success {
    // 工具执行但业务逻辑失败
    log.Warn(result.Error)
}
// 使用 result.Output 或 result.Data

清理资源

// 在会话结束或应用关闭时
registry.Cleanup(ctx)
// 目前只清理 DataAnalysisTool 创建的临时表

边界情况与注意事项

1. 工具名冲突

现象:注册了一个工具,但执行时发现是另一个实现。

原因:先注册的工具覆盖了后注册的同名工具(实际上是后者被忽略)。

排查

// 检查已注册的工具列表
names := registry.ListTools()
for _, name := range names {
    tool, _ := registry.GetTool(name)
    fmt.Printf("%s: %T\n", name, tool)  // 查看实际类型
}

2. 并发执行安全

要求:工具实例可能被多个会话并发调用。

建议

  • 工具内部状态应该是会话隔离的(如 sessionID 字段)
  • 避免在工具中存储可变的全局状态
  • 使用 context.Context 传递会话相关信息

3. 错误处理的双重性

ExecuteTool 返回两个错误指示器:

result, err := registry.ExecuteTool(...)
// err != nil: 工具不存在或执行 panic
// result.Success == false: 工具执行了但业务逻辑失败

最佳实践

result, err := registry.ExecuteTool(ctx, name, args)
if err != nil {
    // 工具不存在,应该返回给用户友好提示
    return fmt.Errorf("工具 %s 不可用", name)
}
if !result.Success {
    // 工具执行失败,将错误信息返回给 LLM 让它重试或告知用户
    return fmt.Errorf("工具执行失败:%s", result.Error)
}
// 成功,处理 result.Output 或 result.Data

4. 参数序列化

工具参数是 json.RawMessage 类型,这意味着:

  • 调用方负责序列化参数
  • 工具内部负责反序列化
  • 没有统一的参数验证(每个工具自己验证)

风险:如果参数格式错误,工具可能在反序列化时 panic。

建议:在工具的 Execute() 方法中使用 json.Unmarshal 的返回值进行错误处理,而不是直接假设参数格式正确。

5. 清理的局限性

当前 Cleanup() 只处理 DataAnalysisTool。如果未来添加了其他需要清理的工具(如临时文件、外部连接),需要:

  1. 修改 Cleanup() 方法添加新的类型检查
  2. 或者重新设计为通用的 Cleanup() 接口

扩展指南

添加新工具

  1. 实现 types.Tool 接口:
type MyNewTool struct {
    tools.BaseTool  // 可选:继承 BaseTool 获得默认实现
    // 依赖的服务
    someService interfaces.SomeService
}

func (t *MyNewTool) Name() string {
    return "my_new_tool"
}

func (t *MyNewTool) Description() string {
    return "描述这个工具做什么,给 LLM 看的"
}

func (t *MyNewTool) Parameters() json.RawMessage {
    return json.RawMessage(`{
        "type": "object",
        "properties": {
            "param1": {"type": "string", "description": "..."}
        },
        "required": ["param1"]
    }`)
}

func (t *MyNewTool) Execute(ctx context.Context, args json.RawMessage) (*types.ToolResult, error) {
    // 1. 反序列化参数
    var input MyInput
    if err := json.Unmarshal(args, &input); err != nil {
        return &types.ToolResult{Success: false, Error: err.Error()}, nil
    }
    
    // 2. 执行逻辑
    // ...
    
    // 3. 返回结果
    return &types.ToolResult{
        Success: true,
        Output: "人类可读的结果",
        Data: map[string]interface{}{"key": "value"},
    }, nil
}
  1. 在应用启动时注册:
registry.RegisterTool(&MyNewTool{someService: svc})

修改工具行为

由于采用先注册者优先策略,修改工具行为需要:

  1. 找到工具注册的位置
  2. 确保你的修改版本先于其他版本注册
  3. 或者修改原有工具的实现(如果可能)

相关模块

On this page