
从去年开始我一直在做 AI 能力相关的技术选型和落地。最大的感受是模型服务商越来越多接口风格却各有各的脾气。今天接一个 OpenAI 兼容的端点明天换一家提供同样模型能力的厂商后天也许还要接本地部署的推理服务。每次切换业务代码里那些直接依赖特定 SDK 的调用、消息格式转换、工具调用处理都要跟着推倒重来。这种“改代码改到头皮发麻”的体验相信不少写过 AI 功能的开发者也经历过。这也是我最终决定把 Microsoft.Extensions.AI 写进项目技术规范的原因。它是微软推出的统一 AI 抽象库把常见的“聊天补全、流式输出、结构化输出、工具调用、中间件管道、可观测性”都收敛到一套接口上。也就是说只要你的代码面向这套接口开发底层是哪个模型服务商只是配置差异而已。这篇内容我会把它的核心模型拆开讲清楚从一次最简单的对话调用到中间件原理、工具调用、结构化输出再到实际项目中容易踩的坑尽量一篇讲透。1. 为什么我最终决定把这套库写进项目规范1.1 一次真实的切换经历换 AI 服务商的成本到底有多高我最早接触 AI 集成的时候做法很朴素直接在业务代码里调用某个厂商的 SDK把模型返回的文本拼到页面里再做一些简单的参数组装。项目前期跑得挺顺问题出现在一次需求变更——产品负责人告诉我因为成本原因要把部分对话能力切换到另一家服务上并且要求“改动尽量小”。结果一评估项目里散落着二十多处直接构建请求参数的地方消息历史的结构、系统提示词的组织方式、超时重试的逻辑全部和原来的 SDK 绑死。改动量不是几行而是半个月工时。那次之后我就在想如果有一个抽象层把“我发一条消息给模型”和“具体是哪个厂商的 API 执行”彻底分开这种迁移成本应该能压缩到几小时以内。Microsoft.Extensions.AI 就是按这个思路设计的。它定义了一套中立的聊天接口你的业务代码只面向这层接口模型服务商的 SDK 作为适配层被插到接口后面。换服务商时你替换的是适配层和配置业务逻辑几乎不动。1.2 抽象层和“又一个 SDK 封装”的区别在哪里很多开发者第一次看到这库会疑惑这不就是把厂商 SDK 又包了一层吗自己也能写个接口把 OpenAI 调用包起来为什么非要引入微软这套区别主要在三个地方。第一它的接口设计是围绕 AI 场景完整建模的不是只包一个“发消息拿回复”。聊天消息、角色、上下文、工具调用、结构化输出、中间件、遥测这些在 AI 应用里高频出现的概念都被纳入了统一模型而不是自己用字典和字符串拼来拼去。第二它内置了中间件管道机制类似 ASP.NET Core 的请求管道把日志、拦截、缓存、限流这些横切关注点抽出来按顺序插到请求里。自己写封装要想做到同样灵活工作量并不小。第三它是一套生态微软官方已经为多家常见模型服务商提供了适配包还支持自定义适配器。也就是说如果你要接入一个冷门端点只需要实现一个很小的适配类剩下的事情框架都接住了。1.3 什么时候适合上这套库什么时候别用我并不是建议所有项目无脑引入。如果你的场景非常简单只在某一个固定模型服务商上调用一次文本生成代码量不超过几十行裸写 SDK 完全够用还省去一层抽象的心智负担。但如果你的项目满足下面任意一条我建议评估一下存在两个及以上模型服务商的调用或者预期未来可能切换服务商你要在多个业务模块里复用 AI 能力希望统一日志、限流、缓存策略你需要实现工具调用、结构化输出这类相对复杂的交互模式你的应用已经用了依赖注入、配置系统和可观测性体系希望 AI 调用也纳入同一套治理框架。上面这些条件命中越多这套库的价值就越大。2. 核心模型拆解IChatClient 与一次对话的完整生命周期2.1 把一次 AI 对话拆成五个对象理解了整体的设计动机接下来就要认识这套库最核心的几个类型。它们共同描述了一次完整的对话IChatClient统一聊天客户端接口业务代码面向它编程。你只需要记住它有两个核心方法获取一次性回复以及获取流式回复。ChatMessage一条消息。包含消息角色系统、用户、助手等和内容。整个对话历史就是一组ChatMessage列表。ChatOptions一次请求的配置。模型名、温度、最大 token 数、工具列表、结构化输出要求等都挂在这个对象上。ChatResponse一次请求的完整结果包含回复文本、工具调用信息、token 消耗统计等。ChatResponseUpdate流式输出中的增量片段用于渲染逐字输出的效果。把这五个对象串起来一次对话的完整生命周期就清楚了业务代码维护好“消息列表”构造一个ChatOptions调用IChatClient获取回复或流式增量再把结果文本与消耗统计落到应用层。至于底层怎么和模型服务商通信IChatClient的适配实现会去处理。2.2 三行代码跑通一次对话的最小路径先看一个最简示例感受一下这套库的入门成本。假设你已经安装好了适配某个模型服务商的包并配置了 API 密钥using Microsoft.Extensions.AI; IChatClient innerClient new InnerSdkChatClient(apiKey, modelId); IChatClient client new ChatClientBuilder(innerClient).Build(); string reply await client.GetResponseAsync(介绍一下自己, cancellationToken);严格来说这段代码一共就三个动作创建底层适配客户端、包一层构建器、发起对话。如果你不想用构建器也可以直接把innerClient当成IChatClient用构建器只是为了后面加中间件。注意上面示例里的InnerSdkChatClient是示意写法实际使用时每个适配包会提供各自的扩展方法比如某个官方 OpenAI 包的写法是IChatClient client new ChatClientBuilder() .Use(new OpenAIClient(apiKey).GetChatClient(modelId)) .Build();2.3 完整调用链里发生了什么当你调用GetResponseAsync时事情并不是“直接发给模型”这么简单。由于引入了构建器和中间件机制调用会先进入一个管道经过一系列处理后再到达底层适配客户端最后通过厂商 SDK 发送请求。响应回来时再沿管道逆序返回。管道里每一层都只关心一件事。有的负责给请求加日志有的负责拦截并发有的负责检查缓存。这是整个库最精妙的部分也是我后面单独用一整章讲中间件的原因。你只要明白默认情况下你拿到的IChatClient可能并不是一个直接的 SDK 封装而是一条完整的处理链。3. 接入不同的模型服务OpenAI、Azure 与本地端点3.1 接入 OpenAI 兼容服务的方式实际项目中最常接入的其实是两类一类是 OpenAI 官方端点一类是各种兼容 OpenAI 协议的服务。微软官方为 OpenAI 提供了专门的适配包接入时先创建一个OpenAIClient再把它转成IChatClientusing OpenAI; var innerClient new OpenAIClient(apiKey).GetChatClient(gpt-4o-mini); IChatClient client new ChatClientBuilder(innerClient) .UseLogging() .Build();这里的OpenAIClient来自 OpenAI 官方 SDKGetChatClient(modelId)返回的就是一个适配好的IChatClient。也就是说你不需要手动维护协议细节官方 SDK 已经帮你实现了协议通信微软把IChatClient接口套在了上面。如果你使用的是其他提供 OpenAI 兼容服务的平台思路完全一致只要它暴露了/v1/chat/completions这类标准端点你就可以用 OpenAI 官方的客户端类来构造底层连接。区别只在于把OpenAIClient的构造函数换成传入自定义的端点和密钥var innerClient new OpenAIClient(new Uri(endpoint), new ApiKeyCredential(apiKey)) .GetChatClient(modelId);3.2 接入 Azure 上部署的模型如果你的模型资源部署在 Azure 的 AI 服务里接入方式也类似只是构造参数不同。通常你需要提供端点地址、部署名称和访问凭证using Azure; using Azure.AI.OpenAI; var endpoint new Uri(https://你的资源名.openai.azure.com/); var credentials new AzureKeyCredential(apiKey); IChatClient client new ChatClientBuilder() .Use(new AzureOpenAIClient(endpoint, credentials).GetChatClient(deploymentName)) .UseOpenTelemetry() .Build();这里有个非常容易混淆的点OpenAI 端点的模型参数是“模型名”Azure OpenAI 端点的模型参数是“部署名”两者不一定一致。因为 Azure 上的模型是以部署的形式存在的同一个模型可以有多个部署配置每个部署有自己的名字。如果你在切换时直接把模型名填进来大概率会得到“模型不存在”或者“部署不存在”的错误。第一次接入 Azure 的朋友请重点核对这个字段。3.3 接入本地模型服务与兼容网关除了云服务商的公开端点现在很多团队会考虑把模型部署到自己的 GPU 机器上或者通过内部网关统一转发。这类场景通常是这样的架构本地推理进程暴露一个 HTTP 端点和 OpenAI 的/v1/chat/completions兼容然后应用层直接请求这个端点。这种情况下接入方式和 OpenAI 兼容服务完全一样只是端点的地址变成了http://localhost:11434/v1之类的局域网地址。密钥可以传一个占位值因为本地服务通常不校验。如果你要接入的本地服务不提供 OpenAI 兼容端点而是自己搞了一套私有协议这时候就需要写自定义适配器了。好在IChatClient接口本身比较小只需要实现非流式调用和流式调用两个核心方法再加上Dispose即可。你可以先用一个最简实现跑通再逐步补全工具调用等高级能力。3.4 多模型切换的配置实践当你的项目里同时存在多个端点时我建议把所有连接信息收敛到配置文件里然后用依赖注入根据配置创建对应的IChatClient。services.AddSingleton(sp { var config sp.GetRequiredServiceIOptionsAiOptions().Value; return config.Provider switch { openai new ChatClientBuilder(new OpenAIClient(config.ApiKey) .GetChatClient(config.ModelId)).Build(), azure new ChatClientBuilder(new AzureOpenAIClient( new Uri(config.Endpoint), new AzureKeyCredential(config.ApiKey)) .GetChatClient(config.DeploymentName)).Build(), local new ChatClientBuilder(new OpenAIClient( new Uri(config.Endpoint), new ApiKeyCredential(local)) .GetChatClient(config.ModelId)).Build(), _ throw new NotSupportedException($Unsupported provider: {config.Provider}) }; });这样做的好处是业务层只依赖IChatClient切换模型时改动配置文件加一个分支业务代码完全不用动。我在实际项目中用这个模式管理了三个端点非常稳定。4. 中间件机制这才是这个库的灵魂4.1 一次请求如何在中间件管道中流动先说结论IChatClient可以被一层套一层最外层负责接收你的调用最内层负责和厂商 SDK 通信中间任何一层都可以在请求发出前做“前置处理”在响应返回后做“后置处理”。这就是中间件管道。理解它最好的方式是类比 ASP.NET Core 的请求管道。你在app.UseMiddlewareXxx()里注册的每一个中间件都会接收到请求可以选择直接放行也可以选择做点什么再传给下一层。这里也是同一个模型。构建器注册中间件的顺序就是管道执行的顺序。最先注册的中间件最早拿到请求底层客户端返回响应后响应沿着管道原路返回最后注册的中间件最先看到响应。这一点非常重要后面讲坑的时候会反复提到。4.2 内置中间件能省下哪些重复代码这套库自带了一批开箱即用的中间件每一个都解决一类高频需求。我最常用的几个如下中间件作用典型使用场景UseLogging记录请求摘要和响应信息排查问题、审计调用UseOpenTelemetry接入链路追踪和指标统一可观测性体系UseFunctionInvocation自动执行模型发起的工具调用让模型调用外部函数UseCaching对相同请求做缓存减少重复调用、降低成本UseRetry对失败调用做重试提升稳定性举个例子很多项目原本需要在业务代码里手写日志记录“发了什么模型、返回了什么、耗时多久”。用UseLogging()后这些都会被中间件自动处理。至于UseOpenTelemetry()我可以直接把 AI 调用的耗时、token 消耗、状态码全部接进现有的监控大盘不用写一行埋点代码。4.3 手写一个限流中间件理解了内置用法后再来看一个自定义中间件的完整示例。假设你只想让模型接口最多支持 10 个并发请求多出来的排队等待。实现这样一个RateLimitMiddleware非常简单using Microsoft.Extensions.AI; public sealed class RateLimitMiddleware : IDisposable { private readonly IChatClient _next; private readonly SemaphoreSlim _gate; public RateLimitMiddleware(IChatClient next, int maxConcurrent) { _next next; _gate new SemaphoreSlim(maxConcurrent); } public async TaskChatResponse GetResponseAsync( IReadOnlyListChatMessage messages, ChatOptions? options null, CancellationToken cancellationToken default) { await _gate.WaitAsync(cancellationToken); try { return await _next.GetResponseAsync(messages, options, cancellationToken); } finally { _gate.Release(); } } public async IAsyncEnumerableChatResponseUpdate GetStreamingResponseAsync( IReadOnlyListChatMessage messages, ChatOptions? options null, CancellationToken cancellationToken default) { await _gate.WaitAsync(cancellationToken); try { await foreach (var update in _next.GetStreamingResponseAsync( messages, options, cancellationToken)) { yield return update; } } finally { _gate.Release(); } } public void Dispose() _gate.Dispose(); }然后通过扩展方法注册到构建器public static class ChatClientBuilderExtensions { public static ChatClientBuilder UseRateLimit( this ChatClientBuilder builder, int maxConcurrent) { return builder.Use(next new RateLimitMiddleware(next, maxConcurrent)); } }接入时只需要IChatClient client new ChatClientBuilder(innerClient) .UseRateLimit(10) .Build();这里的难点其实不是写一个中间件而是两个方法都要实现。很多自定义中间件只完善了非流式方法结果流式调用时完全没被拦截保护。请记住只要业务里有流式输出你自定义中间件就必须同时考虑GetResponseAsync和GetStreamingResponseAsync否则流式路径会绕开你的逻辑。4.4 顺序调错了会发生什么观察一次脱敏失败再来一个真实的案例。有个模块需要调用模型生成文案但要求先把用户输入中的身份证号、手机号脱敏再发送给模型。同事一开始把中间件顺序写成下面这样IChatClient client new ChatClientBuilder(innerClient) .UseLogging() .Use(new DesensitizeMiddleware()) .Build();看起来似乎没问题先日志后脱敏。但实际上UseLogging最先注册会最先拿到原始请求也就是说——原始的用户信息已经被写进日志了脱敏中间件根本来不及处理。正确的顺序必须让脱敏在前日志在后IChatClient client new ChatClientBuilder(innerClient) .Use(new DesensitizeMiddleware()) .UseLogging() .Build();这个例子充分说明中间件顺序不是装饰性的而是功能性的。你必须在心里把管道画出来谁最早接触请求谁最后拿到响应都取决于注册顺序。端到端调试的时候这个心智模型非常重要。5. 让模型“动手”函数调用与结构化输出5.1 function calling 的本质与两种声明方式很多 AI 应用不只是聊天还需要模型主动调用系统的函数。比如用户问“帮我查一下明天的天气”模型本身没有查天气的能力它只能先解析出意图然后发起一次工具调用等你的系统拿到这个调用并执行完再把结果喂回给模型模型才能生成最终回复。在 Microsoft.Extensions.AI 里实现函数调用很方便。你只需要把函数包装成AIFunction对象放进ChatOptions.Tools里using Microsoft.Extensions.AI; var options new ChatOptions { Tools [AIFunctionFactory.Create(GetWeather)] }; ChatResponse response await client.GetResponseAsync( 北京明天会下雨吗, options);这里GetWeather是一个普通方法框架会根据方法的参数信息、说明和 JSON 序列化配置自动生成模型需要的工具描述。模型如果决定调用这个工具响应里会出现对应的工具调用信息。如果没有启用UseFunctionInvocation中间件你需要手动从响应里取出工具调用参数自己执行函数再把结果构造成消息发回去。如果你启用了中间件整个过程会自动完成模型请求调用函数中间件自动执行并回传结果最后返回模型基于工具结果生成的文本。这也是中间件模式的好处之一。5.2 让模型只输出 JSON结构化输出的正确姿势过去写结构化输出最痛苦的是要做 JSON 解析还要处理模型输出的杂散文字。现在你可以在ChatOptions里指定输出格式为 JSONvar options new ChatOptions { ResponseFormat ChatResponseFormat.Json }; ChatResponse response await client.GetResponseAsync( 从这段话里提取三件待办事项以JSON数组输出字段包括 title、dueDate, options);和“传统的解析方式”相比指定 JSON 格式后模型输出内容两侧不再包裹代码块也基本不会出现“好的这是你要的 JSON”这类多余文本。你仍然要做防御性解析但解析失败的概率大幅下降。我这里要强调一个经验结构化输出并不是万能的。有些兼容端点的底层模型对 JSON 模式的遵循度不高一次调用里仍然可能出现无效 JSON。更稳的做法是在应用层设置一个兜底如果 JSON 解析失败就自动降级为普通文本输出或者发起一次修复请求。5.3 流式输出和工具调用的交互细节如果你让模型边生成边输出同时还要走工具调用中间件的处理顺序会直接影响到体验。一个常见的错误是直接在流式响应的await foreach里判断工具调用文本然后提前返回给前端。但工具调用有时候不是一次输出完整参数的而是增量分片。如果你把每个分片当作最终参数去解析拿到的往往是残缺 JSON自然执行失败。正确的做法是让UseFunctionInvocation中间件先走完工具调用的完整流程流式接口里返回的是最终可渲染的文本增量。如果你的自定义代码需要手动处理工具调用务必将所有增量收集完毕后再解析 JSON而不是边收边解析。大多数“工具调用莫名其妙失败”的问题都是因为这里写得太着急。6. 上线之后才意识到的几个坑实测记录6.1 超时与重试的默认行为最好一开始就覆盖直接使用底层 SDK 时你往往能拿到更底层的配置比如超时时间、网络重试策略。但包上IChatClient后这些配置你需要显式传下去。如果使用 OpenAI 客户端底层OpenAIClient在构造时是可以配置网络超时的但很多人不知道直接用默认值遇到模型推理时间过长就会看到“任务被取消”的异常。我的建议是在项目初始化阶段先明确你需要的超时上限和重试策略。可以借助UseRetry中间件对特定异常做重试但要注意不是所有请求都适合重试。如果用户的上下文已经因为流式输出被中途消费重试可能需要用户重新触发而不是在后台静默重试。6.2 Token 统计、日志与遥测的接入顺序问题单位成本的核算是 AI 项目必须做的事情。好消息是这套库在很多适配实现里会填充 token 消耗信息ChatResponse里有对应的计量数据。但你是否有能力拿到这份数据取决于你是否注册了合适的遥测中间件。UseOpenTelemetry()通常会把 token 消耗作为指标项上报。但如果你同时用UseLogging()打日志注册顺序会影响你能否在日志里看到完整输入。这里特别提醒日志中间件默认记录的摘要未必包含全部消息正文如果你需要审计输入内容要考虑日志级别的配置和个人信息保护别为了审计把敏感数据全量落盘。6.3 并发场景下的缓存与限流你以为的“高性能”其实有副作用有些开发者一看到UseCaching()就开心觉得所有请求都可以缓存既省钱又提速。但 AI 请求的缓存必须非常克制。第一聊天请求里经常包含动态时间、用户上下文缓存命中率其实很有限第二缓存键的生成方式如果不包含模型版本、参数配置很容易串数据。我的建议是只缓存那些幂等、参数稳定的请求比如系统提示词固定的总结类调用。另外前面写的限流中间件在并发场景下也有一个容易忽略的副作用。我用SemaphoreSlim限制并发所有等待的请求会排队。如果队列里积压了太多请求后面的请求可能等很久前端表现为“一直转圈”。稳妥的做法是为限流排队设置一个超时时间超出后快速失败并提示稍后重试而不是让用户无限制等待。6.4 依赖注入与生命周期单例还是瞬时最后是一个比较隐蔽的坑。IChatClient本身应该是线程安全的可以在多请求之间共享所以注册成单例通常没问题。但如果你把某一条会话消息列表也注册进了单例那就危险了——多个用户会互相看到对方的聊天记录。正确的做法是IChatClient注册为单例每条会话的消息列表按请求或用户维度隔离。区分好“可共享的客户端”和“不可共享的会话状态”是 AI 应用架构设计里非常基础但重要的边界。7. 给团队落地时的几条实操建议如果看完前面六章你决定在项目里试用这套库下面几条建议可以作为起步参考。第一不要一上来就全量替换。找一个新模块或者简单的 AI 功能用这套库跑通一条最小链路感受一下中间件和适配层的工作方式。全量迁移旧代码往往比新写一遍更难收场。第二一定要锁定版本。这个库还在快速迭代不同版本的 API 有小范围调整。如果项目里多个团队各拉一个版本集成的时候会非常痛苦。写在项目规范里锁定某个官方发布版本升级时统一评估。第三提前写好一份内部接入文档。项目里会有经验不同的开发者如果每个人都靠读源码去理解中间件成本太高。把“如何新增一个模型端点”“如何注册自定义中间件”“如何排查常见异常”这三个问题写清楚团队效率会有质的提升。第四测试策略上建议把底层端点抽象出来。平时测试用例调用的是一个可控的模拟客户端这样不需要真的消耗 API 配额也方便模拟各种错误响应。等需要验证真实效果时再切换到真实端点跑一轮集成测试。我在实践中的一个体会是这类抽象层带给团队最大的价值不是省下某个适配代码的编写量而是让所有人在同一个概念模型下协作。消息列表、请求选项、中间件管道、工具调用这些词汇一旦统一讨论方案和排查问题都会顺畅很多。就算某一天产品方向变了需要从单一模型切换到多点接入技术侧的调整也在可控范围之内。建议你找一个最小场景先从安装包、写一个入口调用开始亲手跑一遍这个管道。五分钟之后你就能明显感觉到这种抽象到底和直接糊 SDK 代码有什么不同。接下来再慢慢深入中间件和函数调用整个过程会非常顺畅。