
eShopSupport 项目的整体架构示意图。左侧为离线运行的工具(DataGenerator、DataIngestor、Evaluator)用于数据准备和评估;右侧为在线运行的系统,由多个服务和前端组成,通过 .NET Aspire 编排在本地或云端运行。
eShopSupport 是一个面向电子商店客户支持的参考 .NET 应用,采用服务化架构,将不同功能划分为多个模块/子项目并通过 .NET Aspire 进行统一编排。整体架构包括离线数据工具、在线后端服务、前端应用以及基础设施服务几大部分:
Program.cs 中依次执行 TicketIngestor、ProductIngestor、ManualIngestor 等,将生成的数据转换为可种子化的数据文件。
evalquestions.json)作为基准。它会调用后端的 Assistant API(聊天机器人接口),输入测试问题,获取回答并与预期答案进行比对打分,从而衡量聊天机器人的准确性、速度和成本等指标。这一模块帮助开发者客观评估和改进 AI 功能的表现。
/classify 端点,对客户提交的工单文本进行自动分类。该服务在启动时加载 Hugging Face 的一个小型零样本分类模型(如 cross-encoder/nli-MiniLM2-L6-H768)到 GPU,并对输入文本预测最合适的标签。通过将 Python 擅长的丰富AI模型生态与 .NET 应用集成,eShopSupport 展示了在不重写现有 .NET 项目的情况下利用 Python 来增强 AI 能力的方式。值得注意的是,PythonInference 项目除了分类外还实现了一个嵌入向量生成(embedder)的路由,但在本示例中未被实际使用。
上述模块通过 .NET Aspire 进行组合和运行。.NET Aspire 是一个云原生应用开发栈,可方便地定义和启动多服务应用以及所需的依赖资源。在本地运行时,Aspire 会以 Docker 容器方式启动 PostgreSQL(关系数据库)、Qdrant(向量数据库)、Redis(缓存/消息)和 Blob 存储 等基础服务,每个服务都有持久化卷以保存数据。Aspire 仪表盘提供了直观视图让开发者查看各服务的运行状态。通过模块化架构和 Aspire 编排,eShopSupport 展现了一个多项目协作、前后端分离且AI赋能的企业应用范例。
eShopSupport 实现了一系列面向客户支持场景的 AI 核心功能,每项功能背后都有相应的技术实现:
/classify 接口,将客户填写的文本和预定义的候选标签列表传给模型。PythonInference 使用 Hugging Face 提供的零样本文本分类模型(cross-encoder MiniLM)在本地 GPU 上运行推理,输出与文本最匹配的标签。例如,候选标签可能包括 "问题咨询", "产品投诉", "功能请求" 等,模型会计算哪个标签与用户描述语义最相近并返回之。后端接收该结果后,将该支持工单归类标记,从而无需人工初筛即可自动分配类别。这一流程充分利用小型预训练模型实现快速分类,提高工单处理的自动化程度。
通过上述功能,eShopSupport 展示了将生成式 AI 融入业务应用的多种典型场景,不仅实现了智能客服聊天,还将 AI 用于分类、搜索、总结、创成内容等方面。在实现过程中,项目充分利用了 .NET 8 提供的新特性和库:例如使用 Microsoft.Extensions.AI 抽象来编写与模型无关的代码,使同一套逻辑可以无缝切换本地或云端的大语言模型;通过 .NET Aspire 工作负载,一键启用所需的 AI 推理基础设施(如本地 LLM 推理器 Ollama、向量数据库等)。这种架构确保核心业务与AI逻辑解耦,开发者可以方便地替换模型或调整推理方式而不改变应用主体代码。总的来说,eShopSupport 的每个核心功能都体现了 AI 技术在实际业务中的应用方式,从而为开发者提供了宝贵的参考。
eShopSupport 所使用的技术栈覆盖前后端、AI框架和基础设施多个层面,构建了一个现代化的 .NET 智能应用:
dotnet aspire 工作负载),提供了类似 Infrastructure as Code 的能力用于声明应用的各项资源和服务依赖。eShopSupport 利用 Aspire 定义了数据库、缓存、AI 模型等资源,并实现一键启动所有组件的本地运行。Aspire 还支持将 Python 项目纳入 .NET 解决方案共同编排运行,在本项目中通过 AppHost 启动 FastAPI 服务,确保 .NET 与 Python 服务协同工作。Aspire 简化了复杂分布式应用的调试和部署,被称为一个 “约定式、云原生的 .NET 开发栈”,帮助开发者以一致的方式配置微服务、容器和资源。
transformers.pipeline API 构建了一个 Zero-Shot Classification 管道,加载 HuggingFace 上的 cross-encoder/nli-MiniLM2-L6-H768 微型模型(约数百万参数量),并在启动时将其加载到 CUDA GPU 上。Hugging Face 模型提供了本地执行的能力,不需要外部API即可完成诸如文本分类等任务。依赖管理方面,项目附带了 requirements.txt,列出了所需的 Python库版本(FastAPI、transformers等),开发者需使用 pip 安装。通过 VS Code 或 Visual Studio 的 Python 工具,可以在 .NET 开发环境中顺畅地编辑和调试该 Python 服务。
Directory.Packages.props 引入 NuGet 包。不过 eShopSupport 默认通过自己实现的 HttpClient 扩展来访问 Qdrant 而未必使用官方客户端。所有依赖在仓库的 nuget.config 和各子项目 .csproj 中均有声明,可供参考。
综上,eShopSupport 构建在 Microsoft 最新的 .NET 技术栈 之上,并融合了 Python 机器学习生态 和 容器化部署。这种多元的技术组合展示了在企业应用中引入 AI 的端到端方案:既利用了 .NET 强大的 Web 开发能力和新推出的 AI 支持库,也不避讳引入 Python 等异构技术来用其所长。通过 PostgreSQL、Qdrant、Redis 等组件,项目还涉及现代后端存储和检索技术,为实现语义搜索和大规模数据处理提供支持。对于希望将 AI 集成到现有技术栈的开发者而言,该项目提供了全面且前沿的技术示范。
在了解架构和功能后,我们进一步深入代码层面,分析 eShopSupport 若干关键模块的实现方式和逻辑流程:
自动分类流程:当客户在 CustomerWebUI 提交支持请求后,前端会通过 HTTP 调用 Backend 提供的 API(例如 POST /api/tickets)创建工单。Backend 接收到请求后,会提取其中的描述文本,并调用内部的分类逻辑。具体实现上,Backend 使用 ServiceDefaults 提供的 PythonInferenceClient,向 PythonInference 服务的 /classify 接口发送分类请求。例如,请求内容为:{"text": "产品在水下能用吗?", "candidate_labels": ["咨询","投诉","反馈"]}。在 PythonInference 的 classifier.py 中,该请求被路由到 classify_text 函数处理。代码首先利用 HuggingFace 的零样本分类管道对文本进行推理:
classifier = pipeline('zero-shot-classification', model='cross-encoder/nli-MiniLM2-L6-H768', device='cuda')
classifier('warm up', ['a','b','c']) # 预热模型
@router.post("/classify")
def classify_text(item: ClassifyRequest) -> str:
result = classifier(item.text, item.candidate_labels)
return result['labels'][0]如上所示,模型会返回各候选标签的置信度并排序,代码取置信度最高的标签作为结果返回。Backend 收到结果后,将该类别写入数据库中新工单的记录中(例如标记 Ticket.Category 字段),并可通过 WebSocket/SignalR 通知 StaffWebUI 有新工单到来。StaffWebUI 页面上立即显示新工单以及系统分配的类型标签,实现了提交->分类->展示的一条龙自动处理。整个过程体现了前后端、Python 服务的联动:前端提交->后端调用Python模型->结果回传->前端更新 UI,代码清晰地分工于各模块,协同完成需求。
聊天问答实现:内部聊天助手(Assistant API)的实现包含信息检索和语言生成两个步骤。Assistant API 在 Backend 中作为一个 Minimal API 端点实现(例如 POST /api/assistant/ask)。当 StaffWebUI 调用该端点并传入问题时,Backend 首先执行语义检索:利用 Qdrant 向量数据库查找与问题相关的知识片段。代码逻辑可能类似:调用 QdrantHttpClient 扩展,检索存储的手册段落,筛选出若干相似度最高的段落。接着,Backend 将问题和检索到的段落一起提交给 LLM。由于项目使用 Microsoft.Extensions.AI 抽象,这里代码会通过注入的 IPromptCompletion 或类似接口来调用模型。例如,Backend 可能构造一个 Prompt 包含:「问题:{用户问题}\n知识:{片段1}\n{片段2}\n请根据以上知识回答:」这样的提示,然后调用 promptCompletion.GenerateAsync(prompt)。模型提供者可以是本地 Ollama(例如加载了 Llama2 7B 模型)或 Azure OpenAI(例如调用 gpt-35-turbo),具体由配置决定。生成的答案通常带有引用标记(模型被提示在引用知识片段时加编号或出处标识)。Backend 获取模型输出后,将答案返回给 StaffWebUI 前端。工作人员在界面上会看到 AI 的回答以及引用来源(通常对应产品手册章节)。若满意可采纳,不满意也可以忽略。这个过程中涉及的关键代码路径包括:向量搜索(Backend 调 Qdrant)、LLM 调用(通过 Extensions.AI 统一接口调用本地或云模型)和结果组装。所有这些都在 AssistantApi 的实现中串联起来。Evaluator 项目对 AssistantApi 的测试也证明了其工作方式:Evaluator 逐条读取 evalquestions.json 里的问题,通过 HTTP 调用 AssistantApi,得到回答 JSON,再解析比对答案。因此,可以推断 AssistantApi 返回的数据结构包含模型回答文本,可能还有引用的原始段落或标识,供前端呈现。在代码组织上,Backend 很可能将检索和生成封装在一个 AssistantService 或 QAService 中,AssistantApi 调用该服务的方法得到回答,从而使逻辑易于测试和复用。
对话摘要与情感分析:当客服打开某个支持工单或查看对话线程时,系统会呈现AI 总结和情绪评分。这一功能由 Backend 的 TicketSummarizer 服务实现。其核心逻辑是在对话内容每有更新后,或当客服请求摘要时,汇总该线程的所有消息文本,然后调用 LLM 生成概要。由于可能涉及多轮对话,提示词会精心设计,例如「请总结以下客服对话的主要内容和结论,并判断客户情绪倾向」。Backend 调用 LLM 完成生成后,将结果存储在数据库(如 Ticket.ThreadSummary 字段)以及情感分(如 Ticket.SentimentScore)。在 StaffWebUI 上对应界面位置显示这些信息。从代码角度,TicketSummarizer 可能被实现为后台任务:每当有新客户回复到达,触发重新总结。或者也可以在用户点击“生成摘要”按钮时即时调用生成。情感分析部分可能复用 LLM 返回的信息或者通过简单的规则/模型另行计算。例如,如果摘要中包含「客户对解决方案表示非常不满」,可以解析出负向情绪。更专业的做法是在 PythonInference 中增加情感分析模型路由,但据项目介绍,情感分析是与总结一起由 LLM 完成的。代码实现上,Microsoft.Extensions.AI 提供的接口使调用 OpenAI 的情感分析 API 也很方便,但本项目选择了让 LLM一并输出。总结与情感这两个功能相辅相成地提供对对话的洞察,其代码关键在于 prompt 设计和结果解析存储,保障生成内容的可靠性和可用性。
数据种子与初始化:当 AppHost 启动整个应用时,会执行数据库和向量库的初始化逻辑。Aspire 配置中声明了 ImportInitialDataDir 环境变量,指向 seeddata/dev 目录。Backend 在启动时检测该目录下的种子文件(例如 products.json、tickets.json 等),读取内容并写入数据库对应表;同时读取向量嵌入文件,通过 Qdrant 客户端批量写入向量数据库。Manual PDF 则被复制到 Blob 存储容器中以供下载。当使用默认的 dev 数据集运行时,这些初始化数据已经由 DataGenerator 和 DataIngestor 准备妥当。因此代码层面,Backend 项目很可能包含一个 DataSeeder 类或在 Program.cs 中编排调用种子加载函数。如果发现数据库非空则跳过,以免重复导入。值得一提的是,Jason Haley 博客提到后台还会在启动时处理手册内容,比如将 PDF 文档文本索引到向量库。这暗示 Backend 启动逻辑与 DataIngestor 输出紧密配合:DataIngestor 已经输出了手册的分段和向量,但是最终插入 Qdrant和生成 Embedding索引的步骤可能放在 Backend 来做(或至少由 Backend 调用 Qdrant 导入 API)。因此 Backend 的初始化部分代码对于理解整个数据流很重要:从生成的数据文件到真正可被查询的持久化存储,完成闭环。
端到端测试控制:E2ETest 项目旨在测试员工界面的关键功能,包括 AI 辅助。由于 AI 输出存在随机性,测试难点在于确保每次运行有可比性。项目通过 Aspire 的配置来控制这一点:当 E2ETest 启动 AppHost 时,会传入特殊标志,例如 isE2ETest=true,Aspire 针对此标志调整一些行为。可能的机制是,AppHost 在检测到 E2E 测试模式时,使用固定的模型应答或模拟服务。例如,用规则化的 stub 替换真正的 LLM 调用(返回预定义答案),或者加载特定的“小模型”使结果可预测。Jason Haley 提到 Evaluator 项目目前不会跑完全部500个问题,因为成本较高,取而代之希望用 Microsoft.Extensions.AI.Evaluation 等包来替代 Evaluator 的功能。这暗示今后可能有更标准的测试库。但在本项目中,为展示概念,E2ETest 采取了冻结外部因素的做法。代码实现上,可能在 AppHost 的配置里有类似:if (isE2ETest) modelConfig.UseDeterministicMode(); 或对随机种子 RandomSeed 统一设定。这样,Playwright 脚本就可以假定特定的输入会得到特定输出,从而验证 UI 上显示的 AI 回复是否等于预期值。该设计体现了对 AI 应用进行自动化测试的一种尝试,其代码细节复杂但思想值得借鉴,即通过依赖注入或条件分支,将AI服务替换为可预测的实现来进行测试。
关键逻辑和类职责:在代码组织方面,eShopSupport 广泛使用了面向接口和依赖注入。例如,Backend 中可能定义了 ITicketRepository 接口来抽象数据库操作,由 EF Core 或 Dapper 的实现类提供具体功能,使得更换数据库或模拟数据变得容易。对于 AI 相关的逻辑,也可能有 IClassifierService、IQAService 等接口,由实际调用 PythonInference 或 LLM 的类实现。在 ServiceDefaults 里提供的 PythonInferenceClient、StaffBackendClient 则是封装 HttpClient 的工具类,它们使用 Typed HttpClient 功能注册到 DI 容器,以便在需要调用时直接注入使用。这样的封装将底层 REST 调用隐藏,提供了类似方法调用的体验,调用代码更简洁易读。例如,StaffWebUI 中获取AI建议回复可能直接调用 staffBackendClient.GetSuggestedReply(ticketId),内部实现就是对 Backend API 的HTTP请求。Aspire 还负责将不同项目连接起来,如在 IdentityServer 启动时需要知道前端的回调URL,在 Backend 需要知道 IdentityServer 的公开地址等,这些都通过 Aspire 提供的参数传递和 .WithEnvironment(...) 方法注入进环境变量。因此,代码中大量配置依赖于环境变量(如数据库连接串、Identity URL、OpenAI Key 等)。这一切配置均集中在 AppHost 的 Program.cs,通过 Aspire 的 fluent API 声明。可以说,AppHost.Program.cs 是整个应用的“总装配”代码,定义了服务清单和依赖关系。而各子项目则各司其职,遵循 SOLID 原则,保持清晰的职责划分:生成数据的只管生成,导入处理的只管加工文件,后端聚焦业务和AI调用,前端负责交互呈现。每个部分既相对独立又通过明确定义的接口契约衔接,体现了良好的架构设计思想。
代码示例:以 分类功能 为例,贯穿多种技术栈:在 .NET Backend 中,相关代码可能位于 TicketController 的 POST 动作里,或在 TicketService.CreateTicketAsync 方法中。伪代码可能如下:
// 从请求DTO创建Ticket实体
var ticket = new Ticket { Title = dto.Title, Description = dto.Description, ... };
_db.Tickets.Add(ticket);
await _db.SaveChangesAsync();
// 调用分类服务获取类型标签
string label = await _classifierClient.ClassifyAsync(ticket.Description, _predefinedLabels);
ticket.Label = label;
await _db.SaveChangesAsync();其中 _classifierClient 就是通过 DI 注入的 PythonInferenceClient,其内部使用 HttpClient 调用Python服务的 /classify,将结果直接返回。Python 端如前所示,使用模型推理并返回标签字符串。这样的交互模型简单直观。在 PythonInference 部分,代码示例已给出。在 问答功能 上,虽然未能直接获取代码片段,但根据 Semantic Kernel 类似案例,伪代码可能是:
// 检索知识
var queryEmbedding = _embeddingGenerator.GenerateVector(question);
var topDocuments = await _qdrantClient.SearchAsync(queryEmbedding, topK:3);
// 构造提示
string prompt = PromptTemplate.Fill(question, topDocuments);
// 调用LLM
string answer = await _llmClient.GetCompletionAsync(prompt);这里 _qdrantClient 可能是通过 QdrantHttpClientExtensions 配置的 Named HttpClient 实例。_llmClient 则是通过 Extensions.AI 注入的接口,例如 IChatCompletion 或 ITextCompletion 实现。PromptTemplate 包含预定义的系统提示,指导 LLM 按所需格式作答(例如要求引用文档)。整个流程高度模块化,每步都有独立的实现类,便于理解和维护。
总结而言,eShopSupport 在代码实现上展示了清晰的分层和模块边界:数据生成/处理层、后端业务逻辑层、AI调用层、前端交互层各尽其职,并通过依赖注入和接口解耦降低耦合度。关键的 AI 功能实现并没有神秘难懂的黑盒,而是以合理的代码结构集成在常规的 .NET 项目中。例如,调用 OpenAI 被包装成普通的方法调用,处理向量搜索也有专门的客户端扩展。通过查看实际代码(例如 PythonInference 的 classifier.py 或 Backend 某些 Service 类),可以看到这些 AI 特性的实现也遵循常见的编程范式,只是在内部调用了强大的预训练模型而已。代码逻辑简洁明了且富有工程思想,使开发者能够快速上手了解每个功能的运作。总之,eShopSupport 将前沿的生成式 AI 技术以工程最佳实践方式融入解决方案,其代码既是 AI 应用的范例,也是 .NET 微服务架构的优秀示例,值得深入研读和借鉴。
参考:https://jasonhaley.com/2024/08/23/introducing-eshopsupport-series/