
平时读英文资料比较多的朋友应该能明显感觉到这两年翻译工具进步的速度。我自己有段时间同时装着三四个翻译插件却始终没有一个让我完全满意免费版有字数门槛长文翻起来束手束脚翻译腔太重读译文跟读原文似的还要二次理解更让我不放心的是网页内容全部回传到厂商服务器涉及内部资料和未公开信息时根本不敢用。后来我把目光放到了免费大模型 API 上——现在不少国产大模型厂商都开放了免费档位翻译质量比传统机翻高出一个量级日常阅读完全够用。于是我用一个周末写了款浏览器翻译插件把网页正文抽出来交给免费大模型接口翻译再原位插回页面。整个过程零成本代码量不大但细节远比想象中多。这篇文章不打算只贴代码而是把从选型、架构、提示词到踩坑的完整链路都讲清楚适合想自建翻译工具、又不愿为订阅反复付费的朋友。1. 为什么放着现成插件不用非要自己搭一个1.1 市面翻译插件最让人头疼的三件事先说隐私。市面上的网页翻译插件尤其是那些口碑好的大厂产品几乎都是云翻译模式你打开一个英文页面脚本把全文抓走送到厂商服务器翻译再返回结果。整个过程对用户是黑盒。用来翻译公开的技术文档倒没什么但碰上公司内部 Wiki、未公开的竞品分析、带保密协议的合同条款你敢不敢整页丢给第三方服务器反正我不敢。自建插件最大的价值就在这——请求只发给你自己指定的模型接口数据链路清清楚楚。其次是翻译质量。传统机翻这几年进步不小但遇到长难句、俚语、行业术语还是经常交出逐词死译的答卷。尤其阅读英文技术文档时很多翻译插件会把 call stack 翻成调用堆栈这种格式看到后面还得自己脑补英文原词。大模型翻译的强项恰好是上下文理解它能根据整段语气和领域习惯给出更自然的结果这个优势后面细说。最后是成本和定制空间。市面上做得好的翻译插件流畅体验都在订阅墙后面月费算下来一年也不少。免费版不是限字数就是弹广告偶尔还来个今日额度已用完。自建方案里模型按量计费每千 token 几分钱或者直接用免费档位成本几乎可以忽略。而且想加什么功能加什么功能——比如指定术语词典、切换翻译风格、导出双语对照都是自己说了算。1.2 大模型翻译比传统机翻好在哪拿一句有点行业背景的英文举例Apples 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 是这项目第一个关键决策。当时我列了几个能直接申请的国内大模型开放平台把它们的免费档位、接口风格和申请门槛粗略比较了一下服务商免费档位模型额度特点接口兼容性智谱 AIGLM-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 依然受页面源限制。解决办法就是上文说的把请求全部转移到 backgroundcontent 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 永远拿不到 Keypopup 配置界面写入后也不显示明文。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 文献条目的字段就是一个文献翻译插件。核心逻辑都差不多剩下的就看你想往哪个方向扩展了。