一、前言

  在阅读英文文献或浏览国外技术文档时,往往需要翻译工具辅助阅读。市面上的 DeepL、Google Translate 虽然强大,但 API 调用往往昂贵且有频率限制。而本地部署大模型(如 LLaMA)进行翻译虽然效果好,但对显存和算力的要求极高,普通的云服务器难以负担。
  最近在 github 上发现一个极其轻量级的开源项目 MTranServer,它完美解决了速度与资源的平衡问题。我已经将其部署在了我的阿里云 ECS 服务器上,本文将记录部署过程及使用体验。

二、简介

  MTranServer 是一个专注于离线翻译、极速响应和跨平台部署的翻译模型服务器。
  它与传统的大模型翻译(LLM)不同,它不追求极致的文学性翻译质量,而是致力于成为一个无需 GPU、在低配置 CPU 上也能跑得飞起的翻译引擎:

  • 极致轻量:无需 N 卡,纯 CPU 推理,非常适合学生党的低配 VPS(云服务器)。
  • 速度极快:单个请求平均响应时间仅需 50 毫秒。
  • 完全免费:本地离线运行,没有 token 计费焦虑,实现 “无限续杯”。
  • 生态丰富:原生兼容沉浸式翻译、DeepL、Google Translate 等常见插件的 API 接口。

客观评价:作者在文档中也很诚实地提到,由于受限于模型大小,其翻译质量肯定不如 GPT-4 或 DeepL 等在线大模型。但对于网页快速浏览、代码注释翻译等场景,它的可读性已经完全足够。

三、后端部署

  我的博客部署在阿里云 ECS 服务器上,系统为 Ubuntu 20.04,配置为 2vCPU + 2G RAM,不过我没有使用 Docker 的方案,而是直接使用 C++ 原生内核以节省内存开销。

1. 获取服务端程序

  在云服务器上创建服务端运行环境:

# 创建工作目录
mkdir -p /opt/mtranserver
cd /opt/mtranserver

# 在项目 Release 页面下载 MTranServer linux-amd64 服务端,通过 ssh 上传到服务器 /opt/mtranserver
# 也可以在服务器上直接使用 wget 等命令下载
chmod +x mtranserver

2. 配置 Systemd 守护进程

  输入命令sudo nano /etc/systemd/system/mtranserver.service,写入以下内容:

[Unit]
Description=MTranServer Translation Service
After=network.target

[Service]
Type=simple
# 限制内存,防止模型加载过大导致服务器 OOM 死机,根据实际情况调整
MemoryHigh=1200M
MemoryMax=1700M

WorkingDirectory=/opt/mtranserver
ExecStart=/opt/mtranserver/mtranserver

Restart=always
RestartSec=5
User=root

[Install]
WantedBy=multi-user.target

3. 启动并验证后端配置

  启动 MTranServer 服务:

sudo systemctl daemon-reload
sudo systemctl start mtranserver
sudo systemctl enable mtranserver
sudo systemctl status mtranserver

  MTranServer 默认端口为 8989,输入netstat -tlnp | grep 8989,如果出现 MTranServer 条目,说明已经成功开启后端服务监听。

4. Nginx 反向代理

  我使用支持的 Google Translate API v2 接口作为前端请求格式,为了避免跨域问题 (CORS) 并复用 443 端口,将 API 挂载在博客的子路径下/api/translate。编辑 Nginx 配置文件(通常在/etc/nginx/sites-available/下的.conf文件):

server {
# SSL 和 Server Name 等原有配置 ...

# 翻译 API 反向代理
location /api/translate {
proxy_pass http://127.0.0.1:8989/google/language/translate/v2;

# 标准代理头
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

# 性能优化:禁用不必要的缓冲,适应流式/快速响应
proxy_buffering off;

# 允许跨域 (如果 Hexo 部署在不同域名下需要此配置)
add_header Access-Control-Allow-Origin *;

# 针对 MTranServer 50ms 响应速度的优化
proxy_read_timeout 60s;

# 安全:仅允许 POST 方法,防止探测
limit_except POST {
deny all;
}
}
}

  重载 Nginx 配置:

sudo nginx -t
sudo systemctl reload nginx

四、前端配置

  在博客根目录执行hexo new page "translate"创建 Hexo + Butterfly 框架下的前端页面source/translate/index.md,或者根据自己喜好将页面创建在自定义目录下。我这里为了统一,前端 md 文件放置在source/tools/translate/index.md中。md 文件内容如下:

