首页 / 资讯中心 / 文章详情

.NET Agent Skills 集成实战:基于 Microsoft Agent Framework 的技能注册与调度

.NET Agent Skills 集成实战:基于 Microsoft Agent Framework 的技能注册与调度 ★ FEATURED ARTICLE
1. 这个项目到底在做什么先厘清 Agent Skills 的定位最近在捣鼓 .NET 上的 AI Agent 项目发现很多人把 Agent Skills 和普通的函数调用Function Calling、插件Plugin混为一谈结果一上来就踩坑。这个标题里说的基于 Microsoft Agent Framework 集成 Agent Skills核心不是教你调一个 API而是让你理解当一个 Agent 需要具备某种能力时你该怎么把能力包装成它可以自主调用的技能并且让这个技能可以被框架统一管理、统一调度。我自己的理解是Agent Skills 就是把一组原来需要你手动写死逻辑的操作封装成 Agent 可以按需决策、按需执行的模块。比如你的 Agent 要能查天气、要能算数学、要能操作某个文件——这些都可以做成一个 Skill。Agent 在跑任务时会根据用户的意图自动决定我现在要不要调用这个 Skill。这就是它比普通函数调用更智能的地方调用与否、调用顺序、参数填充都是模型在运行时决定的不是你在代码里提前写死的。那为什么选 Microsoft Agent Framework因为它是 .NET 生态里现阶段把Agent 运行时 技能注册 对话流程编排结合得比较顺手的方案。你不用自己从零去搭状态机、去管理多轮上下文、去处理工具的 schema 描述框架已经把很多脏活累活干了。对于做 .NET 的老手或者团队里本来就以 C# 为主的工程团队这个选型在长期维护上会比Python 一套、.NET 一套的混合栈省心很多。这篇文章适合谁适合已经跑通了一个最简单的 Agent Demo比如能聊天、能回答几个固定问题但不知道下一步怎么让它真正干点活的人。也适合架构师和技术负责人——你需要评估把 Agent Skills 引入现有 .NET 服务的成本和收益。文章里我不会贴完整的官方文档而是从我实际怎么一步步把它跑起来的角度写踩着坑的地方我会重点标出来。2. 设计思路拆解为什么是技能注册 运行时调度而不是硬编码2.1 先搞清楚 Agent、Skill、Tool 这三者的关系很多人一开始会被概念绕晕。我换一个生活化的类比Agent 是一个实习生你要交给他一个目标比如把这份数据整理成周报。Skill 是这个实习生掌握的技能比如会用 Excel、会写 PPT、会查数据库。Tool 则是执行技能时用到的工具Excel 软件、PPT 模板、数据库连接器。但在 Microsoft Agent Framework 里Skill 和 Tool 并不是严格的上下位关系。Skill 是一个更完整的能力单元它可以内部包含多个 Tool 的协作而 Tool 通常是最小的可调用单元。如果你只是给 Agent 挂一个孤立的函数那叫 Tool如果你把查询数据库 → 处理结果 → 生成摘要这一串流程打包成一个语义明确、Agent 一看就知道何时该用的模块那才叫 Skill。我在实际项目里一开始犯过糊涂把每个工具函数都注册成了Skill结果 Agent 在决策时出现了选择困难——待办事项一多它自己都分不清该先调哪个、参数该按什么格式传。后来我调整思路Skill 的粒度要跟用户目标对齐而不是跟函数粒度对齐。比如生成周报是一个 Skill内部包含拉取数据汇总指标调用模板渲染三个步骤Agent 只需要决定要不要生成周报至于里面每一步怎么串那是 Skill 内部的事。2.2 为什么要把 Skill 的描述写到位模型是靠描述做决策的这是我觉得最核心也最容易被忽略的一点。Agent 本身没有读过你的代码它只知道你给它的 Skill 描述Description和参数 Schema。也就是说你写 Description 的水平直接决定了 Agent 会不会在正确的场景调用它。我自己调试过印象很深的一次一个获取项目进度的 Skill我在描述里写的是GetProjectProgress。Agent 面对小明今天干了什么这种问题时完全没触发这个 Skill因为它觉得这个问题和 GetProjectProgress 无关。后来我把描述改成当用户查询某个成员在某段时间内的工作产出、任务完成比例、里程碑状态时使用。输入参数为成员姓名和时间范围。再试Agent 的命中率明显提升。这个细节值得反复打磨多花半小时写描述比事后调 prompt 调一晚上有效得多。2.3 选 Microsoft Agent Framework 而不自己造轮子的三个理由第一它帮你解决了并发状态下 Agent 状态的隔离。你自己写 Agent 循环时多用户同时访问session 状态非常容易串。框架内部的状态管理机制至少给了你一个可靠的基线而不是让你从零开始处理并发问题。第二它的 Skill 注册机制和 DI依赖注入结合得比较顺。在 .NET 里你本来就习惯用IServiceCollection管理依赖Agent Framework 允许你把 Skill 作为服务注册进去Agent 运行时再按需解析。这让 Skill 之间可以共享服务、共享配置而不是每个 Skill 自己 new 一堆依赖。第三它和 Semantic Kernel 的衔接比较平滑。如果你之前已经在用 Semantic Kernel 做 prompt 或插件管理你可以在 Agent Framework 的 Skill 内部调用 Semantic Kernel 的 function复用已有的 prompt 资产。这一点对已经在 .NET 生态里沉淀了东西的团队特别友好。3. 环境准备与最小工程搭建3.1 一套能跑起来的最简环境组合我这次用的组合是.NET 8 Microsoft.Extensions.AI如果没有这个库很多底层抽象要自己写 Microsoft.Agent.Framework 的预发布包。建议直接建一个 ASP.NET Core 空项目或者控制台项目都行——如果只是验证流程控制台更轻如果你最终要对外提供接口ASP.NET Core 更合适。我自己的习惯是先控制台跑通再迁到 Web API 里因为控制台里打印 Agent 的中间思考过程更方便。需要装的包大致如下具体版本以官方 NuGet 上的最新预发布版为准这里只列关键项dotnet add package Microsoft.Agent.Framework --prerelease dotnet add package Microsoft.Extensions.AI --prerelease dotnet add package Microsoft.Extensions.AI.OpenAI --prerelease如果你用的是 Azure OpenAI再加一个Microsoft.Extensions.AI.AzureOpenAI。这里提醒一句预发布包的 API 变动会比较频繁不同周之间的示例代码可能对不上。我的建议是锁定一个你验证过能跑的版本然后在工程里统一引用不要追新追到一半发现接口全变了。3.2 注册基础服务让 Agent 框架先认识聊天模型Agent Framework 本身不绑定模型厂商它通过IChatClient这个抽象来跟模型对话。所以在搭建工程时第一步是注册一个IChatClient到依赖注入容器里。下面是控制台程序里典型的最小配置using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.AI; using Microsoft.Agent.Framework; var builder Host.CreateApplicationBuilder(args); builder.Services.AddSingletonIChatClient(sp { // 这里以 OpenAI 兼容接口为例 return new OpenAIChatClient( new Uri(https://your-endpoint), your-api-key, your-model-name ); }); builder.Services.AddAgentFramework(); // 注册框架核心服务 var host builder.Build(); await host.RunAsync();这段代码的作用很简单把模型客户端注册成单例然后调用AddAgentFramework()把 Agent 运行时、状态管理、Skill 调度等核心组件注入容器。你不需要自己手动把框架的每一个接口都注册一遍一个扩展方法搞定这也是我为什么说框架省心的原因之一。如果你用的是 Azure OpenAIOpenAIChatClient换成AzureOpenAIChatClient参数调整为 endpoint、apiKey、deploymentName 即可。接口形状基本类似迁移成本不大。3.3 创建第一个最简单的 Agent 实例在注册好服务和模型之后创建一个 Agent 只需要指定它的指令Instruction和名称。这里的指令就是 Agent 的系统提示词决定了它的角色定位和决策边界。举一个非常简单的例子var agent new Agent( id: assistant-demo, name: 小助, instruction: 你是一个负责协助用户查询信息的助手。当你认为有必要时可以调用技能来获取数据。 );这里有一个经验别把指令写得太满给模型留出调用 Skill 的自主性。如果你在指令里写你必须永远先调用技能再回答遇到无关闲聊时模型也会强行调用反而拉低响应速度、增加 token 消耗。更好的做法是写清楚什么情况下可以调用而不是必须调用。4. 核心实操一步步把一个 Agent Skill 集成进来4.1 先写一个最简单的技能查询本机时间第一个 Skill 不建议做太复杂选一个参数少、逻辑明确、容易验证结果对错的功能。我选的是查询当前时间。这样做的好处是你能清楚地看到 Agent 是否在正确场景下调用了它一旦调用你也能立刻比对返回结果是否正确从而排查是模型决策问题还是代码执行问题。先定义一个输入和输出的类型public class TimeQueryInput { public string? TimeZone { get; set; } } public class TimeQueryOutput { public string CurrentTime { get; set; } string.Empty; public string TimeZoneApplied { get; set; } string.Empty; }然后定义一个 Skill 类实现框架规定的接口。不同版本的接口签名有差异但核心思想是一致的你告诉框架这个技能的执行函数是什么以及它的 JSON Schema 长什么样。下面是一个典型写法接口用ISkillpublic class CurrentTimeSkill : ISkill { public string Name GetCurrentTime; public string Description 获取指定时区的当前日期和时间。当用户询问现在几点、今天的日期等时间相关信息时使用。; public JsonElement InputSchema { get; } JsonDocument.Parse( { type: object, properties: { timeZone: { type: string, description: IANA 时区名称例如 Asia/Shanghai, nullable: true } } } ).RootElement; public async TaskSkillResult ExecuteAsync( JsonElement input, CancellationToken cancellationToken default) { var query input.DeserializeTimeQueryInput(); var tz string.IsNullOrEmpty(query?.TimeZone) ? Asia/Shanghai : query.TimeZone; var time TimeZoneInfo.ConvertTimeBySystemTimeZoneId(DateTime.UtcNow, tz); return SkillResult.Success(JsonSerializer.Serialize(new TimeQueryOutput { CurrentTime time.ToString(yyyy-MM-dd HH:mm:ss), TimeZoneApplied tz })); } }整个过程其实就是三步定义 schema告诉模型这个技能接收什么参数实现执行方法真正干活返回一个结构化结果让模型能读懂输出。框架不关心你这个技能内部是查数据库、调 HTTP 接口还是读文件它只需要你提供标准的输入输出边界。4.2 把 Skill 注册到 Agent 上这一步千万别漏Skill 类写完之后要把它挂在 Agent 实例上。最常用的方式是直接通过 Agent 的构造参数传入var agent new Agent( id: assistant-demo, name: 小助, instruction: 你是一个负责协助用户查询信息的助手。当你认为有必要时可以调用技能来获取数据。, skills: new ListISkill { new CurrentTimeSkill() } );这里有一个常见的坑有人会把 Skill 注册到 DI 容器里就以为 Agent 能自动发现它。至少在目前版本里Agent 不会自动扫描容器中的所有 Skill你必须显式告诉它你这个 Agent 能用哪几个技能。这反而是个好设计——不同 Agent 可以有不同的技能集合避免 A 场景的 Agent 误调 B 场景的技能。如果你有多个 Agent又希望每个 Agent 使用统一的技能集合可以抽一个工厂方法统一构建比如public static class AgentFactory { public static Agent CreateDefaultAgent() { var skills new ListISkill { new CurrentTimeSkill(), new WeatherSkill(), new ReportSkill() }; return new Agent( id: assistant-default, name: 小助, instruction: ..., skills: skills ); } }这样一来技能列表只需要维护一处以后加新技能时改一个地方就够了。4.3 运行 Agent看看模型是怎么自主选择调用技能的Skill 注册好之后就可以跑对话了。为了看到内部细节我会把ChatOptions里的工具相关设置打开并打印每次模型返回的中间内容。下面是一段完整的控制台调用代码var chatHistory new ListChatMessage(); while (true) { Console.Write(你); var userInput Console.ReadLine(); if (string.IsNullOrEmpty(userInput)) continue; chatHistory.Add(new ChatMessage(ChatRole.User, userInput)); var response await agent.RunAsync( chatHistory, new ChatOptions { Tools true, ToolMode ToolMode.Auto }, cancellationToken: CancellationToken.None ); chatHistory.Add(new ChatMessage(ChatRole.Assistant, response.Text)); Console.WriteLine($Agent{response.Text}); }我实际跑的时候输入现在几点了模型返回了类似这样的过程先解析出用户意图是查时间然后决定调用GetCurrentTime技能参数里的 timeZone 没有显式给出于是模型用了默认值。最终 Agent 把技能的返回结果组织成一句人话当前时间是 2025-06-01 14:23:45Asia/Shanghai。这里要注意的一个细节是在ToolMode.Auto模式下模型并不是必定调用技能而是按需调用。如果你希望强制先调用某个技能再做回答可以把 mode 改成ToolMode.Force并指定工具名称。但在多技能场景下我更推荐 Auto因为强制调用会压制模型的自主判断力。4.4 用一个稍微复杂的 Skill 演示多步骤能力打包只查时间太简单我们升级一下做一个查询项目进度并生成摘要的 Skill。这个 Skill 内部包含两个步骤——先从模拟的数据源拉取进度数据再根据数据生成一段摘要文本。关键是这两步对模型都不透明模型只看到我调用了一个技能它返回了一段摘要。实现上我模拟了一个数据源用一个字典存储成员的任务完成情况public class ProjectProgressInput { public string MemberName { get; set; } string.Empty; } public class ProjectProgressOutput { public string Summary { get; set; } string.Empty; } public class ProjectProgressSkill : ISkill { private static readonly Dictionarystring, List(string Task, int Percent) Data new() { [张三] new() { (需求评审, 100), (接口开发, 80), (联调测试, 40) }, [李四] new() { (UI 设计, 100), (组件封装, 90), (文档编写, 60) } }; public string Name GetProjectProgress; public string Description 查询指定成员在当前迭代中的任务完成度和整体进度摘要。当用户询问某人干了什么、进度如何时使用。; public JsonElement InputSchema JsonDocument.Parse( { type: object, properties: { memberName: { type: string, description: 成员姓名 } }, required: [memberName] } ).RootElement; public async TaskSkillResult ExecuteAsync(JsonElement input, CancellationToken ct) { var req input.DeserializeProjectProgressInput(); if (req is null || !Data.ContainsKey(req.MemberName)) { return SkillResult.Failure(未找到该成员的数据); } var tasks Data[req.MemberName]; var avg tasks.Average(t t.Percent); var summary ${req.MemberName} 当前迭代共 {tasks.Count} 个任务平均完成度 {avg:0}%其中已完成 {tasks.Count(t t.Percent 100)} 个。; return SkillResult.Success(JsonSerializer.Serialize(new ProjectProgressOutput { Summary summary })); } }这个例子想说明一件事Skill 内部可以是任意复杂度的逻辑但暴露给模型的一定是一个语义清晰的动作。模型不需要关心你从哪个表查数据、怎么算平均值它只需要知道查进度这个动作返回了什么结论然后基于结论回答用户。这其实就是 Agent Skills 跟普通 API 接口最大的差别——API 是给程序员用的Skill 是给 Agent 用的。5. 实操中的坑与排查技巧这些问题我基本都踩过5.1 描述写得不够好模型就是不调用你的 Skill这是最隐蔽、也最让人抓狂的问题。表现是你的 Skill 代码完全正确注册也没问题但运行起来模型就是视而不见。排查思路特别简单——先问自己如果我是模型我只看这个技能的 Name 和 Description我能判断出什么场景该用它吗我自己曾经写过这样一个描述Processes data related to projects. 这种描述给程序员看还行但给模型看等于没说。模型不知道related to projects具体指什么自然不敢贸然调用。改进后的写法是当用户希望获取项目列表、查询某个项目的状态、更新项目信息时调用此技能。如果用户只是闲聊或询问与项目无关的内容请不要调用。记住一个原则描述里要写清楚触发器trigger也要写清楚非触发器non-trigger。光有正向触发条件还不够负向排除条件能显著降低误调用率。5.2 参数 Schema 写错了模型传参传得乱七八糟Schema 是模型生成参数的唯一依据它必须清楚、完整、无歧义。最常见的问题是忘了标记required导致模型漏传必填参数或者参数描述写得太笼统模型不知道该填什么格式。我建议每个参数都写明三件事类型、含义、取值范围/格式。比如{ type: object, properties: { memberName: { type: string, description: 成员姓名必须是系统内已存在的成员例如张三, minLength: 1 }, startDate: { type: string, description: 开始日期格式为 YYYY-MM-DD例如 2025-06-01 } }, required: [memberName] }模型不是人它没有常识来判断字段含义你写得多细它就能传得多准。这跟你在团队里带新人一样需求文档写不清楚新人做出来的东西一定跑偏。5.3 技能内部抛异常Agent 直接懵了有一种情况是技能逻辑在某个边界条件上抛了异常Agent 拿到异常信息后不知道怎么处理于是开始编答案。这非常危险——模型会一本正经地告诉你一个错误的结果而你完全看不出它调用技能失败了。解决方法是在 Skill 内部做好异常捕获把错误转换成标准化的返回值而不是让异常直接冒泡到 Agent 运行时。public async TaskSkillResult ExecuteAsync(JsonElement input, CancellationToken ct) { try { // 业务逻辑 } catch (Exception ex) { return SkillResult.Failure($获取数据失败原因{ex.Message}); } }当模型收到一个Failure结果时它的反应通常是技能执行失败我如实告诉用户并询问下一步而不是强行编造一个答案。这虽然不是 100% 保险但比让异常裸奔好太多了。5.4 多技能冲突模型选错了技能当你有三五个技能时模型偶尔会把两个相似的技能搞混。比如你有查询天气和查询空气质量两个技能用户问的是今天适合跑步吗模型可能调用天气技能也可能调用空气质量技能。解决手段有两个。第一是加强描述的区分度明确说这个技能只负责 X不负责 Y。第二是考虑合并技能——如果两个技能经常被同时调用干脆合并成一个查询户外运动条件的技能内部同时查天气和空气质量对外只暴露一个入口。技能不是越多越好而是越贴合用户意图越好。5.5 官方示例版本不一致由于这个框架还处于快速迭代期我在网上找资料时经常遇到示例代码跟我本地编译不过的情况。这不是你写错了多半是包版本升级导致接口签名变了。碰到这种情况我的建议是先看你本地实际安装的包版本再看该版本的官方文档或 GitHub 示例别拿一个月前的教程硬套。如果你只是验证概念直接锁定一个已知可用的版本号把--prerelease换成具体版本再装一遍。6. 参数与经验参数速查我把常用的配置项都整理成了一份表6.1 Runtime 与模型参数参考配置项推荐值/说明踩坑提醒Temperature0.2 - 0.5偏高会导致模型编造技能调用结果ToolModeAutoForce 模式适合调试不适合生产MaxOutputTokens1024 以上输出太短时模型可能截断影响后续判断技能超时至少 30 秒模型调用技能是串行的超时太短会误报失败技能注册方式显式挂到 Agent 上不要依赖 DI 自动发现描述风格写触发条件和排除条件获取...当用户...比处理...好得多温度这个参数我单独说说。Agent 场景和纯文本生成场景不同Agent 需要的是确定性决策而不是创造性表达。温度太高模型可能今天调技能 A、明天同样的提问却调技能 B行为不可控。所以 Agent 类的温度我一般压到 0.3 以下宁可它回答得干巴巴也不要让它随机发挥。6.2 我总结的 Skill 描述模板下面是我自己写技能描述时常用的模板不一定适合所有场景但可以当起点当用户[触发场景描述]时使用此技能。 输入参数包括[参数清单及格式]。 返回结果为[返回内容说明]。 如果用户只是[排除场景]不要调用此技能。举个例子当用户询问某个成员的迭代进度任务完成情况开发状态时使用此技能。 输入参数包括成员姓名必须为系统的成员。 返回结果为该成员的任务列表和平均完成度摘要。 如果用户只是问项目整体或组织架构不要调用此技能。这套模板的好处是它强迫你把决策边界想清楚而不是随手写一句GetProgress糊弄模型。很多人技能命中率低根源不是模型笨而是你根本没给它足够清晰的决策依据。7. 进阶思路如何让 Agent Skills 在真实项目中发挥更大价值7.1 给技能加上缓存和降级策略真实项目里很多技能调用是重复的。比如用户的 Agent 每次都要查同一份报表数据如果每次都实时拉取响应慢不说还可能把下游系统压垮。我的做法是在技能内部加一层缓存数据源返回后缓存 5 分钟5 分钟内的重复查询直接走缓存。降级策略也很重要当技能依赖的外部服务挂了不要让 Agent 直接报错而是返回一个降级结果比如当前数据源暂时不可用以下为 2 小时前的快照。模型拿到这个降级结果会诚实地告诉用户数据不是最新的。这比一个冷冰冰的错误提示体验好很多。7.2 技能的多轮状态怎么管理有些技能天然需要多轮交互比如下单技能用户可能先说我要买一个白色的杯子Agent 需要确认颜色、确认数量、确认地址最后才真正下单。这时你不能让下单技能在一轮内完成所有事而要让 Agent 跟用户多轮澄清。我的经验是把下单拆成两个技能——收集订单信息和确认并提交订单。第一个技能负责解析用户意图、生成一个待确认的订单对象第二个技能只负责最终提交并且要求参数里带上用户已确认的标记。这样既避免了一次性要求用户提供太多信息也让流程更可控。框架自带的状态管理机制可以帮你保存对话上下文但技能内部如果依赖中间状态比如用户选了颜色还没选数量你需要把状态存到技能内部的服务里而不是依赖模型去记住。因为模型的上下文窗口再大也是有限的而且每次调用的 token 都会消耗成本。7.3 从单技能到技能编排让技能之间互相协作更进阶的玩法是让 Agent 的某一个技能内部去调用另一个技能。这在框架里是允许的只要你在技能实现里注入ISkillExecutor或者直接拿到其他技能实例。举个例子我有一个生成周报技能它内部需要先调用查询项目进度技能拿到数据再调用渲染 Markdown技能把数据变成周报格式。对模型来说它只需要决定要不要生成周报至于周报内部需要哪些数据、怎么渲染都是技能内部的事。这种技能组合拳模式我觉得是 Agent Skills 真正拉高智能上限的地方。它让 Agent 不是会一两个孤立的工具而是具备完成一个完整工作流的能力。7.4 多 Agent 场景下如何共享技能库如果你有多个 Agent比如客服 Agent、运维 Agent、数据分析 Agent它们之间的技能肯定有重叠也有各自私有的部分。我的建议是把技能拆成公共技能和私有技能两类。公共技能放在一个共享的项目目录里通过 DI 注册一次私有技能各自挂在对应 Agent 下。这样既能减少重复代码又能确保每个 Agent 的行为边界清晰——客服 Agent 不会去调用运维 Agent 的日志查询技能。框架在技能注册上没有做强制隔离所以这个边界要靠你自己的组织方式来保证。8. 关于这套方案我最后想聊几句说实话Agent Skills 这个概念并不神秘本质上就是把工具调用做得更规范、更语义化。但它的价值恰恰体现在这种规范化上当你把技能描述、参数 Schema、错误处理、超时控制都做扎实之后你得到的不仅仅是一个能调 API 的聊天机器人而是一个能在复杂任务里自主拆解步骤并逐步执行的智能体。从实际效果看真正拉开两个 Agent 项目体验差距的往往不是模型选得多强而是技能体系设计得多合理。模型是所有人的同一起跑线但把业务能力封装成一套 Agent 能理解的技能库这件事每个团队做出来的质量千差万别。如果你现在正准备开始做我的建议是先挑一个哪怕很简单的技能比如查时间、查天气完整跑通注册到调用的流程然后马上做一个参数稍微复杂一点、内部逻辑稍微多一点的技能比如查询项目进度并生成摘要。这两个技能跑完之后你对 Agent Skills 的掌控感会完全不同。最后留一个小技巧做技能描述时你可以在草稿里放两三个用户可能会问的示例问题然后看这些问题是否都能从你写的描述里推断出该调用这个技能。如果有一个示例问题连你自己都觉得模糊那就是描述需要补充的信号。这个技巧我几乎每次都用省去了很多来回调试的功夫。
阅读完成 · 觉得有帮助?
咨询建站