做AI智能体开发这段时间踩过的坑不少沉淀下来的经验也不少。今天想聊的是我们团队在.NetCoreKevin框架上落地AgentFramework模块的一段实战记录核心任务是想办法把AI智能体的Skill技能和Tool工具做成可动态管理和加载的。如果你也在做.NET侧的智能体应用或者在纠结Agent的能力到底该怎么组织、怎么扩展这篇内容应该能给你一些可以直接抄作业的参考。这个需求不是从天上掉下来的。我们的业务线里面知识问答、制度学习助手、自动化工单处理这些场景越来越多Agent不能只会聊天它得能调数据库、跑接口、算指标、生成文档。一开始大家习惯了硬编码一个场景写一大坨方法调用结果场景一多代码变得又臭又硬。后来我们就决定在.NetCoreKevin框架里单独拉一个AgentFramework模块专门负责Agent能力资产的注册、发现、加载、执行和卸载把Skill和工具的动态管理做成一条完整的基础链路。这篇里我会按为什么这么设计、核心模型怎么建、动态加载怎么做、实操怎么跑通、踩了哪些坑的顺序来展开。全程基于我们实际使用的.NET 8环境代码会贴关键片段不会把无关的配置和样板代码都堆上来。1. 项目缘起与整体设计思路1.1 为什么非要做Skill动态管理先把这个为什么掰开揉碎讲清楚。Agent应用和传统API应用有个本质区别传统API的接口是预先定义好的调用方按契约来Agent应用里用户输入是开放的大模型要在运行时决定接下来该调用什么能力。这就意味着能力集合不能是封闭的它必须能被动态发现和组合。举一个特别真实的例子。我们做了一个制度条例学习助手一开始只有一个技能根据用户提问去检索制度文档然后生成回答。上线两周后业务方提了新需求——要能根据制度内容自动生成测试题还要能够在回答里附上制度原文的PDF附件。如果按老路子来就得改主程序加方法、改路由、重新发布。但如果用了动态技能管理这些增量能力就是一个个独立的Skill程序集开发完往技能目录一丢框架自动扫描注册零侵入。所以动态管理的价值不在于炫技而是解决三个很实际的问题扩展性新增能力不动主程序团队并行开发互不阻塞。稳定性出问题的Skill可以单独卸载或禁用不至于让整个Agent服务挂掉。可运营性在面向具体业务时运营人员可以按需启用、停用某些技能不用麻烦研发发版。这个思路和现在很多插件化系统是一致的只不过在AI智能体场景下插件的主体从一个普通的实现类变成了带语义描述的Skill。1.2 AgentFramework在NetCoreKevin中的定位NetCoreKevin框架在我们团队内部承担的是企业级后端底座的角色各种基础能力都有对应模块。AgentFramework是后来补上的一块拼图它不直接写业务而是专门做一件事管理Agent的能力资产。这里我们分了两层概念Skill技能面向具体任务的完整能力单元。比如制度查询技能、销售数据分析技能。它是编排层内部可以定义步骤也可以调用若干工具。Tool工具原子化的动作。比如调用ERP查询接口、执行只读SQL、发送邮件、生成Word文档。它是执行层一个Skill执行过程中可以按需调用多个Tool。为什么要分层因为复用性。很多工具是通用的比如发送邮件这个动作几乎每个技能都会用。如果工具和技能混在一起每个技能都自己实现一遍邮件逻辑就是灾难。分开之后工具可以作为底层服务被多个技能共享而技能专注自己的编排逻辑。整个AgentFramework模块的工作流可以概括成四段注册、发现、调度、执行。注册是指把Skill和Tool登记到框架里发现是指根据用户请求和上下文找到合适的Skill调度是指在Skill内部规划工具调用顺序执行则是真正运行。动态管理和加载就落在注册和发现这两段上。1.3 技术选型的几个关键决策这个模块的技术选型我们反复推敲过最终确定下来几条关键路径用Attribute标记 反射扫描而不是XML配置文件。配置文件方式最大的问题是改配置容易出错而且配置和代码分离后类型关系不直观。我们在Skill实现类上用自定义Attribute标记名称、描述等信息框架启动时通过反射拿到这些元数据。改一个技能就改类文件本身类型安全还少一层维护成本。用AssemblyLoadContext实现技能程序集的加载与卸载。.NET里程序集默认加载进默认上下文后没法卸载。要实现真正的动态也就是热更新就必须用可收集的AssemblyLoadContext。这个选择让后续的技能升级和故障隔离成为可能代价是代码复杂度上来了后面我会详细讲。用MetadataLoadContext做预扫描。不是每个dll都得加载进运行进程才可以看到它的类型先用MetadataLoadContext以只读方式看程序集元数据过滤出我们关心的类型再做真实加载。这样能避免无效程序集污染进程。注册表 依赖注入双层管理。注册表管理有哪些能力元数据和类型信息依赖注入容器管理能力实例及其依赖。两者结合既能动态增删注册项又能让Skill实例获得框架的完整依赖注入支持。这四条选型贯穿了整个实现后面每一段代码都是围绕它们展开的。2. Skill与工具的核心模型设计2.1 Skill抽象一个接口撑起所有能力整个模块的起点是一套抽象接口。我们设计得比较克制没有一上来就搞复杂的继承体系。核心就是ISkill和ITool两个接口public interface ISkill { string Name { get; } string Description { get; } string[] Tags { get; } TaskSkillResult ExecuteAsync(SkillContext context, CancellationToken ct); } public interface ITool { string Name { get; } string Description { get; } IReadOnlyListToolParameter Parameters { get; } TaskToolResult InvokeAsync(ToolCall call, CancellationToken ct); }这里插一句为什么接口要设计得这么简单。因为大模型是通过描述来选择技能的并不是直接绑方法。模型看到的是一段文字说明和参数结构它判断这个技能适不适合当前用户问题然后决定调用谁。所以接口的职责很纯粹把能力暴露成一个可供模型识别的契约。SkillContext里我们塞了哪些东西用户原始输入、解析后的意图、历史消息摘要、作用域内的配置项、一个用于取消的CancellationToken。不要小看这个上下文设计它是让Skill独立执行的基础。一个技能拿到上下文后不依赖外部全局状态就能完成自己的任务。2.2 能力元数据与JSON Schema转换光有接口还不够因为大模型API不认C#对象它认的是JSON Schema。所以Skill和Tool的元数据最终都要转换成一套标准的JSON结构塞给大模型API的tools参数。我们定义了一个ToolDefinition类专门承载这种描述信息public sealed class ToolDefinition { public string Name { get; set; } public string Description { get; set; } public JsonObject Parameters { get; set; } public string Type { get; set; } // skill or tool }Parameters就是一个JsonObject标准格式就是function calling要求的parameters结构。比如一个查询制度文档的工具它的parameters大概长这样{ type: object, properties: { keyword: { type: string, description: 查询关键词 }, limit: { type: integer, description: 返回条数默认10 } }, required: [keyword] }那这个JSON从哪来两种途径。一种是Skill实现者手动写一个返回JsonObject的方法另一种也是我们推荐的是写一个小的表达式树解析器把强类型参数类自动转换成JSON Schema。后面这套转换器对工具类特别好用因为工具的入参本来就是强类型DTO。2.3 插件化目录规范动态管理的落点之一是约定的插件目录。我们规定了一个skills根目录所有技能程序集打包后以技能名/技能版本的目录结构放进去/skills /DocQuerySkill /1.0.0 DocQuerySkill.dll manifest.json /1.1.0 DocQuerySkill.dll manifest.json /SalesReportSkill /1.0.0 SalesReportSkill.dll manifest.jsonmanifest.json里包含技能名、版本、入口类型、依赖的程序集列表、作者和描述。这个文件不参与核心逻辑它有两个辅助作用一是在加载前做校验避免把一个完全不相关的dll硬塞进来二是给运维在管理界面上看信息。指定版本目录的做法让多版本并存成为可能。比如正在运行的是1.0.0我们把1.1.0部署上去框架可以在下次加载时优雅切换到新版本而不用停服务。这在传统单体应用里想都不敢想但在AI智能体这种迭代频繁的场景里价值非常直接。3. 动态管理与加载的完整实现3.1 程序集扫描与反射加载核心代码绕不开的核心环节就是扫描和加载。前面说过我们用了MetadataLoadContext做预扫描再用AssemblyLoadContext做真实加载。扫描的目的只有一个在把程序集加载进进程之前先确认它确实是我们要的技能程序集。预扫描的核心逻辑大致是这样public IEnumerableSkillAssemblyInfo ScanSkillAssemblies(string rootPath) { var result new ListSkillAssemblyInfo(); var files Directory.GetFiles(rootPath, *.dll, SearchOption.AllDirectories); foreach (var file in files) { // 用只读的元数据上下文扫描避免程序集真实加载 var paths new[] { Path.GetDirectoryName(file)!, Path.GetDirectoryName(typeof(object).Assembly.Location)! }; using var mlc new MetadataLoadContext(new PathAssemblyResolver(paths)); Assembly assembly; try { assembly mlc.LoadFromAssemblyPath(file); } catch (BadImageFormatException) { continue; } var skillTypes assembly.GetTypes() .Where(t t.IsClass !t.IsAbstract) .Where(t t.GetCustomAttributeSkillAttribute() ! null) .ToList(); if (skillTypes.Count 0) { result.Add(new SkillAssemblyInfo(file, skillTypes.Select(t t.FullName!).ToList())); } } return result; }这段代码的核心意义在于它只是看不是装。只有当类型信息确认匹配后才进入下一步真实加载。这样即使插件目录里混进了一些不相关的dll比如某个依赖库不小心被拷贝进来了也不会被误注册更不会引起类型加载冲突。真实加载则用AssemblyLoadContext来做。我们封装了一个SkillAssemblyLoadContext继承自AssemblyLoadContext构造时传入插件目录路径并设置isCollectible为true这样才能支持后续卸载。加载时把dll文件流读到内存用LoadFromStream加载避免文件被占用导致后续热更新时无法覆盖文件。3.2 依赖注入容器如何配合Skills加载进来之后最自然的问题是Skill里面可能需要注入日志服务、配置服务、HttpClient、数据库上下文怎么办我们的做法是在扫描和加载之后动态把Skill类型和Tool类型注册进NetCoreKevin框架的ServiceCollection里。框架在构建Agent服务的时候会额外调用一个RegisterAgentModules扩展方法把扫描到的类型按需注册public static IServiceCollection AddAgentSkills(this IServiceCollection services, string skillsRoot) { var scanner new SkillScanner(); var assemblies scanner.ScanSkillAssemblies(skillsRoot); foreach (var info in assemblies) { var loadContext new SkillAssemblyLoadContext(skillsRoot); var assembly loadContext.LoadFromAssemblyPath(info.AssemblyPath); foreach (var typeName in info.SkillTypeNames) { var type assembly.GetType(typeName)!; services.AddTransient(typeof(ISkill), type); } // 记录loadContext与服务的对应关系便于热更新时准确卸载 skillContexts.Add(new SkillContextRecord(loadContext, info.AssemblyPath, info.SkillTypeNames)); } return services; }这里有个细节如果一个程序集里同时有Skill和Tool我们分别注册到ISkill和ITool的集合里。后面Agent执行时从DI容器拉取IEnumerable 和IEnumerable 再配合注册表使用。为什么注册表还要一份因为DI容器本身并不擅长做根据名字找服务。Agent拿到大模型返回的function name需要一个快速索引名称到类型信息的映射。注册表就是一个ConcurrentDictionarystring, RegisteredSkill在注册的同时把元数据索引好运行时的查询就是一次字典查找非常快。3.3 热更新FileSystemWatcher与AssemblyLoadContext动态管理最有含金量的部分是热更新。我们的目标很简单技能程序集更新后不需要重启Agent服务框架能感知到变更并且优雅地切换版本。做法是用FileSystemWatcher监控技能目录的变动事件private void WatchSkillsDirectory(string path) { _watcher new FileSystemWatcher(path, *.dll) { IncludeSubdirectories true, NotifyFilter NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size }; _watcher.Changed OnSkillFileChanged; _watcher.Created OnSkillFileChanged; _watcher.Deleted OnSkillFileChanged; _watcher.Renamed OnSkillFileRenamed; _watcher.EnableRaisingEvents true; }事件触发后不是立刻重新加载而是先做一个300毫秒的防抖debounce。为什么因为文件拷贝是一个持续过程可能dll还没拷完就触发了事件这时候强行加载只能得到一个损坏的程序集。防抖之后再执行一次完整的扫描差异、卸载旧版本、注册新版本流程。卸载旧版本时最关键的一步是让程序集真正从内存中释放。AssemblyLoadContext提供了Unload方法但前提是该上下文里的所有类型实例都已经被释放没有任何引用残留。这就是前面提到的Skill实现类不能持有静态变量、不能把全局事件挂在自己身上的原因。我们把技能代码不是长期驻留对象写进了开发规范所有Skill都尽量设计成无状态或状态外部化。3.4 与大模型API的tools参数衔接动态加载的最终目的是要让大模型看到这些能力并调用它们。所以必须把注册表里的能力描述转成模型可识别的tools参数。我们在Agent调用链中加入了一个BuildToolsPayload的方法遍历当前所有已注册的Skill和Tool把元数据转换成ToolDefinition再序列化成大模型API需要的格式。大模型返回的内容里如果带了function_call或tool_use指令我们解析出工具名和参数JSON然后做两件事根据工具名在注册表中查找到对应的执行器。把参数JSON绑定到具体的强类型DTO上执行调用把结果回传给模型生成最终答案。到这一步整条链路就算闭环了。用户提问 - Agent拼接上下文和tools - 大模型返回工具调用 - 框架路由到Skill/Tool - 执行并返回结果。4. 实操过程与核心环节实现4.1 从零搭建一个Skill项目模板为了让团队新成员快速上手我把Skill项目的脚手架做成了模板。简单说一个新的Skill项目只需要四步创建类库项目目标框架和Agent服务保持一致我们用的是net8.0。引用NetCoreKevin.AgentFramework.Abstractions包这个包只含接口、Attribute和基础类型。创建一个实现ISkill的类打上SkillAttribute标记写好描述。编译输出到技能目录对应的子文件夹。类库项目做成了独立进程外的插件形态所以引用的包要尽量精简。Abstractions包设计成零依赖只包含接口和基类Skill项目里不要引入业务服务的dll一切依赖通过框架的DI容器在运行时提供给它。在csproj里有一个关键配置就是把生成输出复制到统一技能目录免去每次手动拷贝PropertyGroup TargetFrameworknet8.0/TargetFramework Nullableenable/Nullable /PropertyGroup Target NameCopyToSkillsFolder AfterTargetsBuild Copy SourceFiles$(TargetPath) DestinationFolder$(SolutionDir)skills\MySkill\$(Version) / /Target4.2 注册一个自己的Skill制度查询助手实战拿我们做过的制度条例学习助手里的一个技能来举例。这个技能做的事情是接收用户关于制度条文的提问检索全文库返回最相关的若干条文和原文摘录。先定义参数类public class PolicyQueryParams { public string Keyword { get; set; } string.Empty; public int Limit { get; set; } 5; public string? Department { get; set; } }然后是技能主体[Skill( Name PolicyQuery, Description 查询企业内部制度条例相关内容可用于制度问答、条文检索、合规判断等场景, Tags new[] { 制度, 条例, 知识库 } )] public class PolicyQuerySkill : ISkill { private readonly IPolicySearchService _searchService; private readonly ILoggerPolicyQuerySkill _logger; public PolicyQuerySkill(IPolicySearchService searchService, ILoggerPolicyQuerySkill logger) { _searchService searchService; _logger logger; } public string Name PolicyQuery; public string Description 查询企业内部制度条例相关内容可用于制度问答、条文检索、合规判断等场景; public string[] Tags new[] { 制度, 条例, 知识库 }; public async TaskSkillResult ExecuteAsync(SkillContext context, CancellationToken ct) { var query JsonSerializer.DeserializePolicyQueryParams(context.Arguments.ToString()); _logger.LogInformation(执行制度查询{Keyword}数量限制{Limit}, query?.Keyword, query?.Limit); var results await _searchService.SearchAsync(query!.Keyword, query.Limit, query.Department, ct); return new SkillResult { Succeeded true, Data JsonSerializer.SerializeToNode(new { results }), Message 查询成功 }; } }这里有几个细节值得说一下。Attribute里的Tags字段不是摆设我们注册表做能力筛选时会拿Tags做粗粒度过滤。如果注册了十几个技能每次请求都把全部技能描述发给大模型既费token又可能让模型误选。所以我们加了一个预筛选逻辑根据用户问题和Tags的相关性只把可能命中的3到5个技能描述放进tools参数。实现不复杂用简单的关键词匹配加Embedding相似度就够了但对效果和成本的影响非常大。4.3 Agent完整调用链路的代码走读为了让这篇文章不悬在空中我把Agent调用的最小链路串一遍。整个流程从用户的请求进来开始。第一步AgentHttpHandler接收请求把消息交给AgentOrchestrator。AgentOrchestrator做的事情是先做技能预筛选从注册表里拿候选技能列表。第二步组装请求消息体把候选技能的ToolDefinition转成JSON拼到messages后面发给大模型。这里拼接的tools参数要注意得遵循各家API对tools的格式要求我们现在做了一个小的Adapter层来适配不同服务商核心结构基本一致。第三步大模型返回响应如果是一个普通的文本回复直接返回用户。如果带function_call解析出函数名和参数。第四步框架根据函数名找到Skill执行器。这个查找是通过注册表做的O(1)的字典查询。第五步执行Skill把结果序列化成一个工具执行结果消息附带到会话上下文中再次调用大模型让模型基于真实执行结果生成最终回答。这个链路本身不算新颖但加上动态加载后它就有了一个杀手级特性每一次请求时候选技能集合都是实时的。也就是说同一个Agent服务上午还在跑3个技能下午运维悄悄部署了第4个技能下一次请求它就能被模型看到并调用全程不需要发版。为了让大家看得更直观这五个步骤的伪代码可以压缩成这样public async TaskAgentResponse HandleAsync(string userMessage, CancellationToken ct) { var candidates _registry.PreFilter(userMessage).ToList(); var toolsPayload _toolPayloadBuilder.Build(candidates); var firstResponse await _llmClient.ChatAsync(toolsPayload, userMessage, ct); if (!firstResponse.TryGetToolCall(out var toolCall)) return AgentResponse.FromText(firstResponse.Content); var skill _registry.Find(toolCall.FunctionName); var result await skill.ExecuteAsync(toolCall.Arguments, ct); var finalResponse await _llmClient.ChatAsync( toolsPayload, new[] { userMessage, toolCall.Message, result.ToMessage() }, ct); return AgentResponse.FromText(finalResponse.Content); }别笑这个实现看起来轻巧但实际生产环境里两件麻烦事都藏在细节里一个是Streaming模式下tool call的解析另一个是对大模型幻觉式工具调用的容错。这两个问题我放到后面排查章节细说。5. 常见问题与排查技巧实录5.1 程序集加载失败一箩筐的坑动态加载听起来很酷落地时第一批坑全是程序集层面的。最典型的是插件dll依赖了外部包但这个包没有被拷贝到技能目录。运行时直接抛FileNotFoundException异常信息还特别误导人只说找不到xxx.dll排查半天才发现是依赖缺失。解决办法有两个层面一个治标一个治本。治标的方法是在SkillAssemblyLoadContext里注册Resolving事件在程序集解析失败时去公共依赖目录里找一圈loadContext.Resolving (ctx, name) { var candidate Path.Combine(sharedLibPath, ${name.Name}.dll); return File.Exists(candidate) ? ctx.LoadFromAssemblyPath(candidate) : null; };治本的方法是做技能依赖分析。我们在manifest.json里声明技能依赖的程序集列表发布工具在打包时把这些依赖一并复制到技能目录下的lib子目录。框架扫描时优先从本技能目录加载依赖找不到再去公共依赖目录。这个约定解决了一大半加载问题。还有一个坑是重复加载不同版本的同一个依赖。两个技能都依赖了同一个第三方库但版本不同动态加载下很容易产生类型不匹配。我们后面用了一个取巧但有效的方案公共依赖统一采用高版本兼容策略框架层把最常用的公共依赖锁定到一个共享目录技能内不允许重复携带。特殊版本冲突时才允许技能目录内保留私有依赖但要在文档里标注清楚。5.2 生命周期管理踩过的那些坑热更新听起来省事但生命周期管不好就是事故现场。我们最早一批测试技能里有一个技能用了静态的HttpClient缓存第一次加载后把HttpClient存进了静态字段。结果更新这个技能时旧程序集卸载失败静态字段一直握着旧类型不放。内存持续上涨而且新老版本的行为在同一个进程里并存极其诡异。后来我们定了几条死规矩Skill实现类禁止使用静态可变字段。如果必须要缓存连接或数据缓存对象放到框架提供的ICacheService里由框架管理生命周期。Skill里的HttpClient统一通过IHttpClientFactory获取禁止自己new出来再存起来。事件订阅必须在Dispose里退订最好用弱事件模式。同时在AgentFramework里增加了一个技能回收站机制。卸载时先把技能实例标记为不可用新请求不再路由到它再给一个宽限期我们设的是30秒确保正在执行的请求处理完最后才真正卸载AssemblyLoadContext并调用GC.Collect做一次主动回收。这个宽限期设计很关键否则会频繁出现执行到一半技能被卸载的诡异错误。5.3 调试与观测技巧动态加载的特性让调试难度上了一个台阶。最烦人的是代码明明是我刚写的为什么还用着旧逻辑。这里我养成了一个习惯把所有技能加载和更新事件都打进结构化日志包括程序集路径、加载的上下文ID、注册的技能列表、卸载的触发原因。排查问题的时候先看时间线再定位代码。围绕观测我们还给AgentFramework接了一套OpenTelemetry埋点。每个技能的调用耗时、成功率、参数大小都作为Metric暴露。这不止是技术洁癖它对运营特别重要——哪个技能被频繁调用、哪个技能老报错一眼就能在监控面板上看到。最后分享一个我个人的土办法开发环境下在Agent服务里留一个debug接口可以手动触发技能目录的全量重扫描和加载同时返回所有已注册技能的状态。遇到我明明更新了dll模型就是不调用这种问题先调这个接口看看技能是否真的在注册表里、描述信息是否正常。多数时候答案都在这个接口里。做这套东西最深的体会是AI智能体的工程化和传统后端工程化一样都是先把边界划清楚再把扩展点留出来。Skill和工具的动态管理不是一个新概念但当我们把它和.NET的AssemblyLoadContext、依赖注入、插件目录规范结合起来它就成了一个能真正支撑业务持续迭代的底盘。
阅读完成 · 觉得有帮助?