做了两年多的 .NET 应用开发,我最大的感受是:真正难收拾的不是模型选型,而是围绕模型写出来的那几十行对话循环。起初我习惯手动管理历史消息,请求模型后解析返回里的函数调用标记,执行完再把结果塞回上下文,来回折腾。等工具函数一多、上下文一长,这套代码就变成了没人敢碰的泥潭。后来我把模拟项目X整体重构成基于 Microsoft Agent Framework 的 AI 代理结构,问题才一下子清晰很多。这篇文章就聊聊我在 C# 里用 Agent Framework 搭代理的完整经验,包括它解决了哪些痛点、最小可运行代码、函数调用链路、多代理协作,以及实测里最容易翻车的几个环节。适合正在做企业自动化、客服工单、内容总结方向的人,也适合想从裸 API 调用升级到结构化 Agent 的 .NET 开发者。
1. 从手写工具循环到 Agent Framework,我到底被什么逼疯了
1.1 最痛苦的不是模型,是那个 while 循环
很多从裸 API 开始做代理的人,第一版代码长这样:
var messages = new List<ChatMessage>(); messages.Add(new ChatMessage(Role.System, systemPrompt)); while (true) { var resp = await chatClient.GetResponseAsync(messages); messages.Add(resp.Message); // 从 assistant 消息里翻函数调用标记 var toolCalls = ParseToolCalls(resp.Message); if (toolCalls.Count == 0) break; foreach (var call in toolCalls) { var result = ExecuteLocalFunction(call); messages.Add(new ChatMessage(Role.Tool, result, toolCallId: call.Id)); } }看着还行对吧?但真实业务根本不会这么温柔。一次请求里可能同时返回多个工具调用,工具执行后模型可能又提出新的工具请求,甚至同一个工具被反复调用;历史消息一多,你想做裁剪就得自己去数 Token;网络超时了要整套重来;换一家模型服务商时,工具参数格式又对不上了。整个代理的核心逻辑完全淹没在调度细节里。
我印象最深的是一次售后工单场景:模型在判断是否升级工单时,工具返回了一个带嵌套 JSON 的字符串,结果模型把升级条件判断错了,导致一张要加急的工单静默搁置。排查到最后,问题不在模型,而在我的工具调用链路实在太脆。那一刻我明白,代理真正的基础设施是调度层,不是模型本身。
1.2 Agent Framework 替我回答了三个问题
Microsoft Agent Framework 这套体系,在我理解里是分了三层解决上面的问题:
- 底层有统一的客户端抽象,屏蔽不同模型服务商的差异;
- 中间层把“代理”建模成实体,包含人设指令、工具集合、会话绑定;
- 上层提供会话和群聊编排能力,管理多轮对话、多代理协作。
把这段思路落到代码上,就是我不再手动维护那个 while 循环。框架接管了工具调用的识别、执行、结果回填,我只需要定义好“这个代理有哪些工具”,剩下的循环被抽象成框架的调用约定。
当然,这句话不是让你完全当甩手掌柜。工具方法内部的权限控制、业务校验、异常处理还是要自己写。框架解决的是“模型与本地方法之间如何可靠对话”,不是“业务逻辑该怎么写”。这是我认为它跟普通 SDK 最大的区别,也是我后来敢把核心业务交给它托管的原因。
1.3 什么时候值得上框架,什么时候别上
根据我自己的经验,以下场景用 Agent Framework 收益很大:
- 业务需要多轮工具调用,且工具之间存在依赖;
- 想在不同模型服务商之间切换,不想改上层代码;
- 需要做会话持久化、成本统计、观测性审计;
- 项目会演化出多个代理,而不是只有一个聊天入口。
反过来说,如果只是做一次性的文本分类、简单抽取,或者你的系统已经有一套成熟的任务调度引擎,只是想把 LLM 当作其中一个计算节点,那就不必为了用框架而用框架。引入框架意味着接受它的抽象和升级节奏,轻量场景徒增成本。
我个人的判断标准是:只要代码里开始出现两处以上手写模型循环调用,或者三五个工具函数堆在同一个类里,就可以考虑交给 Agent Framework 管理了。
2. 先别急着建 Agent,用三行代码把 ChatClient 跑通
2.1 版本选择与包引用的隐藏坑
很多人在 Agent Framework 上第一步就摔跟头,原因大多不是框架本身,而是 .NET 生态里相关包名太多:核心抽象包、OpenAI 兼容客户端包、Agent 拓展包,名字相近但版本节奏不一致,一旦主版本号对不上,编译时会出现一连串莫名其妙的类型冲突。
我的习惯是:先把项目固定在 .NET 8 以上,然后到 NuGet 搜 Agent Framework 相关的主包,并连带引入统一的 AI 抽象包和模型客户端包。这里有个容易忽略的细节:不同模型服务商的包命名差异很大,但配置结构高度相似——无非是服务地址、ApiKey、模型名三件套。建议先看官方 README 确认当前推荐版本组合,不要直接拿网上几个月前的版本号照抄。
还有个更实际的经验:如果你在公司内网环境开发,NuGet 源可能默认指向公共源,拉包会很慢,先把项目源切换到内部镜像再折腾。这种环境问题看起来小,但会消耗很多耐心。
2.2 最小可运行代码,先忘记 Agent 是什么
我搭建 AI 代理时从不一上来就引入 Agent 类,而是先把最底层的聊天客户端跑通。以某类 OpenAI 兼容服务为例,最小结构就是:
using Microsoft.Extensions.AI; // 这里按你选的模型服务商 SDK 构造底层客户端 var baseClient = new CompatibleChatClient(endpoint, apiKey, modelId); // 用统一抽象包包一层,得到 ChatClient var chatClient = new ChatClientBuilder(baseClient).Build(); var response = await chatClient.GetResponseAsync("请用一句话说明售后流程"); Console.WriteLine(response);这段代码做的事情很朴素:构造一个能对话的客户端,发一条消息,拿回文本结果。但它是后面所有 Agent 能力的地基。
ChatClientBuilder是理解这套体系的关键入口,它让你在构建链上一层层叠加能力,比如函数调用、缓存、日志、遥测。如果没有这层抽象,后面想给所有代理统一加超时重试,就得改一堆调用点。这层抽象还有一个好处:模型服务商切换时,上面业务代码基本不用动。
2.3 一次性响应与流式响应,先验证再体验
跑通上面的代码后,我强烈建议把流式响应也顺手测一下:
await foreach (var chunk in chatClient.GetStreamingResponseAsync("请写一段详细说明")) { Console.Write(chunk.Text); }两种模式各有用途:
- 一次性响应适合程序内部判断,比如工具路由、状态判断,需要拿到完整结果再决策;
- 流式响应适合对话产品,比如客服面板、用户界面,要一个字一个字吐出来才自然。
我在模拟项目X里甚至混用:底层调用工具时用一次性响应拿到结构化结果,最终面向用户展示时切到流式,体验会明显好一截。但要注意,混用前先把业务逻辑封装好,不要在视图层直接操作 ChatClient,否则后面加 Agent 层时要重构不少地方。
这里有个很实际的建议:初期调试千万别开流式,先把一次性响应跑通,确认消息内容正确,再换流式。流式输出排错时很难判断是模型没想清楚,还是流式管道把内容截断了。
3. Agent 的真正骨架:消息、角色与装配逻辑
3.1 Agent 不是一个“有大脑的对象”,它是策略的组合
当你把 Agent 当成一个神秘盒子时,很容易写出不可维护的代码。其实 Agent 在框架里就是一组策略组合:模型客户端、系统指令、工具集合、会话状态。
我当时是这样定义代理的:
var agent = new Agent("售后助手") { Instructions = """ 你是售后工单助手。 只处理与售后相关的问题。 如果用户投诉物流异常,先查询物流状态,再决定是否升级工单。 不要编造订单数据,所有信息必须来自工具返回结果。 """, ChatClient = chatClient };我特别想把“系统指令”这块讲透。很多初学者把指令放在数据库里,调用时临时拼进消息列表,最后 Agent 对象里啥都没有,只留一具空壳。但 Agent Framework 的思路是把指令作为 Agent 的属性,让代理的职责边界在代码结构上就固定下来,而不是靠调用方每次手动拼。
这带来的好处是:你可以给不同 Agent 配不同的指令,方便单独测试和替换;写单元测试时也不再是测一个巨型聊天循环,而是测某个 Agent 在给定输入下是否选择正确工具。
3.2 ChatMessage 不是随便一个 DTO
消息对象比看起来重要。框架里消息是按角色区分的强类型结构,系统、用户、助手、工具之间彼此独立;更重要的是,助手消息在函数调用过程中的结构化信息会被保留下来,而不是被压成纯文本。
我自己就踩过这种坑:早期手写循环时,把模型返回的文本直接塞进 List 当历史,导致下一轮请求完全丢失了函数调用轨迹。模型收到一段没有任何结构的历史,就会反复追问已经回答过的问题。
还有一点是关于 Token 用量统计。很多模型服务商会在返回消息的元数据里附带 Token 明细,比如输入 Token、输出 Token。Agent Framework 的消息对象会保留这些元数据,项目需要做成本核算时,直接从消息集合里汇总就好,不需要另起炉灶去解析服务商日志。
3.3 装配一个 AgentChat,让代理开始“有记忆地”对话
当 Agent 只是一个孤立对象时,它还谈不上什么代理行为,因为它没有会话记忆。真正把它串起来的是 AgentChat 一类会话容器:
var chat = new AgentChat(); chat.AddAgent(agent); await chat.SendAsync("订单 X 一直没到,用户很急,怎么处理?");AgentChat 管理会话里所有消息的流转和累积,调用方不需要手动追加历史消息,也不需要关心上一轮工具执行的中间产物。这一点对生产系统非常关键:当多个入口同时操作同一个会话时,状态一致性由框架保证,而不是靠开发者自己锁一个静态消息列表。
我还建议在进入下一步之前,先用真实的输入输出把 AgentChat 跑通,确认模型在带工具的环境下能准确回答。这个阶段不需要复杂业务,只要验证“会话容器 + 代理 + 模型”三者能协同工作。
4. 让代理会“干活”:Function 调用是这样一路打通到真实系统的
4.1 给普通 C# 方法穿上“模型可读”的外衣
代理真正产生业务价值的地方,是它能够调用你现有的系统能力。在 Agent Framework 里,你不需要学习专门的脚本语言,只需要把普通方法通过描述暴露出去:
public class LogisticsTools { [Description("查询指定订单号的物流状态,返回JSON结构:{status, latestTrace, signedTime}")] public static async Task<string> GetLogisticsAsync(string orderId) { // 调用真实物流接口 var trace = await LogisticsApi.GetTraceAsync(orderId); return JsonSerializer.Serialize(trace); } }这里的关键是[Description]。它相当于给模型的“工具使用说明书”,模型靠这段描述判断什么时候调用、传什么参数。描述写得越清晰,模型选错工具的概率越低;反之,一个含糊的描述会埋下大坑。
我见过有人把整个方法体逻辑写进描述,导致模型返回超长参数,完全没必要。描述只需要说明“做什么、输入是什么、输出是什么”,实现细节对模型而言毫无意义。
4.2 UseFunctionInvocation 到底替我们做了什么
方法写好后,剩下的就是把它挂到 ChatClient 上:
var chatClient = new ChatClientBuilder(baseClient) .UseFunctionInvocation() .Build(); // 调用时把工具实例交给 Agent Framework 管理 var functionInvocation = new FunctionInvocationContext(tools);UseFunctionInvocation是理解框架价值的关键行。它做的事非常聪明:请求阶段把工具定义转成模型服务商认识的 schema;响应阶段检查模型是否想调用某个函数;如果决定调用,框架就在本地空间执行对应 C# 方法,把返回值转换成一个标准工具消息,再塞回对话流程。整个过程里,模型感知到的只是“我背后有一堆可用函数”,而业务代码感知到的只是“我定义了几个干净的方法”。
这个抽象对多服务商特别友好。不同模型服务商对工具参数格式的细节要求不同,但经过这层统一处理,你的工具类只用写一次,模型切换时不需要重写。
有一点必须强调:函数调用机制本身不做权限校验。也就是说,只要模型判断需要调用,框架就会执行你的方法。如果某个工具涉及高危操作,比如删除数据、转钱款,必须在方法内部做二次校验,绝不能只依赖模型“不调用”的自觉。
4.3 完整链路:查单、判断、升级一件事
在模拟项目X里,我实现过一个典型的售后场景,完整链路大概是这样的:
- 用户说:“订单 X 显示签收,但用户说没收到。”
- 模型查看可用工具后,决定调用
GetLogisticsAsync("X")。 - 框架执行真实查询,返回 JSON:
{"status":"SIGNED","latestTrace":"已签收,签收人:前台","signedTime":"..."}。 - 模型对比用户描述与物流结果,判断有争议。
- 模型调用
EscalateTicketAsync("X", "物流显示签收但客户反馈未收到")。 - 框架执行升级动作,返回新工单号。
- 模型基于上述结果向用户输出最终答复。
整个链路里,业务方只看见了工具方法的实现,模型只看见了工具描述和结果,而框架则负责中间的来回调度。
有一点初用者容易忽略:模型在一次输出中可能调用多个工具,而且部分模型会并行发起多个函数请求。如果你的工具之间有依赖关系,比如必须先查用户再查订单,后一个工具依赖前一个的结果,那就要在描述里明确写清楚执行顺序,否则模型可能乱序调用。
4.4 工具返回内容的稳定性,直接决定整个代理成败
工具返回值是模型下一步决策的依据,但很多人把工具返回写得非常随意,比如直接抛出一个普通字符串错误:
工具出错:数据不存在模型面对这种描述时,经常会“脑补”出一个具体原因,进而做出错误判断。正确做法是返回结构化的结果,哪怕失败也要结构清晰:
{"success": false, "errorCode": "ORDER_NOT_FOUND", "message": "订单号不存在,请核对后重试"}这套经验在我实际项目里救过多次:模型的判断准确率大幅提升,因为它在失败时拿到了明确的错误码,而不是含糊的一句话。如果你想让 Agent 稳定可用,工具返回值的结构设计一定要当成接口契约来对待,而不是内部实现细节。
5. 单代理搞不定的时候,用 AgentGroupChat 做职责拆分
5.1 为什么要拆成多个代理,而不是堆提示词
单一代理的边界很容易被撑爆。你在一个 System Prompt 里想让模型既当客服、又当质检、又当督办,结果往往是工具列表越来越长,模型频繁选错工具,出一句话都要思考半天。
拆成多个代理的核心原因是隔离复杂度:每个代理只维护自己的工具集合,指令专注明确。这样测试也更加轻松,你可以针对单个代理做独立的输入输出验证,不用每次都被整个巨型黑盒拖累。
我在模拟项目X里最初就是一个“超级代理”,后来拆分出来的经验是:如果系统提示词超过两千字,或者工具函数超过十个,我就会认真考虑拆代理。工具少、职责少,模型的选择正确率会肉眼可见地上升。
5.2 AgentGroupChat 的编排机制:一群人围着一张桌子
多代理协作,在框架里就是让多个 Agent 坐在同一个会议室里,消息按一定顺序流转:
var triageAgent = new Agent("调度员") { Instructions = "判断用户意图,将问题分派给对应处理代理。", ChatClient = triageChatClient }; var serviceAgent = new Agent("售后处理员") { Instructions = "处理售后问题,只能使用售后工具。", ChatClient = serviceChatClient }; var groupChat = new AgentGroupChat(triageAgent, serviceAgent); await groupChat.SendAsync("客户投诉物流异常,请处理");AgentGroupChat 内部会按一定的轮转或路由策略决定下一个发言者。我在实际使用中的理解是:它更像一种转变驱动协议,而不是简单的“轮流说话”。调度代理先判断意图,然后售后处理员介入,下一个代理是否参与由当前消息状态决定。
这里我要坦白说:多代理协作比单代理复杂一个量级。你需要考虑谁先发言、消息要给谁看、怎么避免两个代理互相纠正、怎么终止会话。建议先从小规模实验开始,比如一个调度代理加一个执行代理,再逐步扩成三人团队,而不是一开始就铺五个代理。
5.3 我在实际项目里的代理边界划分
模拟项目X最终拆成了四类代理:
- 接待代理:负责理解用户问题,判断是否是售后诉求;
- 售后执行代理:处理退款、补发、物流查询,拥有全部售后工具;
- 质检代理:只读检查聊天记录和工单状态,不改变任何数据;
- 督办代理:在工单超时未处理时发起升级提醒。
这个拆分里有一个我很坚持的原则:质检代理只读不写,因为它如果也能改工单,很容易与执行代理相互冲突。把“观察性代理”和“动作性代理”分开,是避免多代理互相打架的最好办法。
另外我也发现,多代理不一定都需要同一套昂贵模型。调度和质检这类任务相对简单,可以用成本更低的模型;售后执行涉及复杂工具调用,才用能力更强的模型。AgentGroupChat 允许每个 Agent 挂不同的 ChatClient,这种组合在实际项目里能显著降低成本。
6. 实测里最容易翻车的几个环节,和我的解决办法
6.1 工具返回的 JSON 结构不稳定
我在测试阶段遇到的第一个大坑,是工具方法返回了散乱结构,时而对象、时而字符串,模型在后续推理中完全摸不着头脑。比如有人会在工具里写:
return "ok"; // 或者 result ? “1” : “0”模型看到这种返回值,根本不知道该当字符串还是当状态码,于是开始自由发挥。后来我给所有工具方法定了硬性约定:返回值必须是强类型对象,统一走 JSON 序列化;失败时也要返回标准错误结构,而不是抛一个裸异常。
[Description("尝试为用户办理退款,返回处理结果")] public static async Task<RefundResult> RefundAsync(RefundRequest request) { try { var result = await RefundService.ApplyAsync(request); return RefundResult.Success(result.RefundId); } catch (Exception ex) { return RefundResult.Failed("REFUND_REJECTED", ex.Message); } }这个改动带来的提升非常直观:模型在拿到结构化结果后,可以明确判断下一步动作,不再产生“到底是成功还是失败”的歧义。
6.2 上下文一长,模型开始“失忆”
对话轮次一多,模型会出现对早期信息的遗忘:前面刚确认过的订单号,后面又重新问一遍。本质上是因为上下文窗口被无关历史占满了。
解决办法我在实践中总结出一个组合策略:
- 只保留最近几轮完整消息;
- 把更早的关键信息抽成摘要,作为系统级背景提示;
- 对每一轮进行主题标记,确保摘要能抓住核心状态。
if (messages.Count > maxMessages) { var firstBatch = messages.Take(earlyMessages).ToList(); var summary = await summarizer.SummarizeAsync(firstBatch); messages = new List<ChatMessage> { new ChatMessage(Role.System, $"早期对话摘要:{summary}") }; messages.AddRange(messages.Skip(messages.Count - recentMessages)); }这段摘要代码虽然简单,但它背后代表的思想很重要:不要让 Agent 在无限增长的历史里自己找重点,你要主动帮它维持焦点。尤其是在工具调用场景里,早期工具的执行结果往往中途就不再重要,保留到最后只浪费 Token。
6.3 短超时与重试,会让整个代理一起失败
模型服务并不像想象中稳定,偶尔会超时、限流、返回瞬时错误。如果你只在调用处简单设一个默认超时,代理很可能因为一次网络抖动就整体失败。
我的做法是给 ChatClient 加统一的超时和重试策略,并保证工具本身支持幂等。比如升级工单这类操作,如果超时后自动重试,可能导致同一张工单被重复升级两次。我在工具实现里加入了请求幂等键,用工单号加动作类型生成唯一键,服务端重复收到时直接返回原结果。
重试策略不要一上来就猛冲,指数退避加少量抖动在绝大多数场景下足够:
for (int attempt = 1; attempt <= maxRetries; attempt++) { try { return await chatClient.GetResponseAsync(messages); } catch (Exception e) when (IsTransient(e)) { var delay = TimeSpan.FromMilliseconds(Math.Min(2000, 100 * Math.Pow(2, attempt))); await Task.Delay(delay + TimeSpan.FromMilliseconds(Random.Shared.Next(0, 200))); } }这里真正耗时的是排查“哪些异常值得重试”,比如限流、超时可以重试,参数错误、鉴权失败就不该重试。建议给异常分类打标,别一股脑重试。
6.4 调试顺序:先固定模型输入,再碰代码
多代理系统排错时最忌讳的就是同时改提示词和改代码。我的调试习惯是:先用同一段输入反复跑模型,观察函数调用记录,确认它是否稳定选择预期工具;然后再打开日志,检查每次函数调用的参数和返回值;最后再回头审视 Agent 的指令设计。
Microsoft Agent Framework 提供的可观测性扩展在这时特别有用,它可以记录每一轮模型交互中的工具选择、参数、耗时,几乎等于给代理装了黑匣子。排错时我只需要看黑匣子里模型当时“在想什么”,而不必凭感觉猜。
这几轮重构下来,我最大的体会是:Agent Framework 并不是银弹,它真正替代的是你那套越写越长的 while 循环,把模型调度、工具回填、会话状态从业务代码里剥离出来;剩下真正考验人的,还是对业务边界的理解。如果你的 .NET 项目里已经开始出现多个模型调用点,或者工具函数越堆越多,那就建议早日把这一层框架引入,先跑通一个最小代理,再来完善边界,最终你会发现 Agent 并没有想象中那么神秘,它只是你把现有系统能力梳理清楚之后,顺手交给模型使用罢了。