平时读英文资料比较多的朋友,应该能明显感觉到这两年翻译工具进步的速度。我自己有段时间同时装着三四个翻译插件,却始终没有一个让我完全满意:免费版有字数门槛,长文翻起来束手束脚;翻译腔太重,读译文跟读原文似的还要二次理解;更让我不放心的是,网页内容全部回传到厂商服务器,涉及内部资料和未公开信息时根本不敢用。后来我把目光放到了免费大模型 API 上——现在不少国产大模型厂商都开放了免费档位,翻译质量比传统机翻高出一个量级,日常阅读完全够用。于是我用一个周末写了款浏览器翻译插件:把网页正文抽出来,交给免费大模型接口翻译,再原位插回页面。整个过程零成本,代码量不大,但细节远比想象中多。这篇文章不打算只贴代码,而是把从选型、架构、提示词到踩坑的完整链路都讲清楚,适合想自建翻译工具、又不愿为订阅反复付费的朋友。
1. 为什么放着现成插件不用,非要自己搭一个
1.1 市面翻译插件最让人头疼的三件事
先说隐私。市面上的网页翻译插件,尤其是那些口碑好的大厂产品,几乎都是云翻译模式:你打开一个英文页面,脚本把全文抓走,送到厂商服务器翻译,再返回结果。整个过程对用户是黑盒。用来翻译公开的技术文档倒没什么,但碰上公司内部 Wiki、未公开的竞品分析、带保密协议的合同条款,你敢不敢整页丢给第三方服务器?反正我不敢。自建插件最大的价值就在这——请求只发给你自己指定的模型接口,数据链路清清楚楚。
其次是翻译质量。传统机翻这几年进步不小,但遇到长难句、俚语、行业术语,还是经常交出"逐词死译"的答卷。尤其阅读英文技术文档时,很多翻译插件会把 "call stack" 翻成"调用堆栈"这种格式,看到后面还得自己脑补英文原词。大模型翻译的强项恰好是上下文理解,它能根据整段语气和领域习惯给出更自然的结果,这个优势后面细说。
最后是成本和定制空间。市面上做得好的翻译插件,流畅体验都在订阅墙后面,月费算下来一年也不少。免费版不是限字数就是弹广告,偶尔还来个"今日额度已用完"。自建方案里,模型按量计费每千 token 几分钱,或者直接用免费档位,成本几乎可以忽略。而且想加什么功能加什么功能——比如指定术语词典、切换翻译风格、导出双语对照,都是自己说了算。
1.2 大模型翻译比传统机翻好在哪
拿一句有点行业背景的英文举例:
Apple's third-quarter earnings beat estimates, but the company warned that supply chain constraints would persist into the holiday season.
传统机翻的结果大概是:"苹果第三季度收益超过估计,但公司警告说供应链限制将持续到假日季节。" 字面意思都对,可读起来就是别扭。"beat estimates" 在财报语境里更自然的说法是"超出预期","supply chain constraints" 译成"供应链瓶颈"更符合中文财经报道习惯,"holiday season" 在这个语境下指"假期购物季"。这些取舍需要模型理解财经报道的文体,而不是单纯做词对词映射。大模型在训练阶段见过海量高质量平行语料,所以能在翻译时自动带入这种领域知识。
这不是说传统机翻没用,而是在"读起来像人写的"这个标准上,大模型的优势是压倒性的。对经常读论文、文档、新闻的人来说,这个差距直接影响阅读效率和信息吸收质量。
1.3 这件事适合什么样的人来做
说句实在话,自建翻译插件不是零基础项目,但门槛也没有想象中高。需要的基础能力就三样:会写一点 JavaScript、看得懂 JSON 格式、耐得住性子调试网络请求。适合的人群我大概分三类:
- 轻中度英文阅读者:每天读三五篇英文文章、看技术文档,一天翻译量几千字,免费额度绰绰有余。这类人最能直接受益,因为省下的是订阅费用。
- 隐私敏感的用户:公司资料、个人笔记、未公开内容需要翻译,但不想经过第三方翻译服务。自建方案可以让你完全掌控数据流向。
- 爱折腾的开发者:把这个项目当成练手也好,当成起点也好,跑通之后可以顺手扩展成 PDF 翻译、划词翻译、Zotero 文献翻译插件,应用面很广。
反过来,如果你的需求是每天翻译几十篇长文、对吞吐量要求极高,或者完全不想处理技术问题,那直接用成熟商业产品更省心。免费档位终究有配额,这个预期要摆正。
2. 免费大模型 API 的选型思路与额度实测
2.1 国内能直接用的免费 API 横向对比
选 API 是这项目第一个关键决策。当时我列了几个能直接申请的国内大模型开放平台,把它们的免费档位、接口风格和申请门槛粗略比较了一下:
| 服务商 | 免费档位模型 | 额度特点 | 接口兼容性 |
|---|---|---|---|
| 智谱 AI | GLM-4-Flash | 每日免费调用额度,个人使用足够 | OpenAI 风格,改个地址就能用 |
| 硅基流动 | 多个开源模型 | 部分模型永久免费,新用户有赠送额度 | OpenAI 风格 |
| 阿里云百炼 | qwen-turbo 等 | 新用户有免费额度包 | OpenAI 风格 |
| DeepSeek | 基本无免费档 | 按量计费,单价很低 | OpenAI 风格 |
不同平台的赠送额度和免费模型会随时间调整,申请前记得以官网当前说明为准。另外要留意"免费额度"和"免费模型"的区别:前者是一次性送的体验金,用完就得充值;后者是真正长期 0 元,只是可能限速、限量。做插件这种长期跑的工具,优先选真正有免费模型的平台。
2.2 为什么我最终选了智谱 GLM-4-Flash
我最后用的是智谱的 GLM-4-Flash,理由有三点。
第一,翻译质量对免费档来说确实能打。我拿技术文档、新闻、论文摘要三类文本试了一圈,译文的流畅度和术语处理都稳定在线,比我预期的"免费模型凑合能用"好不少。第二,接口兼容 OpenAI 格式,这意味着大量现成的封装库、示例代码都能直接复用,省掉了对接私有协议的麻烦。第三,请求频率对单用户场景很宽松,我日常一天几十次调用的量级,从未撞上过限流。当然这个结论有很强的时效性,真心建议你也拿手头文本挨个平台测一遍,选最顺手的。
2.3 API Key 申请和额度管理的注意事项
申请流程基本都是:注册账号 → 实名认证 → 进入控制台创建 API Key。其中有几个细节容易踩:
- API Key 创建立即全量显示,很多平台只展示一次,之后只能重新生成。拿到手先存到本地密码管理器里,别随手贴在文档里。
- 免费模型可能在控制台单独列出,不是默认可用。找一下"免费模型"或"限时免费"的入口,手动开通。
- 量级估算:一个英文网页正文大概 2000-5000 字符,折合 token 大约 1000-2500。免费档即使一天限几十次调用,也足够翻十来篇长文。超出这个量,按量计费的价格每千 token 也就一两分钱,仍然比订阅划算。
管理额度方面,建议直接在插件设置里加一个"剩余额度提示"位,每次请求返回后在状态栏更新。很多平台 API 响应会带 usage 字段,顺手把 token 消耗存下来,月底统计就知道自己到底用了多少。
3. 插件整体架构:Manifest V3 下三个模块怎么配合
3.1 为什么必须把请求放到后台线程(跨域问题的本质)
这是整个项目里最容易卡住新手的一步,值得先说透。
浏览器插件的渲染进程(也就是运行在网页里的 content script)发出的 fetch 请求,依然受网页本身的同源策略和 CORS 限制。你在百度页面上用 content script 去请求智谱的 API,浏览器会直接拦下来,报 "No 'Access-Control-Allow-Origin' header"。这不是代码写错了,是浏览器安全模型在起作用。
解决办法是让插件后台的 service worker 来发请求。扩展的 background 进程拥有 manifest 里声明过的跨域权限,不受页面 CORS 限制。于是整体架构变成一条单向链路:
网页 DOM → content script 抽取正文 → chrome.runtime.sendMessage → background service worker → 大模型 API → 结果原路返回 → DOM 原位插入
这条链路理解透了,后面所有模块都是在往里填细节。
3.2 manifest.json 配置与权限边界
Manifest V3 的配置不长,但每个字段都对应一个权限决策。我的最小配置长这样:
{ "manifest_version": 3, "name": "AI 网页翻译", "version": "1.0.0", "description": "调用免费大模型 API 的网页全文翻译插件", "permissions": ["storage", "activeTab"], "host_permissions": ["https://open.bigmodel.cn/*"], "background": { "service_worker": "background.js" }, "action": { "default_popup": "popup.html" }, "content_scripts": [ { "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" } ] }几个字段的解释:
permissions里的storage用来保存 API Key 和配置项,activeTab用来在点击插件图标时读取当前标签页信息。host_permissions必须精确到 API 域名。千万别图省事写<all_urls>,等于把访问所有网站的权限都交给了插件,既危险又容易在应用商店审核时出事。我只给智谱的接口域名开了跨域权限,这是零信任原则在插件上的体现。content_scripts里的matches: ["<all_urls>"]表示在所有网页注入脚本,run_at: "document_idle"等页面主要结构加载完再运行,避免和页面本身脚本抢执行时机。
3.3 从 content script 到 background 再到 API 的完整链路
先说 background 侧,它负责发真实的 HTTP 请求。核心就一个消息监听器加一个 fetch 函数:
// background.js const SYSTEM_PROMPT = "你是一名专业翻译引擎。规则见下方,每次都会传入原文。"; chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === "TRANSLATE_REQUEST") { handleTranslate(message.text) .then((result) => sendResponse({ ok: true, text: result })) .catch((err) => sendResponse({ ok: false, error: err.message })); return true; // 异步响应,必须返回 true 保持通道打开 } }); async function handleTranslate(text) { const config = await chrome.storage.local.get(["apiKey", "model", "endpoint"]); if (!config.apiKey) throw new Error("未配置 API Key"); const resp = await fetch(config.endpoint, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer " + config.apiKey }, body: JSON.stringify({ model: config.model || "glm-4-flash", temperature: 0.2, messages: [ { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: text } ] }) }); if (!resp.ok) { if (resp.status === 429) throw new Error("RATE_LIMIT"); throw new Error("API 错误: " + resp.status); } const data = await resp.json(); return data.choices[0].message.content; }content script 这边的职责是:抽文本、发消息、接结果、改 DOM。翻译触发逻辑先放一个最简版:
// content.js function translatePage() { const nodes = collectTextNodes(); if (!nodes.length) return; const batchText = nodes.map((n) => n.nodeValue.trim()).join("\n\n"); chrome.runtime.sendMessage( { type: "TRANSLATE_REQUEST", text: batchText }, (resp) => { if (!resp || !resp.ok) { console.warn("翻译失败:", resp && resp.error); return; } applyTranslations(nodes, resp.text); } ); }这里最关键的是chrome.runtime.sendMessage的第二个参数——回调函数。background 异步返回结果时,会走这个回调把数据传回 content script。新手最容易犯的错是忘在onMessage监听器里return true,导致消息通道被提前关闭,回调永远不触发。
3.4 配置入口:popup 虽然简单但不能省
popup 是一个小窗口,用户点浏览器工具栏的插件图标就会弹出。它至少得提供三样配置能力:填 API Key、选择模型、开关翻译功能。逻辑都是chrome.storage.local.set/get的增删改查,代码量不大,但这个入口决定了插件的可用性。一个连配置界面都没有的插件,换个浏览器就要改代码,那就太业余了。
我还在 popup 里加了一个"翻译当前页"按钮,点击后往当前标签页的 content script 发指令,比自动全量翻译更好控。给用户选择权,而不是一进页面就哐哐一顿翻,体验差别很大。
4. 翻译质量的核心:提示词设计和上下文策略
4.1 同一个模型,为什么提示词不同结果天差地别
很多人调大模型 API 有个误区:以为模型足够强,随便写句"帮我翻译"就行。实际测下来,同样的 GLM-4-Flash,用"请把下面内容翻译成中文"和用一套结构化翻译指令,结果差距非常明显。
原因在于模型对翻译任务的默认理解偏向"字面忠实"。如果不加约束,它会倾向于逐句直译,尤其面对长难句时会把英文语序硬搬过来。你需要在提示词里显式告诉它:中文要自然、术语要处理、不要解释、保留格式。这套逻辑和指挥真人译员的沟通是一样的——需求定义越清楚,产出越可控。
4.2 打磨过的一套翻译提示词
在多次对比之后,我的 system prompt 沉淀成下面这样,每条规则都有明确目的:
你是一名专业的中文翻译引擎,负责把用户提供的文本翻译成简体中文。 翻译规则: 1. 准确传达原文含义,不增删内容,不遗漏任何段落。 2. 译文必须符合中文表达习惯,避免翻译腔,长句按中文语序重组。 3. 专有名词、产品名、代码、数字、链接保持原样;首次出现的专业术语若没有通用译法,保留英文并附括号说明。 4. 如果原文是技术文档,请沿用该领域的通行译法。 5. 原文中出现的分隔符 ===PAGE_BREAK=== 必须在译文中成对保留,不能删除或移动。 6. 不要输出任何解释、提醒或补充说明,直接输出译文。这个提示词里我特别强调了两点。一是"不增删内容"——大模型自由发挥起来很可怕,会自己脑补解释,这对翻译来说是灾难;二是分隔符保留规则——因为我批量翻译时会把多个段落用分隔符拼成一次请求,靠它切分结果,如果模型把分隔符吞了,我的段落对照逻辑就全乱了。
temperature 参数我固定设成 0.2。翻译是确定性任务,不追求模型创造性发挥,温度越低结果越稳定。试过默认的 0.7,同一个段落翻两次能给出两个版本,虽然都通顺,但作为工具型应用,稳定比偶尔的"灵光一现"重要得多。
4.3 长文本分段、术语统一和并发控制
一次性把整个网页几万字符塞给模型的后果通常是:请求超时、token 超限、或者模型在中途"失忆"忘了前面内容。所以必须分段。我的策略是每个文本块控制在 1500 字符左右,用分隔符拼接成一批,一次请求处理 3-5 块。
这个方案有个附带好处:同一个页码内的术语天然统一。因为所有段落是一次性送给同一个模型的同一个上下文窗口,模型会感知到全文语境,而不是一段一个"世界"。如果你用逐段多次请求的方式,就要额外维护术语表,复杂得多。
并发方面,免费接口的并发上限通常不高,我直接在 content script 里做串行队列:一批翻译完成、写入 DOM 之后,等 200 毫秒再发起下一批。宁可慢一点,也别触发限流导致整页翻译中断。
5. 网页正文定位与原位替换:最容易翻车的地方
5.1 用 TreeWalker 只捞正文文本节点
很多人会把"翻译网页"直接写成document.body.innerText一把梭,然后整个页面结构就崩了。正确做法是用 TreeWalker 遍历文本节点,只挑出那些"真正需要翻译的正文片段"。
const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "CODE", "SELECT"]); function collectTextNodes() { const nodes = []; const walker = document.createTreeWalker( document.body, NodeFilter.SHOW_TEXT, { acceptNode(node) { const parent = node.parentElement; if (!parent || SKIP_TAGS.has(parent.tagName)) { return NodeFilter.FILTER_REJECT; } if (parent.hasAttribute("data-ai-translated")) { return NodeFilter.FILTER_REJECT; } const text = node.nodeValue.trim(); if (text.length < 15) return NodeFilter.FILTER_REJECT; if (/^[\s\d\W]+$/.test(text)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } } ); while (walker.nextNode()) nodes.push(walker.currentNode); return nodes; }过滤规则我解释一下:SCRIPT、STYLE、CODE这类标签里的内容混进去会把页面搞坏;TEXTAREA和INPUT里的是用户输入,翻译它们没有任何意义;长度小于 15 字符的碎片大多是导航文字、按钮标签,翻了反而吵眼睛;纯符号纯数字的文本直接跳过。这样筛下来,捞到的几乎全是正文段落、列表项、标题这类值得翻的东西。
5.2 SPA 动态页面的监听与去抖
现代网页大多不是一次加载完的。你滚动几下、点个分页、展开个手风琴,新内容就渲染出来了——这些动态内容靠一次性扫描根本抓不到。解决办法是 MutationObserver 监听 DOM 变化,新节点出现就再次触发翻译。
但这里有个天坑:你自己往页面里插入译文节点,同样会触发 MutationObserver,于是"插入译文 → 触发监听 → 再翻一遍 → 再插入 → 再触发",形成死循环。两个手段同时用:一是插入操作加一个injecting标志位,插入期间观察器直接忽略变化;二是用去抖,变化发生后等 800 毫秒再执行扫描,把用户滚动和框架渲染引起的一连串小变化合并成一次处理。
let timer = null; let injecting = false; const observer = new MutationObserver(() => { if (injecting) return; clearTimeout(timer); timer = setTimeout(() => translatePage(), 800); }); observer.observe(document.body, { childList: true, subtree: true });5.3 防止重复翻译和页面布局跳变的处理方法
重复翻译的防护靠自定义属性>function applyTranslations(nodes, translatedText) { const parts = translatedText.split("==="); injecting = true; try { nodes.forEach((node, index) => { const part = parts[index]; if (!part) return; const span = document.createElement("span"); span.className = "ai-translation"; span.textContent = part.trim(); node.parentElement.appendChild(span); node.parentElement.setAttribute("data-ai-translated", "1"); }); } finally { injecting = false; } }
配合注入一段全局样式,让译文和原文明显区分开:
.ai-translation { display: block; color: #555; font-size: 0.92em; margin: 2px 0 8px 0; }这个方案的取舍很明确:牺牲了"替换式翻译"的整洁感,换来了可对照阅读、不破坏原布局、可随时恢复的多重好处。实际用下来,双语对照看技术文档反而更高效——遇到不确定的译法,扫一眼原文就清楚了。
6. 真实踩坑记录:从跑通到稳定需要的四件事
6.1 跨域请求被浏览器拦截
这是我跑通最小 demo 时撞上的第一堵墙。当时图省事,直接在 content script 里 fetch API,结果控制台红字报 CORS 错误,我还反复检查 API Key 是不是填错了。后来才意识到,content script 的 fetch 依然受页面源限制。解决办法就是上文说的:把请求全部转移到 background,content script 只负责发消息。这个坑太典型了,几乎每个自建插件的人都会踩一次,写在这里希望大家少走弯路。
6.2 免费接口的限流与重试策略
有段时间我把整页 30 多个段落一次性并发打出去,下一秒就收到一排 429。免费接口对并发和频率的容忍度远低于我的预期。后来我一共做了三层防护:串行队列,一批只发一次请求,完成后隔 200 毫秒再发下一批;本地缓存,用文本的哈希值做 key,翻译结果存进chrome.storage.local,再次遇到相同文本直接命中缓存;指数退避重试,429 时依次等待 2 秒、4 秒、8 秒再试,最多重试 3 次。
async function translateWithRetry(text, retries = 3) { try { const resp = await chrome.runtime.sendMessage({ type: "TRANSLATE_REQUEST", text: text }); if (!resp.ok) throw new Error(resp.error); return resp.text; } catch (err) { if (err.message === "RATE_LIMIT" && retries > 0) { await new Promise((r) => setTimeout(r, 2000 * (4 - retries))); return translateWithRetry(text, retries - 1); } throw err; } }加了这套机制之后,我再也没遇到过整页翻译中断的情况。免费接口偶尔限流很正常,关键是工具要优雅地降级,而不是直接罢工。
6.3 API Key 的存储安全
API Key 是敏感凭证,泄露出去会被人盗刷额度。我定的规矩很明确:Key 只存chrome.storage.local,并且只存在于 background 进程的内存变量里;content script 永远拿不到 Key,popup 配置界面写入后也不显示明文。host_permissions精确锁定 API 域名,既给我自己的请求开后门,也限定恶意页面无法借插件之手向其他域名发请求。这套边界看着简单,但真正做到"最小权限"才能睡得着觉。
6.4 翻译后样式错乱,怎么兜底
接上文,即使我用追加 span 的方案,有些网站还是会出问题:比如商品卡片用了固定高度、表格行内容超长溢出、某些站的 CSS 会把所有后代元素变成行内元素。我的兜底手段是给ai-translation加!important级别的 display 声明,并且保留"还原页面"按钮,一键移除所有译文节点和标记属性。按钮逻辑很简单,遍历[data-ai-translated]元素,删掉子节点里的.ai-translation,再移除标记属性。这个功能我强烈建议留一个,整页汉化后想切回原文对照的场景太多了。
另外提一句,个别网站用 Shadow DOM 隔离内部样式,常规 TreeWalker 扫不进去,这类页面我的插件会跳过内部节点只翻外层。支持 Shadow DOM 需要额外递归进入 shadowRoot,代码复杂度上升一个档次,优先级不高,但知道这个限制能避免你误以为插件坏了。
7. 最后再分享几个实操中的体会
这个插件我用了几个月,最大的感受是"够用就好"这四个字。一开始我也想过上缓存、术语表、快捷键、专属 UI 一大堆功能,但实际天天在用的功能就三个:整页翻译、双语对照、一键还原。很多功能属于"做了很爽,不做也不影响使用",先跑起最小版本再逐步迭代,是这类项目最务实的路线。
一个小技巧分享给同样在折腾的人:调试 content script 时,尽量用浏览器扩展的 service worker 控制台查看 background 日志,而 content script 的日志要看页面控制台。两者打印到不同的控制台,搞混了会浪费大量时间找日志。另外,免费档位的模型不是一成不变的,每隔一两个月我会重新跑一遍选型测试,看看有没有更便宜的免费模型上线,顺手把插件里的 model 字段换掉就行。
如果你用的场景和我类似——主要读英文技术文档和新闻,又不介意动手改几行代码,这个方案是真的能省钱又提升体验。更进一步,这套"抽文本 → 调大模型 → 写回"的流水线稍微改改,就能从浏览器延伸出去:把网页正文换成 PDF 的段落文本,就是一个论文翻译工具;换成 Zotero 文献条目的字段,就是一个文献翻译插件。核心逻辑都差不多,剩下的就看你想往哪个方向扩展了。