从 Ollama 到云端:Hexo AI 摘要的多平台进化之路

从 Ollama 到云端:Hexo AI 摘要的多平台进化之路

之前我写了《使用 Ollama 为 Hexo 博客部署 AI 文章摘要》,介绍了本地部署大模型生成摘要的方案。运行一段时间后,发现几个问题:3B 模型效果一般,更大的模型又吃资源;生成速度慢,用户体验不佳;服务器配置有限,经常卡顿。于是,我重新设计了一套架构:保留前端交互,后端加入代理层,支持多个云端 API,并用 Redis 缓存加速。本文分享这个升级过程。

Ollama 方案的局限

之前的架构:

1
前端 JS → Ollama API (本地 11434 端口)

遇到的问题:

问题 说明
模型效果 qwen2.5:3b 摘要质量一般,7b 又太慢
资源占用 8G 内存服务器,跑模型容易卡死
生成速度 本地生成一次摘要要 5-20 秒
维护成本 需要定期更新模型,管理模型文件

新架构设计

升级后的架构:

1
2
3
前端 JS → Nginx → Node.js 代理 → Redis 缓存 / 云端 API

静态文件 (Hexo)

核心变化:

  1. 加入代理层:前端不再直接调 AI API,而是通过代理转发
  2. 支持多平台:日日新、DeepSeek、Ollama 自由切换
  3. Redis 缓存:相同文章直接返回缓存,不重复调用 API
  4. Key 安全:API Key 放在服务器,前端不可见

部署

Docker拉起Redis(容器编排)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 创建目录
mkdir -p /opt/redis/data
cd /opt/redis

# 创建 docker-compose.yml
cat > docker-compose.yml << 'EOF'
version: '3.8'
services:
redis:
image: redis:7-alpine
container_name: redis-ai
restart: always
ports:
- "127.0.0.1:6379:6379"
volumes:
- /var/lib/docker//redis:/data
command: redis-server --appendonly yes
EOF

# 启动
docker-compose up -d

# 验证
docker exec redis-ai redis-cli ping

部署AI代理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 创建目录
mkdir -p /opt/ai-proxy
cd /opt/ai-proxy

# 初始化
npm init -y
npm install express node-fetch ioredis

# 创建 index.js(代理代码)

# 启动
pm2 start index.js --name ai-proxy
pm2 save
pm2 startup
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
const express = require('express');
const fetch = require('node-fetch');
const Redis = require('ioredis');
const crypto = require('crypto');

const app = express();

// Redis 连接
const redis = new Redis({
host: '127.0.0.1',
port: 6379,
db: 0
});

redis.on('connect', () => console.log('[Redis] Connected'));
redis.on('error', (err) => console.error('[Redis] Error:', err));

// 缓存时间:7天(秒)
const CACHE_TTL = 7 * 24 * 60 * 60;

// 只用内容哈希生成缓存 key
function getCacheKey(text) {
const contentHash = crypto.createHash('md5').update(text.slice(0, 4000)).digest('hex').substring(0, 16);
return `ai:summary:${contentHash}`;
}

app.use(express.json());

app.use((req, res, next) => {
const allowedOrigins = ['https://blog.eucalyptus.cc'];
const origin = req.headers.origin;

if (allowedOrigins.includes(origin)) {
res.header('Access-Control-Allow-Origin', origin);
}

res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');

if (req.method === 'OPTIONS') return res.sendStatus(200);
next();
});

app.post('/api/ai-summary', async (req, res) => {
const { text } = req.body || {};

if (!text || text.length < 50) {
return res.status(400).json({ error: 'Text too short' });
}

const cacheKey = getCacheKey(text);

try {
// 1. 查 Redis 缓存
const cached = await redis.get(cacheKey);
if (cached) {
console.log('[Cache] Hit:', cacheKey);
return res.json({ summary: cached, cached: true });
}

console.log('[Cache] Miss:', cacheKey);

// 2. 调日日新 API
const response = await fetch('https://token.sensenova.cn/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': 'Bearer 输入自己的key',
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [
{
role: 'system',
content: '请为以下文章生成一段简洁的摘要,突出核心内容。'
},
{
role: 'user',
content: text.slice(0, 4000)
}
],
max_tokens: 200,
temperature: 0.3
})
});

