我的 GitHub 项目一直有比例不低的海外用户, 也有许多人用英文给我发送邮件, 但我的博客的访客还是以国内用户为主 (显然, 这是因为文章都是中文的). 考虑到我写的大部分文章本就小众, 为了吸引说不定对我的博客也有兴趣的海外用户, 拓展读者群体, 我决定为我的博客加入英文支持.
就以此文记载一下我为博客加入多语言支持的过程, 主要分为两部分: 内容翻译和 Hexo 框架修改.
内容翻译 首先, 要有英文博客, 必须先要有英文内容.
博客的文章全部由 Markdown 组成, 需要以某种方式将其翻译为英文的同时, 保留 Markdown 的格式. 我开始想的比较传统的方法是把 Markdown parse 出来, 然后每个部分里的文本单独送去翻译 API 翻译, 最后再拼回来.
不过这样翻译的时候就容易丢失更长的上下文, 出现翻译的不一致, 以及翻译产出的文本受 Markdown 格式约束过重. 而且传统的机器翻译, 翻译质量想想也知道不会太高.
还能说啥, 直接请出 LLM 吧. 我尝试使用 LLM 直接一把梭翻译我的博客成为英文的同时保留 Markdown 的格式. 使用的初版 Prompt:
1 请将以上 Markdown 翻译成英文,不用深度思考,保留所有格式,不要修改任何链接或代码(最多翻译代码中的注释),确保你的输出还是合法的 Markdown,不用放在一个代码块中,直接输出结果。
我在 OpenRouter 同步尝试了国内外的 SOTA 模型, 包括海外的 Gemini 2.5 Pro, Claude Sonnet 4.5, GPT-5 和国内的 Qwen3 235B A22B Instruct 2507, Qwen3 30B A3B Instruct 2507, DeepSeek V3.1 Terminus, DeepSeek R1, GLM 4.6 这堆模型.
在我的测试中, 所有的模型的 prompt following 能力都至少足以理解我的输入, 并输出一个基本合法的 Markdown. 但测试的所有包含深度思考的模型都忽略了我 不用深度思考 的 prompt, 仍然要自言自语半天才能输出最终结果, 造成了不少 token 的浪费. 如果要使用它们还得找别的办法让它们不要输出 <think>.
在翻译层面, 各个模型输出的语言风格不同, 我也看不太出明显的好坏. 唯一让我觉得不满意的是 DeepSeek R1, 在长输入会产生较为明显的幻觉, 丢掉了比较多原文的内容. 海外的模型在理解中文的梗的能力上较差, 有时候会翻译出意料之外的结果, 而且价格还贵得多, 并且没有开放权重.
经过一些权衡后, 我选择了 Qwen3 235B A22B Instruct 2507 这个模型, 使用 Q6_K 量化本地部署在了 EPYC 9654 服务器上. 同时优化了一个版本的提示词:
1 请将以上 Markdown 翻译成英文,不用深度思考,保留所有格式,但遇到一些中文的梗或玩笑时,可以灵活的调整翻译内容,使其更加符合英语表达习惯。除此之外,不要修改任何链接或代码(最多翻译代码中的注释),确保你的输出还是合法的 Markdown,不用放在一个代码块中,直接输出结果。
最终使用的脚本如下 (使用了 Qwen3 推荐的参数 temp=0.7;top_p=0.8;top_k=20):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 import osimport timeimport requestsimport jsondir_translate = { '学习日记' : 'Learning Notes' , '其他' : 'Other' , '软件研究=)' : 'Exploration' , } complete = 0 prompt = 0 t = time.time() for dirpath, dirnames, filenames in os.walk('source/_posts' ): for filename in filenames: if filename.endswith('.md' ): file_path = os.path.join(dirpath, filename) en_path = file_path for k, v in dir_translate.items(): en_path = en_path.replace(k, v) print (en_path) os.makedirs(os.path.dirname('en/' + en_path), exist_ok=True ) with open (file_path, 'r' , encoding='utf-8' ) as file: content = file.read() if os.path.exists('en/' + en_path): print ('File already exists: ' + en_path) continue print ("Translating: " + en_path) response = requests.post( url="http://localhost:8008/v1/chat/completions" , json={ "model" : "Qwen3-235B-A22B-Instruct-2507-GGUF/Q6_K/Qwen3-235B-A22B-Instruct-2507-Q6_K-00001-of-00004.gguf" , "max_tokens" : int (len (content)), "temperature" : 0.7 , "top_p" : 0.8 , "top_k" : 20 , "min_p" : 0.00 , "messages" : [ { "role" : "user" , "content" : content + '\n\n' + '请将以上 Markdown 翻译成英文,不用深度思考,保留所有格式,但遇到一些中文的梗或玩笑时,可以灵活的调整翻译内容,使其更加符合英语表达习惯。除此之外,不要修改任何链接或代码(最多翻译代码中的注释),确保你的输出还是合法的 Markdown,不用放在一个代码块中,直接输出结果。' } ], } ).json() if 'usage' not in response: print (response) print (response['usage' ], time.time() - t) t = time.time() prompt += response['usage' ]['prompt_tokens' ] complete += response['usage' ]['completion_tokens' ] print (f'Input: {prompt} , Output: {complete} , Total: {prompt + complete} ' ) assert response['choices' ][0 ]['finish_reason' ] == 'stop' with open ('en/' + en_path, 'w' , encoding='utf-8' ) as file: file.write(response['choices' ][0 ]['message' ]['content' ])
也许我应该研究某种 benchmark 来评估一下各个模型的翻译质量, 不过我决定分布推进, 先发布一个版本的翻译试水, 后续翻译可能会更新. 如果你有相关经验, 也欢迎评论交流.
最终, 我的博客消耗了 223k 的输入 token 与 198k 的输出 token. 即使使用在线 API, 这个价格也在可以接受的范围. (以 Qwen3 235B A22B 的 OpenRouter 价格估算, 花费甚至不到 1RMB.)
Hexo 魔改 Hexo 和我使用的 Cactus 主题, 并没有提供 i18n 的支持. 于是我面临的有这么几个选择:
另起炉灶, 自己从头做一个 SSG 框架生成我的博客 换一个支持 i18n 的 SSG 博客框架 修改 Hexo 使其”正确的”支持多语言的博客 将中英文分两次生成两份站点, 把生成的产物打补丁拼在一起 前三者的工作量显然都不小, 我的博客用 Hexo 也很久了, 也积攒了一些依赖 Hexo 运行的插件, 我直接果断选择第四个.
思路是: 先正常 hexo generate 生成原本的中文网页, 再删掉原来的中文 posts, 替换为英文 posts, 生成英文站点, 然后把英文站点的内容作为子目录放在中文站点下, 再在两者直接加入互相切换的超链接就能搞定.
构建脚本 如下, 我直接将英文博客生成的产物放在 /en/ 这个目录下, 这样访问 https://blog.lyc8503.net/en 就能访问到英文站, Hexo 本身就能在子目录正常工作 (需要在 _config.yml 指定正确的 URL, 很多插件都会读取这个配置).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 rm -rf db.jsonnpx hexo generate mv public public_cncp en/_config.yml _config.ymlcp en/_config_theme.yml themes/cactus/_config.ymlcp -r source /friends source /categories source /search en/source/rm -rf source cp -r en/source .rm -rf db.jsonnpx hexo generate mv public public_cn/enmv public_cn publicrsync -av --prune-empty-dirs --exclude='*.html' post/ en/post/ rsync -av img/ en/img/
这样的一个弊端是, 一些公共文件, 比如 js 或文章的图片会有两个链接但指向同一个内容, 可能会导致额外的 cache miss. 理想情况下英文站点应该直接引用根目录下的资源, 但目前 Hexo 并不支持这一点, 所以暂时先这样了.
合并 sitemap, 修改 robots.txt 这样会产生两个 sitemap, 直接拼成同一个, 放在根目录下, 让搜索引擎能正确找到英文页面.
1 2 3 4 5 6 cat sitemap.txt en/sitemap.txt > merged.txt{ echo -e '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' ; sed -n '/<url>/,/<\/url>/p' sitemap.xml en/sitemap.xml; echo '</urlset>' ; } > merged.xml mv merged.txt sitemap.txtmv merged.xml sitemap.xmlrm en/sitemap.txt en/sitemap.xml
合并完成后可以用一些在线的 sitemap.xml 检查工具检查一下格式是否合法, 我这里的写法能通过检查, 没有问题.
此外, 我手动修改了 robots.txt, 允许搜索引擎索引英文站点的所有路径.
添加切换链接及 alternate links 英文站点基本成型后, 现在可以手动在链接 pathname 前面加上 /en 访问英文版本了. 不过光靠 SEO 吸引英文读者肯定不行, 还是得有个手动切换的按钮.
顺便, 我们使用同一个脚本注入 <head> 中的 alternate link, 让搜索引擎可以明白同一篇文章不同语言之间的对应关系, 向用户展示最合适的版本, 同时也防止搜索引擎认为我们的多个页面内容接近而降低权重.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 hexo.extend .filter .register ('after_render:html' , function (htmlString, data ) { if (data.page .path .startsWith ('categories/' ) && data.page .path !== 'categories/index.html' ) { return htmlString; } const baseUrl = hexo.config .url .replace (/\/en/ , '/' ); const zhUrl = new URL (data.page .path , baseUrl).href ; const enUrl = new URL ('en/' + data.page .path , baseUrl).href ; const hreflangTags = ` <link rel="alternate" hreflang="zh-CN" href="${zhUrl} " /> <link rel="alternate" hreflang="en" href="${enUrl} " /> <link rel="alternate" hreflang="x-default" href="${zhUrl} " /> ` ; htmlString = htmlString.replace ('</head>' , hreflangTags.replaceAll ('\n' , '' ).trim () + '</head>' ); const isEnglish = hexo.config .url .includes ('/en' ); let switcherContent = '' ; if (isEnglish) { const targetUrl = '/' + data.page .path ; switcherContent = ` <a href="${targetUrl} " style="color: #c9cacc; text-decoration: none;">简体中文</a> <span style="color: #c9cacc;">/</span> <span style="color: #2bbc8a;">[English]</span> ` ; } else { const targetUrl = '/en/' + data.page .path ; switcherContent = ` <span style="color: #2bbc8a;">[简体中文]</span> <span style="color: #c9cacc;">/</span> <a href="${targetUrl} " style="color: #c9cacc; text-decoration: none;">English</a> ` ; } const switcherContainerStyle = ` position: absolute; top: 15px; left: 15px; z-index: 9999; font-size: 14px; font-family: Menlo, 'Meslo LG', monospace; ` .replace (/\s\s+/g , ' ' ).trim (); const switcherHtml = `<div style="${switcherContainerStyle} ">${switcherContent.replace(/\s\s+/g, ' ' ).trim()} </div>` ; const finalHtml = htmlString.replace (/<body(.*?)>/i , `<body$1>${switcherHtml} ` ); return finalHtml; });
添加警告 由于我没有时间人工核对和修正每一篇博客的翻译, LLM 也肯定会产生错漏, 我选择在每篇文章开头添加一段 Warning. 具体实现方法和之前在结尾添加 CC 版权说明类似:
1 2 3 4 5 hexo.extend .filter .register ('before_post_render' , function (data ) { if (data.layout != "post" ) return data; data.content = '> ⚠️ This article is currently an experimental machine translation and may contain errors. If anything is unclear, please refer to the original Chinese version. I am continuously working to improve the translation.\n\n' + data.content return data; }, 5 );
结尾 大概就是这么多~ 这就是你现在看到的我的博客了, 接下来就看能有多少海外读者了.