Skip to content

6.5 AI 摘要与标签生成 ​

博客已有正文后,可以请 AI 帮忙拟摘要、提标签,作者再决定是否采用。就像编辑提供修改建议,生成内容需要保留人工判断的空间。本节重点讨论调用失败、后台处理和人工编辑之间的关系。


智能导读与灵感副驾 ​

在将大模型接入博客前,我们先通过通俗的生活场景理解这套 AI 原生架构:

💡 一、智能导读与灵感副驾

  • 📖 100 字导读摘要:就像是专业的报刊特约主编。读者面对几千字的长文不用逐字通读,AI 主编整理出约百字的导读,快速抓住核心亮点;
  • 🏷️ 内容感知自动打标:就像是图书馆的智能分类标签机。根据文章的技术栈与内容主题,自动打上 #Python #Markdown #Blog 等精准标签,便于全站聚合检索;
  • 🪄 存量批量回填(Backfill):就像是数字档案馆的历史文献翻新机器人。面对此前已经发布的几十篇旧文章,无需人工一篇篇点开编辑,点击一次「AI 回填」,后台自动逐篇补齐导读与标签;
  • 🛡️ 优雅降级(Graceful Degradation):如果暂时没有配置 API Key,博客的所有核心功能(发布、编辑、浏览、评论、点赞)应继续可用;需要用模型未配置和调用失败的情况验证。

实战开发流程:计划先行与 OpenSpec 驱动 ​

在开启本阶段前,我们先分享一个非常关键的工程开发习惯:

💡 核心开发思路 —— 计划先行:
在真实团队与个人开发中,强烈建议大家:
“先列一份详细的计划文档到 docs/ 目录下,再围绕计划文档开展 OpenSpec 规格演进!”

这样做有两大核心优势:
1. 思考更沉着:在动代码前先与 AI 充分讨论边界、异常处理与降级策略;
2. 方便版本回溯与 Git 回滚:所有的设计决策白纸黑字留档,后续追溯一目了然!
(注:本教程到现在一直保持纯净本地改动未提交,同学们不用着急,下一章我们将正式系统性引入 Git 版本控制!)

1. 向 AI 发送需求说明 ​

在 Trae 对话框中输入以下指令,与 AI 展开方案规划:

列计划文档关于:AI 原生赋能(智能摘要提炼与自动打标)
放在docs/下,有什么模糊的地方可以和我讨论。

AI 会深入分析当前工程代码,并在 docs/ 目录下生成完整的实施蓝图:docs/phase07_AI智能摘要提炼与自动打标.md。