if (!response.ok) {
const error = await response.text();
return res.status(response.status).json({ error });
}

const data = await response.json();
const summary = data.choices?.[0]?.message?.content?.trim();

if (!summary) {
return res.status(500).json({ error: 'Empty summary' });
}

// 3. 写入 Redis 缓存
await redis.setex(cacheKey, CACHE_TTL, summary);
console.log('[Cache] Saved:', cacheKey);

res.json({ summary, cached: false });

} catch (err) {
console.error('[AI Proxy] Error:', err.message);
res.status(500).json({ error: err.message });
}
});

const PORT = 3000;
app.listen(PORT, '127.0.0.1', () => {
console.log(`AI Proxy running on http://127.0.0.1:${PORT}`);
});

Nginx反向代理

这里直接再宝塔面板中配置

前端集成

这里不在做过多缀述,参考之前的文章即可,但是需要修改ai-summary.js代码

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
/**
* 安知鱼主题 AI 摘要 - 代理版(无前端缓存)
*/
(function() {
'use strict';

const CONFIG = {
apiUrl: 'https://ai.eucalyptus.cc/api/ai-summary',
typeSpeed: 35,
maxSummaryLength: 1000,
retryCount: 1
};

let isProcessing = false;
let typeWriterTimer = null;
let abortController = null;

function waitForElement(selector, timeout = 3000) {
return new Promise((resolve, reject) => {
const element = document.querySelector(selector);
if (element) return resolve(element);
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) { observer.disconnect(); resolve(el); }
});
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(() => { observer.disconnect(); reject(new Error(`Timeout waiting for ${selector}`)); }, timeout);
});
}

function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

function typeWriter(element, text, speed = CONFIG.typeSpeed) {
return new Promise((resolve) => {
if (typeWriterTimer) { clearInterval(typeWriterTimer); typeWriterTimer = null; }
let index = 0;
element.textContent = '';
typeWriterTimer = setInterval(() => {
if (index < text.length) {
element.textContent += text.charAt(index);
index++;
} else {
clearInterval(typeWriterTimer);
typeWriterTimer = null;
resolve();
}
}, speed);
});
}

