Contents
1 为什么要给博客加摘要
在经过这个系列的前五篇文章后,我已经在博客文章正文后实现了“系列文章卡片”、“相关文章”以及“猜你所想”这几个功能。结合几个“地图”以及每篇文章的“内容结构提示”,实际上我的博客内容已经在纵向和横向上建立了关联:纵向是从知识地图到具体文章的结构链路,横向则是文章之间的主题联系。
这意味着读者不仅可以沿着主题线索浏览相关文章,还能通过整体地图快速定位到某篇文章,或者从某篇文章返回整体地图。通过这种方式,读者能够自由在整体知识框架和单篇文章内容之间切换,更清晰地理解博客的主题结构和内容脉络。
不过,还有一点不足——每篇文章仍然缺少一个独立的“摘要”部分。
其实,我早就考虑过这个问题,只不过我最初认为:文章的标题、目录以及第一章内容,已经足够让读者了解文章的主干,似乎可以直接替代摘要。
然而,随着博客文章数量和篇幅的增加(尤其是我写的长文类文章真的不少),我担心不少心急的访客朋友,在看到文章篇幅过长时可能会直接放弃阅读。对于这些读者来说,如果标题和目录的提示不够直观,无法快速判断长文章是否值得深入阅读,就很容易导致阅读流失——尤其是在这个短视频盛行、只要不感兴趣就划走的年代。
因此,我认为有必要在博客系统中引入“摘要”这一独立的内容层:一方面,它可以帮助读者快速了解文章主旨,从而判断是否继续深入阅读;另一方面,它也为博客内部的语义系统提供基础,使每篇文章的核心信息能够以结构化、可编辑、可重建的方式存在。
在明确了摘要对于读者体验和博客系统的重要性之后,接下来的问题就是:我们该如何把摘要真正落地?是完全手写?还是通过现成插件借助云端大语言模型自动生成?亦或是手搓脚本通过本地自建大语言模型半自动生成?生成后的摘要又该如何存储,才能保证可编辑、可重建,并方便后续在知识地图、推荐系统或搜索功能中使用?
针对这些问题,第2章将从现有实现方式出发,梳理摘要生成的两条主要路径:传统的基于规则或统计方法的摘要技术,以及近两年逐渐成为主流的基于大语言模型的生成式摘要方案。通过对比这两种方式的基本原理与适用场景,先建立一个对“摘要是如何被生成出来的”的整体认知框架。
2 现有博客摘要方案
在正式开始实现之前,我们不妨先看看博客领域目前常见的几种摘要方案。
最早期的博客摘要其实非常简单,那就是由作者手工编写。很多博客系统都提供了专门的摘要字段,作者可以自行填写一段对文章内容的概括性描述。这样做的优点很明显:摘要内容完全由作者控制,因此通常也是最准确的。不过缺点同样明显——当文章数量逐渐增多之后,维护成本会越来越高。对于一个已经积累了数百篇甚至上千篇文章的博客来说,为每篇文章单独编写摘要显然不是一件轻松的事情。
随着博客系统的发展,自动摘要开始逐渐流行起来。以 WordPress 为例,其自带的 Excerpt 功能就可以自动从文章内容中提取一部分文字作为摘要。一些插件则会进一步增强这一过程,例如提取文章的第一段内容、截取固定字数,或者通过规则算法筛选出几个关键句。
不过严格来说,这类方案更像是一种“内容截断”而不是“内容理解”。它能够解决自动化的问题,却很难真正概括一篇文章的核心思想。对于篇幅较长、逻辑层次较多的技术文章来说,这种方式生成的摘要往往只能反映文章开头的部分内容,未必能够准确体现整篇文章的重点。
而最近两三年,随着大语言模型的发展,AI 摘要逐渐成为新的主流方案。与传统摘要最大的不同在于,AI 不再是简单地截取文章中的部分内容,而是能够对整篇文章进行理解后,再重新组织语言生成一段新的摘要。因此,无论是在可读性还是内容概括能力方面,通常都能够获得更好的效果。
对于我这样的技术博客来说,文章普遍偏长,而且很多内容都存在较强的前后依赖关系。人工维护摘要成本过高,而传统自动摘要又很难准确概括文章主旨。因此,基于大语言模型的 AI 摘要方案,自然就成为了最值得尝试的方向。不过,即便都是 AI 摘要,不同实现方案之间也存在着不小的差异。
目前市面上的大多数博客 AI 摘要功能,通常以插件的形式存在。对于用户来说,只需要配置好 API Key,剩下的工作都会由插件自动完成。这种方式最大的优点是简单方便,但与此同时,摘要生成过程也往往被封装在插件内部。文章是如何被处理的、模型实际看到了哪些内容、使用了什么提示词以及最终结果如何存储,用户通常很难深入了解。
更重要的是,这类插件的设计目标往往只是生成一段能够展示给读者阅读的摘要,因此关注的重点更多是“能否快速概括文章内容”。而对于我来说,这样的目标还不够。因为我的很多文章篇幅较长,内容往往存在较强的前后逻辑关系,如果文章在送入模型之前被截断,或者摘要生成策略与文章类型不匹配,就有可能导致最终结果偏离文章真正想表达的重点。
与此同时,对于正在构建“轻量级知识索引”的博客而言,摘要也不仅仅是一段展示给读者看的文字。在我看来,它本身同样是一种可以被复用的数据资源。未来无论是语义搜索,或着其他 AI 功能,都有可能依赖这些摘要数据。所以,相比于直接获得一个摘要结果,我更关心摘要是如何生成的、生成之后能否方便的修改、生成过程中使用了哪些数据,最终结果能否达到我的要求以及能否以我需要的形式保存下来。
因此,与其选择一个现成的黑盒方案,我更希望从整个生成流程出发,构建一套完全可控的摘要体系。
3 博客摘要系统设计思路
3.1 整体架构设计
在确定要为博客增加文章摘要功能之后,我并没有急着研究提示词(Prompt)应该怎么写,也没有一开始就把注意力放在大模型本身上。因为对于整个系统来说,大模型只是其中的一个环节。
真正需要优先考虑的问题是:如何让摘要生成这件事成为一个能够长期维护、持续运行的系统。如果只是针对单篇文章生成摘要,其实非常简单。把文章内容发送给大模型,然后保存返回结果即可。
但当目标变成整个博客时,情况就完全不同了,例如:
- 博客已经积累了数百篇文章;
- 后续还会持续新增内容;
- 摘要生成需要消耗一定计算资源;
- 未来可能更换模型或者调整 Prompt;
- WordPress 前台需要快速读取,而不是实时调用 AI。
这些因素决定了:摘要生成必须采用离线预计算模式,而不是实时生成模式。
因此,我最终采用了一种与前面“轻量级知识索引”类似的设计思路:
WordPress 原始内容
↓
summary-source.json
↓
summary-index.json
整个流程被拆分为两个阶段:第一阶段负责从 WordPress 中提取生成摘要所需要的数据,并整理成统一格式的 summary-source.json;第二阶段则基于这些数据调用大模型生成摘要,并最终输出供 WordPress 前台直接读取的 summary-index.json。
从结果上看,这似乎比直接生成最终摘要文件多了一步,但这样做的好处在于,不同阶段只负责各自的任务:数据提取阶段负责准备输入;AI 生成阶段负责生成摘要;WordPress 前台负责展示结果。三者之间彼此独立,不会相互耦合。
例如,当未来需要更换模型时,只需要重新生成摘要文件即可;当需要调整摘要生成策略时,也不需要修改 WordPress 前台代码;而如果文章内容发生变化,也只需要重新构建对应的数据源,整个系统的数据流始终保持稳定。
事实上,这也是我最近在博客功能设计中越来越倾向采用的一种思路:先构建稳定的数据层,再基于数据层实现具体功能。
这样做虽然会增加一些前期设计工作,但后续无论是扩展功能、调整策略,还是维护系统,都会轻松很多。
3.2 3.2 summary-source.json:摘要生成的数据源
在整体架构确定之后,首先需要解决的问题就是:应该向大模型提供什么样的数据。从理论上来说,完全可以直接把 WordPress 导出的文章内容发送给大模型进行处理,但实际测试后我发现,这样做并不是一个理想方案。
一方面,WordPress 原始数据中包含大量与摘要生成无关的信息,例如作者、发布日期、分类、标签、评论数等内容。这些数据虽然对博客系统有意义,但对于生成文章摘要几乎没有帮助。另一方面,如果直接把所有数据都交给大模型处理,不但会增加上下文长度,也会提高模型理解成本,最终影响生成效率和稳定性。
因此,我决定在摘要生成之前,先构建一个专门面向大模型的数据源文件:summary-source.json。这个文件的作用并不是给 WordPress 使用,而是作为摘要生成阶段的输入层。换句话说,它本质上是一份经过预处理和筛选的文章数据集。
对于每篇文章,我最终保留了以下几个核心字段:
{
"id": 14310,
"title": "基于 Cloudflare 的 Emby 影视展示站改造:从公网发布到访问体验优化",
"url": "https://blog.xxx.com/technology/xxxxx/",
"headings": [
"1 问题背景",
"2 方案设计",
"3 访问体验优化"
],
"content": "文章正文内容..."
}
其中,id 用于后续建立与 WordPress 文章之间的对应关系;title 用于向大模型提供文章主题信息;headings 用于帮助模型理解文章结构;而 content 则提供实际的内容来源。在这些字段中,我认为标题和章节结构尤其重要。
对于人类读者来说,阅读一篇文章时通常会先浏览标题和目录,然后再决定是否深入阅读正文。大模型其实也存在类似的特点。当模型能够提前获得文章标题和章节结构时,往往更容易理解整篇文章的组织方式,从而生成更加准确的摘要内容。
例如,对于一篇 Cloudflare 教程来说,即使模型尚未阅读完整正文,仅仅看到标题以及各级章节名称,也已经能够大致判断文章讨论的是缓存规则、安全防护还是 Tunnel 部署。这种结构信息实际上能够帮助模型更快建立对文章内容的整体认知。
除此之外,我还对正文内容进行了长度控制,因为摘要生成的目标并不是让模型重新阅读整篇文章,而是让它快速理解文章核心内容。如果将数万字的长文全部送入模型,不仅会显著增加生成时间,也可能导致模型关注过多细节,从而影响摘要的整体收敛效果。
因此,在构建 summary-source.json 时,我只保留经过清洗后的正文内容,并在后续生成阶段对输入长度进行适当限制。这样既能够保留文章的主要信息,又能够将生成时间控制在可接受范围内。
最终形成的 summary-source.json 可以理解为一层专门为大模型准备的“摘要生成数据源”。它不承担展示功能,也不参与前台逻辑,而是负责把原本复杂的 WordPress 内容转换成适合 AI 处理的结构化输入。
通过这一层预处理,后续摘要生成阶段所面对的就不再是杂乱无章的原始文章数据,而是一套统一、稳定且可控的输入格式。这也是整个摘要系统能够长期维护和持续扩展的重要基础。
3.3 summary-index.json:供 WordPress 直接消费
当 summary-source.json 准备完成之后,摘要系统的第二个阶段便开始了。在这一阶段中,系统会读取 summary-source.json 中的数据,调用本地部署的大模型生成文章摘要,并将最终结果写入 summary-index.json。
与前面作为 AI 输入层的 summary-source.json 不同,summary-index.json 已经属于面向 WordPress 前台的数据层。它不再关心文章的完整内容,也不需要保留用于生成摘要的各种上下文信息,而是只保存最终展示所需要的数据。
以”基于 Cloudflare 的 Emby 影视展示站改造:从公网发布到访问体验优化“这篇文章为例,目前生成后的数据结构如下:
{
"14310": {
"version": "v1",
"source_version": "v1",
"title": "基于 Cloudflare 的 Emby 影视展示站改造:从公网发布到访问体验优化",
"summary": "针对家庭媒体服务器公网展示的挑战,本文通过Cloudflare Tunnel实现无公网IP的稳定发布,并围绕Emby系统进行多维度优化。核心问题包括家庭网络暴露风险、登录流程繁琐及资源加载延迟,解决方案涵盖Guest账号简化访问、Cloudflare缓存加速静态资源、Worker启动页优化首屏体验等。最终实现访问门槛降低、页面响应提速,形成可稳定运行的影视展示站,为个人收藏展示提供可行方案,同时为后续静态化架构演进奠定基础。",
"model": "qwen3:14b",
"updated": "2026-06-18 16:13:33"
}
}
可以看到,相较于 summary-source.json,这里的数据结构已经变得非常简单:整个文件以文章 ID 作为索引键,每篇文章对应一个独立的数据对象。这样设计的一个直接好处就是查询效率极高。
当 WordPress 文章页面打开时,只需要获取当前文章的 Post ID,然后直接在 JSON 中查找对应记录即可:
当前文章 ID
↓
summary-index.json
↓
读取摘要
↓
页面展示
整个过程不需要访问数据库,也不需要调用 AI 服务,更不需要进行任何实时计算。
对于访问者而言,摘要内容与普通文章内容几乎没有区别;而对于服务器来说,读取一个本地 JSON 文件的开销几乎可以忽略不计。
除了Post ID和摘要内容(summary)之外,我还额外保留了几个辅助字段。其中,version 和 source_version 用于记录数据结构版本。当未来调整 JSON 格式或者修改摘要生成策略时,可以通过版本号快速判断不同数据之间的兼容关系。model 字段则用于记录摘要生成时所使用的大模型。目前我的系统使用的是本地部署的 Qwen3-14B,因此对应记录为:
{
"model": "qwen3:14b"
}
这个字段对前台展示并非必需,但我最终还是保留了下来。一方面,它能够帮助我在未来更换模型时追踪摘要来源;另一方面,也可以直接在文章页面中显示摘要生成信息,例如:
文章摘要
......
Qwen3-14B · 2026-06-018
这样既能明确摘要来源,也方便后续进行质量对比和系统维护。
最后的 updated 字段则记录摘要的生成时间。虽然对于普通读者来说意义不大,但对于系统维护而言,它能够帮助判断摘要是否需要重新生成,以及当前缓存数据是否已经过期。
从系统职责划分的角度来看,summary-index.json 的定位其实非常明确:
- 它不是 AI 输入数据;
- 它不负责摘要生成;
- 它也不参与任何复杂计算;
它唯一的职责,就是为 WordPress 提供一个能够被快速读取的摘要索引。
这种设计与前面介绍的轻量级知识索引系统其实有着相同的思路:所有计算都在离线阶段完成,而前台只负责读取结果。这样做的最大好处在于,摘要生成所消耗的计算资源与用户访问完全解耦。无论未来文章数量增加到几百篇还是上千篇,前台展示逻辑都不会发生变化,访问性能也不会受到影响。
对于一个长期运行的个人博客来说,这种预计算、轻消费的设计模式,往往比实时生成方案更加稳定,也更容易维护。
4 工程实现
4.1 数据源构建:build_summary_source.py
在完成前面章节中的结构设计之后,接下来就需要解决一个实际问题:如何从 WordPress 中提取文章内容,并生成统一格式的 summary-source.json 数据源。
虽然摘要生成最终依赖大模型完成,但模型本身并不能直接读取 WordPress 数据库中的内容。因此,在调用模型之前,首先需要将文章转换成结构化数据。
为此,我编写了 build_summary_source.py 脚本,负责从 WordPress 中提取文章信息,并生成后续流程所需要的数据源文件。
脚本完整代码如下:
import requests
import re
import html
import json
import os
from urllib.parse import urlparse
BASE_URL = "http://127.0.0.1:50443"
# -------------------------
# 获取 WordPress 文章
# -------------------------
def fetch_posts(base_url):
posts = []
page = 1
while True:
url = f"{base_url}/wp-json/wp/v2/posts?page={page}&per_page=100"
resp = requests.get(url)
if resp.status_code != 200:
break
data = resp.json()
posts.extend(data)
total_pages = int(resp.headers.get("X-WP-TotalPages", 1))
print(f"Fetched page {page}/{total_pages}")
if page >= total_pages:
break
page += 1
return posts
# -------------------------
# URL -> Path
# -------------------------
def extract_path(url: str) -> str:
if not url:
return ""
return urlparse(url).path
# -------------------------
# 提取 headings
# -------------------------
def extract_headings(html_content):
headings = re.findall(
r"<h[1-6][^>]*>(.*?)</h[1-6]>",
html_content,
flags=re.IGNORECASE | re.DOTALL
)
result = []
for h in headings:
h = re.sub(r"<[^>]+>", "", h)
h = html.unescape(h).strip()
if h:
result.append(h)
return result
# -------------------------
# HTML 清洗
# -------------------------
def clean_html(raw_html):
text = raw_html or ""
# code
text = re.sub(
r"<pre.*?>.*?</pre>",
" ",
text,
flags=re.DOTALL | re.IGNORECASE
)
text = re.sub(
r"<code>.*?</code>",
" ",
text,
flags=re.DOTALL | re.IGNORECASE
)
# script/style
text = re.sub(
r"<script.*?>.*?</script>",
" ",
text,
flags=re.DOTALL | re.IGNORECASE
)
text = re.sub(
r"<style.*?>.*?</style>",
" ",
text,
flags=re.DOTALL | re.IGNORECASE
)
# figure
text = re.sub(
r"<figure.*?>.*?</figure>",
" ",
text,
flags=re.DOTALL | re.IGNORECASE
)
# img
text = re.sub(
r"<img[^>]*>",
" ",
text,
flags=re.IGNORECASE
)
# shortcode
text = re.sub(r"\[/?[^\]]+\]", " ", text)
# headings -> markdown
for level in range(1, 7):
text = re.sub(
rf"<h{level}[^>]*>",
"\n\n# ",
text,
flags=re.IGNORECASE
)
text = re.sub(
rf"</h{level}>",
"\n\n",
text,
flags=re.IGNORECASE
)
# paragraph
text = re.sub(
r"</?p[^>]*>",
"\n",
text,
flags=re.IGNORECASE
)
# br
text = re.sub(
r"<br\s*/?>",
"\n",
text,
flags=re.IGNORECASE
)
# ul / ol
text = re.sub(
r"</?(ul|ol)[^>]*>",
"\n",
text,
flags=re.IGNORECASE
)
# li
text = re.sub(
r"<li[^>]*>",
"\n- ",
text,
flags=re.IGNORECASE
)
text = re.sub(
r"</li>",
"",
text,
flags=re.IGNORECASE
)
# 删除剩余 HTML
text = re.sub(r"<[^>]+>", " ", text)
text = html.unescape(text)
# 删除 URL
text = re.sub(r"https?://\S+", " ", text)
# 特殊符号
text = re.sub(r"[丨||•·■◆►▶●]+", " ", text)
# 压缩空格
text = re.sub(r"[ \t]+", " ", text)
# 压缩空行
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
# -------------------------
# 构建正文
# -------------------------
def build_content(clean_text):
return clean_text.strip()
# -------------------------
# 构建 summary-source
# -------------------------
def build_summary_source(posts):
result = []
for post in posts:
raw_html = post.get("content", {}).get("rendered", "")
title = html.unescape(
post.get("title", {}).get("rendered", "")
)
post_id = str(post.get("id", ""))
url = extract_path(post.get("link", ""))
headings = extract_headings(raw_html)
clean_text = clean_html(raw_html)
article = {
"id": post_id,
"title": title,
"url": url,
"headings": headings,
"content_length": len(clean_text),
"content": build_content(clean_text)
}
result.append(article)
return result
# -------------------------
# 输出 JSON
# -------------------------
def write_json(data, filename="summary-source.json"):
base_dir = os.path.dirname(os.path.abspath(__file__))
output_path = os.path.join(base_dir, filename)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(
data,
f,
ensure_ascii=False,
indent=2
)
print(f"\nSaved: {output_path}")
# -------------------------
# Main
# -------------------------
if __name__ == "__main__":
posts = fetch_posts(BASE_URL)
print(f"\nFetched posts: {len(posts)}")
summary_source = build_summary_source(posts)
print(
f"Built summary source: "
f"{len(summary_source)} articles"
)
write_json(summary_source)
if summary_source:
print("\nSample:\n")
print(
json.dumps(
summary_source[0],
ensure_ascii=False,
indent=2
)[:2000]
)
从功能上来说,这个脚本主要完成了三项工作:获取文章基础信息;提取文章结构内容;输出统一格式的 JSON 文件。
其中最重要的并不是数据获取本身,而是数据格式的统一。WordPress 中存储的是面向页面展示的内容,而后续处理流程需要的则是面向程序消费的数据。因此,在导出过程中,需要将文章标题、正文、章节结构等信息重新组织成统一的数据结构。
生成后的 summary-source.json 中,每篇文章都会包含3.2中提到的文章 ID、标题、URL、正文内容以及章节标题等信息。通过这种方式,原本分散在 WordPress 中的文章内容,就被转换成了一个结构统一、便于后续处理的数据源文件。
至此,系统已经拥有了一个脱离 WordPress 运行环境的结构化数据源,后续无论是摘要生成、关键词提取还是其它 AI 处理流程,都可以直接基于这个文件进行操作,而不需要再次访问 WordPress。
4.2 摘要生成:build_summary_index.py
当 summary-source.json 生成之后,系统实际上已经拥有了所有文章的结构化数据。但这些数据本身仍然只是原始内容,并不能直接用于前端展示。
接下来需要解决的问题是:如何为每篇文章生成一段能够概括核心内容的摘要,并将结果固化下来——这正是 build_summary_index.py 的职责。
与前面的 build_summary_source.py 不同,这个脚本引入了大模型参与处理:它会读取前一步生成的 summary-source.json,逐篇调用本地部署的 Qwen3 模型生成摘要,然后将结果保存到 /wp-content/themes/主题目录/cache/summary-index.json 中。
脚本完整代码如下:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
import os
import re
import time
from datetime import datetime
import requests
# 使用你已经验证可用的模型
MODEL = "qwen3:14b"
# 输出路径根据自己实际情况修改,我是以博客正在使用的argon主题为例
OUTPUT_FILE = "/docker/wordpress/html/wp-content/themes/argon-theme-master/cache/summary-index.json"
SOURCE_FILE = "summary-source.json"
# API地址需要根据实际情况修改
OLLAMA_URL = "http://127.0.0.1:11434/api/generate"
# 控制输入长度,避免超长文章拖慢 summary
MAX_CONTENT_LENGTH = 10000
# summary 长度限制
MIN_LEN = 80
MAX_LEN = 300
# 重试控制
MAX_RETRY = 2
REGEN_RETRY = 3
# -------------------------
# 排除“结构型页面 / 地图页”
# -------------------------
EXCLUDE_SLUGS = {
"me",
"map",
"cloudflaremap",
"aimap",
"singingmap",
"roadmap",
}
def is_excluded(article):
url = article.get("url", "")
for slug in EXCLUDE_SLUGS:
if f"/{slug}/" in url:
return True
return False
# -------------------------
# 读取 source
# -------------------------
def load_source():
base_dir = os.path.dirname(os.path.abspath(__file__))
path = os.path.join(base_dir, SOURCE_FILE)
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
# -------------------------
# 加载 cache
# -------------------------
def load_cache():
if not os.path.exists(OUTPUT_FILE):
return {}
with open(OUTPUT_FILE, "r", encoding="utf-8") as f:
return json.load(f)
# -------------------------
# 原子写入
# -------------------------
def save_cache(cache):
# 按 id 从大到小排序
sorted_cache = dict(
sorted(
cache.items(),
key=lambda x: int(x[0]),
reverse=True
)
)
tmp_path = OUTPUT_FILE + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(
sorted_cache,
f,
ensure_ascii=False,
indent=2
)
os.replace(tmp_path, OUTPUT_FILE)
# -------------------------
# 摘要清洗
# -------------------------
def clean_summary(text):
text = text.strip()
text = re.sub(r"\s+", " ", text)
text = text.replace("\n", " ")
return text.strip()
# -------------------------
# Prompt
# -------------------------
def build_prompt(article):
title = article.get("title", "")
headings = "\n".join(article.get("headings", []))
content = article.get("content", "")[:MAX_CONTENT_LENGTH]
prompt = f"""
你是一名中文技术博客编辑。
任务:
阅读文章内容,生成一段面向读者的摘要。
摘要要求:
- 单段输出
- 中文摘要
- 优先控制在120~180字
- 如果原文较短,可低于120字
- 禁止出现“本文”“文章”“作者”“介绍”
- 必须覆盖:问题、方案、结果或收获
- 可以适当体现技术背景或应用场景
- 如果属于系列内容或体系化主题,可简要体现其关联方向
- 如果只是单篇实践内容,则重点突出实际问题与解决经验
- 不要简单复述章节标题
- 不要分析写作过程
- 不要虚构原文未提及的背景、效果或结论
- 不要将实验性探索描述为成熟方案
- 禁止分点
文章标题:
{title}
章节结构:
{headings}
正文:
{content}
只输出摘要:
""".strip()
return prompt
# -------------------------
# 调用 Ollama
# -------------------------
def call_llm(prompt):
payload = {
"model": MODEL,
"prompt": prompt,
"stream": False
}
resp = requests.post(
OLLAMA_URL,
json=payload,
timeout=120
)
resp.raise_for_status()
return clean_summary(
resp.json()["response"]
)
# -------------------------
# 修复生成
# -------------------------
def regenerate(last_summary):
prompt = f"""
以下摘要长度或质量不符合要求:
{last_summary}
请重新生成。
要求:
- 单段中文
- 控制在150~250字
- 覆盖问题、方案、结果或收获
- 可以体现技术背景或应用场景
- 不要简单复述标题
- 不要解释生成过程
- 不要分析写作过程
- 不要虚构原文未提及的信息
- 不要将实验性内容描述为成熟方案
- 不要出现“本文”“文章”“作者”
- 禁止分点
直接输出摘要:
""".strip()
return call_llm(prompt)
# -------------------------
# 校验
# -------------------------
def validate_summary(text):
length = len(text)
return MIN_LEN <= length <= MAX_LEN
# -------------------------
# cache entry
# -------------------------
def build_cache_entry(article, summary):
return {
"version": "v1",
"source_version": "v1",
"title": article.get("title", ""),
"summary": summary,
"model": MODEL,
"updated": datetime.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
}
# -------------------------
# 主逻辑
# -------------------------
def build_cache():
source = load_source()
cache = load_cache()
source = [
article
for article in source
if not is_excluded(article)
]
total = len(source)
print(f"Total articles (after filter): {total}")
saved_count = 0
skipped_count = 0
failed_count = 0
for idx, article in enumerate(source, 1):
post_id = str(article.get("id"))
if post_id in cache:
skipped_count += 1
continue
print(f"[{idx}/{total}] Processing {post_id}...")
try:
prompt = build_prompt(article)
summary = None
for _ in range(MAX_RETRY):
summary = call_llm(prompt)
if validate_summary(summary):
break
if not validate_summary(summary):
success = False
for _ in range(REGEN_RETRY):
summary = regenerate(summary)
if validate_summary(summary):
success = True
break
if not success:
print(
f"Skip {post_id}: "
f"invalid summary length ({len(summary)})"
)
failed_count += 1
continue
cache[post_id] = build_cache_entry(
article,
summary
)
save_cache(cache)
saved_count += 1
print(f"Saved {post_id}")
time.sleep(0.3)
except Exception as e:
failed_count += 1
print(f"Failed {post_id}: {e}")
time.sleep(1)
print("\nFinished")
print(f"Saved: {saved_count}")
print(f"Skipped(existing): {skipped_count}")
print(f"Failed: {failed_count}")
# -------------------------
# main
# -------------------------
if __name__ == "__main__":
build_cache()
从实现角度来看,这个脚本最核心的工作其实并不是调用模型,而是如何让模型稳定地产生符合要求的输出。
理论上来说,把整篇文章直接交给大模型,然后要求它生成摘要即可。但在实际运行过程中会发现,大模型的输出并不总是稳定的:有时摘要过短,有时摘要过长,还有可能出现解释性文字、分析过程甚至额外补充内容。
因此在脚本中,我并没有简单地把文章内容发送给模型,而是增加了一层 Prompt 约束:首先会对正文长度进行控制,只保留前面一部分内容作为输入,避免超长文章占用过多上下文窗口;同时在 Prompt 中明确要求摘要必须覆盖核心问题、技术方案以及最终结果,并限制摘要长度范围,从而提高输出的一致性。
即使如此,本地模型仍然偶尔会生成不符合要求的结果。为了解决这个问题,脚本又增加了长度校验机制:当生成的摘要长度超出预设范围时,会自动触发重新生成流程;如果经过多次尝试仍然无法满足要求,则直接跳过当前文章,等待下一次执行时再次处理。
这种设计虽然会增加少量计算开销,但换来的好处是整个摘要库的质量能够保持相对稳定,而不会因为个别异常输出污染最终结果。
另外一个比较重要的设计是增量更新。随着博客文章数量不断增长,如果每次运行都重新生成全部摘要,不仅浪费计算资源,也会显著增加处理时间。因此脚本会优先读取已经存在的 summary-index.json,对于已经生成过摘要的文章直接跳过,只处理新增内容。这样一来,第一次运行可能需要处理全部文章,而后续运行往往只需要处理最近新增的几篇文章即可。
最终生成的 summary-index.json 中,每篇文章都会保存摘要内容以及相关元信息,例如模型名称、更新时间等字段。这些数据在生成完成之后便会被永久保存下来,后续访问博客时无需再次调用模型,而是直接读取结果即可。
至此,摘要系统最耗费计算资源的部分已经全部完成。后续 WordPress 所需要做的事情,仅仅是根据文章 ID 读取对应摘要并展示到页面中,而不再涉及任何 AI 推理过程。
4.3 WordPress 摘要展示实现
当 summary-index.json 生成完成之后,整个摘要系统最复杂的部分实际上已经结束了。此时,大模型已经完成了所有摘要生成工作,WordPress 不再需要调用任何 AI 服务,也不需要进行额外计算。对于前端来说,它所面对的只是一个普通的 JSON 文件。
因此,最后一步需要解决的问题就变得非常简单:当用户打开文章页面时,如何根据当前文章 ID,从 summary-index.json 中找到对应摘要并显示出来。
为实现这一目标,我选择通过code snippets插件(或者functions.php)在 WordPress 主题中增加一个自定义 PHP 函数,在文章正文输出之前读取摘要索引文件,并将对应内容插入到页面中。
实现代码如下:
add_filter('the_content', 'insert_ai_summary_before_content', 5);
function insert_ai_summary_before_content(content) {
if (!is_single()) {
returncontent;
}
static summary_index = null;
if (summary_index === null) {
file = get_theme_file_path('/cache/summary-index.json');
if (!file_exists(file)) {
return content;
}json = file_get_contents(file);summary_index = json_decode(json, true);
if (!is_array(summary_index)) {
return content;
}
}post_id = get_the_ID();
if (!isset(summary_index[post_id])) {
return content;
}item = summary_index[post_id];
summary =item['summary'] ?? '';
model =item['model'] ?? '';
updated =item['updated'] ?? '';
date = substr(updated, 0, 10);
// 模型显示优化
model_map = [
'qwen3:8b' => 'Qwen3-8B',
'qwen3:14b' => 'Qwen3-14B',
];model_label = model_map[model] ?? model;html = '';
// 外层容器(增强结构隔离)
html .= '<div class="article-summary-wrapper">';
// 上分隔线html .= '<div class="article-summary-divider"></div>';
// 内容主体
html .= '<div class="article-summary">';html .= '<div class="article-summary-title">文章摘要</div>';
html .= '<div class="article-summary-content">'
. nl2br(esc_html(summary)) .
'</div>';
html .= '<div class="article-summary-meta">'
. esc_html(model_label . ' · ' . date) .
'</div>';html .= '</div>';
// 下分隔线
html .= '<div class="article-summary-divider"></div>';html .= '</div>';
return html .content;
}
整个实现逻辑并不复杂:首先,代码会读取位于主题目录/cache/中的 summary-index.json 文件,并将其解析为数组结构。由于摘要数据已经在离线阶段生成完成,因此这里只需要进行一次普通文件读取即可,不涉及任何模型调用或网络请求。随后,通过 WordPress 当前文章的 ID 作为索引键,从 JSON 数据中查找对应记录。最后,如果能够找到匹配项,则提取其中保存的摘要内容、模型信息以及生成时间等字段;如果没有找到对应记录,则直接返回原始文章内容,不影响正常访问。
这样做的好处在于,即使未来摘要系统停止运行或者部分文章尚未生成摘要,也不会影响博客本身的正常展示逻辑。
从系统角度来看,WordPress 在这里承担的职责非常单一:它并不负责生成摘要,而只是负责展示摘要。所有复杂计算都已经在离线阶段完成,前台访问时仅仅是一次 JSON 查询操作。对于服务器而言,这种开销几乎可以忽略不计。
当然,仅仅将摘要显示出来还不够。如果直接输出一段普通文本,它很容易与正文内容混在一起,读者甚至无法第一时间意识到这是一段独立的文章概览。
因此在实际实现时,我又为摘要区域增加了一套独立样式,使其以信息卡片的形式呈现。
对应 CSS 如下:
.article-summary {
padding: 18px 22px;
background: rgba(0,0,0,0.03);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 10px;
/* 👉 新增:整体阅读节奏 */
line-height: 1.65;
}
/* =========================
外层间距(摘要 vs TOC)
========================= */
.article-summary-wrapper {
margin: 30px 0 40px;
display: flow-root;
}
/* TOC 和摘要之间拉开距离 */
#toc_container {
margin: 40px 0 !important;
}
/* =========================
内部结构节奏(关键优化)
========================= */
.article-summary-title {
font-weight: 600;
margin-bottom: 12px;
}
.article-summary-content {
margin-bottom: 14px;
line-height: 1.7;
}
.article-summary-meta {
margin-top: 10px;
font-size: 12px;
opacity: 0.7;
}
/* =========================
夜间模式
========================= */
body.dark .article-summary,
[data-theme="dark"] .article-summary {
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.16);
box-shadow: 0 3px 12px rgba(0,0,0,0.28);
}
/* 夜间模式下的节奏保持一致(只微调可读性) */
body.dark .article-summary-title,
[data-theme="dark"] .article-summary-title {
opacity: 0.9;
}
body.dark .article-summary-meta,
[data-theme="dark"] .article-summary-meta {
opacity: 0.65;
}
最终形成的效果是:摘要位于文章标题与目录之间;与正文保持明显的视觉分隔;保持与主题整体风格一致;同时兼容明亮模式与夜间模式。
相比将摘要直接插入正文内容,这种卡片式展示方式能够让读者在进入正文之前,先快速了解整篇文章的大致内容。对于篇幅较长的技术文章而言,这一点尤其重要。
读者往往能够在几十秒内判断这篇文章是否符合自己的需求,从而决定继续深入阅读,还是跳转到其他内容。
最终页面中的摘要展示效果如下:

