为博客构建“轻量级知识索引”(六):AI摘要与可编辑语义层设计
文章摘要
针对博客摘要生成的痛点,文章提出基于大语言模型的离线预计算方案。通过构建summary-source.json统一数据源,提取文章结构信息并控制输入长度,再调用本地Qwen3模型生成结构化摘要存入summary-index.json。该设计实现摘要内容与WordPress解耦,既保证AI生成的准确性,又保留可编辑性,使摘要成为可复用的语义数据层。最终在WordPress前端以独立卡片形式展示,提升长文阅读转化率,为后续语义搜索和知识图谱构建奠定基础。
Qwen3-14B · 2026-06-24

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)之外,我还额外保留了几个辅助字段。其中,versionsource_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;
}

最终形成的效果是:摘要位于文章标题与目录之间;与正文保持明显的视觉分隔;保持与主题整体风格一致;同时兼容明亮模式与夜间模式。

相比将摘要直接插入正文内容,这种卡片式展示方式能够让读者在进入正文之前,先快速了解整篇文章的大致内容。对于篇幅较长的技术文章而言,这一点尤其重要。

读者往往能够在几十秒内判断这篇文章是否符合自己的需求,从而决定继续深入阅读,还是跳转到其他内容。

最终页面中的摘要展示效果如下:

image.png

至此,整个摘要系统的数据流已经形成完整闭环:从文章内容提取、摘要生成,到最终页面展示,所有工作均在本地完成,不依赖第三方在线服务,也无需在用户访问时实时调用大模型。

5 总结

本篇文章完成了“轻量级知识索引”体系中的语义层构建:通过 summary-source.json 对 WordPress 原始文章进行结构化整理,再通过 summary-index.json 固化 AI 生成结果,博客内容第一次拥有了一层独立于原始文章之外的语义表达。

这一阶段的重点并不是单纯增加一个文章摘要功能,而是建立了一种稳定的数据转换流程:将原始内容转化为可重复利用的语义单元,并通过离线生成方式降低前端运行复杂度。

目前,这个语义层主要用于文章摘要展示,负责帮助读者快速理解文章内容。但从系统设计角度来看,摘要本身并不是最终目的,更重要的是它为博客建立了一层统一的语义数据基础。换句话说,本篇完成的是从“文章内容”到“语义表达”的转换,让博客内容第一次拥有了可被进一步处理的语义描述能力。

而当这些语义数据不再只是用于展示,而是开始参与文章之间的关联与组织时,博客的信息结构也将进入下一阶段:从简单的内容索引,逐渐演变为基于语义关系的内容空间。

下一篇文章将继续基于这一语义基础,探索如何利用已有的文章数据与摘要数据,构建更高层次的内容组织机制。

📚 系列文章:为博客构建“轻量级知识索引”(6 / 6)

📌 内容结构提示:
这篇内容属于「AI 学习地图」的一部分,你可以从这里查看完整内容路径: AI 学习地图
查看相关分类·3个匹配
分享这篇文章
博客内容均系原创,转载请注明出处!博客的RSS地址为:https://blog.tangwudi.com/feed,欢迎订阅;如有需要,可以加入Telegram群一起讨论问题。
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
       

👋 欢迎来到“无敌的个人博客”

这里主要围绕以下方向展开长期探索:

🧱 个人数字基础设施与博客系统构建
☁️ Cloudflare 与网络架构实践
🧠 AI 与知识系统探索
🛡️ 网络安全与访问优化
🎵 音乐与声音认知
👁️ 认知视角与世界观