function getArticleText() {
const selectors = [
'#article-container .post-content',
'#article-container #post',
'.post-content',
'article .entry-content',
'#post-content',
'.article-content',
'.markdown-body',
'#content article'
];

let article = null;
for (const sel of selectors) {
article = document.querySelector(sel);
if (article && article.innerText.trim().length > 200) break;
}

if (!article) {
console.warn('[AI Summary] Article content not found.');
return '';
}

const clone = article.cloneNode(true);

const removeSelectors = [
'h1', 'h2.post-title', '.post-title', '.page-title',
'.post-meta', '.meta', '.entry-meta', '.article-meta',
'.post-date', '.post-time', '.published', '.updated',
'.author', '.byline', '.post-author',
'.post-tags', '.tags', '.tag-cloud', '.post-categories',
'.categories', '.post-category', '.article-tags',
'nav', '.nav', '.breadcrumb', '.crumbs',
'aside', '.sidebar', '.widget', '.toc', '#toc',
'header', '.post-header', '.entry-header', '.article-header',
'footer', '.post-footer', '.entry-footer', '.article-footer',
'#post-comment', '.comments', '.comment-area',
'.share', '.social-share', '.reward', '.donate',
'.post-copyright', '.copyright', '.license', '.declaration',
'.relatedPosts', '.related-posts', '.related', '.recommend',
'.anzhiyu-ai-summary', '#ai-summary',
'script', 'style', 'pre', 'code', 'table',
'img', 'figure', 'figcaption', 'svg',
'blockquote',
'.ads-wrap', '.ad', '.advertisement',
'.highlight', '.mermaid', '.katex', '.mathjax',
'.pagination', '.pager', '.page-nav',
'.notice', '.alert', '.tips', '.warning'
];

removeSelectors.forEach(sel => {
clone.querySelectorAll(sel).forEach(el => el.remove());
});

let text = clone.innerText
.replace(/\s+/g, ' ')
.replace(/[*#\-_`~\[\]()>|]/g, ' ')
.replace(/\b[A-Z]{5,}\b/g, ' ')
.replace(/\d{4}[年/-]\d{1,2}[月/-]\d{1,2}[日]?/g, ' ')
.replace(/[^\u4e00-\u9fa5a-zA-Z0-9,。!?;:""''()【】《》.,;:!?'"()\[\]<>、\-—\n]/g, ' ')
.replace(/\s+/g, ' ')
.trim();

if (text.length < 100) {
console.warn('[AI Summary] Content too short, trying fallback...');
const paragraphs = Array.from(document.querySelectorAll('p'));
const longestP = paragraphs
.filter(p => {
const t = p.innerText.trim();
return t.length > 50 && !t.includes('标签') && !t.includes('分类') && !t.includes('作者');
})
.sort((a, b) => b.innerText.length - a.innerText.length)
.slice(0, 10);

if (longestP.length > 0) {
text = longestP.map(p => p.innerText).join(' ');
text = text.replace(/\s+/g, ' ').trim();
}
}

return text;
}

async function callSummaryAPI(text, retry = 0) {
if (abortController) abortController.abort();
abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), 120000);

try {
const response = await fetch(CONFIG.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: text.slice(0, 4000)
}),
signal: abortController.signal
});

clearTimeout(timeoutId);

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

const data = await response.json();
let summary = data.summary || '';

if (!summary) throw new Error('Empty response');

summary = summary
.replace(/^\s*(摘要|总结|概括)[::]\s*/i, '')
.replace(/^\s*["']?|["']?\s*$/g, '')
.replace(/\*\*/g, '')
.replace(/^\s*[-*]\s+/gm, '')
.replace(/^#{1,6}\s+/gm, '')
.replace(/这篇文章主要(介绍|讲述|讨论|分析)了/g, '本文介绍了')
.replace(/本文主要(介绍|讲述|讨论|分析)了/g, '本文介绍了')
.replace(/该文章(介绍|讲述|讨论|分析)了/g, '本文介绍了')
.replace(/此文章(介绍|讲述|讨论|分析)了/g, '本文介绍了')
.replace(/综上所述[,。]/g, '')
.replace(/总之[,。]/g, '')
.replace(/总而言之[,。]/g, '')
.trim();

if (!/^本文(主要)?介绍了/.test(summary)) {
summary = '本文介绍了' + summary.replace(/^[,。!?\s]+/, '');
}

const sentences = summary.split(/[。!?.!?]/).filter(s => s.trim());
if (sentences.length > 3) {
summary = sentences.slice(0, 3).join('。') + '。';
}

if (summary.length > CONFIG.maxSummaryLength) {
summary = summary.slice(0, CONFIG.maxSummaryLength);
const lastPunct = Math.max(
summary.lastIndexOf('。'),
summary.lastIndexOf('!'),
summary.lastIndexOf('?')
);
if (lastPunct > CONFIG.maxSummaryLength * 0.7) {
summary = summary.slice(0, lastPunct + 1);
} else {
summary = summary.replace(/[^。!?]*$/, '') + '。';
}
}

if (!/[。!?.!?]$/.test(summary)) {
summary += '。';
}

return summary;

} catch (err) {
clearTimeout(timeoutId);
if (retry < CONFIG.retryCount) {
console.log(`[AI Summary] Retrying... (${retry + 1}/${CONFIG.retryCount})`);
await new Promise(r => setTimeout(r, 2000));
return callSummaryAPI(text, retry + 1);
}
throw err;
}
}

function renderContainer(targetContainer, isLoading = false, content = '') {
if (!targetContainer) return null;

const loadingHtml = `
<span class="ai-loading-inline">
<svg class="ai-loading-svg" width="16" height="16" viewBox="0 0 50 50">
<circle cx="25" cy="25" r="20" fill="none" stroke="currentColor" stroke-width="3"
stroke-linecap="round" stroke-dasharray="80" stroke-dashoffset="60">
<animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="1s" repeatCount="indefinite"/>
</circle>
</svg>
<span class="ai-loading-dots">AI 正在分析文章</span>
</span>
`;

targetContainer.innerHTML = `
<div class="ai-content">
<div class="ai-header">
<i class="eucalyptus icon-bilibili"></i><span class="ai-titles">AI 摘要</span><span class="ai-badge">AGENT</span>
</div>
<div class="ai-text-box">
<p id="ai-summary-text">${isLoading ? loadingHtml : escapeHtml(content)}</p>
</div>
</div>
`;
return targetContainer.querySelector('#ai-summary-text');
}

function renderError(targetContainer, message) {
if (!targetContainer) return;
targetContainer.innerHTML = `
<div class="ai-content">
<div class="ai-header">
<i class="eucalyptus icon-bilibili"></i><span class="ai-titles">AI 摘要</span><span class="ai-badge">AGENT</span>
</div>
<div class="ai-text-box">
<p style="color: #999;">${escapeHtml(message)}</p>
</div>
</div>
`;
}

function bootstrap() {
if (abortController) {
abortController.abort();
abortController = null;
}
if (typeWriterTimer) {
clearInterval(typeWriterTimer);
typeWriterTimer = null;
}
isProcessing = false;

if (document.getElementById('ai-summary')) {
setTimeout(initAISummary, 100);
}
}

async function initAISummary() {
if (isProcessing) return;
isProcessing = true;

try {
const container = await waitForElement('#ai-summary');
const text = getArticleText();

if (!text || text.length < 50) {
renderContainer(container, false, '本文篇幅较短,暂无详细摘要。');
isProcessing = false;
return;
}

renderContainer(container, true);

let summary;
try {
summary = await callSummaryAPI(text);
}
catch (err) {
console.error('[AI Summary] API failed:', err);
renderError(container, `AI 服务暂时不可用:${err.message}`);
isProcessing = false;
return;
}

const textEl = renderContainer(container, false, '');
await typeWriter(textEl, summary);

} catch (err) {
console.error('[AI Summary] Init Error:', err);
const container = document.getElementById('ai-summary');
if (container) renderError(container, '初始化失败');
} finally {
isProcessing = false;
}
}

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap);
} else {
bootstrap();
}