至此,整个摘要系统的数据流已经形成完整闭环:从文章内容提取、摘要生成,到最终页面展示,所有工作均在本地完成,不依赖第三方在线服务,也无需在用户访问时实时调用大模型。
5 总结
本篇文章完成了“轻量级知识索引”体系中的语义层构建:通过 summary-source.json 对 WordPress 原始文章进行结构化整理,再通过 summary-index.json 固化 AI 生成结果,博客内容第一次拥有了一层独立于原始文章之外的语义表达。
这一阶段的重点并不是单纯增加一个文章摘要功能,而是建立了一种稳定的数据转换流程:将原始内容转化为可重复利用的语义单元,并通过离线生成方式降低前端运行复杂度。
目前,这个语义层主要用于文章摘要展示,负责帮助读者快速理解文章内容。但从系统设计角度来看,摘要本身并不是最终目的,更重要的是它为博客建立了一层统一的语义数据基础。换句话说,本篇完成的是从“文章内容”到“语义表达”的转换,让博客内容第一次拥有了可被进一步处理的语义描述能力。
而当这些语义数据不再只是用于展示,而是开始参与文章之间的关联与组织时,博客的信息结构也将进入下一阶段:从简单的内容索引,逐渐演变为基于语义关系的内容空间。
下一篇文章将继续基于这一语义基础,探索如何利用已有的文章数据与摘要数据,构建更高层次的内容组织机制。