从零搭建自然语言远程控制系统:fnOS中控与Windows算力机协作实践 家里有两台机器一台是装了飞牛fnOS的低功耗主机常年开机、安静、省电专门负责文件存储和常驻服务另一台是带独立显卡的Windows算力机性能强但不是所有时候都需要开机。最开始我只是想“躺在床上用一句人话让算力机帮我干点活”结果折腾了一圈把自己逼成了“从零搭了一套自然语言远程控制系统”的半个运维。这篇文章就是当时的完整搭建记录从双机架构怎么定、fnOS上怎么跑Ollama容器到Python怎么写意图识别、槽位提取再到两条机器之间的通信链路和一堆真实踩坑。如果你手里正好也有“一台NAS/小主机 一台Windows台式机”的组合也想实现类似“说句话就能远程操控”的效果这篇应该能帮你少走不少弯路。1. 双机架构为什么这样分中控与算力分离的决策过程很多人看到“双机协作”第一反应是“把两台机器组个集群”听起来很复杂。但实际上我这个场景和集群没关系核心问题只有一个哪台机器负责“动脑子”哪台机器负责“干活”。1.1 我的两台机器配置和各自的脾气先说硬件。fnOS主机是一台N100小主机16GB内存一块2TB的SATA盘。这机器的优势在功耗低、噪音小搁在客厅角落一个月不关机也没感觉。缺点是CPU性能一般也没有像样的独立显卡真要跑7B以上的大模型推理慢到怀疑人生。Windows算力机则是老配置升级的i7-12700 RTX 3060 12GB 32GB内存。跑Ollama做推理、跑ffmpeg转码、批量处理数据都是它的活。但这机器功耗和噪音都大不可能24小时开着平时睡眠接到任务再唤醒。这个配置组合其实非常典型。很多对折腾感兴趣的人家里都是“一台老台式机 一个小主机/NAS”的结构只是之前一直没想清楚怎么让它们协作。1.2 为什么不在fnOS上一台跑完非要拉上算力机我也试过把服务全部塞进fnOS那台小主机毕竟fnOS自带应用中心、Docker管理器界面点几下就能部署服务。但很快遇到瓶颈Ollama拉了个7B模型CPU推理一次意图解析可能要几十秒中途小主机风扇直接起飞内存占用飙到85%吓得我赶紧把容器停了。反过来如果全在Windows算力机上跑也有问题Windows更新一重启服务没了机器睡眠后HTTP接口调不通而且白天要打游戏、办公开着一堆后台Agent多少有点膈应。更关键的是算力机不该承担“常驻调度”这种任务它的定位是“随时可被唤醒的劳力”不是“24小时值班的秘书”。所以最终思路很清晰了fnOS当“中控”负责接收指令、解析指令、调度任务、存储日志Windows算力机当“执行端”负责真正吃算力的活比如本地小模型推理、程序启动、脚本执行、屏幕截图。两台机器通过局域网HTTP API通信算力机无需常开按需Call醒。1.3 三条可选架构路线对比我为什么选“fnOS中控Windows算力”方案优点缺点我的评价全部服务跑在fnOS小主机常在线、功耗低、统一管理CPU推理慢、内存紧张跑不动稍大的模型适合纯轻量场景我一个7B模型就打退堂鼓了全部服务跑在Windows算力机算力强、跑模型快不能常开、Windows更新/睡眠中断服务只适合当执行端不适合当调度中枢fnOS中控 Windows算力各司其职中控常在线算力按需调用需要自己写通信层前期多花时间方案复杂度可控长期稳定我最终采用说实话第三种方案最开始我嫌麻烦但用了一周后真香了。因为通信层说白了就是一个HTTP接口fnOS上跑FastAPIWindows上跑一个Agent服务两边用token做简单认证根本不用引入消息队列这种重型依赖。2. fnOS上的基础搭建Ollama容器、服务端口、资源分配fnOS本身就是基于Debian的NAS系统底层权限很开放。既然要当“中控”自然得先把它变成能跑Python服务、能调大模型接口的机器。这里最关键的是在fnOS上部署Ollama容器。2.1 用飞牛的应用中心装Docker还是直接命令行fnOS的应用中心确实可以直接装Docker而且有个网页版容器管理界面对新手挺友好。但我的建议是应用中心装Docker运行时真正部署Ollama时用命令行方便指定容器参数。原因是网页版对自定义参数支持有限比如我要限制容器的内存上限、挂载模型目录、指定端口映射在命令行docker-compose里写一遍最清晰。fnOS默认开了SSH直接登录后台操作非常方便。我第一次是在应用中心里搜Ollama镜像然后手动点配置的结果默认没有限制内存模型加载的时候把整个系统的内存吃光了Docker容器被内核杀掉还是通过SSH查日志才定位到原因。后来老老实实换成docker-compose管理。2.2 Ollama镜像的Docker部署与模型拉取实测在fnOS上找一个目录存放配置比如/vol1/docker/ollama然后写下面的docker-compose.ymlversion: 3 services: ollama: image: ollama/ollama:latest container_name: ollama restart: unless-stopped ports: - 11434:11434 volumes: - ./models:/root/.ollama mem_limit: 8g environment: - OLLAMA_NUM_PARALLEL1 - OLLAMA_MAX_LOADED_MODELS1启动后拉取模型docker compose up -d docker exec -it ollama ollama pull qwen2.5:3b docker exec -it ollama ollama list注意几个关键点。第一mem_limit: 8g很重要没有它模型预加载时可能会吃到10GB以上内存fnOS本身的文件服务、媒体服务都会受影响。第二OLLAMA_NUM_PARALLEL1让模型同时只处理一个请求虽然并发能力下降但稳定性大幅提升。第三模型我选了qwen2.5:3b因为我的N100主机没有GPU纯CPU推理3B模型做意图识别单次响应大概在5到10秒尚可接受。2.3 fnOS资源拮据时CPU推理和GPU挂载怎么取舍如果你的fnOS主机插了显卡那根本不用犹豫直接把GPU挂进Ollama容器。但像N100这种小主机压根没有PCIe插槽就只能纯CPU推理。这时候的取舍是模型别贪大3B是甜点值。我试过qwen2.5:7bCPU推理一次要15到25秒Web面板体验会很拖沓。系统换内存。如果小主机能加内存建议至少16GB起步因为30%给系统70%可能要被Ollama吃掉。如果任务复杂就让fnOS上的Ollama只做“意图分类和槽位提取”这种轻量推理凡是需要复杂生成的任务转发给Windows算力机上的更大模型处理。这个思路其实就是把“轻量模型”和“重量模型”分开部署fnOS跑3B模型负责理解指令算力机跑7B甚至14B模型负责具体生成。能力边界清晰性能开销也合理。3. 自然语言控制的核心意图识别、槽位提取和LLM的配合标题里“自然语言远程控制”这句话听着高大上拆解到技术层其实就是两件事意图识别Intent和槽位提取Slot。用户说“打开记事本”系统要识别出意图是“启动应用”同时提取出槽位“记事本”。用户说“看看电影目录还剩多少空间”系统要识别出意图是“查询存储”槽位是“电影目录”。3.1 先搭规则引擎还是直接用大模型我的建议是能用规则就用规则规则覆盖不了的再交给大模型兜底。很多教程一上来就是“用LLM做一切”看起来很先进实际在本地跑时会发现模型理解“打开记事本”这种模板化指令响应速度远不如正则。模型偶尔会输出非法JSON导致程序崩溃。纯CPU推理的延迟放在交互场景里体验一言难尽。所以我设计了解析层时采用“三级策略”第一级正则规则匹配固定指令第二级没匹配上调用本地Ollama生成结构化JSON第三级模型输出异常降级为“把原文原封不动传给执行端让执行端的模糊匹配去处理”。3.2 Python实现从“说人话”到“结构化命令”的完整代码fnOS上我是用FastAPI写了一个中控服务核心解析逻辑如下import re import json import requests from fastapi import FastAPI app FastAPI() def parse_intent(text: str) - dict: # 第一级规则 if re.search(r(磁盘|空间|存储|剩.*少|满了), text, re.IGNORECASE): path_match re.search(r([\u4e00-\u9fa5A-Za-z0-9_\-/\\]目录|电影目录|下载目录), text) return {intent: query_storage, slots: {path: path_match.group(1) if path_match else /vol1}} if re.search(r(打开|启动|运行|打开软件), text): app_map {记事本: notepad, 浏览器: msedge, 计算器: calc, 画图: mspaint} for key, value in app_map.items(): if key in text: return {intent: launch_app, slots: {app: value}} return {intent: launch_app, slots: {app: text.split(打开)[-1]}} if re.search(r(截屏|截图|屏幕), text): return {intent: screenshot, slots: {}} # 第二级Ollama 兜底 prompt f你是指令解析器。请将用户的自然语言指令解析为JSON只输出JSON。 格式示例{{intent: launch_app, slots: {{app: notepad}}}} 可选intentquery_storage, launch_app, run_script, screenshot, shutdown。 用户指令{text} try: resp requests.post( http://localhost:11434/api/generate, json{ model: qwen2.5:3b, prompt: prompt, format: json, stream: False, }, timeout60 ) return json.loads(resp.json()[response]) except Exception: # 第三级降级 return {intent: raw, slots: {raw_text: text}}实际上最开始我用的是langchain那套但后来发现对这个尺寸的任务有点杀鸡用牛刀直接requests调用Ollama API更轻量。有个细节值得注意Ollama从0.5版本开始支持format: json参数会自动约束模型输出合法的JSON结构这一行参数就能省掉大量解析容错代码。3.3 用Ollama的本地模型做意图兜底效果和延迟实测我自己做了个简单的效果对比随机输入20条指令规则引擎直接命中的有12条剩下8条交给qwen2.5:3b其中6条解析正确1条返回了非法JSON被我降级1条意图分错了。延迟方面规则引擎几乎是无感的而调用Ollama一次约5到10秒主要看CPU占用和模型是否已在内存中。这个延迟对“发消息控制机器”的场景完全可以接受毕竟不是实时语音对话。之前我也想用Codex之类的AI编程工具直接接管系统操作但它在Windows上并没有现成的系统操作接口而且没法常驻后台等待指令和我的“常驻Agent”模式不搭。相比之下自建一条“中控解析 算力机执行”的链路反而更自由可控。4. 双机通信链路如何把自然语言指令变成算力机上的真实操作解析出意图和槽位只是第一步真正让指令生效还得把结构化指令从fnOS发给Windows算力机这是整个项目里最需要打磨的部分。4.1 HTTP API 任务队列还是SSH一把梭一开始我图省事直接用paramiko在fnOS上SSH到Windows然后执行命令。但很快发现几个问题Windows默认OpenSSH服务要手动配权限路径里带反斜杠和空格时容易战尤其是中文路径SSH传参的编码问题能把人逼疯。后来我改了方案Windows算力机上常驻一个Python AgentFastAPI服务fnOS中控通过HTTP POST把结构化指令发过去。Agent拿到指令后在本机执行执行完把结果以JSON回传。这样有几个好处编码可控。两端统一用JSON传参路径等字段用base64编码不在传输层碰中文。权限可控。Agent只暴露局域网内可访问的端口并加了一个共享token做校验。扩展性好。以后想让算力机执行更多操作只需要扩建Agent的接口中控不用改。4.2 算力机上的执行器远程桌面mstsc/向日葵与命令行脚本配合这里要说清楚“远程控制”的两层含义。第一层是常规的远程桌面我日常调试Windows算力机会用mstscWindows自带的远程桌面外网环境下则用向日葵。第二层才是我们这个项目的核心自然语言指令最终要通过Agent的API变成系统上的真实操作而不是靠我在远程桌面上点按钮。Windows Agent的执行逻辑大概长这样import subprocess import pyautogui import shutil from fastapi import FastAPI, Header, HTTPException app FastAPI() TOKEN your-shared-token def check_auth(authorization: str Header(None)): if authorization ! fBearer {TOKEN}: raise HTTPException(status_code401) app.post(/execute) async def execute(payload: dict, authorization: str Header(None)): check_auth(authorization) intent payload[intent] slots payload.get(slots, {}) if intent launch_app: subprocess.Popen([cmd, /c, start, slots[app]], encodingutf-8, errorsignore) return {status: ok, message: fapp {slots[app]} started} if intent query_storage: import psutil usage shutil.disk_usage(slots.get(path, C:)) return {status: ok, free_gb: round(usage.free / 1024**3, 2)} if intent screenshot: img pyautogui.screenshot() img.save(C:/agent_screenshots/latest.png) return {status: ok, image_path: C:/agent_screenshots/latest.png} if intent run_script: result subprocess.run(slots[cmd], shellTrue, capture_outputTrue, textTrue, encodingutf-8, errorsignore) return {status: ok, stdout: result.stdout[-2000:], stderr: result.stderr[-2000:]} return {status: error, message: unknown intent}注意我在执行外部命令时encodingutf-8, errorsignore都是必需的。Windows的subprocess默认编码是GBK如果直接透传返回的报错信息里中文会变成乱码排查问题时会疯掉。4.3 结果回传与状态通知给用户一个可感知的闭环自然语言指令执行完用户怎么知道成功还是失败我的方案是让fnOS中控统一处理回传结果并把信息展示在Web面板上。比如用户发“截一张算力机现在的屏幕”执行链路是Web面板收到自然语言 → 中控解析出screenshot→ 通过HTTP发给Windows Agent → Agent截图保存 → 返回图片路径 → 中控把latest.png拷贝到fnOS本地Web目录 → Web面板刷新后显示图片。这一步“拷贝回传结果”非常关键如果没有闭环用户执行一条指令后完全不知道发生了什么整个系统就失去了意义。后续我还加了简单的通知机制如果算力机执行完结果里包含error关键词中控会在Web面板上高亮红条。这样就算我不在现场看到红条也知道任务出问题了。5. 五个真实指令的端到端走查理论说了这么多还是用五个实际跑过的指令看看整套链路的效果。下面这个表格是某一晚我在手机上通过Web面板发出的指令和对应结果。用户输入意图识别结果执行动作返回结果看看电影目录还剩多少空间query_storage(path“电影目录”)fnOS本地读取/vol1/media/movies磁盘使用情况剩余1.2TB用记事本打开E盘的清单文件launch_app(app“notepad”)Windows Agent执行start notepad E:\清单.txt算力机已启动记事本帮我把刚才录屏转成1080p MP4run_script(cmd“ffmpeg ...”)Windows Agent调用ffmpeg转码GPU加速生成output.mp4耗时1分03秒截一张算力机现在的屏幕screenshotAgent用pyautogui截图并回传Web面板直接显示图片汇总两台机器的CPU和内存占用query_healthfnOS读取本机数据同时请求Windows Agent获取数据返回两台机器的资源占用表格其中第一条和第五条是在fnOS本机执行的没经过Windows Agent因为fnOS的存储状态和系统状态它自己就能查没必要绕一圈。这就是双机协作的巧妙之处中控能自己做的绝不转发必须算力机做的才唤醒它。第五条通过Web面板自然语言触发后我在手机上看到的结果是这样的【fnOS】CPU: 12%内存: 6.2GB / 16GB磁盘: /vol1 剩余 1.2TB 【Windows算力机】CPU: 34%内存: 14GB / 32GBGPU: 3060 12GB状态: 空闲整个过程大概8秒大头是Ollama那一次意图识别消耗的5秒。Windows Agent响应基本在1秒内因为只是读了个系统数据。6. 踩坑记录从OOM到远程桌面掉线的排查链路真实项目里运行三个月下来最大的问题都不是功能设计而是一堆零散的“运行时意外”。我把印象最深的几个踩坑链路完整记录下来希望能帮你省下几天排查时间。6.1 Ollama容器一直在重启内存怎么都不够现象是容器启动后没几十秒就退出状态显示restarting。我登录fnOS后台执行docker logs ollama只看到一行Killed没有任何错误堆栈。直觉告诉我不是Ollama自身崩溃而是内核把它杀了。接着看系统日志dmesg | tail -50果然看到Out of memory: Killed process。根源是Ollama加载模型时会按照/root/.ollama/models/manifests里的模型大小预分配内存而我当时拉了个7B模型小主机16GB内存里还跑着文件服务和媒体扫描剩余内存不够内核直接触发OOM Killer。解决办法分三步拉回3B模型给容器加了mem_limit: 8g限制把fnOS里那些不常用的媒体转码、缩略图等服务临时关掉。从那以后Ollama容器再也没被误杀过。这个教训就是Docker部署时该加的mem_limit一定得加不是你机器内存够大就不用管而是内核的OOM Killer从来不看“够不够”只看“还有没有”。6.2 从mstsc到向日葵剪贴板引发远程掉线的谜案当时我发现一个奇怪的现象只要我在mstsc远程桌面里复制或粘贴内容远程会话很可能在几秒后断线重连严重时一天掉七八次。一开始我怀疑是网络问题但局域网Ping延迟一直很稳定排除。后来查资料才知道这是Windows远程桌面剪贴板重定向的通病。当你复制的内容特别大比如一张几MB的截图RDP进程会把剪贴板数据完整传输一遍期间如果网络延迟稍高或CPU争抢资源会话就会异常断开。这和热词里“mac远程控制时获取剪切板后远程就掉线”其实是同一个坑跨平台远程控制软件的剪贴板同步都容易翻车。我的解决方法很粗暴远程桌面连接前在“本地资源 - 剪贴板”处取消勾选“剪贴板”共享。如果确实要传文本我改用Agent的run_script或直接通过HTTP POST上传内容不再依赖剪贴板。后来在业务代码里也让Agent的截图功能直接保存文件并通过HTTP回传根本不碰剪贴板。这样用向日葵远程控制时也不会有剪贴板同步的心理负担。6.3 自然语言解析正确但执行失败谁能想到是编码问题最诡异的一类问题是中控日志显示意图解析完全正确Agent也收到了结构化指令但Windows执行时就是报错。比如“用记事本打开E盘的清单文件”Agent明明收到了{intent: launch_app, slots: {app: notepad, path: E:\\清单.txt}}可subprocess.Popen就是提示找不到文件。排查链路是这样的先在Windows上手工执行notepad E:\清单.txt正常。再用Agent内建的run_script执行同样的命令也正常。那问题出在HTTP传输上仔细打印bytes后发现fnOS发出的JSON在Windows FastAPI接收时中文路径已经变成了E:\\æ¸\x85å\x8d\\list.txt。根源是fnOS中控的requests.post(..., jsonpayload)默认编码和Windows FastAPI的默认解析不一致中文路径在传输过程中被错误转码。最终我不用纯文本路径传输统一用base64编码字段import base64 def b64(s: str) - str: return base64.b64encode(s.encode(utf-8)).decode(ascii) payload { intent: launch_app, slots: { app: notepad, path_b64: b64(E:\\清单.txt) } }Windows Agent端先base64.b64decode还原出原始路径再去执行。从那以后中文路径、中文脚本参数再也没出过问题。虽然看着多了一步编解码但真的是一劳永逸。6.4 算力机睡眠后唤不醒WOL的隐藏开关因为Windows算力机平时是睡眠状态所以我还给中控加了Wake-on-LAN局域网唤醒逻辑。第一次配置时发现发WOL包后机器没醒排查半天是因为Windows默认开启了“快速启动”关机后网卡根本不监听唤醒包。去“电源选项 - 选择电源按钮的功能 - 取消勾选快速启动”然后在设备管理器里把网卡的“允许此设备唤醒计算机”打开。设置之后中控只要检测到Agent未响应就先发3次WOL包等约30秒再重试HTTP请求算力机就能被自动唤醒。这个细节如果不提你很可能在“按理说该醒来却死活没反应”的状态里卡很久。7. 这套系统跑顺之后我还想多说几句整个项目从零到能用我大概折腾了两个周末。第一个周末搭框架、跑通“输入→解析→执行→回传”的最小闭环第二个周末把各种边界情况补齐包括OOM限制、编码问题、唤醒逻辑。到现在稳定跑了三个多月我已经习惯了有什么临时需求就直接打开Web面板输入一句人话让它自己去调度两台机器完成。个人最深的一点体会是双机协作的关键不在“算力有多强”而在于把中控和算力解耦。fnOS这台低功耗小主机天生适合当“家庭网络的秘书”因为它常在线、成本低、系统稳定而高配Windows机器更适合当“被遥控的工人”有活了才唤醒干完继续睡。两者通过一层薄薄的HTTP API连接简单、可靠、好排查。最后再分享一个安全上的小技巧Windows Agent没必要拥有完整的系统权限。我给Agent设定了只能操作指定目录比如C:\agent_workspace执行任意命令前会校验命令上下文中是否包含允许列表之外的危险操作。大模型再准也只是概率模型哪天它真的把“帮我整理一下桌面”理解成“删除所有文件”这层保险能救你一命。远程控制这件事越到后面越会发现安全边界比功能完备更重要。