
这个标题看着绕其实拆开就三件事在Visual Studio里用C#和ASP.Net MVC搭一个能跑起来的Web应用分别把云端QWen和本机QWen接进去中间用GitHub Copilot当写代码的副驾。我前后折腾了小两周把两条链路都调通了这篇文章就把整个工程思路、代码细节和踩过的坑一次性讲清楚。先说结论如果你只是想快速做个LLM的Demo云端QWen是首选五分钟就能跑通如果你对数据敏感、想离线用、或者想省API费用本机QWen才是正道。但本机那条路坑远比想象的多尤其是C#这边对接时各种编码、超时、模型加载的问题网上资料少得可怜。这篇就是冲着这个来的。1. 立项动机Copilot当副驾应用层还得自己动手1.1 为什么不能只在IDE里聊天很多同事问我VS里不是装了GitHub Copilot吗直接聊不就行了何必自己写代码调QWen这里得掰扯清楚。GitHub Copilot的本质是一个IDE内嵌的代码生成与问答助手它能帮你补全函数、解释报错、生成单元测试但它活在编辑器里服务的是写代码这个环节。而标题里的连接QLWen指的是你交付的应用本身要有调用大模型的能力——用户在浏览器里输入一句话你的MVC应用负责把这句话发给远端或本机的QWen拿到回答再渲染回页面。一个是开发期工具一个是运行期业务能力两者不在一个层次上。Copilot在这个项目里的角色是写代码的加速器。我实际用下来让它帮我生成HttpClient封装、JSON反序列化的DTO、甚至MVC控制器的骨架效率提升非常明显。但让它直接告诉你怎么设计云端和本机双通道切换的架构它给的建议就比较泛了架构还得自己拿主意。1.2 云端与本机两条路线的取舍做这个项目之前我先拉了一张对比表把需求列清楚再选路线维度云端QWenDashScope API本机QWenOllama部署部署成本零部署开箱即用需要下载模型占几个G磁盘硬件要求无服务器端跑建议16G以上内存纯CPU也能跑但慢数据安全数据出网数据完全本地响应速度网络好时快但不稳定稳定但受限于本机算力费用按Token计费一次性硬件成本配置难度低一个API Key搞定中要配置模型、端口、并发我的结论很明确生产环境看场景开发环境两条都要。开发时用云端调试方便、反馈快交付给内部系统或涉密场景时切本机。所以这个项目的核心不是二选一而是设计一个可以灵活切换的抽象层。2. 项目骨架搭建VS里创建MVC应用与依赖注入设计2.1 版本选择与创建步骤我用的是Visual Studio 202217.8以上版本.NET 8ASP.NET Core MVC。如果你还在用.NET Framework的老MVC建议直接迁移原因后面讲异步调用时会提到——老框架的异步模型和现代HttpClient配合起来非常别扭。创建步骤很简单VS里选创建新项目 → ASP.NET Core Web应用模型-视图-控制器框架选.NET 8身份验证选无创建完后项目结构里自带Controllers、Views、Models三个文件夹这一步没什么花样但有个细节值得注意在其他信息里勾上配置HTTPS后面调试时本机OLlama如果用localhostHTTP和HTTPS混用会触发浏览器的混合内容拦截提前统一一下省得后面烦。2.2 用接口抽象出双通道这是整个工程的地基。不管云端还是本机对上层MVC控制器来说要的就是一个方法传入用户问题返回模型回答。所以先定义一个接口public interface IQwenChatService { Taskstring ChatAsync(string userMessage, string? systemPrompt null, CancellationToken ct default); }然后分别实现CloudQwenService和LocalQwenService。控制器只认IQwenChatService具体用哪个实现由配置文件决定。这就是依赖注入最朴素也最好用的场景。在Program.cs里这样注册builder.Services.AddHttpClient(); if (builder.Configuration[LLM:Mode] Cloud) { builder.Services.AddSingletonIQwenChatService, CloudQwenService(); } else { builder.Services.AddSingletonIQwenChatService, LocalQwenService(); }以后切换通道只改一个appsettings.json里的配置项业务代码一行不动。这个设计让我后面测试本机模型时省了无数事。2.3 配置文件里的门道appsettings.json里我放了这些内容{ LLM: { Mode: Cloud, Cloud: { ApiKey: sk-xxxxxx, Model: qwen-plus, BaseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1, TimeoutSeconds: 60 }, Local: { BaseUrl: http://localhost:11434, Model: qwen2.5:7b, TimeoutSeconds: 120 } } }两个提醒ApiKey别硬编码在代码里开发环境放用户机密右键项目 → 管理用户机密生产环境走环境变量或密钥管理服务本机超时时间要比云端长很多因为CPU推理的速度远低于云端GPU集群我实测7B模型在纯CPU机器上生成一段200字回答可能要20秒起步。3. 云端QWen接入兼容OpenAI协议的最小闭环3.1 接口选型为什么用兼容模式阿里QWen通义千问的官方接口有两种一种是DashScope原生格式另一种是OpenAI兼容模式。我强烈建议用兼容模式路径是https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions。理由很简单兼容模式请求体和响应结构就是OpenAI的chat/completions格式这意味着你以后想换任何其它家模型DeepSeek、GLM、甚至OpenAI官方只需要改BaseUrl和ApiKey。这个选择给项目留了后路也让你在网上找资料时能直接搜OpenAI的C#示例来参考生态资源完全不一样。注意DashScope兼容模式的认证方式是Authorization: Bearer 你的API Key而不是原生模式的X-DashScope-ApiKey头。这个搞错会直接401。3.2 C#实现代码核心调用代码如下我封装成了服务类public class CloudQwenService : IQwenChatService { private readonly HttpClient _httpClient; private readonly IConfiguration _config; private readonly ILoggerCloudQwenService _logger; public CloudQwenService(HttpClient httpClient, IConfiguration config, ILoggerCloudQwenService logger) { _httpClient httpClient; _config config; _logger logger; } public async Taskstring ChatAsync(string userMessage, string? systemPrompt null, CancellationToken ct default) { var apiKey _config[LLM:Cloud:ApiKey]; var baseUrl _config[LLM:Cloud:BaseUrl]; var model _config[LLM:Cloud:Model] ?? qwen-plus; var requestBody new { model model, messages new object[] { new { role system, content systemPrompt ?? 你是一个乐于助人的中文助手。 }, new { role user, content userMessage } }, temperature 0.7, // 流式响应置false便于MVC里一次性拿到完整结果 stream false }; var request new HttpRequestMessage(HttpMethod.Post, ${baseUrl}/chat/completions); request.Headers.Add(Authorization, $Bearer {apiKey}); request.Content JsonContent.Create(requestBody); using var timeoutCts CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(TimeSpan.FromSeconds(_config.GetValue(LLM:Cloud:TimeoutSeconds, 60))); var response await _httpClient.SendAsync(request, timeoutCts.Token); response.EnsureSuccessStatusCode(); var json await response.Content.ReadAsStringAsync(ct); var result JsonSerializer.DeserializeChatCompletionResponse(json); return result?.Choices?[0]?.Message?.Content ?? string.Empty; } }配套的DTO类public class ChatCompletionResponse { public Choice[] Choices { get; set; } public UsageData Usage { get; set; } } public class Choice { public ChatMessage Message { get; set; } } public class ChatMessage { public string Role { get; set; } public string Content { get; set; } } public class UsageData { public int PromptTokens { get; set; } public int CompletionTokens { get; set; } public int TotalTokens { get; set; } }这里有个细节我用JsonContent.Create而不是手动拼JSON字符串是为了让System.Text.Json自动处理属性命名默认驼峰转下划线不完全是自动的需要配置实际上JsonContent.Create默认会按C#属性名原样序列化。如果要确保下划线命名得在创建时指定var options new JsonSerializerOptions { PropertyNamingPolicy JsonNamingPolicy.CamelCase, DefaultIgnoreCondition JsonIgnoreCondition.WhenWritingNull }; var requestContent JsonContent.Create(requestBody, options: options);实测下来阿里兼容模式对大小写不敏感但别的厂商不敢保证所以统一用驼峰策略最稳妥。3.3 控制器与View的联动MVC控制器很薄就是把HTTP请求转成服务调用public class ChatController : Controller { private readonly IQwenChatService _chatService; public ChatController(IQwenChatService chatService) { _chatService chatService; } [HttpGet] public IActionResult Index() { return View(); } [HttpPost] public async TaskIActionResult Ask(string message, CancellationToken ct) { if (string.IsNullOrWhiteSpace(message)) { return Json(new { ok false, error 消息不能为空 }); } try { var reply await _chatService.ChatAsync(message, ct: ct); return Json(new { ok true, data reply }); } catch (OperationCanceledException) { return Json(new { ok false, error 请求超时或已取消 }); } catch (Exception ex) { // 记日志给用户友好提示 return Json(new { ok false, error 服务器开小差了 }); } } }View里我用了最简单的Ajax方式表单提交到Chat/Ask返回JSON后插入到页面。前端这一块不是重点真正容易翻车的是超时和并发下一节专门说。4. 本机QWen接入Ollama部署与C#对接的完整链路4.1 为什么选Ollama而不是直接跑Python推理在本机跑QWen的方案有好几种用Transformers库加载模型、用llama.cpp编译、用Ollama一键部署。我直接说结论纯C#工程里Ollama是性价比最高的选择。原因有三点第一Ollama把模型下载、量化、常驻内存、并发调度全包了你不需要在Windows上折腾Python环境和CUDA第二它自带的HTTP API支持OpenAI兼容模式/v1/chat/completions和云端代码几乎同构第三模型管理极其简单一条命令行就能换模型。安装步骤从Ollama官网下载Windows安装包装完是个后台服务托盘里有图标命令行执行ollama pull qwen2.5:7b等下载完成执行ollama run qwen2.5:7b能聊天就说明部署成功默认监听http://localhost:11434需要注意模型体积qwen2.5:7b的量化版约4.7GBqwen2.5:14b约9GB。建议7B起步14B在16G内存的机器上勉强能跑但多开几个浏览器标签就直接卡死。如果你有NVIDIA显卡执行ollama run qwen2.5:7b --gpu会启用GPU推理速度能快好几倍。4.2 C#这边怎么对接Ollama对接代码和云端非常像只有BaseUrl和模型名不同。但有一个关键区别Ollama原生API是http://localhost:11434/api/chatOpenAI兼容端点是http://localhost:11434/v1/chat/completions。为了保持上层逻辑统一我直接用兼容端点public class LocalQwenService : IQwenChatService { private readonly HttpClient _httpClient; private readonly IConfiguration _config; public LocalQwenService(HttpClient httpClient, IConfiguration config) { _httpClient httpClient; _config config; } public async Taskstring ChatAsync(string userMessage, string? systemPrompt null, CancellationToken ct default) { var baseUrl _config[LLM:Local:BaseUrl]; var model _config[LLM:Local:Model] ?? qwen2.5:7b; var requestBody new { model model, messages new object[] { new { role system, content systemPrompt ?? 你是一个乐于助人的中文助手。 }, new { role user, content userMessage } }, stream false }; var request new HttpRequestMessage(HttpMethod.Post, ${baseUrl}/v1/chat/completions); request.Content JsonContent.Create(requestBody); using var timeoutCts CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(TimeSpan.FromSeconds(_config.GetValue(LLM:Local:TimeoutSeconds, 120))); var response await _httpClient.SendAsync(request, timeoutCts.Token); response.EnsureSuccessStatusCode(); var json await response.Content.ReadAsStringAsync(ct); var result JsonSerializer.DeserializeChatCompletionResponse(json); return result?.Choices?[0]?.Message?.Content ?? string.Empty; } }注意一个细节本地API不需要ApiKey所以别加Authorization头加了反而有概率被Ollama拒掉。4.3 实测性能与并发表现我在公司的测试机上做过一轮对比配置是i7-12700 32G内存 无独显模型首次响应时间完整回答约300字CPU占用云端qwen-plus1.5秒3秒忽略本机qwen2.5:7b3秒25秒约80%本机qwen2.5:14b6秒55秒接近100%这个差距让我明确了一个事实本机LLM适合企业内部知识库问答这类对延迟不敏感的场景绝不适合做面向C端用户的在线对话产品。另外Ollama默认并发为1也就是同一时间只处理一个请求第二个请求会排队。如果你有并发需求启动Ollama前设环境变量OLLAMA_NUM_PARALLEL4并且给每个请求加keep_alive: 5m来避免频繁重新加载模型。5. 真实踩坑清单四类问题逐一排查与解决5.1 请求超时默认HttpClient挖的坑第一次调用云端QWen我直接用new HttpClient()发请求结果频繁抛TaskCanceledException。排查后发现是HttpClient默认超时只有100秒而QWen在某些复杂问题下生成速度会拖到100秒以上。这个好解决在Program.cs里统一配置builder.Services.AddHttpClient(llm, client { client.Timeout TimeSpan.FromSeconds(180); });但更隐蔽的问题是你用AddHttpClient注册的单例服务内部注入了HttpClient这个客户端如果没给BaseUrl每次调用都要拼完整URL。而且.NET 8里AddHttpClient()注册的客户端默认超时也是100秒。我的方案是注册具名客户端然后服务里通过IHttpClientFactory.CreateClient(llm)获取超时时间、重试策略都集中管理。5.2 中文乱码问题Console正常网页却乱本机模型返回中文时控制台输出完全正常但MVC页面拿到的是\u9519\ u8bef这种Unicode转义序列——严格说不是乱码是JSON里中文被转义了。这是因为JsonContent.Create默认输出转义非ASCII字符而JavaScript解析JSON时会自动解码但如果你的前端没有正确parse直接把字符串显示就会看到一堆反斜杠。两个修法要么在前端JSON.parse之后再显示要么在后端配置不转义中文。我推荐前者因为前端本来就要parse响应。如果非要在后端处理把JsonSerializerOptions改成var options new JsonSerializerOptions { Encoder JavaScriptEncoder.UnsafeRelaxedJsonEscaping };这个UnsafeRelaxedJsonEscaping不要乱用它会把HTML标签也原样输出有XSS风险。正确做法是只管显示层。5.3 上下文管理为什么模型像失忆了一样把QWen接进MVC后我第一个Demo是连续对话结果发现模型完全不记得前文。原因很蠢我每次调用只传了当前这一条用户消息没有把历史对话一起传进去。大模型本身是无状态的上下文全靠你每次请求里带上的messages数组。正确做法是前端维护一个会话列表每次请求把最近N轮对话全部带上var messages new Listobject(); if (!string.IsNullOrEmpty(systemPrompt)) { messages.Add(new { role system, content systemPrompt }); } // 从会话缓存取最近10条 foreach (var item in recentMessages) { messages.Add(new { role item.Role, content item.Content }); } messages.Add(new { role user, content userMessage });但这里有个学问不是带得越多越好。QWen的上下文窗口虽然大qwen-plus有32K但每多一轮请求体和响应时间都会增加。我实测下来10轮以内的历史对话体验最佳超过10轮后建议用摘要压缩而不是穷举。5.4 并发问题ASP.NET Core的异步模型不能乱用这是最危险的坑。有些初学者看到async就无脑Task.Run然后在线程池里调用QwenService。要知道ASP.NET Core的请求管道本身就是异步的你只需要在控制器方法上加async用await等待就能轻松支持高并发不需要自己Task.Run。手动Task.Run反而会额外占用线程池线程在高并发下导致吞吐量下降。我踩过一次用Task.Run(() _chatService.ChatAsync(...))包了一层压测时100个并发请求直接线程池饥饿页面全部超时。后来把Task.Run去掉100并发轻松通过CPU和内存占用反而更低。记住口诀异步代码全程async/await中间绝不出现.Result、.Wait()或Task.Run阻塞等待。这是ASP.NET Core高并发的生命线。6. 工程化扩展从能跑到好用的三个方向6.1 引接RAG让QWen回答你私有的知识跑通了基础对话下一步很自然就是做知识库问答。思路是用Embedding模型把PDF、Word等文档切成块并向量化用户提问时先检索最相关的几段文本然后把这些文本作为上下文拼进System Prompt或User Message里再发给QWen。C#这边我用的方案是文档解析用Aspose.Words或iText7PDF切片逻辑自己写按段落切每段加上前后各一段的overlap避免语义断裂向量化直接用QWen的Embedding接口DashScope有text-embedding-v3不用自己糊一个向量存储初期用内存里的ListT加余弦相似度计算几千条数据完全够用部署到生产再换真正的向量数据库Qdrant、Milvus都有C#客户端。这一步能让QWen真正变成懂你业务的模型而不只是一个通用聊天机器人。6.2 Function Calling让模型变成流程调度器QWen支持Function Calling工具调用这意味着模型可以决定什么时候调用你写的函数。我的实际案例是做一个智能工单系统用户说查一下订单12345的物流状态QWen识别出意图返回一个结构化工具调用请求你的代码解析后调用真实的物流查询接口再把结果交回模型整理成自然语言回复。实现上比普通对话多两步在请求体里加tools字段描述你的函数签名解析响应里的tool_calls执行函数把结果作为role: tool的消息再次提交这个能力能把MVC应用从表单提交式升级成自然语言驱动式也是LLM应用从玩具走向产品的关键一步。6.3 GitHub Copilot在这个项目里的正确打开方式回到标题里的Copilot。我实际用它干了三件出活最快的事生成DTO把QWen的JSON响应结构描述给它让它生成C#类比自己手写快10倍补全HttpClient模板告诉它用IHttpClientFactory和CancellationToken写一个POST JSON的方法直接产出可用代码解释报错遇到OperationCanceledException间歇性冒出来让它分析可能原因它给出的链接令牌超时方案帮我快速定位但Copilot也有明显短板它不了解你这个项目的架构约束比如双通道抽象、依赖注入方式你只问如何调QWen它会给出五花八门的答案。正确的姿势是先把架构定好再用Copilot加速具体片段的编码千万别反过来让Copilot帮你做架构决策。写在最后的一点体会把这一整套跑通之后我最深的感受是LLM工程真正的难点不在模型本身而在工程化——超时怎么处理、上下文怎么管理、并发怎么设计、通道怎么切换。QWen官方文档给的Python示例很完善但C#/ASP.Net MVC的参考少得可怜很多问题都是靠抓包、看源码、一遍遍试错才搞定的。如果这篇能帮你少走两步弯路那就不白写。后续我会再整理RAG落地的完整代码和Function Calling的实战细节有兴趣的可以持续关注。