☰
C#全栈实现本机学伴机器人:ASP.NET MVC+NanoFramework+本地LLM
2026/10/19 11:40:20 网站建设 项目流程

半年前我想做一台摆在书桌上的学伴机器人:能回答常识问题、提醒喝水、还能控制旁边的小灯和蜂鸣器。第一反应是上Python,但我日常写C#,实在不想为一个小玩具再去搭一套FastAPI加虚拟环境。后来发现,用VS里现成的C#.ASP.Net MVC项目当成本机服务层,再配合C#可直接调用的本地LLM运行时,让Windows直接跑.gguf或者.onnx模型,下位机部分用C#的NanoFramework跑在ESP32这类嵌入式设备上,整个链路居然能完整留在.NET生态里。这篇文章就记录这套本机LLM工程的整体思路,适合想用C#做桌面级“学伴/生活机器人”的开发者参考。

我做这个项目之前查了不少资料,发现大多数本地大模型教程默认是Python生态,LLM相关的类库、示例代码全是Python,C#开发者要么临时学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,还是转成onnx

2.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 DenseTensor<float>(inputData, new[] { 1, 3 }); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("input", inputTensor) }; using var results = session.Run(inputs); var output = results.First().AsEnumerable<float>();

这里只是示意,具体输入输出名要看模型定义。你可以在代码里遍历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 { Task<CompanionResult> 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 Task<CompanionResult> 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 Task<ActionResult> 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或Arduino

NanoFramework的优势是可以继续写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 Task<string> 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里我用一个ConcurrentDictionary<string, List<ChatMessage>>保存会话记录,每次推理前把最后6轮对话拼成Prompt,推理后把这次的用户输入和模型回复追加进去。代码逻辑大概是:

private readonly ConcurrentDictionary<string, List<ChatMessage>> _sessions = new(); private string BuildPrompt(string sessionId, string text) { var history = _sessions.GetOrAdd(sessionId, _ => new List<ChatMessage>()); 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 最小可行架构清单

如果你准备照着复现,我建议按这个顺序搭:

  1. Windows主机安装模型,先用LLamaSharp写最小控制台验证能出文字;
  2. 建ASP.NET MVC项目,把控制台逻辑搬进CompanionService,提供/api/chat/query接口;
  3. 用浏览器或POSTMAN测试接口,确认本地模型能通过HTTP对话;
  4. 搭NanoFramework下位机,先只显示回复文本,不接动作;
  5. 再加动作指令和确认机制,最后接语音输入。

这套链路里每一步都有清晰的验证点,不容易出现“所有东西一起坏掉”的状况。

我个人的体会是:本地LLM工程的关键不是模型选多大,而是把“模型推理”和“设备执行”解耦干净。只要C#服务层能稳定把文本转换成回复和结构化指令,下位机无论是什么芯片、用NanoFramework还是其他语言,都只是消费结果。现在我的桌面机器人已经稳定运行了一段时间,按下按钮、本地推理、亮灯提示,全部离线完成。后续想扩展的话,给服务层加个定时任务,让学伴主动发起提醒,比让模型自己去“想”时间要可靠得多。

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

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

立即咨询