去年年底我给自己挖了一个坑:让一台普通Windows电脑当“大脑”,驱动一台上位机,做成一个真正能陪读、能问答、能做点小动作的“学伴/生活机器人”。整套思路落地在VS里,全程用C#——服务端是ASP.NET MVC,聊天和工具页面都挂在MVC上;下位机则是一块单片机,用C#.NanoFramework写固件,控制舵机、灯组、声音这些“身体部分”;模型这边走的是本机LLM路线,Windows直接加载.gguf和.onnx文件,不依赖云端API,也不碰Python环境。折腾了几个月,整套工程完整跑通,过程中踩了不少坑,也把“为什么这么拆”“为什么选这些格式”想得比较清楚了。这篇文章就把整体思路、工程结构和关键坑点一次说透,适合想做离线本地大模型机器人、但又不想被云服务绑死的人参考。
1. 先想清楚整体架构:LLM在机器人里的位置
1.1 大脑放PC、身体放MCU的拆分逻辑
学伴/生活机器人这个词其实包含了两类需求:一类是“脑力活”——答疑、陪读、闲聊、提醒日程,这些需要大语言模型的理解和生成能力;另一类是“体力活”——点头、摇头、抬手臂、亮灯、发出提示音,这些是典型的嵌入式控制。把两件事硬塞进同一块芯片里是最常见的失败原因,因为大模型推理动辄需要几GB内存,而常见单片机的内存连它的零头都不到。
所以最合理的拆分就是“上位机+下位机”:上位机是一台普通Windows电脑,跑本机LLM和ASP.NET MVC服务,负责所有对话、检索、决策;下位机是一块MCU开发板,跑C#.NanoFramework,直接操作物理设备。上位机是大脑,下位机是身体,两者通过串口或TCP通信。这个拆分还有一个隐藏好处:身体坏了不影响大脑,换一块下位机开发板,上位机代码一行不用改。
为什么坚持本机模式而非云端API?一是隐私,学伴会记录孩子的学习数据、生活作息,这些留在本地最安心;二是长期成本,云端按token计费,对话量一大费用很难控;三是离线可用,家里网络不稳时,机器人至少还能正常陪聊和完成本地提醒。
1.2 两个模型文件格式各司其职:.gguf与.onnx
标题里提到“Win直接运行.gguf/.onnx”,这两类格式在实际工程里不是二选一的关系,而是各管一段。.gguf是llama.cpp生态的量化模型格式,把权重、分词器、模型配置打包在一个文件里,非常适合文本生成。Windows上通过LLamaSharp这个C#封装直接加载,CPU就能跑相当不错的多轮对话。
.onnx则是一个开放的模型交换格式,配合ONNX Runtime在Windows上可以直接运行。它的强项是那些不是“端到端生成”的模型:语义向量模型、句子相似度模型、小型分类模型。我把一个onnx向量模型放在上位机里做知识库检索,把教材和日常问答整理成向量,用户提问时先在本地检索最相关的片段,再交给.gguf模型生成回答。这就是典型的RAG思路,也是“学伴”能回答出有上下文、有依据内容的答案的关键。
这里有个心得:别指望一个模型解决所有问题。一个3B-7B的本地小模型,纯靠记忆回答专业问题一定翻车,但有了检索增强,它只需要做好“把检索到的材料组织成回答”这件事,效果好得多,而且幻觉明显减少。
1.3 为什么下位机敢选C#.NanoFramework
嵌入式这块最保守的选择是C++(Arduino风格)或MicroPython,但C#.NanoFramework给了我一个非常舒服的切入点。它是.NET生态在MCU上的实现,能用现代C#语法写固件,支持async/await、GC、线程、各种常用库,最关键是语法和上位机完全同源。
同源的意义不只是“不用学两门语言”,而是协议的定义可以共享。我在解决方案里放了一个独立的Robot.Protocol类库,上位机和下位机都引用它,命令帧的字段、枚举、心跳格式全部在一个地方定义,改协议时两端同步改,不会出现“上位机发了Version=1,下位机还在等CmdType=1”这类对不上号的经典事故。这一点在联调时省了我大量时间。
2. 上位机工程落地:VS里把本机LLM跑成服务
2.1 工程骨架与关键包
在VS里先建一个ASP.NET MVC项目,.NET 8即可,本机服务不需要身份认证,保持最干净的空模板。然后建两个类库:一个负责对话生成(LLM),一个负责语义检索(ONNX向量)。Controller层保持轻薄,只做参数校验和视图渲染,真正的模型逻辑全在服务层。
关键NuGet包就三个,按需添加:
LLamaSharp与对应后端的LLamaSharp.Backend.Cpu(有N卡可再加CUDA后端包),用于加载.gguf模型- 官方ONNX Runtime的NuGet包,用于加载.onnx模型
Newtonsoft.Json或直接用System.Text.Json处理协议
模型文件建议单独放一个models目录,路径写进配置文件。.gguf文件动辄2-5GB,务必确认有足够磁盘空间,加载时也要留意内存占用。我把3B级别的模型放在纯CPU机器上跑,7B级别留给有16GB以上内存的机器,实测体验差异主要是响应速度,架构不变。
2.2 模型加载与生命周期管理
LLM加载一次非常昂贵,动辄十几秒甚至几十秒,绝不能在每次请求时重新加载。正确做法是把模型封装成单例服务,进程启动时加载一次,之后所有请求复用。核心代码大致如下,API细节以你安装的包版本为准:
public sealed class ChatService : IDisposable { private readonly LLamaWeights _weights; private readonly LLamaContext _context; public ChatService(IConfiguration config) { var modelPath = config["Model:Path"]; var parameters = new ModelParams(modelPath) { ContextSize = 4096, GpuLayerCount = 0, // 0为纯CPU MaxTokens = 1024, }; _weights = LLamaWeights.LoadFromFile(parameters); _context = _weights.CreateContext(parameters); } public IAsyncEnumerable<string> InferAsync(string prompt, InferenceParams inferenceParams, CancellationToken ct) { var executor = new InteractiveExecutor(_context); return executor.InferAsync(prompt, inferenceParams, ct); } public void Dispose() { _context?.Dispose(); _weights?.Dispose(); } }配置注入时用单例生命周期:builder.Services.AddSingleton<IChatService, ChatService>();。这里有个容易被忽略的细节:ChatService自己又不能被回收,所以进程退出、模型热切换这类操作都要靠宿主生命周期去管理。想在不停服的情况下换模型,可以再包一层代理服务,内部持有一个“当前模型”的引用,切换时先Load新的再Swap,避免请求打到半加载状态。
2.3 对话接口与流式输出
MVC里提供一个Chat控制器,POST接口接收用户消息和会话ID,返回模型生成的文本。但大模型推理是逐token生成的,一次性返回会让用户盯着转圈好几十秒,体验极差。解决方案是流式输出:接口用text/event-stream(SSE)把token一个个推给前端,浏览器用fetch边读边渲染。前端页面就是一个简单的Razor视图,用户看到的文字是像真人打字一样逐字出现的,首token一旦出来,体感延迟立刻降到“可接受”的区间。
实现要点:Controller返回IAsyncEnumerable<string>,内部把ChatService.InferAsync的每个token直接yield return,SSE格式自动处理。注意设置Response.Headers.Cache-Control = "no-cache",否则部分浏览器会把整个流缓存住,推送变成一次性。
2.4 上下文管理与学伴人格
小模型的上下文窗口有限,而多轮对话越聊越长,不做管理会很快把窗口塞满、导致新问题得不到有效信息。我的方案是“滑动窗口+摘要压缩”:保存最近10轮完整消息进会话缓存,超过之后,把最早的部分交给模型生成一段摘要,再连同最近几条一起作为新上下文。
人格设定放在System Prompt里,例如“你是一位耐心的学伴,优先用引导式提问帮助学习者思考;回答尽量用中文,控制在200字以内”。这里提醒一句:本地小模型对超长System Prompt的服从性比云端大模型弱,人格描述最好精简,重点放行为约束,而不是堆砌华丽词藻。
3. 下位机落地:C#.NanoFramework控制机器人的身体
3.1 开发环境与固件烧录
NanoFramework的工程体验比想象中现代得多。VS装好NanoFramework插件后,新建项目直接选“Class Library (NanoFramework)”,目标平台可以是常见的ESP32这类开发板。首次使用需要给开发板烧录nanoCLR固件,用nanoff命令行工具一条命令搞定,烧录后再用VS的“部署”功能把程序传到板子,之后每次调试就像普通.NET程序一样,断点、变量监视都支持,这对嵌入式开发来说相当奢侈。
需要提醒的是:NanoFramework的NuGet包版本必须和烧录进去的固件版本匹配。我第一次就吃了这个亏,包是最新的、固件是半年前的,部署时各种莫名字段找不到,最后统一版本才稳定。
3.2 基本外设控制
学伴机器人常见的“身体”动作包括:舵机驱动头部左右转动、LED灯带表达情绪、蜂鸣器发提示音、按键作为交互入口。NanoFramework控制这些外设的方式和标准.NET IoT很接近,用GPIO和PWM即可:
using System.Device.Gpio; using System.Device.Pwm; var gpio = new GpioController(); var led = gpio.OpenPin(2, PinMode.Output); led.Write(PinValue.High); // 点亮指示灯 var servo = PwmChannel.FromId(3, 0, 50, 0.5); // 50Hz舵机,初始占空比0.5 servo.Start(); servo.DutyCycle = 0.075; // 转到某角度,具体值按舵机型号标定舵机供电一定要单独走一路电源,直接让开发板上的3.3V给舵机供电,舵机一启动就会把整块板的电压拉塌,轻则复位,重则把IO口烧掉。教训很直接:宁可多买一个5V降压模块,也别省这条线。
3.3 上下位机通信协议设计
我最初用TCP长连接,但调试时发现PC端做服务、板子做客户端的连接管理挺费事,后来改成最简单可靠的串口(UART)。协议帧用“帧头+长度+JSON+校验”的定长结构:下位机收到合法帧就执行动作并回一个ACK,上位机每隔几秒发一次心跳,超时则判定下位机掉线,进入重连流程。
命令类型举几个标准例子:
move_head:头部转动角度和速度set_emotion:设置LED色带(开心/平静/思考/提醒)play_tone:播放提示音频率和时长report_state:下位机主动上报按键、电量、状态
所有动作命令都被设计成“幂等”的,即重复发送同一条命令不会造成叠加或错乱。这在异常重发场景下非常重要,否则一次网络抖动可能导致机器人转头转过了头。
3.4 从LLM文本到硬件动作
模型输出的是自然语言文本,怎么变成动作指令?我的方案是“规则映射优先,结构化输出为辅”。下位机执行的动作其实有限,不需要让模型自由发挥。上位机拿到LLM回复后,先用一个轻量规则引擎扫描关键意图词:出现“点头”“挥手”“站起来”等词就触发对应动作;初始化情绪时,如果回复语气积极则点亮暖色灯,否则冷色。更可控的做法是引导模型在回答末尾附带[action:wave]这样的标记,但本地小模型偶尔会漏,所以规则扫描必须兜底。
这个设计背后是“机器人动作安全”的考量:动作判决必须确定性、可重复、可回滚,任何模型幻觉都不应该引起物理危险,所以动作层永远不被AI直接驱动,只被规则引擎间接驱动。
4. 联调实测与性能优化
4.1 延迟与资源实测
跑通之后最关心的就是性能。我给一套主流配置做了记录:3B级别、Q4量化、纯CPU推理,模型加载约12-20秒,首token约2-5秒,后续生成速度约8-12 token/秒,整机内存占用3.5-4.5GB。同一台机器换成7B模型,内存逼近6GB,生成速度掉到4-6 token/秒,响应明显变肉。
结论很直接:先管优化,再管模型。4.2的上下文管理和4.3的检索增强能把3B模型的实用度拉到接近7B裸奔的水平。如果追求速度,量化等级Q4_K_M基本是“质量与体积”的甜点,再往下压到Q2,速度提升有限,但回答质量肉眼可见下降。
4.2 并发访问与串行化
LLM是单实例单上下文,多个请求同时来必然互相干扰。ASP.NET MVC默认是并发处理的,所以必须在ChatService内部加一个信号量,把推理串行化:
private readonly SemaphoreSlim _gate = new(1, 1); public async Task<string> ChatAsync(...) { await _gate.WaitAsync(ct); try { return await RunInferenceAsync(...); } finally { _gate.Release(); } }串行化带来的问题是:如果有人在跑一个很长的生成任务,其他人的请求会一直排队。所以我同时加了超时策略和队列上限,超过队列长度直接返回“当前正忙,请稍后再试”,宁可拒绝也不无限堆积。对本机单用户场景,这其实已经绰绰有余。
4.3 知识库检索的收益
把教材、使用手册、家庭提醒等文本切块后,用onnx向量模型转成向量存进本地列表,查询时算余弦相似度取Top-K,再拼进Prompt。这套RAG上线后,学伴回答“这道题怎么做”的准确率提升非常明显,因为它不再凭空编答案,而是能引用具体知识点。
切块大小我建议200-400字左右,太小丢失上下文,太大检索命中率降低。向量模型选中文效果好的通用句向量模型即可,CPU上单条检索延迟通常不超过50ms,完全可以接受。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| LLamaSharp加载时DllNotFound | 缺少VC++运行库或后端包不匹配 | 安装VC++ 2015-2022运行库,确认Cpu/Cuda后端包与LLamaSharp版本对应 |
| 模型加载即崩溃 | 模型文件损坏或量化类型不受支持 | 重新下载,优先选Q4_K_M等常见量化,不要用来源不明的自转格式 |
| ONNX推理输入报错 | 输入张量名称或形状与模型不一致 | 打印session输入元数据,核对input_ids/attention_mask等名称和维度 |
| 下位机部署后无反应 | 固件版本与NuGet包版本不匹配 | 用nanoff统一刷写与包版本匹配的固件 |
| 舵机一动整板复位 | 供电不足 | 单独供电,共地连接,避免开发板直接驱动舵机 |
| 串口收不到数据 | 波特率不一致或线序接反 | 两端统一波特率,检查TX/RX交叉连接,确认接的是UART引脚而非USB虚拟串口 |
5.2 几个值得单独说的坑
第一个坑是模型路径里的中文。把模型放在带中文的目录下,某些Native库会解析失败,表现为Log里路径明明存在却加载不了。建议模型目录全用英文路径,省得排查。
第二个坑是杀毒软件误隔离。第一次加载GGUF时,安全软件把ggml.dll当可疑文件处理了,推理直接失败。处理方法是在Visual Studio部署和运行时把项目目录加白名单,或用DLL目录定位的方式加载原生库。
第三个坑是下位机掉线后上位机不知道。串口拔掉不会自动抛异常,必须依赖心跳。我把心跳超时定为10秒,连续3次无响应就判定掉线,然后把机器人UI状态切成离线并尝试重连。这个机制上线后,机器人再也没出现过“看起来在线、实际不理人”的魂游状态。
第四个坑是上下文摘要本身会占token。超过窗口长度后触发的摘要任务可能生成几百字的概括,这会挤占下一轮回答的空间。我的处理是把摘要也限制在150字以内,并只保留最近5轮完整对话,既保住关键信息又控制篇幅。
6. 怎么把整套工程继续做深
整套本机LLM工程跑通之后,我觉得它最大的价值不是“我做出了一个机器人”,而是一个可以持续扩展的地基。当前版本里,学伴的知识来源还是静态文档;下一步我打算加一个定时扫描本地笔记的机制,每天自动把新增内容切块入库,让知识库跟着使用者的学习进度增长。动作层也计划让规则引擎支持脚本化配置,这样换一套机械结构时,不用改C#代码,只改配置就能把动作映射重新编排。
最后分享一个我实际摸索出来的小技巧:上下位机之间的协议版本号一定要放在每一帧里,不只是放在握手阶段。因为下位机固件升级往往比上位机慢,新旧固件混跑是常态,帧里带版本号,上位机就能根据版本自动降级或启用新功能,而不是一棍子打死。这个设计帮我避免了好几次“升级了下位机忘了升级上位机”的尴尬。
如果你也打算做类似的东西,我的建议是不要一上来就奔着完整机器人去。先把PC端的ChatService跑通,用浏览器聊天,这已经是学伴1.0;再把通信协议打通,让下位机点头应答,这才算学伴2.0。硬件永远排在软件后面,软件跑稳了,身体想怎么接都行。