用LM Studio与Firefox扩展打造本地大模型聊天助手
本地跑大模型这事儿最尴尬的从来不是模型跑不起来而是它跟日常操作隔着一层。模型在独立的对话框里开聊了你想让它看看当前网页某段话得先把文字复制出去、切窗口、粘贴、问完再切回来来回几次耐心就没了。我最近一直在试一种顺手得多的组合用 LM Studio 把本地模型起成一个 HTTP 服务再在 Firefox 里塞一个自己的聊天小工具页面上选中任何文字都能直接丢给模型完全不用把内容上传到任何外部服务器。这套方案对两类人特别实用一是数据敏感、不想让日常浏览内容经过第三方接口的人二是干活时希望模型就在手边、而不想频繁切窗口的人。接下来我把完整搭建过程、代码细节和踩过的坑一次说清楚你照着操作就能在半小时内得到一个属于自己的浏览器端本地聊天助手。1. 为什么要在 Firefox 里接一个本地模型1.1 本地模型与浏览器之间的割裂问题本地大模型工具现在其实已经很成熟了下载模型、加载、对话都有图形界面用起来不难。但问题在于模型服务和浏览器是两个独立的世界一边是你正在阅读的网页、正在处理的文档、正在编辑的邮件另一边是等待你粘贴内容的对话窗口。你真正想要的效果是什么是我正看着这段文字顺手就能让模型给个解释、帮我改个措辞、总结个要点而不是先把内容搬运过去再提问。这个需求一旦明确解决方案就清楚了只要把模型做成一个本地 HTTP 服务浏览器的任何页面都能通过编程方式调用它再把这层调用封装成自己习惯的交互界面。LM Studio 在这里的优势很明显它把模型下载、加载、服务启停都集中在一个桌面应用里不需要你自己折腾 Python 环境、CUDA 配置也不需要在终端里敲命令挂着服务。它暴露出来的接口走的是 OpenAI 兼容结构也就是说你用 fetch 发一段 JSON 过去就能收到模型生成的文本。对浏览器来说这就是一次普通的网络请求。1.2 三条接入路线扩展、用户脚本、侧边栏页把本地 API 接到 Firefox 里我实际尝试下来可行路线主要有三条各有各的适用场景。一条是写浏览器扩展。这是最正统、功能上限最高的做法扩展的后台脚本拥有跨域请求权限不受普通网页的同源策略限制内容脚本可以注入到任意页面实现选中文字、悬浮按钮、聊天浮窗这类交互扩展面板还可以做成独立的聊天界面。缺点是你得写一点 JavaScript对没写过扩展的人来说有个小小学习曲线。另一条是用户脚本配合脚本管理器使用。脚本管理器提供的跨域请求能力同样可以绕过页面限制而且改起来非常快几行代码就能在当前页面加一个简陋浮窗。但缺点是交互自由度受制于脚本管理器浮窗样式要用纯 DOM 手工拼长期维护比较容易乱。适合只想快速试一下、不打算长期用的情况。第三条最简单直接在 Firefox 侧边栏里放一个本地 HTML 文件。Firefox 支持给扩展配置侧边栏面板也可以直接加载本地文件做成一个常驻聊天区。零注入、零侵入但只能作为侧边栏使用没办法在任意网页上选中文字直接提问。三条路线各有取舍我最后选的是浏览器扩展。它虽然前期多写一点代码但后续想要什么交互都可以自己加而且代码量其实很少。下面这份对比表可以帮你快速判断选哪条路。路线交互自由度实现成本维护成本适合场景浏览器扩展高可做浮窗/右键菜单/侧边栏中等需写 manifest 和脚本低代码结构清晰长期使用、功能定制用户脚本中依赖脚本管理器能力低几段脚本即可中功能多了容易乱快速验证想法、短期使用侧边栏本地页低只能放在侧边栏里最低一个 HTML 文件低只需要持续对话、不要页面交互2. 首先把 LM Studio 调成一台本地模型服务2.1 模型下载、加载与服务启动要点在写任何浏览器代码之前先把 LM Studio 这头弄利索。它的界面主要分几个区域模型浏览、对话、本地服务。你需要做的第一步是找到合适的模型并下载。选多大参数量级取决于你的内存和是否有可用显卡。如果机器只有 CPU 和 16GB 内存优先选社区里常见的 7B 档位量化模型这类模型在纯 CPU 推理下还能保持可用速度如果显存充足可以再往上一档。模型下载完成后在模型列表里点击加载。这里有个很容易忽略的细节加载不等于启动服务。你不能只在模型页面看到模型名称就以为浏览器能访问了必须在 LM Studio 的本地服务页面里启动服务模型才会通过 HTTP 接口对外可用。服务的默认端口是 1234绑定地址是 127.0.0.1。绑定 127.0.0.1 这个细节我强烈建议保留它意味着只有本机程序可以访问这个端口局域网里的其他设备无法连接敏感数据不会意外暴露。启动完服务后你在自己电脑的浏览器地址栏里直接访问http://127.0.0.1:1234/v1/models应该能看到一个 JSON 数组里面列出了当前可用的模型标识。能看到这个结果说明服务已经正常在跑了。2.2 用 curl 验证接口提前排掉两个大坑服务启动后先不要急着写扩展我建议先用一个最简单的请求把链路验证通。命令如下curl http://127.0.0.1:1234/v1/chat/completions \ -H Content-Type: application/json \ -d { model: 你从 /v1/models 里看到的模型标识, messages: [ {role: user, content: 用一句话介绍你自己} ], max_tokens: 128 }注意里面有一个非常关键的字段model。LM Studio 对这个字段有要求它必须和你已加载模型的实际标识完全一致通常不是你在模型界面上看到的那个好听的名字而是包含组织名、参数量、量化类型在内的一长串标识。如果你在 LM Studio 里加载的是 A 模型但请求里写的是 B 模型的标识返回结果里就会出现模型找不到的报错。正确做法是先请求/v1/models从返回结果里复制完整的id字段再粘贴到请求里。另一个容易踩的坑是没有加载模型就去请求。服务即使开着如果当前没有加载任何模型接口也无法完成生成会返回错误信息。所以启动服务之前请先确认模型已经加载完成。验证成功后你会看到返回的 JSON 里有一个choices数组里面的message.content就是模型生成的回答。到了这一步接口层面已经全部打通剩下的就是浏览器那边怎么调用它。2.3 关于跨域限制你需要知道的边界浏览器里有同源策略这回事普通网页直接发请求到http://127.0.0.1:1234大概率会被拦截这就是浏览器控制台里那一串跨域报错的来源。很多刚开始接触的人在这块会卡很久其实只要弄清楚边界就好办了。如果你是在扩展的background脚本里发请求那么不存在跨域问题。扩展只要在 manifest 里声明了对本地端口的访问权限后台脚本就像本地命令行一样自由请求。如果你是在普通网页的控制台里直接发请求那就会被浏览器的跨域策略拦住。这时候要么在 LM Studio 的服务设置里放开 CORS 限制把允许来源设为所有来源本地调试场景通常无妨要么干脆别在页面里直连把请求逻辑放到扩展后台里去。我个人的建议是虽然 LM Studio 提供了 CORS 开关但扩展方案完全用不到它。保持默认或者保守设置需要放开再说没必要为了调试方便把本地服务暴露给任意网页。毕竟这个接口运行在你的机器上能请求到它的网页越多被利用的可能性就越大。3. 手写一个最小可用的 Firefox 扩展3.1 扩展目录结构与 manifest 配置现在开始写扩展。整个项目只需要几个文件local-chat-extension/ ├── manifest.json ├── background.js ├── content.js ├── popup.html └── popup.js先说 manifest。这里我选择manifest_version: 2。很多人一听 MV2 就觉得过时了但在 Firefox 生态里 MV2 依然被完整支持而且这种形式的后台脚本是经典的事件页模型写起来直观、调试也容易。如果以后想发布到 Firefox 附加组件商店再根据审核要求迁移到 MV3 也不难。{ manifest_version: 2, name: Local Chat Companion, version: 0.1.0, description: 在 Firefox 里调用本地 LM Studio 服务的聊天工具, permissions: [ storage, activeTab, http://127.0.0.1/*, http://localhost/* ], background: { scripts: [background.js] }, content_scripts: [ { matches: [all_urls], js: [content.js], run_at: document_idle } ], browser_action: { default_popup: popup.html, default_title: 本地模型聊天 } }这里有几个字段需要注意。permissions里必须包含对127.0.0.1和localhost的访问权限否则后台脚本请求本地服务时会被拒绝。content_scripts里的matches设置为所有网址它负责在每个页面上注入聊天浮窗browser_action是工具栏上的扩展按钮点击后弹出一个小对话窗。3.2 后台脚本把本地 API 封装成消息接口后台脚本承担的角色是一个翻译层。前台脚本内容脚本或弹窗通过browser.runtime.sendMessage发送一条消息过来后台脚本收到后负责真正请求 LM Studio再把结果返回给前台。这样做的价值在于后台脚本拥有扩展权限可以随意访问本地端口前台脚本则不需要关心请求细节抛一个 prompt 进去拿一段文本出来。browser.runtime.onMessage.addListener(async (msg) { if (msg.type ! chat) { return { ok: false, error: unknown message type }; } const controller new AbortController(); const timeout setTimeout(() controller.abort(), 120000); try { const response await fetch(http://127.0.0.1:1234/v1/chat/completions, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ model: msg.model || local-model, messages: [ { role: system, content: msg.system || 你是我的本地助手回答简洁准确。 }, ...(msg.history || []), { role: user, content: msg.prompt } ], temperature: 0.7, max_tokens: 1024, stream: false }), signal: controller.signal }); const data await response.json(); if (!data.choices || !data.choices[0]) { return { ok: false, error: JSON.stringify(data) }; } return { ok: true, text: data.choices[0].message.content }; } catch (err) { return { ok: false, error: String(err) }; } finally { clearTimeout(timeout); } });这里有一个我特别想强调的部分超时控制。模型推理和普通接口不一样token 数一多或者模型一慢请求可能几十秒都不返回。如果不加AbortController后台脚本会一直挂在那里用户那边的界面就一直转圈。设置一个 120 秒的超时至少能保证最坏情况下扩展不会假死。这个习惯值得在以后写任何本地模型调用时都保留。模型的默认标识我在代码里占位写了local-model请替换成你从/v1/models里查到的实际标识。你也可以自己动手做成配置项但第一版先写死跑通了再优化。3.3 页面注入聊天浮窗与选中文本入口内容脚本负责在页面右下角放一个可折叠的聊天浮窗并且在用户选中文本时给出一个小按钮。它的核心逻辑包括创建浮窗 DOM、监听鼠标选中事件、收集选中的文字、转发给后台脚本、展示模型回复。(function () { if (window.__localChatInjected) return; window.__localChatInjected true; const container document.createElement(div); container.style.cssText [ position:fixed, right:20px, bottom:20px, z-index:2147483647, width:340px, background:#ffffff, border:1px solid #e0e0e0, border-radius:12px, box-shadow:0 4px 20px rgba(0,0,0,0.15), font:14px/1.6 system-ui,sans-serif, display:none ].join(;); document.body.appendChild(container); container.innerHTML div stylepadding:10px 12px;border-bottom:1px solid #f0f0f0;font-weight:600本地模型助手/div div stylepadding:10px 12px textarea idlocal-chat-input stylewidth:100%;height:80px;border:1px solid #ddd;border-radius:6px;padding:8px;resize:vertical/textarea button idlocal-chat-send stylemargin-top:8px;width:100%;padding:8px;background:#2563eb;color:#fff;border:0;border-radius:6px发送/button div idlocal-chat-output stylemargin-top:10px;white-space:pre-wrap/div /div ; const input container.querySelector(#local-chat-input); const output container.querySelector(#local-chat-output); const button container.querySelector(#local-chat-send); function sendToModel(prompt) { output.textContent 思考中...; browser.runtime.sendMessage({ type: chat, prompt }).then((res) { output.textContent res.ok ? res.text : 错误 res.error; }); } button.addEventListener(click, () { const text input.value.trim(); if (text) sendToModel(text); }); document.addEventListener(mouseup, () { const selection window.getSelection().toString().trim(); if (selection.length 0 selection.length 2000) { input.value selection; } }); })();代码里有个细节window.__localChatInjected这个标志位。因为内容脚本在刷新页面时会重新执行如果没有这个判断同一页面可能会被注入多个浮窗叠加在一起非常难看。你可以把这段代码直接放进content.js里保存后先别急着加载到浏览器我们接着把弹窗页面也写上。popup.html就是一个极简的输入框加按钮点击工具栏扩展图标时弹出方便在不开页面浮窗的情况下快速问一句。它的popup.js几乎和上面的sendToModel一样只是把输出区换成了弹窗里的元素。完整的弹窗界面我就不展开了核心逻辑都是一份消息发到后台、拿回复渲染到页面。4. 把聊天从能对话打磨到好用4.1 侧边栏常驻模式工具栏弹窗和页面浮窗适合偶发提问但如果你一边看文档一边持续讨论问题最好让聊天区域常驻在浏览器侧边栏里。Firefox 对扩展侧边栏有原生支持你只要在 manifest 里加一个sidebar_action字段然后在扩展里放一个sidebar.html页面即可。{ sidebar_action: { default_title: 本地模型助手, default_panel: sidebar.html } }侧边栏页面本身也拥有扩展权限可以直接请求本地服务也可以在页面里调用browser.runtime.sendMessage走到后台脚本。我实际使用时的分工是侧边栏放一个完整会话列表和输入区用于长时间连续讨论页面右下角的浮窗只处理选中一段话马上提问这种轻量操作。两者可以共存并不冲突。需要注意的一点是侧边栏是和浏览器窗口绑定在一起的开多个窗口时会各自显示一个侧边栏实例。如果你同时提问相当于同一个模型的多个请求并发此时本地推理速度会被明显拉低。所以总结下来平时只保持一个浏览器窗口开着侧边栏其他窗口按需用浮窗就够了。4.2 多轮对话的历史管理单轮提问很简单但真正用起来你一定会发现只有上下文连续的对话才有意义。你让模型继续润色刚才那段话它不知道刚才指的是什么除非你把整个对话历史都发给它。历史管理的代码如下const history []; const MAX_HISTORY 10; async function sendUserMessage(text) { history.push({ role: user, content: text }); const recentHistory history.slice(-MAX_HISTORY * 2); const res await browser.runtime.sendMessage({ type: chat, prompt: text, history: recentHistory }); if (res.ok) { history.push({ role: assistant, content: res.text }); } return res; }这里我用slice(-MAX_HISTORY * 2)做了一个滑动窗口只保留最近 10 轮以内的消息因为绝大多数模型的上下文窗口是有限的不是塞得越多越好。真实场景里我在长对话时每隔一段时间会点一次清空会话避免模型被边角料信息绕晕也避免生成速度变得很慢。4.3 针对浏览场景的两个 prompt 模板浏览器里调用模型的场景和单纯聊天不太一样很多需求是重复的。我长期用下来有两个模板出镜率最高。第一个是选中文本润色。在内容脚本里写好固定指令选中一段文字后点击浮窗里的润色按钮请求的消息体大概是这样{ messages: [ { role: system, content: 你是一名中文编辑。请润色用户提供的文本保持原意只输出修改后的版本。 }, { role: user, content: 这里粘贴选中的那段话 } ] }第二个是总结当前页面。内容脚本读取页面正文时可以先尝试document.querySelector(article)的文本取不到就用document.body.innerText然后截断到一定长度再请求。这里一定要控制长度因为网页正文动辄几万字全部塞进上下文既慢又无效。我通常截取前 8000 个字符左右对大部分模型已经足够生成一份像样的总结。document.querySelector(article)?.innerText || document.body.innerText.slice(0, 8000)页面越长这两个模板的价值越明显。以前看长文档要做摘要还得复制到模型工具里现在我直接选中目录标题或正文开头让模型给个整体概括效率提升非常明显。4.4 响应太慢先考虑这四种优化本地模型响应慢的原因主要来自几个方向。第一是模型太大推理本身费时间这个没什么好办法只能换更小的量化档位。第二是没有 GPU 加速纯 CPU 推理时大模型每秒只能生成几个 token一眼就能看出差距。第三是并发请求太多比如侧边栏和浮窗同时在等回复模型是串行处理的。第四是生成参数里的max_tokens太大模型会在没必要的时候生成大量内容占时间。如果你想要更接近聊天工具的实时反馈感还可以启用流式输出。在请求体里加stream: trueLM Studio 就会按 SSE 格式一段一段返回内容。前端需要解析ReadableStream里的数据每次收到新的片段就追加到文本区。这样做的好处是用户在第一句话冒出来的瞬间就知道模型已经开始工作了不需要干等一个完整的回复。副作用是前端代码复杂度明显上升得处理流式解析、断流重连这些边缘情况。我的建议是第一版先用非流式跑通整体链路等基本功能稳定再考虑把浮窗的回复改成流式。不要一开始就把所有特性堆上去容易把验证问题变成排查问题。5. 踩坑记录与调试方法5.1 高频问题速查表写这个扩展的过程中我碰到的问题不少有些是环境配置有些是代码细节。把高频问题整理成了一张表你遇到对应现象可以直接照表查。现象大概率原因解决办法请求返回连接被拒绝服务没启动或端口不对确认 LM Studio 本地服务已开启浏览器访问http://127.0.0.1:1234/v1/models验证返回模型找不到的报错请求里的 model 标识不对先访问/v1/models复制完整 id 填进去浏览器控制台出现跨域报错普通网页直接请求本地接口改用扩展后台请求或在 LM Studio 临时放开 CORS浮窗没出现在页面上内容脚本注入失败或标志位冲突刷新页面检查about:debugging里扩展是否有报错回复一直在思考中请求超时或后台脚本报错到后台控制台看 fetch 错误加短超时便于快速失败问答速度特别慢模型过大、无 GPU、并发请求换小模型、减少同时打开的聊天窗口、降低 max_tokens里面最隐蔽的问题是模型标识。它不像你肉眼看到的模型名那么直观尤其是在多个模型反复切换之后请求里的标识还是旧的结果就是怎么问都不出结果。这算是本地模型服务这一层最典型的坑了。5.2 扩展调试的三个习惯调试 Firefox 扩展不需要额外装什么复杂工具有这三个习惯就够了。第一个习惯学会用about:debugging页面里的临时载入附加组件。开发阶段所有扩展都通过这个入口加载每次修改代码后点一下重新载入就能生效不需要打包成 xpi。比拖拽安装方便很多。第二个习惯打开浏览器内置的开发者工具后注意在控制台面板把显示范围切换成扩展相关的上下文。Firefox 的控制台可以切换查看页面上下文还是扩展上下文后台脚本的日志只有在扩展上下文里才看得见。很多人抱怨写好的扩展没反应其实就是因为一直在看页面控制台根本没看到后台日志。第三个习惯给每个分支情况都打上日志。在发送消息、收到响应、捕获异常这三个关键点各打一条console.log问题出在哪一环就非常清楚了。不要等到代码写完整再去调试那样你面对的是几十个疑点而不是三五个。5.3 资源占用与日常使用心得本地跑模型资源占用这点我觉得必须给你打个预防针。一个 7B 参数的量化模型在 LM Studio 里常驻内存后占用基本是 4GB 到 6GB如果同时开着不少浏览器标签页16GB 内存的机器会有点吃紧。好在现代桌面系统对内存的管理还算智能只要你别同时开着虚拟机又挂着游戏日常办公浏览没什么问题。CPU 推理时一旦请求开始处理器占用会明显拉高此时标签页滚动都会感觉到阻力。我的做法是把推理任务安排在需要专注阅读的时段不去抢同一时间窗口做其他重活。如果你有一块支持加速的显卡建议在 LM Studio 的运行时设置里把模型加载到显卡上体验会好非常多。最后分享一个我个人的使用体会这类工具真正高频的场景不是把它当成一个什么都能答的全能助理而是把它当作选中的这段文字给我解释一下、改一改、展开讲一讲。大部分时候我甚至不会去打开浮窗聊天而是直接选中内容、点击润色按钮、拿到结果、继续阅读。这个使用方式跑通之后我回头看那套从页面复制粘贴到模型对话框的流程才意识到原来的割裂感有多重。你先按最小闭环把扩展跑起来再逐步加侧边栏、多轮对话和模板功能整个过程会比你想象中顺利得多。