这不是简单套一层聊天界面,而是一次完整的本地 AI 服务工程化实践:FastFlowLM 负责把模型跑在 NPU 上,Node.js 负责服务管理、推理调度、流式输出、长期上下文、多用户隔离与数据持久化。
大模型在本机跑起来,只解决了“能推理”的问题。
真正要让家里或办公室的多台电脑、手机都能稳定使用,还要继续解决一串工程问题:模型服务怎样启动?如何确认 NPU 可用?多人同时提问时怎样避免把推理服务压垮?图片如何送进视觉模型?长对话怎样控制上下文?历史记录和用户数据又怎样隔离?
这个项目给出的答案,是一套面向Windows + FastFlowLM的中文局域网聊天服务——FLM 局域网助手。
它把 FastFlowLM 和 NPU 留在主机内部,只向局域网开放一个聊天入口;用户看到的是类似在线大模型的网页体验,底层推理却全部在本地完成。
本文不只介绍功能,更重点拆解它是怎样调用 NPU 完成推理的。
一、先说清楚:项目如何“调用 NPU”
这个项目没有在 Node.js 中直接调用底层 NPU SDK,也没有自己处理算子、显存或设备驱动。
它采用了更清晰的分层方式:
TEXT手机 / 电脑浏览器│ HTTP + SSE▼Node.js 局域网聊天服务(0.0.0.0:8787)├─ 登录与用户隔离├─ SQLite 历史记录├─ 图片与上下文处理└─ 单任务公平队列│ OpenAI 兼容接口▼FastFlowLM(127.0.0.1:52625)│▼本机 NPU
也就是说,Node.js 是控制面,FastFlowLM 是推理面,NPU 是执行面。
应用负责启动、检查和调度 FastFlowLM,再通过 /v1/chat/completions提交请求。模型加载、张量计算和 NPU 执行由 FastFlowLM 完成。
这种架构有三个明显好处:
1.Web 应用不需要绑定特定硬件 SDK,业务代码更简单。
2.FastFlowLM 只监听回环地址,不把底层推理接口直接暴露给局域网。
3.账号、队列、上下文和数据权限全部可以在应用层统一控制。
二、第一步:把 NPU 推理服务安全地拉起来
应用启动后,会先执行:
TEXTflm list --filter installed --json
它只读取已经安装的模型,并把结果作为模型白名单。这样,用户在网页上选择模型时,不会因为输入了一个陌生模型名而意外触发下载。
接着,应用检查127.0.0.1:52625:
•如果已经存在健康的 FastFlowLM,就接入它,并标记为“外部管理”;
•如果端口空闲,就由应用启动 FastFlowLM;
•如果端口被其他程序占用,则直接报错,不结束未知进程;
•应用停止时,只结束自己创建的 FLM 子进程。
启动参数由server/flm.mjs统一构造:
JSexport function buildServeArgs(config) { const args = [ "serve", "--host", config.flmHost, "--port", String(config.flmPort), "--cors", "0", "--pmode", "performance", "--socket", "10", "--q-len", "10", ]; if (Number(config.flmContextLength) > 0) { args.push("--ctx-len", String(Math.floor(config.flmContextLength))); } if (Number.isInteger(Number(config.flmImageResize))) { args.push("--img-pre-resize", String(config.flmImageResize)); } return args;}
默认实际命令如下:
TEXTflm serve --host 127.0.0.1 --port 52625 --cors 0 \ --pmode performance --socket 10 --q-len 10 \ --ctx-len 262144 --img-pre-resize 2
这里最值得关注的参数有三个:
•--pmode performance:以性能模式启动推理服务;
•--ctx-len 262144:为支持的模型配置最高 256K 上下文;
•--img-pre-resize 2:视觉输入默认按 720p 预处理,在识别效果、首 token 延迟和内存占用之间折中。
应用还会访问/api/npu/status,读取npu_available和active_requests,把“是否可用”“是否正在生成”显示在网页上。这里检查的是推理服务报告的真实状态,而不是仅凭进程存在就判断 NPU 正常。

