
网页翻译这件事说大不大说小也不小。浏览器自带的翻译要么机翻味重得离谱要么干脆把代码块和专有名词一起翻掉读技术文档的时候简直是灾难。商业翻译插件倒是有做得不错的但免费额度用完之后要么限速要么收费长期用下来也是一笔开销。我自己的需求其实很朴素读英文技术文档、看外文博客、偶尔翻几篇论文翻译质量要过得去术语别乱翻最重要的是——别让我每个月为这个掏钱。折腾了一圈之后我发现现在国内好几家大模型厂商都提供了免费额度的 API像智谱、DeepSeek 这些注册就送一笔 token日常翻译这点量根本用不完。那为什么不干脆自己搭一个翻译插件把免费大模型 API 接进去说干就干这篇文章就把我整个搭建过程完整记录下来——从选模型、拿 API Key到用 Node.js 写一个本地翻译服务再到做成浏览器插件最后处理各种实际使用中遇到的坑。整个过程零成本代码量也不大有一点 JavaScript 基础就能跟着做下来。1. 为什么我不直接用现成的翻译插件1.1 商业翻译插件的三个硬伤先说清楚我为什么要自己造轮子不然你可能会觉得这是多此一举。市面上主流的网页翻译插件我基本都用过一轮问题集中在三个地方。第一个是术语翻译不可控。技术文档里 token 这个词在认证场景下应该翻成令牌在计费场景下是词元在大模型语境下又可能是标记。传统翻译引擎基本是固定译法翻出来经常驴唇不对马嘴。而大模型的好处是你可以通过 prompt 告诉它这是技术文档token 统一译为词元它就能按你的要求来。第二个是格式破坏。很多翻译插件是把整个页面的文本节点抽出来翻译再塞回去遇到代码块、行内代码、链接这些元素要么翻掉要么搞乱结构。我读 GitHub README 的时候经常遇到代码块里的注释被翻译了复制出来直接报错。第三个是隐私和成本。商业插件要么把你的页面内容传到它自己的服务器要么免费额度用完就限速。自己搭的话请求直接发给你选的大模型厂商中间不经过第三方而且用的是免费额度心里有底。1.2 大模型翻译和传统机翻的本质区别这里得稍微展开说一下为什么大模型翻译值得折腾。传统统计机器翻译SMT和早期的神经机器翻译NMT本质上是句子级的映射它看到一个句子从训练语料里找最可能的对应译法。这种方式在通用场景下还行但缺乏上下文理解能力。大模型翻译是理解后重述。你给它一段话它先理解这段话在说什么然后用目标语言重新表达一遍。这意味着几个实际的好处一是上下文连贯代词指代、术语一致性都能保持二是可以下指令你可以在 prompt 里规定风格、术语、格式要求三是能处理长文本现在主流大模型的上下文窗口动辄 128K 甚至更大整篇文档丢进去都没问题。当然代价是速度比传统机翻慢毕竟要跑推理。但对于读文档这种场景慢个一两秒完全可以接受质量提升是实打实的。1.3 免费 API 额度到底够不够用这是最关键的问题。我算过一笔账一篇中等长度的英文技术文档大概 3000 到 5000 个英文单词换算成 token 大约 4000 到 7000 个。翻译成中文输出 token 数差不多也是这个量级。也就是说翻译一篇文档消耗大约 1 万到 1.5 万 token。目前国内几家厂商的免费额度注册送的基本都是百万 token 级别。按这个算免费额度够你翻译七八十篇长文档。而且很多厂商的免费模型是长期免费的不是一次性赠送。日常使用的话一个月翻个几十篇文档完全在免费范围内。就算偶尔超了按量付费的价格也是每百万 token 几块钱的水平比订阅制插件便宜太多。提示不同厂商的免费策略会调整注册前先看清楚当前的政策。有的免费额度有有效期有的免费模型有并发限制这些都会影响实际体验。2. 选哪家的大模型 API 最划算2.1 主流免费 API 横向对比我把当时能拿到的几家免费 API 都试了一遍下面这张表是我自己的实测感受不是官方参数仅供参考。厂商免费额度模型能力接口兼容性我的评价智谱注册赠送有免费模型中文理解强兼容 OpenAI 格式翻译质量稳推荐首选DeepSeek注册赠送推理能力强兼容 OpenAI 格式翻译准确偶尔过度意译其他国内厂商各有政策参差不齐多数兼容 OpenAI可作为备用选型的核心逻辑是优先选接口兼容 OpenAI 格式的。因为这样你的代码只需要改一个 baseURL 和 model 名字就能切换厂商不用为每家写一套适配。这个兼容性太重要了后面我会详细讲怎么利用这一点。2.2 接口兼容 OpenAI 格式意味着什么OpenAI 的 Chat Completions 接口格式现在基本成了行业事实标准。请求体长这样{ model: 模型名称, messages: [ {role: system, content: 系统提示词}, {role: user, content: 用户输入} ], temperature: 0.3 }响应体里取choices[0].message.content就是模型输出。只要厂商兼容这个格式你的代码里换个baseURL就能无缝切换。我自己的做法是在配置文件里把厂商信息做成一个列表想换哪家改一行配置就行非常省事。2.3 注册拿 Key 的完整流程以智谱为例流程大概是进官网注册账号完成实名认证国内厂商基本都要求进控制台找到 API Keys 页面创建一个新的 Key复制保存。这个 Key 就是一串字符后面调用接口时要带上它做身份验证。注意API Key 等同于你的账号密码绝对不能提交到 Git 仓库或者发到公开地方。我习惯把它放在环境变量里代码里通过process.env.XXX读取这样即使代码开源了也不会泄露。DeepSeek 的流程类似注册后在控制台创建 API Key。两家都建议先把 Key 存到本地一个安全的地方因为创建后有些平台只显示一次关掉页面就看不到了。3. 用 Node.js 搭一个本地翻译服务3.1 为什么需要一个中间层你可能会问浏览器插件直接调大模型 API 不就行了为什么要多一层 Node.js 服务原因有三个。第一是跨域问题浏览器插件直接请求第三方 API 经常会遇到 CORS 限制中间加一层服务就绕过了。第二是Key 安全如果把 API Key 写在插件代码里别人解包就能看到放在本地服务里就安全得多。第三是可以做缓存和批处理翻译过的内容存下来下次遇到相同内容直接返回省额度也省时间。所以整体架构是浏览器插件 → 本地 Node.js 服务 → 大模型 API。插件负责抓取页面文本和展示译文Node.js 服务负责调模型和缓存。3.2 环境准备Node.js 安装与版本选择Node.js 是运行环境去官网下载 LTS 版本就行。写这篇文章的时候 LTS 是 20.x建议用 20 或更高版本因为要用到原生的 fetch API18 以下还得装额外的库。安装过程一路下一步就行。装完之后打开终端验证node -v npm -v能打印出版本号就说明装好了。如果你在 Ubuntu 上用包管理器装可能会遇到版本太旧的问题建议去 Node.js 官网按官方指引装或者用 nvm 这种版本管理工具切换版本方便。提示网上有些教程会让你装最新版而不是 LTS 版但最新版可能还没稳定生产环境或者日常使用都建议用 LTS。我踩过一次坑用最新版跑某个依赖直接报错换回 LTS 就好了。3.3 初始化项目与安装依赖新建一个文件夹进去初始化mkdir translator-service cd translator-service npm init -y然后装两个核心依赖express用来起 HTTP 服务dotenv用来读环境变量。npm install express dotenv在项目根目录建一个.env文件写入你的配置API_KEY你的APIKey API_BASEhttps://open.bigmodel.cn/api/paas/v4 MODELglm-4-flash PORT3000这里的API_BASE和MODEL根据你选的厂商填。智谱的 base 地址和模型名去它文档里查DeepSeek 的也类似。用.env的好处是配置和代码分离换厂商只改这个文件。3.4 核心翻译接口的实现新建server.js核心逻辑就是接收前端传来的文本拼好 prompt调大模型 API返回结果。关键代码大概是这样require(dotenv).config(); const express require(express); const app express(); app.use(express.json()); const cache new Map(); app.post(/translate, async (req, res) { const { text, targetLang 中文 } req.body; if (!text) return res.status(400).json({ error: text is required }); const cacheKey ${targetLang}:${text}; if (cache.has(cacheKey)) { return res.json({ result: cache.get(cacheKey), cached: true }); } const systemPrompt 你是一个专业翻译引擎。请将用户提供的文本翻译成${targetLang}。 要求 1. 保持原文的格式和换行 2. 技术术语保留英文原文首次出现时可在括号内注明中文 3. 代码、变量名、命令不要翻译 4. 只输出译文不要添加任何解释; try { const response await fetch(${process.env.API_BASE}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.API_KEY} }, body: JSON.stringify({ model: process.env.MODEL, messages: [ { role: system, content: systemPrompt }, { role: user, content: text } ], temperature: 0.3 }) }); const data await response.json(); const result data.choices[0].message.content; cache.set(cacheKey, result); res.json({ result, cached: false }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.listen(process.env.PORT || 3000, () { console.log(翻译服务已启动端口 ${process.env.PORT || 3000}); });这段代码有几个设计点值得说。temperature设成 0.3 是为了让翻译结果稳定太高了模型会自由发挥太低又可能死板。system prompt 里明确要求保留格式和不翻译代码这是针对技术文档场景的优化。缓存用 Map 实现简单够用重启服务会清空如果要持久化可以换成文件或数据库。3.5 启动服务并做第一次测试启动服务node server.js看到翻译服务已启动就说明跑起来了。用 curl 测一下curl -X POST http://localhost:3000/translate \ -H Content-Type: application/json \ -d {text:The quick brown fox jumps over the lazy dog.}如果返回了中文译文说明整条链路通了。这一步很重要先把服务端调通再去搞插件不然出了问题不知道是哪一层的事。注意如果报错说找不到 API Key 或者认证失败先检查.env文件里的 Key 有没有多余空格再确认 base 地址和模型名对不对。我遇到过 base 地址末尾多了个斜杠导致 404 的情况这种小细节最容易忽略。4. 把翻译服务做成浏览器插件4.1 浏览器插件的最小结构浏览器插件以 Chrome 扩展为例本质上就是几个文件加一个配置文件。最小可用的结构包括manifest.json插件的配置文件声明权限、入口、版本等popup.html点击插件图标弹出的界面popup.js弹窗的逻辑content.js注入到网页里的脚本负责抓取和替换文本background.js后台脚本处理跨域请求等Manifest V3 是目前的标准配置里要声明host_permissions允许访问本地服务还要声明activeTab和scripting权限来操作页面。4.2 抓取页面文本的正确姿势抓文本这一步是坑最多的地方。最朴素的做法是document.body.innerText但这会把整个页面的文本混在一起丢失结构翻译完塞回去就乱了。正确的做法是遍历 DOM 树只处理文本节点跳过script、style、code、pre这些标签。核心逻辑function collectTextNodes(root) { const walker document.createTreeWalker( root, NodeFilter.SHOW_TEXT, { acceptNode(node) { const parent node.parentElement; if (!parent) return NodeFilter.FILTER_REJECT; const tag parent.tagName.toLowerCase(); if ([script, style, code, pre, noscript].includes(tag)) { return NodeFilter.FILTER_REJECT; } if (!node.textContent.trim()) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } } ); const nodes []; let n; while (n walker.nextNode()) nodes.push(n); return nodes; }用TreeWalker遍历的好处是它原生支持过滤性能也好。把符合条件的文本节点收集起来批量发给翻译服务拿到结果后按顺序写回node.textContent。这样页面结构完全不动只换文字。4.3 批量翻译与并发控制一个页面可能有几百个文本节点如果每个都单独发一次请求一是慢二是容易触发 API 的速率限制。我的做法是把文本节点按长度分组短的合并成一批一起翻译用特殊分隔符隔开翻译完再拆开。但合并也有风险模型可能会把分隔符也翻译了或者打乱顺序。所以更稳妥的做法是控制并发数比如同时发 5 个请求用 Promise 池来管理。这样既不会太慢也不会把 API 打爆。async function translateBatch(texts, concurrency 5) { const results new Array(texts.length); let index 0; async function worker() { while (index texts.length) { const i index; const res await fetch(http://localhost:3000/translate, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ text: texts[i] }) }); const data await res.json(); results[i] data.result; } } await Promise.all(Array.from({ length: concurrency }, worker)); return results; }这个并发池模式很通用值得记住。它保证同时最多有concurrency个请求在跑跑完一个补一个直到所有任务完成。4.4 处理动态加载的内容现代网页很多内容是滚动加载或者异步渲染的你翻译完当前页面往下滚又出来新内容。这时候可以用MutationObserver监听 DOM 变化发现新节点就自动翻译。不过这个功能要谨慎用因为有些页面会频繁变动导致翻译请求爆炸。我的做法是加一个开关默认关闭自动翻译需要的时候手动点一下翻译新内容。另外监听的时候要加防抖DOM 变动后等个几百毫秒再处理避免频繁触发。5. 实际使用中踩过的坑和解决方案5.1 翻译结果格式错乱最常见的问题是模型返回的译文里多了 markdown 标记或者解释性文字。比如你让它翻译一句话它回你这句话的意思是……。解决办法是在 system prompt 里反复强调只输出译文不要任何解释并且把temperature调低。如果还是不行可以在代码里做后处理把常见的多余前缀去掉。另一个格式问题是换行丢失。原文有换行的地方译文可能变成一整段。这个在 prompt 里要求保持原文换行能解决大部分情况但模型偶尔还是会偷懒。对于段落级的文本我建议一个段落一个请求不要合并这样换行天然就保留了。5.2 速率限制和超时处理免费 API 通常有速率限制比如每分钟多少次请求。并发太高就会收到 429 错误。处理方式是加退避重试遇到 429 就等一会儿再试等待时间指数增长。async function fetchWithRetry(url, options, maxRetries 3) { for (let i 0; i maxRetries; i) { const res await fetch(url, options); if (res.status 429) { await new Promise(r setTimeout(r, Math.pow(2, i) * 1000)); continue; } return res; } throw new Error(重试次数用尽); }超时也要处理fetch 默认没有超时请求卡住会一直等。可以用AbortController加超时const controller new AbortController(); const timeout setTimeout(() controller.abort(), 30000); const res await fetch(url, { ...options, signal: controller.signal }); clearTimeout(timeout);5.3 长文本超出上下文限制大模型有上下文窗口限制虽然现在动辄 128K但如果你把一整本书丢进去还是会超。而且就算不超长文本的翻译质量也会下降模型容易忘记前面的内容。我的策略是按段落切分每个段落单独翻译。段落之间本来就有语义边界分开翻译不会影响连贯性。如果某个段落特别长比如超过 2000 字再按句子切。切分的时候注意不要在句子中间断开按句号、问号、感叹号这些标点切比较安全。5.4 缓存策略与额度节省缓存是省额度的关键。我的缓存键是目标语言 原文这样同一段文本翻译过一次就不会再消耗额度。实际使用中技术文档里重复的句子、导航栏、页脚这些内容特别多缓存命中率相当高。缓存可以做成两级内存缓存Map用于当前会话文件缓存用于跨会话。文件缓存简单点就用 JSON 文件存复杂点用 SQLite。我自己的用量不大内存缓存加定期导出就够了。提示缓存要注意失效策略。如果你改了 prompt 或者换了模型之前的缓存可能就不适用了这时候要清空缓存重新翻译。我一般会在配置里加个版本号版本变了就自动清缓存。6. 进阶优化与扩展思路6.1 针对不同场景切换 prompt翻译技术文档和翻译新闻、小说要求完全不一样。技术文档要术语准确、格式保留新闻要通顺流畅小说要保留文风。我的做法是在插件里加一个场景选择器不同场景用不同的 system prompt。比如技术文档的 prompt 强调保留代码和术语小说的 prompt 强调保留原文语气和风格意译优先。这个切换成本很低但效果提升明显。6.2 术语表的接入如果你经常翻译某个特定领域的文档可以维护一个术语表翻译前把术语表塞进 prompt 里告诉模型这些词必须按指定译法翻译。比如术语对照 - token → 词元 - embedding → 嵌入 - fine-tuning → 微调这个功能对专业文档翻译帮助极大能保证全文术语一致。术语表可以做成一个 JSON 文件插件读取后拼进 prompt。6.3 划词翻译与整页翻译的取舍整页翻译适合快速浏览但有时候你只想翻译某一段。划词翻译就是选中文本后弹出译文适合精读。这两个功能可以共存整页翻译用批量接口划词翻译用单条接口共用同一个后端服务。实现划词翻译用mouseup事件监听选区拿到window.getSelection().toString()发给翻译服务在选区附近弹个浮层显示结果。注意浮层要能点击关闭不然会挡住内容。6.4 多厂商自动切换前面提到接口兼容 OpenAI 格式的好处这里就体现出来了。你可以在配置里放多个厂商的信息代码里做一个简单的故障转移主厂商请求失败或者超时自动切到备用厂商。这样即使某家服务临时抽风你的翻译也不会中断。const providers [ { base: 主厂商地址, key: 主Key, model: 主模型 }, { base: 备用地址, key: 备用Key, model: 备用模型 } ]; async function translateWithFallback(text) { for (const p of providers) { try { return await callAPI(p, text); } catch (e) { console.warn(${p.base} 失败尝试下一个); } } throw new Error(所有厂商都失败了); }这个模式在实际使用中非常实用尤其是免费 API 偶尔会有波动的时候。7. 一些实际使用中的体会整套东西搭下来代码量其实不大核心逻辑加起来也就几百行。真正花时间的是调 prompt 和处理各种边界情况。我自己的使用感受是大模型翻译在技术文档场景下确实比传统机翻好一大截尤其是术语处理和格式保留这两块提升非常明显。有几个小技巧是我用了一段时间才总结出来的。第一prompt 里明确说不要翻译代码比事后过滤有效得多模型很听话你说了它基本就不翻。第二缓存一定要做不然重复翻译同样的内容既慢又费额度。第三并发不要开太高免费 API 的速率限制比你想的严格开 5 个并发基本是安全线开 20 个大概率触发限流。还有一点这套方案不只可以用来翻译网页。同样的后端服务你接个命令行工具就能翻译本地文件接个输入框就能当通用翻译器用。我自己就顺手写了个脚本把整个 markdown 文档目录批量翻译成中文用来读开源项目的文档特别方便。核心思路就是翻译能力做成一个独立的服务前端怎么用是另一回事这样复用性最好。如果你也想搭一套建议先从最小可用版本开始——一个能调通 API 的 Node.js 脚本然后逐步加缓存、加插件、加并发控制。不要一上来就想着做得多完美先把链路跑通后面优化都是水到渠成的事。