
最近接了个活儿——帮一个在读博士的朋友搞定文献阅读的翻译问题。他常年要看英文专利和论文手头用的翻译插件总是把专业术语翻得乱七八糟长段落结构也经常被拆得七零八落读起来比看原文还费劲。最要命的是想自定义术语表、想调整翻译风格现成插件一概不支持。我给他的方案是干脆自己写一个浏览器翻译插件接免费的大模型API。全程不花一分钱API费用代码量控制在几百行以内效果却比很多商业插件舒服得多——术语可以自己定义prompt可以按需求调整翻译结果稳定可控。这篇文章就把整个搭建过程拆开来讲包括免费API的选型、插件的最小骨架、段落级翻译的完整实现以及我被各种报错折磨后总结出来的坑。适合想折腾的开发者也适合那些被现成翻译工具恶心到、想换个思路的普通用户。1. 为什么放着谷歌翻译不用非要自己写插件先说清楚最核心的问题市面上免费翻译工具那么多凭什么要自己造轮子我的答案很简单——通用翻译引擎解决不了专业场景下的精准翻译。拿学术文献举例。一个普通翻译插件面对A novel approach to the synthesis of heterocyclic compounds这句话大概率会把synthesis翻译成合成——这没错。但当上下文变成音乐理论、生物学或者哲学论文时同一个词完全可能是综合共生或者综合论述。通用引擎没有你的领域背景它只会按最常见语义处理而专业读者恰恰不能忍受这种粗糙。自己搭插件之后我可以在prompt里明确写一句你是一名材料科学领域的资深译者以下文本涉及锂离子电池电极材料请用该领域的标准术语翻译。同样的API翻译质量立刻上了一个台阶。这是任何通用工具都给不了的自由度。还有个大多数人没意识到的问题翻译结果的可解释性和可修正性。商业插件是个黑盒它为什么这么翻、依据什么术语体系你完全无从查证。自己写插件prompt是你写的模型输出你能逐字检查翻得不对随时调整提示词。这个特性对做学术研究、搞技术文档的人特别有价值。当然也得承认自己搭插件的代价需要注册API账号、要写代码、要应付各种奇怪的报错。但如果你每周要读大量外文资料、被翻译质量反复折磨过这点成本完全值得。提示如果你是纯小白看到代码就头疼我建议至少读完第2节和第5节——这两节能帮你判断接API自己搞这条路适不适合你。代码部分可以直接抄但理解原理才能避免踩坑。2. 免费大模型API怎么选DeepSeek、智谱、Kimi的取舍翻译插件的核心引擎是大模型选对API供应商就成功了一半。我的判断标准只有三条有没有免费额度、中文翻译质量怎么样、接口容不容易调。2.1 三家主流免费API的横评对比目前国内能稳定白嫖的API主要是DeepSeek、智谱和Kimi这三家。我用自己的测试文档包含一段材料学论文摘要、一段法律合同条款、一段技术博客开头分别跑了一遍结果如下API供应商免费额度中文翻译质量接口复杂度我的评价DeepSeek注册送一定额度目前对老用户也比较宽松术语准确度高长段落逻辑连贯简单OpenAI兼容格式首选性价比极高智谱GLM新用户有免费token包充值门槛低中文表达自然但长文本偶尔丢细节兼容OpenAI格式需要用API key备份方案质量稳定Kimi有免费体验金额度但有效期限制严格擅长长文本理解翻译风格偏口语化兼容OpenAI格式但部分接口有额外限制适合追求流畅度、非专业场景如果让我推荐无脑选DeepSeek。原因不只是翻译质量更重要的是它的API设计完全兼容OpenAI接口规范这意味着你可以用现成的SDK和大量开源工具换一个base_url就能跑。对动手党来说这能省掉一半的排查时间。2.2 获取API Key的完整流程以DeepSeek为例注册和拿key的流程很简单打开DeepSeek开放平台手机号注册账号进入API Keys管理页面点击创建API key复制生成的key格式类似sk-xxxx保存好——这个key只在创建时完整显示一次丢了就只能删除重建查看充值或余额页面确认账户有可用额度注意很多人拿到key后直接硬编码在代码里这是个坏习惯。一是key会泄露二是频繁更换时得改代码。正确做法是存在浏览器的chrome.storage里插件设置页填写一次即可。2.3 为什么我不推荐本地部署大模型热词里很多人搜ollama部署大模型企业大模型私有化部署这确实是另一个思路但对网页翻译插件这个场景我明确不建议。本地跑一个7B级别的量化模型内存占用轻松超过6GB推理速度也就每秒几十个token。翻译一篇论文摘要要等半分钟体验太差。而免费的云端API响应速度通常在1到3秒质量还更高。本地部署适合两种人一是对数据隐私有极端要求文本绝对不允许出本机二是搞离线环境开发断网也要用。普通用户自己搭翻译插件完全没有必要背这个包袱。3. 翻译插件的最小骨架Manifest V3下的四个核心文件明确了API选型接下来就是动手写插件。很多教程一上来就堆代码结果新手看半天不知道文件之间怎么配合。我换个讲法先理解插件的运行骨架再往骨架上填肉。现代浏览器插件Manifest V3至少需要四个角色配合manifest.json插件的身份证声明权限、入口、运行方式background service worker后台进程负责接收右键菜单点击、管理全局状态content script注入页面的脚本负责读取网页文本、把翻译结果写回页面popup页面用户点图标弹出的界面用来配置API key和prompt提示Manifest V3是谷歌从Chrome 88开始强制的新标准旧教程里的background.js那种后台页写法已经过时了。网上搜资料时认准Manifest V3关键词避免被老教程带偏。3.1 manifest.json怎么声明权限才能不踩坑直接上我用的配置{ manifest_version: 3, name: Free AI Web Translator, version: 1.0.0, description: 免费大模型API驱动的网页翻译插件, permissions: [ contextMenus, storage, activeTab ], background: { service_worker: background.js }, content_scripts: [ { matches: [all_urls], js: [content.js], run_at: document_idle } ], action: { default_popup: popup.html, default_title: AI翻译设置 }, host_permissions: [ https://api.deepseek.com/* ] }这里有几个关键决策点host_permissions必须加。如果你不加这个content script里直接fetch第三方API会被CORS拦截。我最初就是漏了这一项花了一个小时查Request blocked by CORS policy。这个权限的意思是允许插件代码访问指定域名的接口。contextMenus权限用于右键菜单。选中文本后右键翻译比点图标再操作快得多属于基本体验项。matches写all_urls代表所有网页都注入content script。如果你只想翻译特定网站可以改成具体的域名数组减少资源占用。run_at用document_idle等页面加载完再注入避免一上来就操作还没渲染好的DOM。3.2 三个核心文件的职责划分background.js 负责监听右键菜单收到点击后把指令和选中文本传给content scriptchrome.runtime.onInstalled.addListener(() { chrome.contextMenus.create({ id: translate-selection, title: AI翻译选中内容, contexts: [selection] }); }); chrome.contextMenus.onClicked.addListener((info, tab) { if (info.menuItemId translate-selection) { chrome.tabs.sendMessage(tab.id, { type: TRANSLATE_SELECTION }); } });这段代码的巧妙之处在于content script在页面里才能拿到用户选中的文本所以background仅仅充当传话筒具体取文本和翻译逻辑都在content.js里完成。这也是Manifest V3的常见模式——service worker不直接碰DOM交互都通过消息通信。content.js 是插件真正的翻译工坊它的核心任务是拿到选中文本、组装prompt、调用API、显示结果。完整实现放在第4节展开这里先打个底。popup.html 则是配置入口。由于涉及API key的存储和加载我会用chrome.storage.sync来保存配置避免每次都打开设置页面重新输入。4. 核心代码段落级翻译的完整实现这一节是整个插件的精华。我按三个子任务拆解怎么从网页里干净地取出文本、怎么构造一个高质量的翻译prompt、怎么把翻译结果优雅地显示出来。4.1 从网页提取文本别一股脑全抓新手最常犯的错误是直接把document.body.innerText扔给API。这样做有两个问题一是网页里混着导航栏、广告、脚本输出等一堆噪音文本二是文本过长了API一次处理不完还会浪费token。我推荐的策略是只翻译用户选中的内容并且把长文本按句子或段落切块。选中内容天然排除了页面噪音切块则保证了每次请求的长度可控。function getSelectedText() { const selection window.getSelection(); if (!selection.rangeCount) return null; return selection.toString().trim(); } function splitTextByLength(text, maxChars 1500) { const sentences text.match(/[^.!?。][.!?。]?/g) || [text]; const chunks []; let current ; for (let sentence of sentences) { if ((current sentence).length maxChars current) { chunks.push(current.trim()); current sentence; } else { current sentence; } } if (current.trim()) chunks.push(current.trim()); return chunks; }这段逻辑分两个层次先通过正则把文本拆成单个句子再按1500字符左右聚合成分块。为什么是1500因为DeepSeek等模型的中文token效率大约是2到3个字符一个token1500字符大概对应600个token左右加上prompt模板单次请求远低于模型上下文上限响应速度也快。注意正则拆句不能保证百分百准确——比如Mr. Smith came.会被拆成Mr. Smith came.和.两段。对翻译任务影响不大如果实在介意可以换用Intl.Segmenter API它是浏览器原生支持的分词接口兼容性还行。4.2 构造prompt质量好坏全在这一句同样是API翻译出来质量天差地别关键就在prompt。底层的裸prompt是Translate the following text into Chinese. Keep the original meaning and tone. Output only the translation.这个能用但离专业级翻译还差得远。我自己调试后沉淀了一个模板function buildTranslatePrompt(text, customTerm) { let prompt 你是一名专业的翻译专家擅长中英互译。; prompt 请将以下内容翻译成简体中文要求\n; prompt 1. 准确传达原文含义保留专业术语的规范译法\n; prompt 2. 句子结构按中文习惯调整必要时拆分长句避免翻译腔\n; prompt 3. 只输出翻译结果不要解释、不要额外说明。\n; if (customTerm) { prompt 4. 当遇到以下术语时请严格按照指定翻译 customTerm \n; } prompt \n原文\n text; return prompt; }为什么强调只输出翻译结果不要解释因为大模型默认喜欢在回答前后加好的以下是翻译结果这类废话不但浪费token回填页面时还要做额外清洗。直接在prompt里声明输出格式是成本最低的约束手段。术语表怎么用比如我的专利场景会要求claim必须翻译为权利要求embodiment翻译为实施例。把这些映射放在popup设置里保存构建prompt时拼进去专业度立刻拉满。4.3 调用API并回填结果异步流程的完整处理这里用DeepSeek的接口做示例。注意它的base_url是https://api.deepseek.com模型名deepseek-chatasync function translateChunk(chunk, apiKey, baseUrl, model) { const response await fetch(baseUrl /chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer apiKey }, body: JSON.stringify({ model: model || deepseek-chat, messages: [ { role: system, content: You are a professional translator. }, { role: user, content: buildTranslatePrompt(chunk, customTerm) } ], temperature: 0.3 }) }); if (!response.ok) { throw new Error(API request failed with status response.status); } const data await response.json(); return data.choices[0].message.content.trim(); }有几个参数值得解释temperature: 0.3是我实测下来的甜点值。temperature越高模型输出越随机翻译任务需要稳定和准确所以我把它压得比较低。试过0.7同一个句子每次翻译结果都有差异不可接受。system消息用来框定角色我给的是professional translator。如果你没写system纯靠user里的prompt也能工作但加了这个角色设定模型在风格上会更收敛。Authorization头必须带Bearer前缀这是OpenAI兼容接口的通用规范漏掉会报401。调用完成后需要把结果展示在用户选中的位置附近。这里有个技巧不要用alert()弹窗也不要直接用document.title之类的全局结构而是创建一个悬浮提示层。这样既不影响原文阅读也能随时关闭function showTranslation(text, x, y) { const existing document.getElementById(ai-translation-popup); if (existing) existing.remove(); const popup document.createElement(div); popup.id ai-translation-popup; popup.textContent text; popup.style.position fixed; popup.style.left x px; popup.style.top y px; popup.style.width 400px; popup.style.maxHeight 300px; popup.style.overflow auto; popup.style.background #fff; popup.style.border 1px solid #ccc; popup.style.boxShadow 0 4px 12px rgba(0,0,0,0.15); popup.style.zIndex 99999; popup.style.padding 12px; document.body.appendChild(popup); }配合mouseup事件用户选中完文本松开鼠标时就能获取鼠标坐标并弹出翻译结果。这个交互设计比选中后去点右键菜单再等翻译更顺滑。5. 踩坑实录热词里那些API报错到底怎么治我在调试过程中被一串报错折磨过后来发现热词搜索榜上全是问同样问题的人。这里集中复盘帮你少走弯路。5.1 no api key for provider route deepseek-official是怎么回事这句报错是典型的API key未生效或未正确加载。当时我的代码在background里读取存储的key时key还没写进chrome.storage相当于以空字符串发起了请求。后来又发现如果用的是第三方聚合工具比如直接套OpenAI的SDK工具内部可能配置了多个provider路由DeepSeek的key没绑到正确的路线上。排查思路检查请求头里Authorization是否真的带上了key可以在发请求前console.log(apiKey)打印确认检查API key有没有空格或换行复制时容易带进不可见字符检查key是否过期DeepSeek的key被删除或重置后旧key立刻失效如果用了SDK确认SDK的base_url确实指向https://api.deepseek.com而不是OpenAI官方地址我当时的问题出在第一条popup页面里点了保存但chrome.storage.sync是异步写入的保存完立刻翻译时还没写完于是拿到了undefined。解决方法是保存后加个回调提示或者保存完等1秒再翻译。5.2 maximum context length is 1048576 tokens上下文过长怎么办这条报错看着吓人实际是请求内容超过了模型上下文窗口。但1048576 tokens这个数字明显是模型的最大窗口值不是你的实际输入量。谁会把翻译请求发出一百万token这时要警惕你的文本提取是不是出了问题。我复盘后发现自己的抓取逻辑把整个页面的innerText都塞了进去包括埋藏在隐藏层里的长文本再加上切块逻辑写错位置导致合并后的块远超预期。热词里有人遇到同样的问题多半是提取时不小心把nodeType判断错了把整个body节点都文本化了。解决方案分三层只翻译用户选中的文本从源头控制长度强制设置切块上限比如maxChars 1500超过就直接split不做语义完整性校验在fetch之前加一个保护性检查如果拼好的文本长度超过模型上限按保守值20000字符算直接截断或报错提示function safeTruncate(text, maxLength 20000) { if (text.length maxLength) return text; return text.substring(0, maxLength) \n[内容过长已截断]; }能跑出这种报错的另一个常见场景是在content script里误调用了document.body.outerHTML把整个HTML源码都传给了API。这类错误不会每次都触发但一旦遇到就是不可复现的灵异问题排查时优先检查自己的文本来源。5.3 请求被限流和CORS拦截浏览器插件特有的两个坎初次运行插件时最常见的报错就是CORS policy。我在第3节已经说了根因——漏配host_permissions。这里再补充一个细节即使加了这个权限有些网站出于安全策略会主动阻止第三方域名请求。这时候不要硬刚换成从background service worker发起请求即可因为service worker的跨域权限由host_permissions控制不受页面CORS策略干扰。限流则是另一个高频问题。免费额度下很多API有每分钟请求数限制RPM。我的插件连续翻译多个段落时经常在第五六个请求后收到429状态码。应对办法是给请求加延时队列let lastRequestTime 0; const MIN_INTERVAL 1000; async function rateLimitedFetch(url, options) { const now Date.now(); const waitTime Math.max(0, MIN_INTERVAL - (now - lastRequestTime)); if (waitTime 0) { await new Promise(resolve setTimeout(resolve, waitTime)); } lastRequestTime Date.now(); return fetch(url, options); }简单说相邻两次请求间隔至少1秒。翻译一段长文本如果被切成5块加上限流总耗时大概6到8秒尚在可接受范围。如果你对速度更敏感可以把间隔降到500ms但要做好被限流的心理准备。6. 从能用到好用缓存、并发和结果质量优化基本的翻译流程跑通之后插件的体验只能说能用。真正拉开差距的是下面这三个细节。6.1 本地缓存同一段文本不要翻译两次学术阅读场景里反复回看同一段话、同一个术语是家常便饭。不缓存的话每次选中都重新调API既费时间又费额度。缓存方案用localStorage就行key是原文的哈希值。不要直接拿原文当key长文本的key太占空间。简单实现function getCacheKey(text) { let hash 5381; for (let i 0; i text.length; i) { hash (hash * 33) ^ text.charCodeAt(i); } return trans_ (hash 0).toString(36); } function getFromCache(text) { return localStorage.getItem(getCacheKey(text)); } function saveToCache(text, result) { localStorage.setItem(getCacheKey(text), result); }这个哈希算法DJB2不是密码学安全但作为缓存key足够碰撞概率极低而且计算开销几乎为零。6.2 并发切块别让请求排死队前面我提到限流时用了等到上一个请求完成再发下一个的策略这是保险做法。但翻好几段长文本时串行请求会让用户等到怀疑人生。优化思路是有限并发同时最多保持2到3个请求在途既不会撞限流又能明显缩短总耗时。async function translateWithLimit(chunks, maxConcurrent 2) { const results []; const queue chunks.map((chunk, index) ({ chunk, index })); const workers Array.from({ length: maxConcurrent }, async () { while (queue.length 0) { const { chunk, index } queue.shift(); results[index] await translateChunk(chunk); } }); await Promise.all(workers); return results; }我给这个函数的默认值是2。实测DeepSeek的免费额度下2并发基本不会触发429而总翻译时长能比串行快接近一倍。6.3 翻译结果后处理修掉翻译腔再展示即使prompt里写了按中文习惯调整句子结构大模型还是偶尔会翻出半文不白的句子。我不建议在prompt里反复强调那是隔靴搔痒。更有效的做法是分区域翻译和术语表绑定。什么叫分区域翻译就是连网页的段落标签一起传给API让它按HTML层级组织输出。比如你在翻译一个p标签prompt里可以提示这是独立段落的开头请保持段落完整性。这样至少保证段落间的逻辑关系不散。术语表绑定则更好理解——在自己配置的术语表里加入专业翻译约束后我实测patent claim从原来的专利声明纠正为了权利要求lithium-ion battery从锂离子电池这个还算对到锂离子电池Li-ion的规范写法整体专业度提升非常明显。提示后处理阶段还要做一件事清理模型输出中的前后引号。有些模型会在翻译结果外层自动加双引号回填页面时看着很出戏。用replace(/^[]|[]$/g, )处理一下即可。7. 打包发布前的最终检查清单功能写完了最后一个环节是把插件装进浏览器。Chrome的加载流程是打开chrome://extensions开启开发者模式点击加载已解压的扩展程序选择插件文件夹。Edge等基于Chromium的浏览器流程一致。但发布前我建议先对照这份清单自检一遍[ ] API key只存在chrome.storage里没有硬编码[ ] 右键菜单在无选中文本时不会触发翻译[ ] 长文本切块后每个块不超过1500字符[ ] 请求失败时有错误提示而不是静默失败[ ] 弹窗在滚动页面时会跟随或自动关闭而不是浮在原地挡视线关于跟随或自动关闭我这里再补个小技巧可以监听window.scroll事件一旦滚动就隐藏翻译弹窗。理由很简单——用户滚动页面说明想继续看原文这时翻译结果已经完成任务再霸屏反而碍事。如果用户还想看重新选中即可反正有缓存响应很快。经过这几个月的试用这个插件已经成了我看文献的标配。虽然它跟成熟的商业插件比还有距离比如没有国内外主流网站的适配美化、缺少划词发音功能但在专业术语可控、零成本、完全私有化这三点上它完全吊打常规方案。如果你动手能力强顺着这条思路还能扩展出更多玩法自动整页翻译、翻译后语音朗读、双语对照模式、甚至把翻译结果导出成双语PDF。API的底座都是现成的剩下的只是你的想象力。