图 1 真实运行状态:FastFlowLM 0.9.45 由应用管理,加载 262,144 tokens 上下文,NPU 状态为“可用”。
三、核心:通过 OpenAI 兼容接口发起 NPU 推理
真正的生成请求集中在FlmManager.completion()中。
下面是经过精简的核心代码:
JSasync completion({ model, messages, settings = {}, stream = false, signal, onDelta }) { if (!this.model(model)) throw new Error("只能使用已经安装的模型"); if (!await this.isHealthy()) throw new Error("FLM 服务当前不可用"); const body = { model, messages, stream, options: { num_predict: Number(settings.maxTokens || 2048), }, }; if (stream) body.stream_options = { include_usage: true }; const response = await fetch(`${this.baseUrl}/v1/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal, }); // 持续解析 FLM 返回的增量内容,再交给浏览器}
请求到达 FastFlowLM 后,才真正进入模型加载、NPU 计算和 token 解码阶段。
项目支持的生成参数包括:
JSconst directKeys = [ "temperature", "top_p", "top_k", "min_p", "presence_penalty", "frequency_penalty", "repetition_penalty", "think", "reasoning_effort", "image-max-tokens",];
一个很细但很重要的设计是:除了最大输出,其他高级参数只在用户明确设置后才发送。这避免了应用的默认值覆盖模型或 FastFlowLM 自身更合适的默认策略。
此外,“最大输出”和“上下文长度”被明确分开:
•--ctx-len决定输入历史与本轮输出合计能占用多大的上下文;
•options.num_predict决定这一轮最多生成多少 token;
•项目的默认最大输出只有 2048,而不是盲目设成 256K。

图 2 模型设置与能力信息:Qwen3.6-MoE 35B-A3B、Q4_K_S 量化、262,144 tokens 上下文,支持文字、图片与推理。
四、一条消息从网页到 NPU,经历了什么
用户点击发送后,服务端并不会立刻把原始请求直接扔给模型,而是依次完成以下工作:
1.校验登录状态和对话所有权;
2.校验模型是否已经安装、是否支持图片;
3.校验生成参数与图片格式、大小和数量;
4.将用户消息和附件写入 SQLite;
5.创建一条状态为generating的助手消息;
6.将任务放入 NPU 公平队列;
7.获得 NPU 后整理长期上下文;
8.调用 FastFlowLM 流式推理;
9.一边向浏览器推送增量,一边累计最终结果;
10.完成后保存正文、推理内容、耗时和 token 统计。
server/index.mjs中的主链路很直观:
JSsse(res, "status", { taskId: task.id, message: "NPU 正在生成…" });let content = "";let reasoning = "";const result = await flm.completion({ model: conversation.model, messages: modelMessages, settings, stream: true, signal, onDelta: (delta) => { content += delta.content; reasoning += delta.reasoning; sse(res, "delta", delta); },});db.updateMessage(user.id, assistantId, { content, reasoning, status: "complete", stats: result.stats,});sse(res, "complete", { taskId: task.id });
浏览器与应用之间使用 SSE,事件统一为 queued、status、delta、complete和error。因此用户能看到排队位置、上下文整理状态以及逐字生成效果,也能随时停止任务。
如果浏览器断开连接,服务端会通过AbortController中止上游推理,避免一个已经无人接收的请求继续占用 NPU。

图 3 真实 NPU 推理结果:Qwen3.6-MoE 35B-A3B 完成本地生成,页面记录 12.6 tokens/s、57.1 秒和 45 tokens。
五、为什么 NPU 前面必须有一条公平队列
本地 NPU 的资源是有限的。多个用户同时生成,可能造成模型争抢、共享内存压力、延迟抖动,甚至加载失败。
这个项目采取了一个务实策略:同一时间只允许一个活跃生成任务。
但仅仅串行还不够。如果一个用户连续提交很多任务,其他人可能一直排在后面。因此队列不是简单的先进先出,而是按用户轮转:
TEXTAlice: A1、A2Bob: B1、B2实际执行:A1 → B1 → A2 → B2
对应的关键控制逻辑是:
JSasync #runNext() { if (this.active || !this.userOrder.length) return; const userId = this.userOrder.shift(); const queue = this.userQueues.get(userId) || []; const task = queue.shift(); if (queue.length) this.userOrder.push(userId); this.active = task; try { await task.run(task.controller.signal); } finally { this.active = null; queueMicrotask(() => this.#runNext()); }}
默认全局最多等待 10 个任务,每个用户最多保留 2 个任务。对家用或小型办公室场景来说,这比追求表面上的高并发更稳定。
六、视觉模型怎样把图片送入 NPU
项目支持 PNG/JPEG 图片的选择、粘贴和拖放。浏览器和服务端都会校验图片,服务端还会检查文件头,避免只改 MIME 类型就绕过格式限制。
历史图片保存在本地附件目录中。构造视觉模型输入时,应用读取图片字节,转为 Base64 Data URL,并按 OpenAI 多模态消息格式发送:
JSconst parts = [];if (message.content) { parts.push({ type: "text", text: message.content });}for (const attachment of message.attachments || []) { const bytes = readAttachment(attachment.id); parts.push({ type: "image_url", image_url: { url: `data:${attachment.mime};base64,${bytes.toString("base64")}`, }, });}
随后,FastFlowLM 对图片进行预处理并调用视觉模型在 NPU 上完成推理。
这里的性能关键并不只是图片文件大小,更是送入视觉编码器的分辨率。项目默认 720p;普通图片问答优先保持这个设置,只有小字 OCR 等任务再考虑 1080p 或 1440p。分辨率越高,首 token 通常越慢,对内存的压力也越大。
七、256K 上下文不能直接“塞满”
项目允许受支持的模型使用 262,144 tokens,但没有把所有历史无限追加。
服务端先用轻量规则估算文本 token 数,并把每张历史图片按 850 tokens 计入。当预计上下文达到模型容量约 70% 时,就在同一条 NPU 队列中调用当前模型,把较早的完整对话压缩成摘要:
JSconst threshold = Math.floor(contextLength * 0.7);if (total < threshold || messages.length <= 16) { return { needsSummary: false, recent: messages, older: [] };}return { needsSummary: true, older: messages.slice(existingSummaryUntil, split), recent: messages.slice(split), summaryUntil: split,};
摘要会保留事实、用户偏好、已经作出的决定、未解决问题和附件信息,同时至少保留最近 8 轮原文。
还有一个值得借鉴的细节:推理内容可以保存和展示,但不会在下一轮重新发送给模型。这样既保留了查看体验,又避免思维过程反复进入上下文,造成 token 浪费和内容污染。
八、怎样统计 NPU 推理速度
项目优先读取 FastFlowLM 返回的:
•decoding_speed_tps:解码速度;
•decoding_duration:解码耗时;
•completion_tokens:输出 token 数。
如果服务端没有返回完整统计,就使用首 token 到最后一个 token 的流式时间估算;再不行,才回退到总耗时估算。
因此页面显示的 tokens/s 有明确优先级:FLM 服务端实测 > 流式时间估算 > 总时间估算。这比简单地用“输出字数 ÷ 总耗时”更接近真实的 NPU 解码表现。
九、推理之外,项目补齐了哪些工程能力
一个能长期使用的本地 AI 服务,不能只有一条模型接口。这个项目还补齐了:
•Node.js 24 内置 HTTP、Crypto 和 SQLite,运行时不依赖第三方 npm 包;
•SQLite 保存用户、会话、对话、消息、模型参数和摘要;
•原始附件使用不可猜测文件名单独保存;
•数据库启动时自动备份,默认保留最近 7 份;
•密码使用带随机盐的 scrypt;
•会话 Cookie 设置 HttpOnly和SameSite=Strict;
•首位管理员只能从主机本机创建;
•每个对话、消息和附件请求都校验用户所有权;
•支持停止、重新生成、模型切换、深浅主题与 PWA 外壳;
•便携包内置 Node.js 24,目标机器无需另装 Node 或 Python。
网络边界也很清楚:
•聊天应用监听0.0.0.0:8787,供局域网访问;
•FastFlowLM 只监听 127.0.0.1:52625;
•防火墙脚本只为 Windows“专用网络”开放 TCP 8787;
•当前方案是可信局域网 HTTP,不能直接暴露到公网;敏感环境应增加 HTTPS 反向代理。
十、部署和使用
主机先安装 FastFlowLM,并至少安装一个模型。随后解压项目的 Windows 便携包,双击:
TEXTStart-FLM-Chat.bat
第一次在主机上访问:
TEXThttp://localhost:8787
创建管理员后,再运行专用网络防火墙脚本。其他设备即可通过主机局域网 IP 访问,例如:
TEXThttp://192.168.31.100:8787
项目提供的主要环境变量包括:
TEXTFLM_CHAT_PORT=8787FLM_PORT=52625FLM_CONTEXT_LENGTH=262144FLM_IMAGE_RESIZE=2FLM_EXECUTABLE=C:\Program Files\flm\flm.exeFLM_CHAT_DATA=%LOCALAPPDATA%\FLM-Chat
当前源码使用便携包自带的 Node.js 24 运行测试,12 项测试全部通过,覆盖账号安全、数据隔离、SQLite 备份、上下文规划、公平队列、FLM 参数、流式统计、图片设置与最大输出等关键路径。
十一、几条真实的踩坑经验
第一,模型标称支持 256K,不等于每轮都应该使用 256K。
上下文越大,通常需要越多内存,首 token 也会更慢。256K 是容量上限,不是推荐的默认输入长度,更不是最大输出应该设置成 256K 的理由。
第二,NPU 推理不只看设备算力,还要看主机内存与共享资源。
项目记录中,qwen3.6-moe:35b-a3b曾报告约 24.3 GB 的加载需求,而当时可用内存约 15.7 GB,最终模型加载失败。这不是聊天界面或队列的错误,而是资源确实不足。
第三,视觉输入要控制分辨率。
高分辨率图片会明显增加视觉预处理、首 token 延迟和内存压力。默认 720p 是一个稳妥起点。
第四,必须区分“应用管理”和“外部管理”。
如果 FLM 是应用自己启动的,修改上下文或图片预处理配置后可以自动重启;如果接入的是外部 FLM,应用不能擅自结束或重启它,参数变更需要用户自行处理。
结语
这套项目最有价值的地方,并不是“又做了一个聊天网页”,而是把 NPU 推理真正包装成了一个可管理、可共享、可持续使用的本地服务。
它用 FastFlowLM 隔离了底层 NPU 细节,用 Node.js 承担了服务治理,再通过单活跃公平队列、流式输出、视觉输入、长期上下文和本地持久化,把一次模型调用扩展成了完整产品。
如果你也准备把 Windows NPU 主机变成家庭或小型团队的 AI 基础设施,这套分层思路很值得复用:
让推理框架专注把模型跑快,让应用层专注把服务管好。
这往往比在业务代码里直接绑定硬件接口,更容易维护,也更接近一个真正能长期运行的本地 AI 系统。
夜雨聆风