---
# title: 多语言翻译工具 # 关闭标题显示,使用页面内自定义标题
type: "page" # 定义页面类型为 page,避免被博客列表等组件误识别
# comments: false # 关闭评论功能(如果不需要)
aside: false # 关闭侧边栏显示
top_img: false # 关闭顶部图片显示
# 由于我使用了全局的 MathJax 和 KaTeX 插件,这里需要针对这个页面单独关闭数学公式渲染
# 否则会导致页面加载时出现解析错误日志,影响性能
mathjax: false # 关闭数学公式渲染
katex: false # 关闭 KaTeX 渲染
highlight_shrink: false # 关闭代码块折叠
---
# <center>多语言翻译工具</center>

> 此功能基于 [MTranServer](https://github.com/xxnuo/MTranServer)轻量化实现。建议仅用于日常词句翻译。

<!-- 这里放置前端 HTML + JavaScript 代码,使用 raw 标签包裹以避免 Markdown 渲染干扰 -->

  为了绕过 Markdown 渲染而直接使用 HTML,需要将前端代码使用 raw 标签进行包裹。源代码后附在本文末尾,欢迎参考和使用。
  修改 butterfly 配置文件,在 menu 字段中添加:

menu:
工具 || fa fa-wrench:
语言翻译: /tools/translate/ || fa fa-language

  最后执行hexo clean && hexo g && hexo d生成静态文件并部署,访问网站域名/tools/translate/(或你自定义链接)即可看到翻译工具界面。
  这里是我部署完成的翻译工具,欢迎试用和反馈~

<style>
:root {
--trans-primary: #49b1f5;
--trans-glass-bg: rgba(255, 255, 255, 0.7);
--trans-glass-border: 1px solid rgba(255, 255, 255, 0.6);
--trans-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15);
--trans-text: #333;
--trans-radius: 12px;
}

[data-theme="dark"] {
--trans-glass-bg: rgba(30, 30, 30, 0.6);
--trans-glass-border: 1px solid rgba(255, 255, 255, 0.1);
--trans-text: #e0e0e0;
--trans-primary: #52a7e0;
}

.trans-container {
max-width: 1000px;
margin: 2rem auto;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}

.trans-header {
display: flex;
justify-content: center;
align-items: center;
gap: 15px;
margin-bottom: 20px;
padding: 15px;
background: var(--trans-glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: var(--trans-radius);
border: var(--trans-glass-border);
box-shadow: var(--trans-shadow);
}

.trans-select {
padding: 8px 16px;
border-radius: 8px;
border: 1px solid rgba(128, 128, 128, 0.3);
background: rgba(255, 255, 255, 0.5);
color: var(--trans-text);
font-size: 15px;
outline: none;
cursor: pointer;
transition: all 0.3s;
}

[data-theme="dark"] .trans-select {
background: rgba(0, 0, 0, 0.3);
}

[data-theme="dark"] .trans-select option {
background: #333;
}

.trans-swap-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid rgba(128, 128, 128, 0.2);
background: rgba(255, 255, 255, 0.5);
color: var(--trans-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
font-size: 18px;
}

[data-theme="dark"] .trans-swap-btn {
background: rgba(255, 255, 255, 0.1);
}

.trans-swap-btn:hover {
transform: rotate(180deg);
background: var(--trans-primary);
color: white;
}

.trans-body {
display: flex;
gap: 20px;
min-height: 350px;
}

.trans-box {
flex: 1;
display: flex;
flex-direction: column;
background: var(--trans-glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: var(--trans-radius);
border: var(--trans-glass-border);
box-shadow: var(--trans-shadow);
position: relative;
transition: transform 0.2s;
}

.trans-box:focus-within {
border-color: var(--trans-primary);
}

.trans-textarea {
flex: 1;
width: 100%;
padding: 20px;
border: none;
background: transparent;
resize: none;
outline: none;
font-size: 16px;
line-height: 1.6;
color: var(--trans-text);
}

.trans-tools {
padding: 10px 15px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid rgba(128, 128, 128, 0.1);
}

.trans-btn-icon {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: #888;
transition: all 0.2s;
background: transparent;
border: none;
}

.trans-btn-icon:hover {
background: rgba(128, 128, 128, 0.1);
color: var(--trans-primary);
}

.trans-btn-icon.copied {
background: #67c23a;
color: white;
}

.trans-action {
margin-top: 25px;
text-align: center;
}

.trans-btn-main {
background: var(--trans-primary);
color: white;
border: none;
padding: 12px 60px;
border-radius: 30px;
font-size: 18px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 15px rgba(73, 177, 245, 0.4);
transition: all 0.3s;
}

.trans-btn-main:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(73, 177, 245, 0.6);
}

.trans-btn-main:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
box-shadow: none;
}

@media (max-width: 768px) {
.trans-body {
flex-direction: column;
}
.trans-box {
min-height: 200px;
}
}
</style>

