☰
C# ASP.NET MVC接入通义千问:云端与本机双通道实践
2026/10/2 18:53:21 网站建设 项目流程

这个标题看着绕,其实拆开就三件事:在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 云端与本机两条路线的取舍

做这个项目之前,我先拉了一张对比表,把需求列清楚再选路线:

维度云端QWen(DashScope API)本机QWen(Ollama部署)
部署成本零部署,开箱即用需要下载模型,占几个G磁盘
硬件要求无,服务器端跑建议16G以上内存,纯CPU也能跑但慢
数据安全数据出网数据完全本地
响应速度网络好时快,但不稳定稳定但受限于本机算力
费用按Token计费一次性硬件成本
配置难度低,一个API Key搞定中,要配置模型、端口、并发

我的结论很明确:生产环境看场景,开发环境两条都要。开发时用云端,调试方便、反馈快;交付给内部系统或涉密场景时切本机。所以这个项目的核心不是"二选一",而是设计一个可以灵活切换的抽象层。

2. 项目骨架搭建:VS里创建MVC应用与依赖注入设计

2.1 版本选择与创建步骤

我用的是Visual Studio 2022(17.8以上版本),.NET 8,ASP.NET Core MVC。如果你还在用.NET Framework的老MVC,建议直接迁移,原因后面讲异步调用时会提到——老框架的异步模型和现代HttpClient配合起来非常别扭。

创建步骤很简单:

  1. VS里选"创建新项目" → "ASP.NET Core Web应用(模型-视图-控制器)"
  2. 框架选.NET 8,身份验证选"无"
  3. 创建完后,项目结构里自带Controllers、Views、Models三个文件夹

这一步没什么花样,但有个细节值得注意:在"其他信息"里勾上"配置HTTPS",后面调试时本机OLlama如果用localhost,HTTP和HTTPS混用会触发浏览器的混合内容拦截,提前统一一下省得后面烦。

2.2 用接口抽象出双通道

这是整个工程的地基。不管云端还是本机,对上层MVC控制器来说,要的就是一个方法:传入用户问题,返回模型回答。所以先定义一个接口:

public interface IQwenChatService { Task<string> 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.AddSingleton<IQwenChatService, CloudQwenService>(); } else { builder.Services.AddSingleton<IQwenChatService, 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 ILogger<CloudQwenService> _logger; public CloudQwenService(HttpClient httpClient, IConfiguration config, ILogger<CloudQwenService> logger) { _httpClient = httpClient; _config = config; _logger = logger; } public async Task<string> 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.Deserialize<ChatCompletionResponse>(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 Task<IActionResult> 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),和云端代码几乎同构;第三,模型管理极其简单,一条命令行就能换模型。

安装步骤:

  1. 从Ollama官网下载Windows安装包,装完是个后台服务,托盘里有图标
  2. 命令行执行ollama pull qwen2.5:7b,等下载完成
  3. 执行ollama run qwen2.5:7b,能聊天就说明部署成功
  4. 默认监听http://localhost:11434

需要注意模型体积:qwen2.5:7b的量化版约4.7GB,qwen2.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/chat,OpenAI兼容端点是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 Task<string> 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.Deserialize<ChatCompletionResponse>(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_PARALLEL=4,并且给每个请求加"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 List<object>(); 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或iText7(PDF)
  • 切片逻辑自己写:按段落切,每段加上前后各一段的overlap,避免语义断裂
  • 向量化直接用QWen的Embedding接口(DashScope有text-embedding-v3),不用自己糊一个
  • 向量存储初期用内存里的List<T>加余弦相似度计算,几千条数据完全够用

部署到生产再换真正的向量数据库(Qdrant、Milvus都有C#客户端)。这一步能让QWen真正变成"懂你业务"的模型,而不只是一个通用聊天机器人。

6.2 Function Calling:让模型变成流程调度器

QWen支持Function Calling(工具调用),这意味着模型可以决定什么时候调用你写的函数。我的实际案例是做一个智能工单系统:用户说"查一下订单12345的物流状态",QWen识别出意图,返回一个结构化工具调用请求,你的代码解析后调用真实的物流查询接口,再把结果交回模型整理成自然语言回复。

实现上比普通对话多两步:

  1. 在请求体里加tools字段,描述你的函数签名
  2. 解析响应里的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的实战细节,有兴趣的可以持续关注。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询