一、前言

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

二、简介

  MTranServer 是一个具备离线翻译、极速响应和跨平台部署的翻译模型,在低配置 CPU 上也能有极快的响应速度。

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

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

三、后端部署

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

1. 获取服务端程序

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

1
2
3
4
5
6
7
# 创建工作目录
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,写入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[Unit]
Description=MTranServer Translation Service
After=network.target

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

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

Restart=always
RestartSec=5
User=root

[Install]
WantedBy=multi-user.target

3. 启动并验证后端配置

  启动 MTranServer 服务:

1
2
3
4
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文件):

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
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配置:

1
2
sudo nginx -t
sudo systemctl reload nginx

四、前端配置

  在博客根目录执行hexo new page "translate"创建 Hexo + Butterfly 框架下的前端页面source/translate/index.md,或者根据自己喜好将页面创建在自定义目录下。我这里为了统一,前端 md 文件放置在source/tools/translate/index.md中。为了绕过 Markdown 渲染而直接使用 HTML,需要将前端代码使用 raw 标签进行包裹:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
---
# 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 渲染干扰 -->

  修改butterfly配置文件,在menu字段中添加:

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

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

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
<!-- 前端源代码 -->
<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>