<div id="mtran-app" class="trans-container">
<div class="trans-header">
<select id="srcLang" class="trans-select">
<option value="en">英语 (English)</option>
<option value="zh">中文 (Chinese)</option>
<option value="ja">日语 (Japanese)</option>
<option value="ko">韩语 (Korean)</option>
<option value="fr">法语 (French)</option>
<option value="de">德语 (German)</option>
<option value="ru">俄语 (Russian)</option>
</select>

<button id="swapBtn" class="trans-swap-btn" title="交换语言"></button>

<select id="tgtLang" class="trans-select">
<option value="zh" selected>中文 (Chinese)</option>
<option value="en">英语 (English)</option>
<option value="ja">日语 (Japanese)</option>
<option value="ko">韩语 (Korean)</option>
<option value="fr">法语 (French)</option>
<option value="de">德语 (German)</option>
<option value="ru">俄语 (Russian)</option>
</select>
</div>

<div class="trans-body">
<div class="trans-box">
<textarea id="inputTxt" class="trans-textarea" placeholder="在此输入内容..."></textarea>
<div class="trans-tools">
<span style="font-size: 12px; color: #999;" id="charCount">0 字符</span>
<button class="trans-btn-icon" id="clearBtn" title="清空内容">
🗑️ 清空
</button>
</div>
</div>

<div class="trans-box">
<textarea id="outputTxt" class="trans-textarea" readonly placeholder="翻译结果显示在这里..."></textarea>
<div class="trans-tools">
<span style="font-size: 12px; color: #999;">MTranServer</span>
<button class="trans-btn-icon" id="copyBtn" title="复制结果">
📋 复制
</button>
</div>
</div>
</div>

<div class="trans-action">
<button id="transBtn" class="trans-btn-main">开始翻译</button>
</div>
</div>

<script>
// 封装初始化逻辑,适配 Pjax 和 首次加载
function initMTran() {
// 检查元素是否存在,防止在其他页面报错
const app = document.getElementById('mtran-app');
if (!app) return;

// 防止重复绑定
if (app.getAttribute('data-loaded') === 'true') return;
app.setAttribute('data-loaded', 'true');

const srcLang = document.getElementById('srcLang');
const tgtLang = document.getElementById('tgtLang');
const inputTxt = document.getElementById('inputTxt');
const outputTxt = document.getElementById('outputTxt');
const transBtn = document.getElementById('transBtn');
const swapBtn = document.getElementById('swapBtn');
const charCount = document.getElementById('charCount');
const clearBtn = document.getElementById('clearBtn');
const copyBtn = document.getElementById('copyBtn');

// 交互逻辑绑定
if(swapBtn) {
swapBtn.onclick = () => {
const tempLang = srcLang.value;
srcLang.value = tgtLang.value;
tgtLang.value = tempLang;
const tempText = inputTxt.value;
inputTxt.value = outputTxt.value;
outputTxt.value = tempText;
charCount.textContent = `${inputTxt.value.length} 字符`;
};
}

if(inputTxt) {
inputTxt.oninput = () => {
charCount.textContent = `${inputTxt.value.length} 字符`;
};
}

if(clearBtn) {
clearBtn.onclick = () => {
inputTxt.value = '';
outputTxt.value = '';
charCount.textContent = '0 字符';
inputTxt.focus();
};
}

if(copyBtn) {
copyBtn.onclick = function() {
const text = outputTxt.value;
if (!text) return;
const btn = this;
navigator.clipboard.writeText(text).then(() => {
btn.classList.add('copied');
btn.innerHTML = '✅ 已复制';
setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = '📋 复制';
}, 2000);
}).catch(err => {
alert('复制失败,请手动复制');
});
};
}

if(transBtn) {
transBtn.onclick = async () => {
const text = inputTxt.value.trim();
if (!text) {
alert('请输入要翻译的内容');
return;
}

transBtn.disabled = true;
transBtn.textContent = '翻译中...';
inputTxt.disabled = true;
outputTxt.value = '正在思考...';

try {
const response = await fetch('/api/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
q: text,
source: srcLang.value,
target: tgtLang.value,
format: 'text'
})
});

if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
const resData = await response.json();

if (resData.data && resData.data.translations) {
outputTxt.value = resData.data.translations[0].translatedText;
} else {
outputTxt.value = '错误: 未能获取翻译结果';
}
} catch (error) {
console.error(error);
outputTxt.value = '连接超时或服务未启动。\n请检查服务器状态。';
} finally {
transBtn.disabled = false;
transBtn.textContent = '开始翻译';
inputTxt.disabled = false;
}
};
}
}

if (document.readyState === 'complete' || document.readyState === 'interactive') {
initMTran();
}

document.addEventListener('DOMContentLoaded', initMTran);

document.addEventListener('pjax:complete', initMTran);
</script>