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

发布时间:2026/7/26 9:39:07
从 Ollama 到云端:Hexo AI 摘要的多平台进化之路 目录从 Ollama 到云端Hexo AI 摘要的多平台进化之路Ollama 方案的局限新架构设计部署Docker拉起Redis(容器编排)部署AI代理Nginx反向代理前端集成重新构建Hexo查看Redis缓存删除Redis缓存进入Redis CLI交互式模式效果对比总结从 Ollama 到云端Hexo AI 摘要的多平台进化之路 | Eucalyptus从 Ollama 到云端Hexo AI 摘要的多平台进化之路 之前我写了《使用 Ollama 为 Hexo 博客部署 AI 文章摘要》介绍了本地部署大模型生成摘要的方案。运行一段时间后发现几个问题3B 模型效果一般更大的模型又吃资源生成速度慢用户体验不佳服务器配置有限经常卡顿。于是https://blog.eucalyptus.cc/2026/06/01/%E4%BB%8E-Ollama-%E5%88%B0%E4%BA%91%E7%AB%AF%EF%BC%9AHexo-AI-%E6%91%98%E8%A6%81%E7%9A%84%E5%A4%9A%E5%B9%B3%E5%8F%B0%E8%BF%9B%E5%8C%96%E4%B9%8B%E8%B7%AF/从 Ollama 到云端Hexo AI 摘要的多平台进化之路之前我写了《使用 Ollama 为 Hexo 博客部署 AI 文章摘要》介绍了本地部署大模型生成摘要的方案。运行一段时间后发现几个问题3B 模型效果一般更大的模型又吃资源生成速度慢用户体验不佳服务器配置有限经常卡顿。于是我重新设计了一套架构保留前端交互后端加入代理层支持多个云端 API并用 Redis 缓存加速。本文分享这个升级过程。Ollama 方案的局限之前的架构前端 JS → Ollama API (本地 11434 端口)遇到的问题问题说明模型效果qwen2.5:3b 摘要质量一般7b 又太慢资源占用8G 内存服务器跑模型容易卡死生成速度本地生成一次摘要要 5-20 秒维护成本需要定期更新模型管理模型文件新架构设计升级后的架构前端 JS → Nginx → Node.js 代理 → Redis 缓存 / 云端 API ↑ 静态文件 (Hexo)核心变化加入代理层前端不再直接调 AI API而是通过代理转发支持多平台日日新、DeepSeek、Ollama 自由切换Redis 缓存相同文章直接返回缓存不重复调用 APIKey 安全API Key 放在服务器前端不可见部署Docker拉起Redis(容器编排)# 创建目录 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代理# 创建目录 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 startupconst 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.mingliangstar.com]; 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代码使用 Ollama 为 Hexo 博客部署 AI 文章摘要 | Eucalyptus/** * 安知鱼主题 AI 摘要 - 代理版无前端缓存 */ (function() { use strict; const CONFIG { apiUrl: https://ai.mingliangstar.com/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 classai-loading-inline svg classai-loading-svg width16 height16 viewBox0 0 50 50 circle cx25 cy25 r20 fillnone strokecurrentColor stroke-width3 stroke-linecapround stroke-dasharray80 stroke-dashoffset60 animateTransform attributeNametransform typerotate from0 25 25 to360 25 25 dur1s repeatCountindefinite/ /circle /svg span classai-loading-dotsAI 正在分析文章/span /span ; targetContainer.innerHTML div classai-content div classai-header i classeucalyptus icon-bilibili/ispan classai-titlesAI 摘要/spanspan classai-badgeAGENT/span /div div classai-text-box p idai-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 classai-content div classai-header i classeucalyptus icon-bilibili/ispan classai-titlesAI 摘要/spanspan classai-badgeAGENT/span /div div classai-text-box p stylecolor: #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 }); })();重新构建Hexohexo clean hexo generate查看Redis缓存#查看所有 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缓存#删除单个缓存 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交互式模式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 到云端代理解决了三个核心问题质量云端大模型效果优于本地小模型速度缓存命中时直接返回无需等待生成成本缓存减少重复调用降低 API 费用对于 2C4G 的服务器这套方案更轻量、更稳定。如果服务器资源充足Ollama 仍是免费的不错选择。