
最近在Visual Studio里把一个本机LLM工程完整搭了起来项目代号就叫学伴机器人。整套技术栈正是标题里那串关键词的组合C# ASP.Net MVC做应用层本机部署开源大模型当中枢再往下用C#的NanoFramework.Net写了一块下位机扩展板负责接传感器、按键这类硬件外设。这篇文章不写广告、不贴宣传话术纯粹是把整个工程从架构思路到关键代码、再到调试现场踩过的坑完整串一遍给同样想在.NET生态里做本地AI应用的朋友一份能直接参考的工程笔记。先说结论如果你想在VS里做一套本机LLM Web界面 硬件控制的综合应用ASP.Net MVC依然是非常稳的中间层选择。它不像Blazor那样有较重的运行时依赖也不像WinForms/WPF那样天然和Web界面割裂更关键的是——控制器、服务层、依赖注入这套结构正好能承接用户请求 - 大模型推理 - 设备控制这条完整链路。加上NanoFramework.Net可以把C#直接跑在MCU上整个项目的技术栈从头到尾都是.NET维护成本大幅降低。1. 整体架构思路为什么用MVC做本机LLM工程的门面1.1 拆解标题里的三层结构先把项目标题拆开看你会发现它其实是一个三层架构应用层VS C# ASP.Net MVC。对外提供Web页面、接收用户输入、展示模型回复。模型层本机部署的LLM。负责理解自然语言、生成回答、输出指令。设备层NanoFramework.Net下位机。通过串口或TCP连接传感器、按键、显示屏、蜂鸣器等硬件。很多人第一反应是本机LLM直接用Python调不就行了确实Python在AI生态里更顺手但如果你本身是做C#的或者你希望整个项目留在.NET体系内方便后面做上位机集成那这套纯C#方案的价值就体现出来了。用一个生活化的类比MVC应用是前台接待本机LLM是坐镇后厨的大厨NanoFramework下位机是负责送餐和端盘子的服务员。前台接了客人点单转给后厨出菜服务员跑腿。三者各司其职中间用明确的接口协议连接这是整个项目能稳定运转的前提。1.2 为什么放弃前端框架和桌面方案这个项目一开始我其实纠结过技术选型。第一个念头是干脆搞个前后端分离Vue WebAPI但后来放弃了。原因很实际本机工具类应用用户只有你自己或家人不需要复杂的工程化前端也不需要打包发布到服务器。ASP.Net MVC的Razor视图直接在服务端渲染把模型返回的文本塞进页面就行部署的时候一个进程搞定不依赖Node环境这对本机运行这个核心诉求来说是最省心的方案。第二个备选是WinForms或WPF。桌面程序做聊天界面其实也不差但有个硬伤后续我想要在手机或平板上访问这个机器人桌面程序就没有任何优势了。而MVC起的就是一个本地Web服务手机浏览器直接访问局域网IP就能用天然跨设备。再加上MVC的URL路由很清晰比如/Chat/Ask、/Device/Status后面接REST接口、接下位机指令都方便。如果你非要用Blazor我不拦你但请想清楚Blazor Server需要维持SignalR长连接每增加一个客户端都会占用服务端内存并且本机跑的模型本来就在吃内存和显存再让Blazor的会话状态上来凑热闹很容易把16G内存的机器拖垮。MVC的无状态请求模型反而更适合每次对话都是一次独立调用的LLM应用形态。2. 核心链路实操本机LLM部署与C#调用细节2.1 LLM选型与Ollama部署本机LLM的选型是第一个关键决策。我在项目里用的方案是Ollama它把模型管理、量化、API服务一并解决了安装后在命令行执行ollama run qwen2.5:7b这个命令会下载并启动Qwen 2.5 7B模型默认监听本机的11434端口提供一个HTTP API接口。选择Ollama的理由其实有三点。第一它对硬件的要求足够宽容7B模型经过4-bit量化后大概需要5到6G内存普通16G内存的电脑跑起来完全没问题连显存都可以不需要纯CPU推理也能出结果第二API接口非常简洁和OpenAI的接口格式基本对齐后面就算想换成其他模型提供商C#侧的代码改动成本也很低第三Ollama自带模型管理命令ollama list查看已下载模型ollama pull拉新模型ollama rm删除模型比手动去Hugging Face下载GGUF文件再配置推理服务省事太多了。这里有一个很实际的补充如果你手里的电脑比较老或者你想在嵌入式级别的设备上跑更小的模型我建议试试qwen2.5:3b或者llama3.2:3b。3B模型在纯CPU环境下每生成一个token大概在20到40毫秒对话体验虽然称不上丝滑但对于学伴机器人这种回答以短文本为主的场景完全够用。7B模型每token大概在50到100毫秒聊天时能感觉到明显停顿但可以接受。部署完之后先用命令行验证一下是否正常工作curl http://127.0.0.1:11434/api/chat -d {\model\:\qwen2.5:7b\,\messages\:[{\role\:\user\,\content\:\你好\}],\stream\:false}如果返回类似{message:{role:assistant,content:你好...}}这样的JSON说明模型服务已经就绪可以进入C#集成阶段了。2.2 MVC侧封装LLM调用服务直接在每个控制器里放HttpClient去Post请求Ollama接口是最简单但也是最容易写出问题的姿势。我建议在MVC项目中单独建一个服务层用依赖注入管理LLM调用这样控制器只关心请求参数和视图模型不关心模型怎么调用。来看一个标准的服务封装示例public class OllamaClient { private readonly HttpClient _http; public OllamaClient(HttpClient http) { _http http; } public async Taskstring ChatAsync(string model, string systemPrompt, string userMessage) { var request new { model model, messages new[] { new { role system, content systemPrompt }, new { role user, content userMessage } }, stream false, options new { temperature 0.6, num_ctx 4096 } }; var json JsonSerializer.Serialize(request); var content new StringContent(json, Encoding.UTF8, application/json); var response await _http.PostAsync(http://127.0.0.1:11434/api/chat, content); response.EnsureSuccessStatusCode(); var resultJson await response.Content.ReadAsStringAsync(); using var doc JsonDocument.Parse(resultJson); return doc.RootElement .GetProperty(message) .GetProperty(content) .GetString(); } }然后在Startup.cs或Program.cs里注册var handler new SocketsHttpHandler { PooledConnectionLifetime TimeSpan.FromMinutes(5), ConnectTimeout TimeSpan.FromSeconds(10) }; services.AddHttpClientOllamaClient(client { client.Timeout TimeSpan.FromMinutes(5); }).ConfigurePrimaryHttpMessageHandler(() handler);这里有几个值得注意的技术细节。第一HttpClient不能每次请求都new一个。如果你在控制器里写using var client new HttpClient()高并发下很快会把Socket耗尽Windows里就会报无法将数据写入传输连接: 远程主机强迫关闭了之类的异常。这个坑在热搜词的关联问题里也出现过。用AddHttpClient注册为单例让连接池复用才是正确姿势。第二Ollama处理一个完整请求可能需要几十秒甚至更久HttpClient.Timeout默认只有100秒如果你用较小模型通常够用但一旦模型首次加载、或者用户输入特别长很容易超时。所以我把超时设成5分钟。第三streamfalse这个参数让API一次性返回完整JSON。流式返回虽然体验更好但解析逻辑会复杂不少我在第一版里优先保证功能跑通后续再优化交互体验。2.3 对话历史与提示词设计先看System Prompt的设计。学伴机器人不是通用聊天机器人它需要有一个稳定的人设。我当前在用的System Prompt是这样你是一个陪伴中学生学习的机器人助手名字叫小伴。 你的回答要简明、耐心、有启发性每次尽量控制在200字以内。 当学生遇到不会的知识点时先引导他回顾相关概念再给出解题思路而不是直接甩答案。 当学生表达情绪困扰时倾听并给予鼓励不要说教。 如果遇到你不确定的内容诚实地告诉学生你不确定并建议查阅课本或向老师请教。这套Prompt有三个关键设计明确回答长度、明确教学风格、明确能力边界。没有长度约束模型会经常写小作文在本地推理的场景下反而是负担。再来看对话历史管理。MVC请求默认是无状态的所以每轮对话的上下文要么存在Session里要么存数据库。我第一版用的是MemoryCache加Session的组合——用Session存当前会话ID用MemoryCache存对话历史列表超过一定轮次就裁剪。public ListChatMessage GetHistory(string sessionId) { var cacheKey $chat_history_{sessionId}; return _cache.GetOrCreate(cacheKey, entry { entry.SlidingExpiration TimeSpan.FromHours(1); return new ListChatMessage(); }); }这里有一个经验上下文窗口不能无限塞。7B模型的num_ctx默认是4096个token大概对应几千个汉字对话轮数一多历史消息加提示词很容易撑爆。我的裁剪策略是只保留最近6轮对话超出部分直接从列表头部移除同时在发API请求前先估算一下token长度超过限制就把最早的对话丢出去。public ListChatMessage TrimHistory(ListChatMessage history, int maxTokens 3000) { var copy new ListChatMessage(history); while (copy.Count 6 || EstimateTokens(copy) maxTokens) { copy.RemoveAt(0); } return copy; }Token估算不用精确计算中文字符大约每1.5到2个字符算一个token英文按单词数估算就行误差20%以内不影响使用。温度参数也要根据场景调整。知识问答和作业辅导我固定用temperature0.4让回答更稳定、更接近事实如果做生活机器人里的闲聊陪伴则调到0.8左右让语气更活泼自然。这个参数的调整直接决定你在学伴和生活陪伴两种角色之间切换的质量。3. 扩展思考NanoFramework.Net下位机与生活机器人场景落地3.1 NanoFramework.Net是什么NanoFramework.Net可能是整个项目里最容易被忽略、却最能拉开体验差距的一层。它是.NET的一个微框架实现专门跑在MCU微控制器上比如STM32系列、ESP32系列。它让C#代码可以直接运行在嵌入式设备上不需要写一行C/C这对我来说非常友好——长期写C#的人去碰Keil、IAR那套工具链真的很痛苦而NanoFramework的思路和Visual Studio完美结合。在VS里接入的方式很简单安装Nano Framework扩展插件创建项目时选择NanoFramework Class Library模板用NuGet安装System.Device.Gpio、System.IO.Ports这些包写代码、编译、通过串口/USB烧录到开发板上然后在VS里打断点调试。这个开发体验和写普通C#几乎一样极大地降低了嵌入式开发的门槛。我用的硬件是STM32F429开发板它有足够的内存跑NanoFramework运行时同时引出了大量GPIO引脚方便接各种传感器。如果你手头是ESP32也可以ESP32本身支持WiFi甚至可以跳过串口直接用TCP和上位机通信。3.2 下位机通信协议设计上位机MVC应用或独立C#服务和下位机之间通常用串口或TCP通信。串口相对简单TCP适合远距离和网络化部署。我先用的串口因为调试直观、不容易出问题。串口通信绝对不能裸发字符串就完事。一定要设计帧格式否则数据错位、半包黏包会让你怀疑人生。我的协议设计如下帧头(0xAA 0x55) 数据长度(1字节) 命令字(1字节) 数据(N字节) CRC8校验(1字节)举个例子下位机向上位机上报按键事件AA 55 03 01 01 8F其中03是数据区包含命令字在内的长度01是命令字表示按键事件01是按键编号8F是前面几个字节的CRC8校验值。上位机C#侧用SerialPort读取并解析帧public class SerialProtocolParser { private readonly Listbyte _buffer new(); private readonly object _lock new(); public void Feed(byte[] data) { lock (_lock) { _buffer.AddRange(data); TryParseFrames(); } } private void TryParseFrames() { while (_buffer.Count 4) { if (_buffer[0] ! 0xAA || _buffer[1] ! 0x55) { _buffer.RemoveAt(0); continue; } int length _buffer[2]; if (_buffer.Count 3 length 1) return; var frame _buffer.GetRange(0, 3 length 1).ToArray(); _buffer.RemoveRange(0, frame.Length); if (Crc8(frame, 0, frame.Length - 1) frame[^1]) { ProcessFrame(frame); } } } }DataReceived事件里要注意串口驱动并不能保证每次回调拿到的就是完整的一帧所以必须用缓冲区拼接然后逐帧解析。这是做串口通信最常见的坑之一我第一版直接按回调里的字节当完整消息处理结果帧错乱到根本没法用。3.3 学伴/生活机器人场景下的设备分工在学伴机器人场景里下位机承担实体互动的角色。我目前规划并已经验证的部分包括答题签到器下位机接两个物理按键代表会和不会。学生每做完一道题按下按键上位机收到命令后记录答题结果同时让LLM根据结果调整后续出题难度。坐姿检测器下位机接超声波传感器检测学生和桌面的距离距离过近就触发蜂鸣器提醒同时上报时间点给上位机上位机生成一段你已经学习45分钟了注意休息的提示。OLED状态屏用I2C接口接一块0.96寸OLED显示当前模型状态、今日学习时长、最近一次交互时间。这是提升机器人感的关键——有屏幕就有反馈用户能实时看到设备在干活。NanoFramework侧的GPIO操作示例using System.Device.Gpio; using System.Diagnostics; var controller new GpioController(); var buttonPin controller.OpenPin(18, PinMode.InputPullUp); var ledPin controller.OpenPin(17, PinMode.Output); if (controller.Read(buttonPin) PinValue.Low) { controller.Write(ledPin, PinValue.High); Debug.WriteLine(Button pressed, notify host); }生活机器人场景则更广泛一些下位机可以接温湿度传感器DHT11或SHT30上报室内环境数据接红外传感器做人体检测接家庭照明开关控制模块做简单的执行器。上位机把这些数据和LLM的回复综合起来MVC页面展示当前室温26.3湿度45%建议开窗通风这样的智能生活建议。4. 常见问题与排查技巧实录4.1 Ollama侧的典型问题模型加载特别慢。第一次请求总是非常慢因为模型要读盘加载进内存。Ollama会保留模型会话一段时间但其实在Windows上如果你长时间不调用内存会被回收。如果你希望模型常驻可以在调用前先执行一次ollama run保持一个交互会话或者在代码里启动后发一个空请求预热。端口被占用。11434端口被其他服务占了的话Ollama会启动失败。排查方式很简单netstat -ano | findstr 11434看看进程然后杀掉占用进程或者在Ollama的环境变量里换端口。显存不足。如果你选择了过大参数的模型比如70B但实际上你只有8G显存跑起来会卡到无法使用。解决办法不在Ollama而在模型本身——选择量化程度更高的版本比如从Q8降到Q4或者直接换用小参数模型。在Ollama里用/set parameter num_gpu 0可以强制纯CPU推理。API返回超时。如果请求体特别长或者模型正在同时处理多个请求API响应时间会很长。C#侧超时设短了就会反复失败。建议把Ollama的并发请求数限制为1设置环境变量OLLAMA_NUM_PARALLEL1避免多个请求同时排队互相阻塞。4.2 C#集成层的经典翻车现场HttpClient不够用。前面提到过每次请求都new HttpClient会导致Socket耗尽在Windows事件日志里能看到大量端口耗尽警告。这不是Slow Code的问题是使用方式的问题。生产环境务必用IHttpClientFactory或者注册单例。这个坑我再强调一次因为热搜词里的远程主机强迫关闭了十有八九就是它引起的。中文乱码。中文乱码通常有两个来源。一个是StringContent没有指定Encoding.UTF8导致JSON请求体以默认编码发送另一个是响应读取时没有正确处理UTF-8。C#里用response.Content.ReadAsStringAsync()默认会按响应头声称的编码解Ollama返回的Content-Type一般带charsetutf-8所以重点检查请求体编码。异步死锁。如果你在老的ASP.NET MVC.NET Framework 4.8及以下里用了async方法又在控制器里用.Result或.Wait()同步等待十有八九会死锁。解决方式是全程await或者在库代码里加ConfigureAwait(false)。在.NET Core里没有这个坑但如果你的项目还是老框架一定要检查。我项目里用的还是经典MVC所以踩过这个坑后来全部改成async TaskActionResult才解决。串口数据帧错乱。这个前面说过了核心是缓冲区拼接和帧校验。另外一个容易忽略的问题是串口缓冲区的清理——如果上位机启动时串口里已有残留数据第一条帧一定是错乱的所以打开串口后先清空缓冲区。我的做法是_port new SerialPort(COM3, 115200, Parity.None, 8, StopBits.One); _port.DataReceived OnDataReceived; _port.Open(); _port.DiscardInBuffer(); _port.DiscardOutBuffer();4.3 NanoFramework调试的坑调试器连接不上。NanoFramework的设备要用专用的调试器连接基于CMSIS-DAP的调试器通常可以通过NuGet包自动识别。如果连不上先检查VS的扩展是否正确安装再检查设备是否处于Bootloader模式。STM32F429进入系统Bootloader的方式是把BOOT0引脚拉高再上电。内存太小。我的板子是STM32F429有2MB Flash和256KB RAM但NanoFramework运行时加上托管堆之后并不宽裕。如果代码里用了大量的string拼接内存会很快见底设备自动重启。解决方案是减少中间字符串分配能用byte[]就用byte[]能复用缓冲区就复用。GPIO编号搞错。NanoFramework的GPIO引脚编号和MCU原理图上的编号不一定一致以程序库识别到的编号为准。先写一个小程序逐个引脚闪烁LED确认引脚号和物理位置对应再开始接传感器。这是避免硬件接线错误的最快方式。5. 安全边界与后续扩展思考5.1 本机部署的隐私价值和边界整套方案最核心的价值就是数据不出门。学伴机器人处理的是孩子的学习数据、答题记录、情绪反馈这些信息属于比较敏感的家庭隐私。本机部署的LLM意味着评论和对话内容不会离开这台机器没有第三方平台收集数据也没有云端审核这在家里使用场景下是不小的心理优势。但本机部署不等于绝对安全。MVC应用通常是监听本机或局域网端口的Web服务如果你在路由器上做了端口映射外部也能访问这时候就必须加认证。最简单的方案是加一个登录页用Cookie认证保护所有页面粗暴一点的方案是在应用启动时只绑定127.0.0.1外部一律不开放。我个人的建议是默认只监听本机要手机访问时再临时放开局域网IP并加一个简单的访问令牌放在请求Header里。5.2 从学伴扩展成生活管家的路径这套架构天然支持后续扩展成生活机器人。下位机已经有传感器和执行器LLM已经有理解和生成能力MVC层已经能展示和记录状态剩下要做的就是丰富场景语义。一种扩展方向是RAG检索增强生成。把家里的使用手册、孩子的课程表、错题本这些私有文档切分、向量化存进本地向量数据库然后在LLM回答前先检索相关片段作为参考上下文。C#侧可以用Microsoft.ML做文本处理或者调用本机部署的嵌入模型服务生成向量整个过程还是保持数据不出门。另一种扩展方向是定时任务与提醒。MVC层做一个后台调度器每天定时把该喝水了、该复习数学了这类消息推送到页面上下位机通过蜂鸣器或LED灯做出物理提醒。用System.Threading.Timer或者Quartz.NET都可以逻辑不复杂但能把学伴从被动问答变成主动关怀体验上完全不一样。我个人在实际操作中的体会是这套架构最难的部分不是写代码而是想清楚LLM该在哪一层介入。很多人一开始会把LLM当成数据库来用让它直接回答室温多少度结果发现模型根本不知道传感器数据。正确做法是传感器数据先由下位机上报、由传统代码解析和存储LLM只负责把解析后的数据转化成自然语言回复。AI负责理解与表达传统代码负责确定性与可靠性两者各管一段整个系统才立得住。最后再分享一个小技巧本机LLM的响应速度不如云API所以在MVC页面里一定要加正在思考的加载提示并且尽量用异步请求不要让用户等页面超时。我第一版是同步表单提交点完按钮页面转圈十几秒体验很差改成AJAX加异步等待后至少交互上不再让人烦躁。先把链路跑通再把体验打磨好这条工程方法论在本地AI应用里一样适用。