document.addEventListener('pjax:complete', bootstrap);
document.addEventListener('turbolinks:load', bootstrap);

let lastUrl = location.pathname;
new MutationObserver(() => {
const url = location.pathname;
if (url !== lastUrl) {
lastUrl = url;
bootstrap();
}
}).observe(document, { subtree: true, childList: true });

})();

重新构建Hexo

1
2
hexo clean
hexo generate

查看Redis缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#查看所有 AI 摘要缓存
docker exec redis-ai redis-cli KEYS "ai:summary:*"

#查看缓存数量
docker exec redis-ai redis-cli DBSIZE

#查看某个缓存内容
docker exec redis-ai redis-cli GET "ai:summary:具体的hash值"

#查看缓存剩余过期时间(秒)
docker exec redis-ai redis-cli TTL "ai:summary:具体的hash值"

#查看缓存详细信息(长度、类型等)
docker exec redis-ai redis-cli DEBUG OBJECT "ai:summary:具体的hash值"

删除Redis缓存

1
2
3
4
5
6
7
8
#删除单个缓存
docker exec redis-ai redis-cli DEL "ai:summary:具体的hash值"

#批量删除所有 AI 摘要缓存
docker exec redis-ai redis-cli EVAL "return redis.call('del', unpack(redis.call('keys', 'ai:summary:*')))" 0

#删除所有缓存(慎用)
docker exec redis-ai redis-cli FLUSHDB

进入Redis CLI交互式模式

1
2
3
4
5
6
7
8
docker exec -it redis-ai redis-cli

# 然后可以执行各种命令
127.0.0.1:6379> KEYS ai:summary:*
127.0.0.1:6379> GET ai:summary:5bc22daf2d8a9026
127.0.0.1:6379> TTL ai:summary:5bc22daf2d8a9026
127.0.0.1:6379> DEL ai:summary:5bc22daf2d8a9026
127.0.0.1:6379> exit

效果对比

指标 Ollama (3b) 新方案 (日日新)
生成速度 5-10 秒 1-3 秒
摘要质量 一般 较好
资源占用 500MB-1GB 内存 20MB 内存
并发能力 单用户 多用户共享缓存
成本 免费 按 API 调用计费

总结

从 Ollama 到云端代理,解决了三个核心问题:

  1. 质量:云端大模型效果优于本地小模型
  2. 速度:缓存命中时直接返回,无需等待生成
  3. 成本:缓存减少重复调用,降低 API 费用

对于 2C4G 的服务器,这套方案更轻量、更稳定。如果服务器资源充足,Ollama 仍是免费的不错选择。