2. 关键决策锁定 ​

  • 统一客户端:采用标准的 openai Python SDK,配合 base_url 一行切换大模型供应商(默认推荐性价比极高的 DeepSeek:https://api.deepseek.com);
  • 人工输入优先原则:发布或更新文章时,如果作者已经手动输入了摘要或标签,AI 绝不强行覆盖;仅在字段为空时进行自动补齐;
  • 异步后台执行:发布文章时采用 FastAPI 的 BackgroundTasks 异步处理,前端发文返回响应,无需等待大模型生成完毕。

3. 环境变量配置(重要步骤。) ​

为了保护敏感密钥不被泄露,项目通过后端环境变量读取 API Key:

在 project_01_个人博客系统二次开发/ 根目录下,参考 .env.example 创建 .env 文件,并填入你自己的大模型凭据:

ini
# .env 配置文件
LLM_API_KEY=sk-your-actual-api-key-here
LLM_BASE_URL=https://api.deepseek.com
LLM_MODEL=deepseek-chat

⚠️ 关键操作提示: 配置文件填写完成后,务必重新启动项目服务器(在终端按 Ctrl+C 停止后重新运行 uv run uvicorn main:app --reload --port 8000),让最新的环境变量加载生效。


核心架构与落地代码说明 ​

1. ORM 模型层:models.py(为 Post 增加 summary 字段) ​

python
class Post(Base):
    __tablename__ = "posts"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    title: Mapped[str] = mapped_column(String(200), nullable=False)
    content: Mapped[str] = mapped_column(Text, nullable=False)
    summary: Mapped[str] = mapped_column(Text, default="")  # 🤖 AI 生成的 100 字导读
    category: Mapped[str] = mapped_column(String(50), default="默认分类")
    tags: Mapped[str] = mapped_column(String(200), default="")
    status: Mapped[str] = mapped_column(String(20), default="published")
    views: Mapped[int] = mapped_column(Integer, default=0)
    created_at: Mapped[datetime] = mapped_column(default=datetime.now)
    updated_at: Mapped[datetime] = mapped_column(default=datetime.now, onupdate=datetime.now)

2. 独立 AI 模块:ai_service.py(结构化输出与解析) ​

python
"""AI 智能中枢模块:OpenAI 兼容客户端与结构化生成"""
import json
import os
from openai import OpenAI

LLM_API_KEY = os.getenv("LLM_API_KEY", "")
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://api.deepseek.com")
LLM_MODEL = os.getenv("LLM_MODEL", "deepseek-chat")

_client: OpenAI | None = None

def ai_enabled() -> bool:
    """判断是否配置了 API Key"""
    return bool(LLM_API_KEY)

def get_client() -> OpenAI:
    global _client
    if _client is None:
        _client = OpenAI(api_key=LLM_API_KEY, base_url=LLM_BASE_URL)
    return _client

def generate_all(title: str, content: str, category: str = "") -> dict:
    """调用大模型,严格输出 JSON 结构化数据"""
    system = (
        "你是中文技术博客的编辑助手。严格只输出 JSON 对象,不要输出任何解释或 Markdown。"
        "字段说明:"
        "summary:根据正文提炼约 100 字的中文导读(吸引读者、点明主旨,不含 HTML);"
        "tags:根据正文内容提取 3~5 个标签,放入字符串数组;"
        "title_suggestion:一个更吸引人的标题建议;"
        "category_suggestion:一个合适的分类名建议。"
    )
    user = f"标题:{title}\n现有分类:{category}\n正文:\n{content[:4000]}"

    resp = get_client().chat.completions.create(
        model=LLM_MODEL,
        messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],
        response_format={"type": "json_object"},
        temperature=0.4,
    )
    return json.loads(resp.choices[0].message.content)

3. 业务路由与后台任务:main.py ​

python
from fastapi import BackgroundTasks
from dotenv import load_dotenv
import ai_service

# 加载 .env 环境变量
load_dotenv()

def _auto_enrich_post(post_id: int):
    """后台异步任务:自动补齐文章的导读与标签(尊重人工已有输入)"""
    with database.SessionLocal() as db:
        post = db.query(Post).filter(Post.id == post_id).first()
        if not post: return
        try:
            res = ai_service.generate_all(post.title, post.content, post.category)
            if not post.summary and res.get("summary"):
                post.summary = res["summary"]
            if not post.tags and res.get("tags"):
                post.tags = ",".join(res["tags"])
            db.commit()
        except Exception as e:
            print(f"[AI 异步生成失败] 文章 {post_id}: {e}")

# 批量回填历史旧文章
@app.post("/api/ai/backfill", response_model=BackfillResponse)
def backfill_ai(
    limit: int = Query(50, ge=1, le=200),
    db: Session = Depends(database.get_db),
    _: User = Depends(security.require_admin),
):
    if not ai_service.ai_enabled():
        raise HTTPException(status_code=503, detail="未配置 LLM API Key,AI 服务不可用")
    posts = db.query(Post).filter(Post.summary == "").limit(limit).all()
    updated, failed = 0, 0
    for p in posts:
        try:
            res = ai_service.generate_all(p.title, p.content, p.category)
            if res.get("summary"): p.summary = res["summary"]
            if not p.tags and res.get("tags"): p.tags = ",".join(res["tags"])
            db.commit()
            updated += 1
        except Exception:
            failed += 1
    return BackfillResponse(total=len(posts), processed=len(posts), updated=updated, failed=failed)

AI 原生能力原理与边界 ​

1. 一次 LLM 调用的完整链路:从请求到 JSON 落库 ​

