
第一次看到“使用 C# 和 Microsoft Agent Framework 构建 AI 代理”这个标题我一度觉得这不过是把聊天接口包了一层壳。真正动手做完一个能自己调用工具、自己维护上下文、还能稳定跑在服务里的代理之后我才意识到它和普通聊天机器人的差距有多大。这篇文章我想把从零搭建一个 AI 代理的完整过程写出来包括框架选型、工具调用设计、上下文管理、流式输出以及一堆只有实际跑起来才会遇到的坑。不管你是 .NET 老手还是刚接触 AI 开发想找切入口的新人只要想用 C# 做一个“能干活”的智能代理这篇都可以当起点。1. 先把项目看明白这不只是一个聊天机器人1.1 AI 代理到底“能”在哪很多人把 AI 代理和聊天机器人混为一谈这是第一个需要掰开的概念。聊天机器人擅长的是“生成文字”你问它一句它回你一段话核心能力是语言模型本身的生成质量。AI 代理则是在文字生成之上多了一层“做事”的能力它能根据用户的目标自己决定调用哪个工具、按什么顺序执行、拿到结果之后要不要继续追问直到最终把任务闭环。我用一个偏生活的例子解释传统聊天机器人像是一本自助手册你翻到哪一页它就给你读哪一页AI 代理更像一个有工具箱的办事员你说“帮我把空调温度调到 26 度并记录到工单里”它不会只回复你“好的”而是会真的去调用温控接口再把工单创建结果反馈给你并且每一步都保留可追踪的记录。这背后的核心循环其实不复杂感知输入 → 规划动作 → 调用工具 → 观察结果 → 再次规划。这个循环在代码里就是“多轮消息追加 函数调用 结果回填”。听起来简单但真正写起来细节都在边界情况里工具参数错了怎么办、上下文太长了怎么办、代理重复调用工具怎么办。这些坑我会在第 4 部分逐个展开。1.2 为什么是 C# 和 Microsoft Agent Framework选型的时候我其实纠结过一阵。Python 生态在 AI 领域确实热闹但这个项目最终要嵌进一套以 .NET 为核心的企业服务架构里团队里也都是 C# 背景硬上 Python 意味着要维护两套技术栈不值得。C# 这边的优势很直接强类型对“工具调用”的约束力非常强。模型返回的 JSON 参数可以反序列化成强类型对象减少运行时错误依赖注入和异步编程模型可以让代理服务和现有业务模块无缝集成部署上也能直接进容器或者 Windows 服务运维手感统一。Microsoft Agent Framework 在这套体系里的定位是解决“怎么把模型能力编排成代理工作流”的问题。它和底层的 Microsoft.Extensions.AI 紧密结合上手路径是先用 IChatClient 抽象接上模型再把工具方法和系统提示词组织成 Agent最后通过状态管理与会话编排让它真正“代理”用户完成任务。它不是要替代语言模型而是要替你把“模型调用 工具注册 会话状态”这些脏活规范化。1.3 这类项目适合落在什么场景我在项目规划阶段列过一张场景清单最后选定了三类适合用 C# 代理去做的业务工单处理与内部服务用户用自然语言描述问题代理解析后调用工单系统接口完成创建、流转、关闭整个流程有日志、有回溯。数据查询与报表解读用户问“上季度华东区销售额是多少”代理先判断需要哪个报表工具调用查询接口再把结果转成一段人话。运维辅助诊断代理读取日志片段、调用状态检查工具把异常现象汇总成诊断结论而不是让运维工程师刷半天监控面板。但也要泼一盆冷水不是所有场景都该上代理。固定流程、输入输出都是结构化数据的任务用传统代码跑得更快、更便宜、更可靠。代理的价值在于“目标不固定、路径需要动态决策”的任务。判断标准我总结成一句话如果这个流程的下一步分支永远取决于上一步的结果而且分支数量多到没法用硬编码 cover 住那就该考虑用代理。2. 环境准备与整体架构设计2.1 基础环境与依赖安装正式动手前环境这一块最好一次配齐。我用的基础组合是.NET 8 SDK长期支持版本项目运行时用起来最稳。Visual Studio 2022 或者 VS Code C# Dev Kit看个人习惯命令行模式也可以。一个 OpenAI 兼容的模型接口或者任何实现了统一聊天抽象的服务开发阶段用某个远端模型接口即可。NuGet 包方面核心是 Microsoft.Extensions.AI 以及对应的 OpenAI 适配包Agent 框架相关的包也一并装上。创建项目的命令很简单dotnet new console -n AgentDemo cd AgentDemo dotnet add package Microsoft.Extensions.AI dotnet add package Microsoft.Extensions.AI.OpenAI我建议不要一上来就套复杂的模板。先用控制台把最小闭环跑通再往 Web API 迁移调试成本会低很多。这里多说一句为什么选择 Microsoft.Extensions.AI 作为底座它提供的是一个模型厂商无关的抽象层IChatClient 就是统一入口后面想换模型只需要换掉注册代码业务层不用动。这个设计在企业项目里非常重要因为模型选型经常变接口抽象能省掉很大一块返工成本。2.2 必须先搞懂的四个核心概念如果你直接翻框架文档会被一堆术语绕晕。我按实际写的顺序整理出四个必须先搞懂的概念概念角色说明类比IChatClient和模型对话的客户端抽象所有消息都从它进出打电话的话筒ChatMessage一条对话消息包含 Role、Content 和附加数据电话里说的一句话ChatHistory按顺序存放消息的集合就是对话历史通话录音工具调用Function Call模型在需要时“请求”执行指定 C# 方法办事员填写的请款单还有两个偏业务层面的概念Agent 是组装体包含系统提示词、工具集合、会话状态策略Turn 是一次“用户发起问题到最终得到满意答复”的完整过程其中可能包含多轮工具调用。这些概念不需要第一次就背熟但写代码时心里要有数你要负责维护 ChatHistory模型才能“记得”上下文你要注册工具并处理调用代理才能“做事”你管理 Turn 边界代理才不会失控。2.3 项目目录与服务注册项目规模变大之后我推荐先把目录分清楚。下面是我实际用下来比较顺的结构AgentDemo/ ├── Agents/ │ └── AssistantAgent.cs ├── Tools/ │ ├── InventoryTool.cs │ └── TicketTool.cs ├── Services/ │ └── AgentService.cs ├── Program.cs └── appsettings.jsonAgents 放系统提示词和代理定义Tools 放可被模型调用的工具方法Services 放会话编排逻辑。这么分不是形式主义而是为了后面加新工具、新代理时不至于把代码堆成一坨。服务注册我用 Microsoft.Extensions.DependencyInjection 来做主要代码如下var services new ServiceCollection(); services.AddSingletonIChatClient(sp { // 这里用工厂方法创建模型客户端密钥从配置读取 return new OpenAIChatClient(new HttpClient(), model-name, api-key); }); services.AddScopedAgentService();注意作用域的选择模型客户端本身是线程安全的可以注册为 SingletonAgentService 承载会话状态我建议注册为 Scoped这样每个请求作用域内都有独立的会话实例避免用户之间串数据。这个细节会在第 4 部分展开讲。3. 从零写一个会调用工具的代理3.1 第一阶段搭出能对话的最小原型我先从最小闭环开始写目标是让代理能基于固定的人设回答用户问题。这里最关键的一步是每一轮模型响应结束之后把它的消息放回 ChatHistory再把下一轮用户消息追加进去。很多新手写出来的机器人“没有记忆”基本都是漏了这一步。using Microsoft.Extensions.AI; var chatClient ...; // 从 DI 容器获取 IChatClient var history new ListChatMessage { new ChatMessage(ChatRole.System, 你是内部运维助手回答要简洁、准确不要编造数据。) }; while (true) { Console.Write(你); var input Console.ReadLine(); if (string.IsNullOrWhiteSpace(input)) break; history.Add(new ChatMessage(ChatRole.User, input)); var response await chatClient.GetResponseAsync(history); history.Add(response.Message); Console.WriteLine($代理{response.Message.Text}); }这段代码看起来简单但它已经是一个代理的最小骨架有系统人设、有历史回填、有连续对话能力。跑通这一步之后再往上加工具调用你的心里会很清楚每一步在做什么。3.2 第二阶段把 C# 方法变成代理的“手”最小原型只能“聊天”不能“干活”。要让代理干活就得把 C# 方法暴露成模型可以调用的工具。在 Microsoft Agent Framework 体系里注册工具的方式通常是通过方法描述和参数 Schema 自动生成。你只需要写一个普通 C# 方法再配上描述框架就有机会把它变成模型可感知的工具。我以一个库存查询工具为例using System.ComponentModel; public class InventoryTool { [Description(查询某个 SKU 的实时库存数量参数为商品编码)] public string GetStock(string sku) { // 真实项目里从这里调用业务系统的库存服务 return $SKU {sku} 当前库存为 23 件; } }重点在于模型并不知道你的 C# 方法内部逻辑它只知道这个工具的名字、描述、参数结构。你写的方法描述质量直接影响模型能不能在正确的时候调用它。千万别写“这是一个库存方法”这样含糊的描述要写“在用户询问剩余库存、补货周期、能否下单时调用”。当用户说“A 商品还有货吗”模型会返回一个工具调用请求框架拿到请求后帮你反射调用 GetStock并把返回值作为一条工具消息放回对话历史最后模型基于这个结果生成给用户的自然语言回答。完整的链路是用户输入 → 模型识别意图 → 生成工具调用参数 JSON → 框架反射执行 C# 方法 → 结果回填历史 → 模型生成最终回答。我建议第一次实现时手动处理工具调用过程不要直接依赖框架的自动执行。这样你能亲眼看到模型返回的原始请求长什么样理解每一个字段的含义。之后再切到自动处理排查问题时才不会有黑盒感。3.3 第三阶段多轮对话里的上下文管理工具调用一旦多起来ChatHistory 的消息数量会快速膨胀。每一轮工具调用都要追加好几天消息用户的请求、模型的工具调用申请、工具的执行结果、模型的总结回复。这些全存下来很快会超过模型的上下文窗口。我的做法是用“滑动窗口 摘要压缩”的组合策略。核心思路是保留最近 N 条完整消息超过部分先让模型压成一段摘要再把摘要作为系统消息放在最前面。示例代码大致如下const int MaxMessages 20; if (history.Count MaxMessages) { var oldMessages history.Take(history.Count - MaxMessages).ToList(); var summary await chatClient.GetResponseAsync( oldMessages.Append(new ChatMessage(ChatRole.User, 把以上对话压缩成一句话摘要保留所有关键操作结果。)) ); history.RemoveRange(0, history.Count - MaxMessages); history.Insert(0, new ChatMessage(ChatRole.System, $历史摘要{summary.Message.Text})); }这只是最基础的版本。真实项目里我还会做“关键信息固化”如果用户在对话里说过“价格必须低于 1000 元”这类硬约束我会把它单独提取出来写入系统消息而不是让它沉到历史消息里被摘要吞掉。摘要适合处理无关紧要的寒暄和过程关键业务约束必须稳定存在。3.4 第四阶段流式输出与对外集成控制台跑通之后就需要把代理能力暴露成接口。这里我推荐流式输出因为模型生成一句话可能要几秒钟如果等完整返回再发给前端体验会非常差。用流式用户可以看着文字逐字出现感知上会快很多。底层用 GetStreamingResponseAsync拿到一个 IAsyncEnumerable逐段输出await foreach (var update in chatClient.GetStreamingResponseAsync(history)) { Console.Write(update.Text); await response.WriteAsync(update.Text); await response.Body.FlushAsync(); }在 ASP.NET Core 里可以包装成一个 SSE 接口前端用 EventSource 接收。这个方案比 WebSocket 轻量也不需要额外的服务端状态我实际用下来效果很好。如果要做双向互动比如用户端发送消息的同时代理需要推送中间状态那再用 SignalR 也不迟最终要看场景复杂度。4. 项目实战中的坑与排查记录4.1 工具调用总是报参数错误这是我在项目里遇到最多的问题典型表现是模型明明准确识别了意图但传给函数的参数却绑不上或者拿到的是字符串方法却要整数。我排查过几个案例之后总结出三个最容易踩的原因参数描述不清晰。模型不知道某个字段该填什么只能自己猜猜错了自然报错。解决办法是每个参数都写上明确说明必要时给默认值。方法没有标记可空或边界条件。比如传入的字符串带空格方法内部没有 Trim导致后续业务查询失败。系统提示词没有约束格式。模型在不确定时可以自由发挥比如日期格式写成 2024/1/1而代码预期是 2024-01-01。我的处理方案是双重保障一方面在提示词里明确“调用工具时必须严格按照方法参数 Schema 生成 JSON不得修改字段名和格式”另一方面在 C# 方法内部做防御性解析不做任何假设。宁可让工具快速失败并明确返回错误原因也不要让一个含糊的异常把整个对话卡死。4.2 上下文一长模型就“失忆”表现是模型回答一半的时候突然忘了用户最初的要求或者重复问同一个问题。查下来几乎都是同一个根因——历史消息已经超出模型上下文窗口被底层策略截断早期关键信息直接被丢弃。我解决问题的步骤是在每次请求前打印当前 history 的消息数量和估算 token 数量先量化问题。接上第 3.3 节的滑动窗口与摘要压缩保证请求量被控制。把业务硬约束提升到 System Prompt不依赖聊天记录保留。这里要特别强调代理和普通聊天不同工具调用产生的中间消息往往比用户消息更有价值。摘要压缩时不能只压缩用户说了什么还要压住关键工具执行结果比如“已创建工单编号 TK-1024”“库存查询结果为 0”。否则代理后续容易给出和事实矛盾的回答。4.3 代理陷入工具调用死循环印象最深的一次事故是代理调用了一个查询工具拿到结果为“无数据”之后它不直接告诉用户反而换了个参数又重新调用一次连续调了十几次接口被它打到触发限流。这类死循环的常见诱因是模型认为自己还没拿到“正确”的结果所以不断重试。解决办法是硬性限制。我在代码里加入了最大轮次控制并且每轮工具调用都记录下来var turnCount 0; const int MaxTurns 5; while (turnCount MaxTurns) { // 发起模型请求检查是否有工具调用 // 有就执行工具并把结果回填 // 没有就返回最终回答 turnCount; } if (turnCount MaxTurns) { history.Add(new ChatMessage(ChatRole.System, 已达到最大尝试次数请如实告诉用户暂时无法完成并说明已完成的步骤。)); }对抗死循环的另一招是把工具的“失败结果”也写得足够直白。比如工具返回“查询条件不合法请检查参数”模型看到这个信号就会停下来。我发现很多时候模型一直循环是因为工具返回值太模棱两可它总觉得再试一次就能成功。4.4 并发请求下的会话隔离当代理从控制台搬到 Web 服务之后并发问题就暴露了。最危险的一个场景是两个用户同时提问结果在 A 用户的问题里看到了 B 用户的上下文。原因基本是同一个——把 ChatHistory 放到了 Singleton 服务里所有请求共享了同一份列表。这个问题的排查其实不难但很容易被忽略。我建议从一开始就做好三件事AgentService 注册为 Scoped让每个请求作用域内都有独立的 history 列表。如果一定要用单例服务那就手动维护一个以 sessionId 为 key 的会话字典并加上过期清理策略。所有和会话相关的状态都严禁用静态字段保存。我在项目里统一用“会话 ID 注入”的方式每个请求带一个唯一 ID代理服务内部判断是该新建会话、续接旧会话还是返回错误。这样既方便水平扩展也方便排查问题。5. 把项目再往前推一步个人体会)5.1 几条在项目里验证过的建议写完这个项目之后我最大的体会是代理框架的代码量不大真正的时间都花在“约束”上——约束模型什么时候该调工具、约束上下文不要无限膨胀、约束代理不要超出自己的职责边界。我可以给几条可复用的经验先跑通控制台再上 Web。工具调用链路在这种模式下调试最舒服可以直接看到每一步的模型响应。日志要记录模型原始的 tool_call JSON不要只记录 C# 方法是否被调用。因为你要排查的是“模型为什么生成这个参数”而不是“代码为什么报错”。工具方法尽量返回纯文本字符串不要返回复杂对象再让模型去猜含义。模型喜欢直接的文本结果。给代理写清楚职责边界例如“你只负责库存查询和工单创建不要回答非相关话题”。没有边界的代理很容易失控。另外我在实际测试里发现一个小技巧给工具起名时使用“动词 业务对象”的结构比如 QueryInventory、CreateTicket。模型对这样的命名更敏感比一堆泛泛的 GetData 准确率高不少。5.2 后续可以扩展的三个方向这个项目做完之后还有几个方向值得继续探索。第一个是多代理协作由一个主控代理负责任务拆分把请求分发给多个专用代理再汇总结果。这种模式适合跨多个业务域的复杂任务但主控代理的提示词设计难度也会上升不少。第二个方向是人机交接。代理不是万能的遇到风险操作或者无法判断的情况应该能主动转给人工处理。我当时设计了一个“转人工”的工具调用后由人工接管会话同时保留同一份上下文这样用户不用重复描述问题。这个体验提升相当明显。第三个方向是可观测性。每次工具调用的耗时、模型输出 token 数、上下文窗口占用率都应该被记录成结构化日志。这样不仅方便排障还能算清楚每个代理请求的真实成本为后续优化提供数据支撑。我自己在这个项目里反复踩过一个循环加了新工具代理变聪明但同时更容易绕圈子加了约束代理变老实但偶尔也会变得过于死板。这个平衡点只能靠大量真实场景测试来调整。如果你正准备用 C# 做自己的第一个 AI 代理我的建议是不要追求一步到位先把一个工具一个小场景跑通再慢慢放开边界。这条路不算短但每一步都能看到实实在在的进展。