Engineering note
用反射把 C# 方法注册成工具:便利与边界
这是「Agent 工程实战」的第 6 篇。专题从一个能聊天、能调工具的 .NET Agent 出发,逐步补齐可靠性、安全、测试与发布能力。
本篇要解决的问题: 从特性、反射到工具元数据,建立自动发现能力,同时认识它的工程边界。
本章定位:本章拆解反射式工具发现的全过程,并讨论它为什么适合快速扩展、又为什么需要被注册中心包起来。你会从特性扫描走向可验证、可测试的工具定义。
建议阅读方式:先通读原理,再对照当前项目源码,最后完成本章实践。不要把反射当成魔法。把发现、描述、绑定和执行分别想清楚,后续接入依赖注入和插件会容易很多。
本章导读
本章拆解反射式工具发现的全过程,并讨论它为什么适合快速扩展、又为什么需要被注册中心包起来。你会从特性扫描走向可验证、可测试的工具定义。
本章采用“源码观察 → 概念拆解 → 工程改造 → 实践验证”的顺序。示例中的接口和代码骨架用于说明设计方向,真正提交代码时应结合项目当前状态逐步落地。
6.1 当前注册流程
HandleTools() 的逻辑是:
- 扫描当前程序集;
- 找到继承
AgentToolBase的类; - 找到带
[Tool]的类; - 找到带
[Function]的方法; - 找到带
[Parameter]的参数; - 创建模型需要的工具 schema;
- 把方法和实例保存到
_toolInstances。
这个设计的优点是新增工具只需要新增类和特性,不必修改中央注册表。
6.2 当前的工具写法
[Tool("文件读写工具")]
public class FileTool : AgentToolBase
{
[Function("读取指定文件中的内容")]
public async Task<AgentToolResult> ReadFile(
[Parameter("读取的文件路径")] string filePath)
{
// 业务实现
}
}
这就是本项目最重要的扩展点。
6.3 反射方案的隐患
当前实现适合原型,但有几个需要明确的问题:
_toolInstances以方法名为 key,不同类中同名方法会冲突;- 所有带
[Parameter]的参数都会被标成 required,无法表达可选参数; - 参数类型只映射了
Int32、Bool、String,数组、枚举、日期和对象会降级为 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,不再依赖 MethodInfo、Activator 和具体工具类。
6.5 本章小结
反射让工具接入很快,但它不应该泄露到整个系统。把反射收口到注册中心,才能逐步加入校验、权限、审计和插件能力。
6.6 反射注册的逐步执行过程
第一次阅读 HandleTools() 时,可以把它拆成四个动作:发现、描述、绑定、存储。
发现:通过程序集找到候选类型。候选类型必须是 class,并且继承 AgentToolBase。
描述:从 [Tool]、[Function] 和 [Parameter] 特性中生成模型看到的 schema。
绑定:把工具名映射到 MethodInfo 和实例,供后续执行。
存储:分别保存给模型的定义列表和程序内部的执行字典。
四个动作现在都写在同一个方法中,所以任何一个动作出错都会影响整个注册过程。重构时可以先拆成四个私有方法,不必立即引入复杂框架。
6.7 启动时应该验证什么
工具注册不是“找到就算成功”。启动时应拒绝:
- 空工具名或重复工具名;
- 没有描述的函数;
- 参数没有名称或类型无法转换;
- 不支持的返回类型;
- 无法实例化的工具类;
- 同名参数;
- 带有
ref、out或指针参数的方法。
失败时最好一次性列出所有问题,而不是修一个启动一次。这样添加新工具时,开发者可以快速得到完整反馈。
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 放进真实工作流,建议完成本篇的实战作业后再继续:每一步都应留下可验证的代码、测试或运行记录。