1. 🧬 一次 LLM 调用的完整链路:从请求到 JSON 落库

  • 请求侧:把“系统指令(System,定义角色与输出规则)”和“用户内容(User,贴入文章标题与正文)”组成消息数组发给大模型;
  • 关键参数:temperature=0.4 让输出更收敛稳定(数值越低越保守、越高越发散);response_format={"type":"json_object"} 强制模型只输出合法 JSON 对象;
  • 响应侧:拿到模型返回的文本后用 json.loads 解析成 Python 字典,再按字段回填数据库。

2. 为什么必须“结构化输出 + 解析兜底”?(红线第 16 条的精髓) ​

大模型本质是“概率预测下一个字”的机器,它并不保证每次输出都是合法 JSON——可能多一句解释、可能漏一个逗号、可能字段名拼错。所以做法是“双保险”:

  1. 软约束:response_format={"type":"json_object"} 从源头大幅提高“输出纯 JSON”的概率;
  2. 硬兜底:json.loads 解析失败时捕获异常,不把脏数据写进数据库(本项目中 generate_all 若抛错,会被上层 try/except 捕获并记录,绝不拖垮发布/浏览主流程)。

🧠 一句话理解:模型输出是“野马”,response_format 是缰绳,解析兜底是“驯马师手里的备用绳索”——缰绳断了还有绳索兜住,绝不会让野马撒欢把系统踩坏。


3. 同步调用 vs 异步后台任务(BackgroundTasks)——为什么要“先返回后干活”? ​

维度同步调用(边等边响)异步后台任务(先返回后干活)
用户体感点“发布”后卡住等待大模型生成(可能 5~20 秒)发布返回,生成在后台悄悄进行
系统吞吐请求长时间占用连接,并发稍高就超时连接迅速释放,高并发无忧
实现方式在路由函数里直接调 generate_all用 FastAPI 的 BackgroundTasks 把生成函数挂到后台执行
适用场景必须立刻返回 AI 结果(如在线问答)AI 辅助增强、批量回填等“可后补”任务

💡 我们的博客发布文章属于用户核心主流程,绝不能因为 AI 慢而卡住发布,因此果断选择异步后台任务;而批量回填接口(/api/ai/backfill)属于显式触发、逐篇处理,则采用同步循环 + 失败计数的方式,逻辑更直观可控。


4. 优雅降级(Graceful Degradation):AI 挂了,博客也不能瘫 ​

我们在 ai_service.py 中通过 ai_enabled() 判断是否配置了 API Key,并在所有 AI 接口中做了“未配置 / 调用失败 → 按约定返回 503/502”的处理:

  • 未配置 Key:/api/ai/backfill 直接返回 503 未配置 LLM API Key;
  • 调用失败:后台任务捕获异常仅打印日志,不影响文章的发布、浏览、评论、点赞等全部主流程。

🧠 打个比方:AI 能力就像餐厅的**“隐藏菜单”——有食材(Key)就给你现做,没食材就礼貌回绝(503),但绝不影响你正常点餐(博客主流程)。这就是“AI 是增强项,不是依赖项”**的工程哲学。


5. 成本与性能控制三个方法(为什么正文要 content[:4000]) ​

  1. 上下文截断:只把正文前 4000 字符发给模型——提炼导读与打标根本不需要读完全文,大幅省 Token;
  2. 单次复用:generate_all 一次调用同时产出摘要 + 标签 + 标题建议 + 分类建议(4 个能力一次请求),而不是拆成 4 次调用,成本降低约 75%;
  3. 批量限速与幂等:批量回填接口默认 limit=50(上限 200),避免一次打爆 API 配额;且只处理 summary == "" 的文章,绝不重复生成(天然幂等)。

💡 通用经验法则:任何 AI 功能的落地都要先问自己三句话——“信息够吗?(截断策略)”、“一次能干完吗?(合并请求)”、“失败怎么办?(降级/兜底)”。


验收与效果展示 ​

项目重启后,我们进入浏览器进行的验收体验:

