
半年前我想做一台摆在书桌上的学伴机器人能回答常识问题、提醒喝水、还能控制旁边的小灯和蜂鸣器。第一反应是上Python但我日常写C#实在不想为一个小玩具再去搭一套FastAPI加虚拟环境。后来发现用VS里现成的C#.ASP.Net MVC项目当成本机服务层再配合C#可直接调用的本地LLM运行时让Windows直接跑.gguf或者.onnx模型下位机部分用C#的NanoFramework跑在ESP32这类嵌入式设备上整个链路居然能完整留在.NET生态里。这篇文章就记录这套本机LLM工程的整体思路适合想用C#做桌面级“学伴/生活机器人”的开发者参考。我做这个项目之前查了不少资料发现大多数本地大模型教程默认是Python生态LLM相关的类库、示例代码全是PythonC#开发者要么临时学Python要么绕道走HTTP调外部服务。绕道走不是不行但小型桌面机器人要的是“本地、离线、低维护”再单独维护一个Python进程这本身就不够工程。真正把模型嵌入到C#工程以后整个开发体验比想象中顺尤其是Visual Studio调试、断点看推理结果、直接挂在MVC里做Web API这些步骤都省心。下面我把完整思路拆开讲。1. 先拆拓扑本机模型、服务层与下位机各干各的活1.1 一套三层的本地架构整个系统可以粗暴分成三层模型层、服务层、设备层。模型层跑在Windows主机上可能是你的开发机、一台旧笔记本、或者一台NUC。它负责加载本地LLM模型文件接收文本生成回复。模型层不关心谁在调用只负责“文字进、文字出”。选择直接运行.gguf或.onnx是因为这两个格式是当前最容易在Windows上离线运行的模型载体比云端API更可控也不会把聊天记录送到外部。服务层是C#.ASP.Net MVC工程。它做的事情是加载模型、管理对话上下文、暴露HTTP接口、解析指令、处理并发。你可以把MVC理解成一个“大脑接线板”把模型和下位机连接起来。上位机不是只有Web界面下位机、手机、网页都能通过HTTP接口来取回复。设备层是NanoFramework下位机。NanoFramework是一个可以在嵌入式设备上跑C#的运行时支持ESP32等常见芯片。它内存极小跑不了大模型但它能读按钮、驱动舵机、控制LED、显示文字。设备层通过Wi-Fi或串口连接到服务层负责“物理动作”。1.2 数据流和依赖方向数据流分两条上行是用户按下按钮或发出语音下位机把文本通过HTTP POST发给服务层服务层调用本地LLM推理得到回复后回传下行是服务层把结构化指令比如“打开台灯”解析出来下位机收到后执行动作。依赖方向永远是设备层依赖服务层服务层依赖模型层不要反过来。我见过有人想把LLM直接塞进ESP32这不现实。一个稍微能看的对话模型最少也要几百MB参数而ESP32只有几百KB内存这个量级差距决定了LLM必须在主机侧。NanoFramework下位机只做两件事采集输入和表现输出。把“思考”和“行动”拆开整个工程量会小很多。1.3 为什么用ASP.NET MVC而不是裸控制台程序有朋友问既然只是给下位机提供接口写个控制台程序监听TCP不就行了确实可以但MVC带来的好处是现成的HTTP路由、JSON序列化、依赖注入、日志、跨域配置这些都不用手写。尤其是你以后想同时服务多个客户端手机APP、网页、下位机、桌面语音助手MVC天然就是一个多客户端网关。另外ASP.NET MVC本身是进程内的Web服务它可以直接引用模型推理的类库不必要再拆一个独立服务。如果模型推理太慢阻塞了请求可以通过异步方法和信号量排队来解决后面我会展开讲。这一层架构的核心思路就是让MVC既当“API网关”又当“模型宿主”减少进程数量和部署成本。2. 模型落地的两条主路Windows直跑gguf还是转成onnx2.1 gguf是当前对话模型最省心的格式GGUF是llama.cpp系列项目推行的模型格式专门为CPU/GPU混合推理优化。它最大的特点是量化做得很好把原始浮点参数压缩成4bit或8bit模型体积显著缩小内存占用也随之降低。一台没有独立显卡的机器只要内存足够也能跑一个中等规模的量化模型。在C#里跑GGUF我用的方式是LLamaSharp它是llama.cpp的C#绑定。加载一个模型文件创建上下文拿到执行器之后就能生成文本。大致代码如下using LLama; using LLama.Common; var modelPath D:\local-models\companion-7b-q4_k_m.gguf; var parameters new ModelParams(modelPath) { ContextSize 2048, GpuLayerCount 0, // 纯CPU模式有显卡可以调大 Threads 6, BatchSize 512 }; using var weights LLamaWeights.LoadFromFile(parameters); using var context weights.CreateContext(parameters); var executor new InteractiveExecutor(context); var prompt 用户说提醒我喝水。\n学伴回复; var outputs executor.Inference(prompt, new InferenceParams { Temperature 0.7f, MaxTokens 256 }); foreach (var text in outputs) { Console.Write(text); }这段代码的关键点是ModelParams里的ContextSize它决定了模型能“记住”多少历史。学伴机器人不需要动不动就处理几十万Token的文档设成2048足够显存和内存都能省不少。GpuLayerCount设为0代表全部走CPU如果你的Windows机器有可用显卡把部分层放到GPU会明显提速。2.2 onnx更轻但大模型转换成本高ONNX是微软主推的开放式模型交换格式C#里可以直接用ONNX Runtime加载。从工程角度ONNX很适合跑小尺寸专用模型比如意图识别、文本分类、句子向量化。这些模型体积小推理速度快加载时间短转成ONNX后可以稳稳地跑在CPU上。但是把一个大语言模型转成ONNX并不是复制粘贴那么简单。原始模型动辄几十亿参数需要处理动态轴、past key values、注意力掩码等一系列算子转完之后的模型文件可能比GGUF量化版更大推理性能也不一定更优。所以我的取舍是对话生成主模型用GGUF直接LLamaSharp跑辅助模型用ONNX比如判断用户这句话是“闲聊”还是“动作指令”或者把问题转成向量做知识库检索。ONNX Runtime在C#里的调用方式很直接using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using var session new InferenceSession(D:\local-models\intent.onnx); var inputData new float[] { 0.1f, 0.2f, 0.3f }; var inputTensor new DenseTensorfloat(inputData, new[] { 1, 3 }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(input, inputTensor) }; using var results session.Run(inputs); var output results.First().AsEnumerablefloat();这里只是示意具体输入输出名要看模型定义。你可以在代码里遍历session.InputMetadata和session.OutputMetadata看模型到底要求什么这比凭感觉猜要稳。2.3 第三条路本地进程当黑盒还有一种很常见的做法在Windows后台跑一个模型服务进程比如ollama、llama.cpp的server模式C#这边直接发HTTP请求。这样C#代码最简单模型管理也交给独立进程。问题在于标题强调“Win直接运行.gguf/.onnx”很多人想要的其实是进程内直接调用不希望再维护一个额外进程。所以我把它当作备用方案不主推。主推还是LLamaSharp进程内推理加ONNX Runtime辅助。三种路径的对比大致如下方案内存占用首次加载耗时与C#集成复杂度适用场景LLamaSharp跑GGUF模型体积加上下文几秒到几十秒低进程内调用对话主模型ONNX Runtime跑小模型相对低几百毫秒低意图识别、向量化独立进程加HTTP取决于模型服务取决于服务极低但多一个进程需要多语言客户端共享3. 用ASP.NET MVC把本机LLM包成“学伴大脑”服务3.1 模型必须常驻内存不能每次请求都加载很多人第一次写的时候会犯一个错把模型加载写在Controller方法里用户问一次加载一次模型。一个7B量化模型可能有一两GB加载要十几秒用户早就放弃了。正确做法是把模型加载放到应用启动时用一个单例服务把模型实例、上下文、执行器都保存在内存里。我在MVC工程里新建了一个ICompanionService启动时通过IHostedService调InitializeAsync。这个服务内部持有LLamaSharp的model和context并提供AskAsync方法。整体接口设计成这样public interface ICompanionService { TaskCompanionResult AskAsync(string text, string sessionId, CancellationToken ct); bool IsModelReady { get; } }Controller只负责解析HTTP请求把业务逻辑留在服务层。这样做的好处是一旦以后要加语音入口、桌面客户端不需要更改模型管理代码。3.2 请求排队模型推理是串行的本地模型推理和GPU服务器不一样窗口进程内跑一个模型同一时刻只能做一次推理如果你同时来两个请求第二个必然被阻塞。如果不管控多个线程同时调用LLamaSharp上下文轻则卡死重则内存错误。解决办法是用一个全局的SemaphoreSlim(1,1)让所有推理请求排队public class CompanionService : ICompanionService { private readonly SemaphoreSlim _inferenceGate new(1, 1); public async TaskCompanionResult AskAsync(string text, string sessionId, CancellationToken ct) { await _inferenceGate.WaitAsync(ct); try { var reply await Task.Run(() RunInference(text, sessionId)); return reply; } finally { _inferenceGate.Release(); } } }这一步很关键。MVC接口看起来是异步的但底层模型接口未必是线程安全的信号量就是加一道保险。3.3 Controller层和健康检查ChatController的代码非常简单[ApiController] [Route(api/[controller])] public class ChatController : ControllerBase { private readonly ICompanionService _companion; public ChatController(ICompanionService companion) { _companion companion; } [HttpPost(query)] public async TaskActionResult Query([FromBody] ChatRequest request, CancellationToken ct) { if (!_companion.IsModelReady) { return StatusCode(503, new { error model loading }); } var result await _companion.AskAsync(request.Text, request.SessionId, ct); return Ok(new { reply result.Reply, command result.Command }); } }另外我强烈建议加一个健康检查接口返回模型是否加载完成、当前排队数、进程内存占用。下位机每次开机后可以轮询这个接口等模型加载完再发第一条消息避免“第一次对话总是超时”的尴尬。4. NanoFramework下位机从按钮触发到动作执行4.1 为什么用NanoFramework而不是MicroPython或ArduinoNanoFramework的优势是可以继续写C#而且它在Visual Studio里的开发体验和普通.NET项目接近。你可以直接用C#写ESP32程序部署时自动处理固件和依赖不需要切换到MicroPython或者C开发环境。对于我这个主栈是C#的人来说整个项目保持一种语言心智负担会小很多。下位机硬件我用的是一块ESP32开发板接了一个按钮、一个OLED屏幕、一个LED灯和一个小蜂鸣器。NanoFramework项目里通过NuGet安装对应包代码还是常见的Debug.WriteLine风格。4.2 通过HTTP POST把问题交给上位机下位机检测到按钮按下读取当前时间拼一个简单的JSON发起HTTP请求using System.Net.Http; using System.Text; static async Taskstring AskCompanion(string question) { using var client new HttpClient(); var payload new { text question, sessionId desk-device }; var content new StringContent( JsonConvert.SerializeObject(payload), Encoding.UTF8, application/json ); var response await client.PostAsync(http://192.168.1.8:5000/api/chat/query, content); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); }这里要注意NanoFramework的API和完整.NET框架有些差异比如HttpClient的基地址用法、TLS版本、JSON序列化库的选择。如果你发现目标板子在HTTPS握手时报错可以用HTTP先跑通整个流程本机服务本来也不需要在公网传输敏感数据。4.3 解析回复并执行物理动作服务层返回的JSON包含文本和命令比如{ reply: 好的帮你打开台灯。, command: {\action\:\light_on\,\target\:\desk\} }下位机拿到之后不直接显示整个字符串而是先判断command字段是不是空。如果非空就解析动作字段点亮LED再在OLED屏幕上显示回复文本。NanoFramework设备内存小不要装太大的JSON库简单场景用字符串查找或者一个极小的解析函数就够了。OLED显示遇到中文时要注意字库和编码是另一个坑。NanoFramework默认字体多半不支持中文我后来换成了只显示简单ASCII信息或者把回复截短成几个英文关键词灯的状态直接用图标/颜色表示。这个问题在完整.NET里不存在但嵌入式端内存有限中文字库要单独处理别等到上板才崩溃。4.4 没有Wi-Fi环境的串口方案如果设备部署在没有Wi-Fi的地方可以用串口实现同样的链路。NanoFramework通过UartController读取字符流ASCII编码按行处理。服务层用System.IO.Ports.SerialPort监听COM口两边约定以\n作为消息分隔符。串口方案的优点是稳定、延迟低缺点是只能一对一通信而且线缆受限。我自己的桌面机器人用的是Wi-Fi原因很简单方便调试不需要额外USB线。下位机上电后自动连Wi-Fi然后轮询健康检查接口模型没ready就等待ready之后就绪整个过程全是自动的。5. 学伴场景中的上下文管理与动作指令识别5.1 滑动窗口式上下文而不是无限记忆对话模型如果不管理上下文聊一会儿就把ContextSize占满后面全是“遗忘”。学伴机器人的对话场景不复杂我采用的方案是会话级滑动窗口每个sessionId维护一个消息列表超过N条就丢弃最老的。在CompanionService里我用一个ConcurrentDictionarystring, ListChatMessage保存会话记录每次推理前把最后6轮对话拼成Prompt推理后把这次的用户输入和模型回复追加进去。代码逻辑大概是private readonly ConcurrentDictionarystring, ListChatMessage _sessions new(); private string BuildPrompt(string sessionId, string text) { var history _sessions.GetOrAdd(sessionId, _ new ListChatMessage()); var builder new StringBuilder(); builder.AppendLine(你是书桌上的学伴机器人说话简短用中文回答。); foreach (var msg in history) { builder.AppendLine(${msg.Role}: {msg.Content}); } builder.AppendLine($user: {text}); return builder.ToString(); }滑动窗口不要设太大。学伴场景要的是实时响应不是长文总结。6到8轮对话足够模型能记住最近说了什么又不至于让推理首Token延迟变大。5.2 用提示词让模型输出结构化指令桌面生活机器人有一个刚需区分“聊天”和“执行动作”。比如用户说“今天天气不错”这是闲聊不需要任何动作说“帮我把台灯调亮点”这必须触发物理动作。如果只靠关键词匹配很容易误判。我用提示词约束模型输出。在系统Prompt里明确告诉模型当检测到用户想操作设备时额外输出一个JSON命令没有动作需求时命令字段为空。Prompt大概是这样的你是学伴机器人。当用户想执行动作时在回复内容后追加一段JSON {action:设备动作,target:目标,confirm: true} 不需要动作时不要输出JSON。模型按照这个约束输出后服务层再用正则或JSON解析提取。解析失败就当作纯文本处理不让异常影响主回复。实测下来一个小尺寸量化模型也能稳定输出规范JSON前提是Prompt要写清楚不能指望它自己猜。5.3 动作确认机制别让模型直接控制一切本地模型偶尔会胡说如果它把“关灯”识别成“开灯”而且是直接执行体验会非常糟糕。我的做法是给高风险动作加一个confirm字段下位机收到动作指令后不立即执行而是先在屏幕上显示“是否执行开灯”并等待按钮确认用户再按一次才真正触发。这个机制简单但极有用。学伴机器人的定位是辅助不是全权代理动作确认机制是底线。聊天可以随便发挥但物理操作必须可控。尤其是以后接了定时提醒、播放声音这类容易打扰人的功能更要在设备端做二次确认。5.4 定时提醒怎么落地大模型擅长“生成文本”不擅长“在准确时间触发事情”。刚开始我有一个错误想法让模型循环监考时间结果发现模型本身没有时钟概念也不可能一直占着推理线程。正确做法是把定时提醒完全交给服务层的普通定时器。用户告诉学伴“半小时后提醒我喝水”LLM解析出时间、内容、目标设备服务层创建一个CancellationTokenSource和Task.Delay到点后HTTP通知下位机播放提示音。LLM只负责理解自然语言调度由编程逻辑负责。这条非常值得记住不要把模型当操作系统用。6. 实际跑起来的资源账本量化档位、响应延迟与内存极限6.1 我的运行环境与选型结论我的测试机是一台没有独立显卡的普通笔记本32GB内存8核CPU。最初想上一个7B级别的量化模型能跑但生成速度大概只有每秒5到10个Token体验一句话要等半天不适合做实时学伴。后来降到中等规模模型用4bit量化速度提升到每秒20个Token左右日常对话可用。如果你有独显可以把部分层放到GPU速度还能再上一档。量化和模型规模的影响我整理了一张经验表仅供参考模型规模常用量化模型文件大小生成速度CPU适合场景1.5B级别q4_k_m约1GB25到40 token/s轻量闲聊、固定指令3B到4B级别q4_k_m2.5GB到3.5GB15到25 token/s学伴日常对话7B级别q4_k_m4GB到6GB5到10 token/s知识问答、文本总结学伴机器人建议从3B到4B级别的量化模型起步兼顾智商和速度。7B级别留给内容质量要求高、对延迟不敏感的场景。6.2 内存和上下文长度的直接影响模型加载后占用的内存不只是文件大小上下文也会占一块。ContextSize设得越大KV Cache占用越高生成速度也会略微下降。我在测试时把同一个模型分别用1024和4096的ContextSize跑内存差了一倍多。对于桌面学伴ContextSize设为2048是一个很平衡的值。如果机器内存紧张可以换更小的量化档位比如q4降到q4_0或者把BatchSize调低。BatchSize影响的是批量prompt处理速度对单轮对话影响不大但能显著降低峰值内存。6.3 最容易踩的几个坑第一个坑是多个模型同时加载。调试时我一度把对话模型、意图识别模型都放在同一个进程里结果内存直接爆掉。后来只保留主对话模型意图识别功能并进Prompt用模型输出JSON完成。如果一定要跑多个模型建议用独立进程隔开通过IPC通信。第二个坑是首次加载慢。一个3B模型加载可能十几秒甚至更久如果你把下位机开机就发请求很容易超时。健康检查接口就是为此存在的下位机必须等待就绪信号。第三个坑是串口编码不一致。NanoFramework端如果发送的是UTF-8的字节流服务层用默认ASCII去读中文会出现乱码。两边统一用UTF-8消息以换行符分隔不要用不定长二进制包。第四个坑是ONNX模型的动态轴。转出来的模型输入维度写死成固定长度实际推理时句子长度变了就报错。如果只是跑小模型尽量选导出时就固定序列长度的版本能少很多麻烦。6.4 最小可行架构清单如果你准备照着复现我建议按这个顺序搭Windows主机安装模型先用LLamaSharp写最小控制台验证能出文字建ASP.NET MVC项目把控制台逻辑搬进CompanionService提供/api/chat/query接口用浏览器或POSTMAN测试接口确认本地模型能通过HTTP对话搭NanoFramework下位机先只显示回复文本不接动作再加动作指令和确认机制最后接语音输入。这套链路里每一步都有清晰的验证点不容易出现“所有东西一起坏掉”的状况。我个人的体会是本地LLM工程的关键不是模型选多大而是把“模型推理”和“设备执行”解耦干净。只要C#服务层能稳定把文本转换成回复和结构化指令下位机无论是什么芯片、用NanoFramework还是其他语言都只是消费结果。现在我的桌面机器人已经稳定运行了一段时间按下按钮、本地推理、亮灯提示全部离线完成。后续想扩展的话给服务层加个定时任务让学伴主动发起提醒比让模型自己去“想”时间要可靠得多。