猪头少年 - 云南AI专家 - 云南独立开发者

Engineering note

用反射把 C# 方法注册成工具:便利与边界

这是「Agent 工程实战」的第 6 篇。专题从一个能聊天、能调工具的 .NET Agent 出发,逐步补齐可靠性、安全、测试与发布能力。

本篇要解决的问题: 从特性、反射到工具元数据,建立自动发现能力,同时认识它的工程边界。

返回专题路线


本章定位:本章拆解反射式工具发现的全过程,并讨论它为什么适合快速扩展、又为什么需要被注册中心包起来。你会从特性扫描走向可验证、可测试的工具定义。

建议阅读方式:先通读原理,再对照当前项目源码,最后完成本章实践。不要把反射当成魔法。把发现、描述、绑定和执行分别想清楚,后续接入依赖注入和插件会容易很多。

本章导读

本章拆解反射式工具发现的全过程,并讨论它为什么适合快速扩展、又为什么需要被注册中心包起来。你会从特性扫描走向可验证、可测试的工具定义。

本章采用“源码观察 → 概念拆解 → 工程改造 → 实践验证”的顺序。示例中的接口和代码骨架用于说明设计方向,真正提交代码时应结合项目当前状态逐步落地。

6.1 当前注册流程

HandleTools() 的逻辑是:

  1. 扫描当前程序集;
  2. 找到继承 AgentToolBase 的类;
  3. 找到带 [Tool] 的类;
  4. 找到带 [Function] 的方法;
  5. 找到带 [Parameter] 的参数;
  6. 创建模型需要的工具 schema;
  7. 把方法和实例保存到 _toolInstances

这个设计的优点是新增工具只需要新增类和特性,不必修改中央注册表。

6.2 当前的工具写法

[Tool("文件读写工具")]
public class FileTool : AgentToolBase
{
    [Function("读取指定文件中的内容")]
    public async Task<AgentToolResult> ReadFile(
        [Parameter("读取的文件路径")] string filePath)
    {
        // 业务实现
    }
}

这就是本项目最重要的扩展点。

6.3 反射方案的隐患

当前实现适合原型,但有几个需要明确的问题:

  • _toolInstances 以方法名为 key,不同类中同名方法会冲突;
  • 所有带 [Parameter] 的参数都会被标成 required,无法表达可选参数;
  • 参数类型只映射了 Int32BoolString,数组、枚举、日期和对象会降级为 string;
  • 参数绑定使用 kv.Values,没有根据参数名绑定,顺序变化可能导致调用错误;
  • 反射调用异常会穿透到 Agent 主循环;
  • 只扫描 Assembly.GetExecutingAssembly(),无法自动加载插件程序集;
  • 没有检查公开方法、静态方法、返回类型和构造函数是否符合约定。

6.4 注册中心的目标接口

后续可以把反射细节封装起来:

public interface IToolRegistry
{
    IReadOnlyList<ToolDefinition> Definitions { get; }
    bool TryGet(string name, out RegisteredTool tool);
}

public sealed record RegisteredTool(
    ToolDefinition Definition,
    Func<JsonElement, CancellationToken, Task<ToolResult>> Execute);

模型层只依赖 IToolRegistry,不再依赖 MethodInfoActivator 和具体工具类。

6.5 本章小结

反射让工具接入很快,但它不应该泄露到整个系统。把反射收口到注册中心,才能逐步加入校验、权限、审计和插件能力。

6.6 反射注册的逐步执行过程

第一次阅读 HandleTools() 时,可以把它拆成四个动作:发现、描述、绑定、存储。

发现:通过程序集找到候选类型。候选类型必须是 class,并且继承 AgentToolBase

描述:从 [Tool][Function][Parameter] 特性中生成模型看到的 schema。

绑定:把工具名映射到 MethodInfo 和实例,供后续执行。

存储:分别保存给模型的定义列表和程序内部的执行字典。

四个动作现在都写在同一个方法中,所以任何一个动作出错都会影响整个注册过程。重构时可以先拆成四个私有方法,不必立即引入复杂框架。

6.7 启动时应该验证什么

工具注册不是“找到就算成功”。启动时应拒绝:

  • 空工具名或重复工具名;
  • 没有描述的函数;
  • 参数没有名称或类型无法转换;
  • 不支持的返回类型;
  • 无法实例化的工具类;
  • 同名参数;
  • 带有 refout 或指针参数的方法。

失败时最好一次性列出所有问题,而不是修一个启动一次。这样添加新工具时,开发者可以快速得到完整反馈。

6.8 反射与依赖注入

当前使用 Activator.CreateInstance(toolType),这要求工具有公开的无参构造函数。后续工具可能依赖路径解析器、日志、数据库或审批服务,此时无参构造就不够了。

可以先定义一个简单工厂:

public interface IToolFactory
{
    object Create(Type toolType);
}

等项目引入 .NET 依赖注入后,再让容器负责创建工具实例。注册中心只关心拿到一个可执行对象,不关心实例如何构造。

6.9 练习:新增一个只读工具

新增 TimeTool,提供 GetCurrentTime,返回 UTC 时间。要求:

  • 工具名不能与已有方法冲突;
  • 参数为空;
  • 返回结果包含 ISO 8601 字符串;
  • 启动时 /tools 能看到它;
  • 写一个注册测试验证它被发现。

这个练习很小,但可以让你熟悉从特性、反射到模型 schema 的完整链路。

6.10 本章产出

本章完成后,应有一个可独立测试的 ToolRegistry,并能在启动阶段发现工具定义错误。后续的参数校验和权限判断都应从这里接入。

单篇实战作业

实践:新增一个无副作用的时间或版本工具,并为重复名称、缺少描述和无法实例化分别设计启动错误。

建议把作业拆成一个独立提交,并在提交说明中写清楚:改动前的行为、改动后的行为、验证命令、尚未解决的风险。不要把反射当成魔法。把发现、描述、绑定和执行分别想清楚,后续接入依赖注入和插件会容易很多。

章节复盘

复盘问题:如果同名工具来自两个程序集,注册中心应该启动失败、自动改名,还是按命名空间区分?

本章的完成标准不是把所有设计一次性做完,而是能把它变成项目中的一个明确边界,并为下一章留下可验证的接口。


下一篇:第 7 篇《文件与命令工具:Agent 的能力从哪里开始失控》

如果你正在把 Agent 放进真实工作流,建议完成本篇的实战作业后再继续:每一步都应留下可验证的代码、测试或运行记录。

Next step

不要停在单篇文章。

沿主专题继续阅读,查看同一问题从概念到实战的完整路径。

返回「Agent 工程实战」路线