1. 顶部导航专属「🪄 AI 回填」按钮 ​

登录 admin 账号后,顶部导航栏在「写文章」按钮左侧多出了一个精致的 🪄 AI 回填 操作按钮:

顶部导航 AI 回填按钮

点击该按钮后,系统会自动调用 /api/ai/backfill 接口,为缺少导读的历史文章执行回填;完成耗时取决于文章数量、模型响应及接口实现,不能把请求已受理当成全部生成完成,并弹出成功 Toast 提示。


2. 文章详情页「📖 导读」卡片 ​

点开任意一篇文章(如 Hello World - 第一篇博客),在正文内容上方赫然呈现出暗黑深蓝质感的 「📖 导读」卡片:

文章阅读器导读卡片

导读实测效果: “📖 导读 · 本文是一篇用于验证前端功能的测试博客,介绍了Markdown渲染、代码高亮和分类管理等核心功能特性,并通过Python代码示例展示了Hello World程序。” —— 概括极度精炼准确。


3. 首页卡片中的摘要与标签 ​

回到首页,每张文章卡片不仅拥有精简的 100 字导读,更被整齐打上了 3~5 个精准的 AI 标签(如 #Docker #部署 #运维、#Vue #JavaScript #前端框架、#AI #编程 等):

文章卡片 AI 摘要与标签展示

4. 博客系统全貌大合影(14 篇文章展示) ​

至此,我们的全栈个人博客系统完成了从无到有、从简单单表到拥有 用户鉴权 + 角色隔离 + 防刷点赞 + 楼层评论 + 性能分页 + AI 原生导读打标 的现代化全功能蜕变:

全站验收大合影

后台生成要处理旧结果和人工编辑的冲突 ​

作者把初稿交给编辑后又改了正文,编辑晚些时候交回的建议可能已经过时。后台生成摘要也有同样的问题:发起请求时的文章,与模型返回时的文章未必还是同一版。

后续改造可以在请求开始时记录文章版本或更新时间;结果写入前重新读取文章,确认正文未变化、目标字段仍为空。用户已经填写的摘要不应被覆盖,文章已经删除时也应停止回填。

情况建议行为
未配置模型凭据保留博客基本功能,提示 AI 暂不可用
返回 JSON 无法解析记录失败,不把原始错误写成摘要
返回字段缺失或类型错误校验后拒绝写入或按字段处理
人工已填写摘要保留人工内容,只处理仍为空的字段
正文在生成期间更新丢弃旧结果或重新排队

BackgroundTasks 适合响应返回后执行的小型进程内任务,并不等于持久任务队列。服务重启可能使未完成任务丢失;需要可靠重试和批量调度时,应另行设计任务记录与执行机制。参见 FastAPI 后台任务文档。

上述版本检查和任务持久化是继续完善项目的方向,不能据此认定配套示例已经实现。验证模型调用时还应区分替身测试与真实服务测试:前者检查程序分支,后者确认实际模型、凭据和参数可用。

AI 生成内容必须保留人工控制 ​

摘要和标签属于可编辑内容,模型结果应当作为候选值,而不是不可更改的系统事实。页面需要允许作者修改或拒绝生成结果;再次生成前,明确是覆盖旧内容、只补空字段,还是生成一份供比较的新版本。

写入数据库前应校验字段类型、长度、标签数量和允许字符。模型返回的 JSON 即使能够解析,也可能包含多余字段或过长文本。校验失败时记录原因并保留文章正文,不能让辅助功能阻断文章保存。

还要限制同时提交的任务数量,并给等待、超时和失败提供可见状态。采用进程内后台任务时,应承认服务重启会丢失任务;需要可靠执行时,再引入持久任务记录、重试次数和人工重新触发入口。无论采用哪种方式,人工已经修改过的摘要都应受到保护。


本节回顾 ​

AI 生成是博客的辅助功能。人工内容优先、调用失败可处理、旧结果不误写,是后续完善时应持续检查的要求。下一节讨论如何整理不断增长的代码。

🎉 五、第六章本节回顾

继续阅读:6.6 代码分层与目录调整。