AI 没有记忆?我给儿童故事 Agent 搭了一套“长期记忆”系统

16 阅读27分钟

一、为什么系列故事需要"长期记忆"

在故事机项目里,故事有两个维度:一个是故事内容本身,一个是系列故事集。小朋友的想象力是无穷的——也许你给他一只小马,他就能顺着编出一整个"小马宝莉"式的系列故事。

系列故事集与单篇故事不太一样。单篇故事可以任意、天马行空、不受约束;故事集就必须有一些约束条件,否则很容易产生一致性问题。一个连贯、情节走向合理的故事集,理论上需要一个"总编辑"来把控全局。这个总编辑需要长期记住:

  • 主角叫什么、长什么样;
  • 角色之间是什么关系;
  • 故事世界遵循什么规则;
  • 哪些事件已经发生过;
  • 角色拥有什么物品和能力;
  • 哪些设定绝对不能被后续分集推翻;
  • ……

常见的一致性问题有这几类:

  • 第一集主角叫小美,第三集变成了小帅;
  • 第四集和第五集的故事内容大面积相似;
  • 道具的能力在不同分集里发生了变化;
  • 角色关系前后不一致。

我们用大模型帮我们创作故事,但大模型本身没有记忆功能——每次调用本质上都是一次全新的上下文。所以要给故事生成 Agent 加上一套长期记忆。用一个公式概括:

本集创作要求 + 传入上下文 = 模型本轮能够使用的记忆。

本文通过分享故事集记忆的实践过程,介绍 Agent 长期记忆的作用以及如何构建它。要解决的问题包括:

  1. Story Bible 应保存哪些信息;
  2. 如何把故事长期记忆存进 PostgreSQL;
  3. 如何使用 pgvector 检索相关记忆;
  4. 如何在生成新分集时组装 RAG 上下文;
  5. 如何在新故事生成后更新长期记忆;
  6. 如何处理冲突、过期、重试与人工修订。

二、故事机已有的记忆能力:V1 的"穷办法"

在第一版故事机里,我用"上下文 + 已有故事摘要 + 提示词约束"的方式给故事机加了一个初步的记忆能力。生成上下文包括:故事集标题、故事主题、故事设定与简介、目标年龄段、本集创作想法、已有分集摘要等信息。提示词模板如下(模板较长,重点看第 5、6 条约束):

GENERATOR_STORY_PROMPT_TEMPLATE = """我希望你能帮我生成一篇儿童故事,故事要求不但充满童真童趣,而且不经意间还有智慧的启迪,故事内容中不能出现暴力、色情或者其他违背公序良俗的元素。
本篇故事是故事集{story_set_title}下的第{index}篇故事。故事内容需要贴合故事集名称、故事集主题、故事集简介、以及故事集设定的适合阅读年龄。
当前故事集的名称是{story_set_title};故事集主题是{subject};故事集简介是{description};适合阅读年龄段为{age_range}。
以下是我对故事内容的一些要求, 请务必按照要求生成:
1、故事字数我希望在{len}左右
2、故事的内容要完整,连贯,情节要合理
3、故事内容与标题不跟故事集中的其他故事相似或者雷同
4、我对本篇故事有如下的一些想法:{story_idea}
5、故事集中已有的故事摘要如下,请避免标题、核心情节和冲突设计与它们雷同:{existing_stories}
6、请严格依据下面已经确认的故事策划方案写作,不要擅自替换核心角色或主线:{story_plan}
生成好的故事请按照如下JSON格式返回: {{
    title: 故事标题,
    content: 故事内容
}}"""

其中"已有分集摘要"由一个包装函数 _format_existing_stories 生成,提取规则是逐级兜底:

  1. 优先取分集摘要 description;
  2. 如果没有摘要,取分集创建时的想法提示词 idea_prompt;
  3. 如果以上均无,取故事正文前 200 个字符。
def _format_existing_stories(
    existing_stories: tuple[StoryEpisodeEntity, ...],
) -> str:
    summaries: list[str] = []
    for story in existing_stories:
        title = story.title.strip() or "未命名故事"
        summary = (
            story.description.strip()
            or story.idea_prompt.strip()
            or story.content.strip()[:200]
            or "暂无内容摘要"
        )
        summaries.append(
            f"- 第 {story.episode_no} 篇《{title}》:{summary[:200]}"
        )
    return "\n".join(summaries) if summaries else "暂无已有故事"

这套方案在不引入任何额外基础设施的情况下,让故事在生成时能看到同故事集下其他分集的主要内容,规模不大时完全够用。但它的局限性也很明显:

#局限后果
1集数越多,Prompt 越长对上下文窗口是持续增长的挑战,成本也线性上涨
2"前 200 字"兜底未必兜得住故事的核心设定(比如一条世界规则)可能根本不在开头
3所有要素混在自然语言里模型很难分辨哪些是"稳定设定"、哪些只是"临时情节"
4修改旧故事后无感知不知道哪些后续记忆依赖它,一致性会被慢慢腐化

第 1、2 条是量的问题,第 3、4 条是质的问题——后者更致命。为了进一步提高可用性,我重新设计了记忆模块,引入一套 Story Bible 记忆架构,对故事记忆做更精细的管理。

三、什么是 Story Bible

Story Bible 不是做故事摘要这么简单。它是一份持续演进的系列故事事实库,记录经过确认的长期设定。核心类别包括:

类别记录什么稳定 / 动态
角色姓名、外貌、能力基本稳定
世界规则故事背景、社会组织、物理规则稳定
地点地点名称、特征稳定
道具道具名称、能力、当前持有者持有者是动态
时间线事件哪一集发生、参与者动态
关系与承诺角色间的关系、已做出的承诺动态
伏笔与未完成线索伏笔内容、当前状态动态
叙事与安全规则目标年龄段、不允许出现的内容稳定

所有条目可以分成两大类:

  • 稳定设定:角色姓名、长相、世界规则等通常不会变的内容;
  • 动态状态:随剧情推进发生变化的内容,比如道具归属、角色位置、伏笔状态等。

这个区分直接决定了后续的更新策略:稳定设定变化少,动态状态应在每集完成后及时更新。忘了这一条,记忆库就会慢慢变成一堆互相矛盾的旧闻。

四、选型:为什么是 PostgreSQL + pgvector

pgvector 是 PostgreSQL 的开源插件,与 PostgreSQL 深度集成,用于向量相似度查询。故事机本来就在用 PostgreSQL,引入 pgvector 是接入与使用成本最低的方案。

但技术选型从来不是只看成本,功能完整可用是底线。诚实地说:PostgreSQL + pgvector 在语义相似度查询的速度和效果上,都比不过专业向量库(比如 Qdrant),数据量越大差距越明显。但是——一个故事集又能有多少集故事呢?儿童故事集撑死几十集,每集提炼十几条记忆,单库不过几千条向量。在这个量级上,选 PostgreSQL + pgvector 是务实的;哪天数据量真的急剧攀升了,再换与之适配的方案也不迟。

更重要的是,故事机的 PostgreSQL 里已经保存着用户、故事集、故事分集、AI 任务、审稿状态等数据。Story Bible 直接放进同一个数据库,几乎白拿一堆能力:外键约束、权限隔离、数据备份,以及与业务数据天然的一致性关联。

pgvector 在这里扮演什么角色

pgvector 允许 PostgreSQL 保存文本的向量表示,并按相似度查询:

flowchart LR
    A["记忆文本"] --> B["Embedding 模型"]
    B --> C["生成向量"]
    C --> D["存入 vector 列"]
    D --> E["与本集创作问题的向量比较"]
    E --> F["取回语义最相关的记忆"]

为什么不只用关键词搜索,或者只用向量数据库?

  • 为什么不能只用关键词搜索? 两句话表达不同但语义相似("钥匙放在小兔子那里" vs "月光钥匙由小兔子保管"),关键词完全关联不上。记忆召回恰恰是语义问题。
  • 为什么不能只用向量数据库? 查询不止于语义查询。"这条记忆属于哪个故事集""是否已失效""来自哪一集"——这些精确的结构化关联,还是需要关系数据库来管。

所以最终的分工是:

PostgreSQL 负责判断这条记忆属于谁、是否有效、来自哪里;pgvector 负责判断这条记忆与本次创作有多相关。

五、存储:一张表 + 一条粒度原则

5.1 表结构

Story Bible 的全部记忆放在一张 story_bible_entries 表里:

CREATE TABLE story_bible_entries (
    id BIGSERIAL PRIMARY KEY,
    story_set_id BIGINT NOT NULL,
    entry_type VARCHAR(32) NOT NULL,
    canonical_key VARCHAR(200),
    title VARCHAR(200) NOT NULL,
    content TEXT NOT NULL,
    metadata JSONB NOT NULL DEFAULT '{}',
    importance SMALLINT NOT NULL DEFAULT 50,
    status VARCHAR(16) NOT NULL DEFAULT 'active',
    source_episode_id BIGINT,
    supersedes_entry_id BIGINT,
    embedding VECTOR(...),
    embedding_model VARCHAR(128),
    embedding_version INTEGER NOT NULL DEFAULT 1,
    created_at TIMESTAMPTZ NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL
);

核心字段的职责:

字段职责示例
entry_type记忆的维度,枚举:character / relationship / location / world_rule / item / event / open_thread / style_rule / safety_rulecharacter
canonical_key逻辑对象的唯一标识。不同的表达可能是同一个逻辑对象,靠它关联、防重复item:moonlight-key
content记忆内容,保存可直接给模型阅读的自然语言事实月光钥匙由小兔子保管,每个自然月只能开启一次星门。
metadata结构化属性:涉及角色、道具归属、出自第几集等见下方 JSON
importance重要程度,0~10080
status记忆状态:有效(active)/ 已被取代(superseded)/ 有争议(disputed)/ 已归档(archived)active
source_episode_id来源分集 ID,标识这条记忆是从哪一集提炼的1

metadata 的实际结构:

{
  "character_names": ["小兔子"],
  "item_name": "月光钥匙",
  "current_owner": "小兔子",
  "usage_limit": "每月一次",
  "episode_no": 3
}

importance 在项目里按分数段划分:

分段含义
90~100不可违反的核心设定
70~89重要角色或连续剧情事实
40~69普通历史事件
低于 40氛围和细节信息

5.2 粒度原则:按事实单元组织,而不是按文档切块

行业里通用的文档切分方式有很多:按固定字符数、按固定 token 数、按段落、语义切分、LLM 驱动切分……但记忆库不是 RAG 知识库,照搬"切块"思路会踩坑——任何片段切法都容易把一个情节切得支离破碎,事实变得不完整、不清晰。

这个场景里的记忆粒度应该更抽象一层:以"事实单元"来组织。每条记忆应满足以下约束:

  • 能被独立理解,不依赖上下文;
  • 构成一个完整事实;
  • 指明涉及的实体;
  • 包含必要的时间与分集信息;
  • 能判断它现在是否仍然有效。

在这个粒度下,记忆库里一定要存三类信息:

  • 分集摘要:帮助模型理解整体剧情走向;
  • 原子事实:精确维持一致性(主角是谁、钥匙归谁、规则是什么);
  • 未完成线索:推进长期剧情的钩子。

六、读取:一次检索要经过四道工序

读取是整套系统里最复杂的一段。整体流程分四道工序:

flowchart TD
    A["工序 1:构造检索问题,生成查询向量"] --> B["工序 2:结构化过滤,取回召回池"]
    B --> C["工序 3:四层召回"]
    C --> D["工序 4:混合排序、去重、组装上下文"]

工序 1:构造检索问题,生成查询向量

检索问题不是"把本集想法原样丢进去",而是拼上分集序号和年龄段,让查询带上"这是第几集、给多大孩子看"的语境,使检索问题带上比较完整的约束条件:

def _build_query_text(
    *, idea: str, episode_no: int, age_range: str
) -> str:
    """构造检索问题。"""
    parts = [f"第 {episode_no} 集创作要求", idea.strip()]
    if age_range:
        parts.append(f"适合{age_range}儿童")
    return "\n".join(part for part in parts if part)

随后调用模型把检索问题构造成向量。这里有一个必须遵守的前提:查询向量必须和记忆向量用同一个模型、同一个版本计算,否则召回就会失准——不同模型的向量空间根本不可比。项目里统一使用阿里云的 text-embedding-v4 做 embedding。

try:
    query_vector = self.embedding_client.embed([query_text])[0]
except EmbeddingError as error:
    # Embedding 服务不可用时不能让生成流程挂掉:
    # 记忆照常保存,检索降级为纯结构化策略(见 retrieval_meta.strategy)。
    ...

工序 2:结构化过滤,先缩小范围再算相似度

相似度计算是有成本的,计算之前先把范围缩小,能减少大量无谓的比较。项目里的过滤条件有三个:

  1. 只看本故事集——别的故事集的记忆与本次创作毫无关系;
  2. 只看有效记录——已经过期或失效(superseded / archived)的记忆不参与计算;
  3. 向量必须已就位——embedding 为空或状态不是 ready 的条目,算了也是白算。

过滤条件和相似度检索写在同一条 SQL 里:

"SELECT id, 1 - LEAST(embedding <=> CAST(:q AS vector), 1.0) AS similarity "
"FROM story_bible_entries "
"WHERE story_set_id = :sid "
"AND status = 'active' "
"AND embedding_status = 'ready' "
"AND embedding IS NOT NULL "
"ORDER BY embedding <=> CAST(:q AS vector) "
"LIMIT :k"

同时,固定注入区还需要一份结构化召回池——本故事集下所有有效条目,按重要度降序取出:

def active_entries_for_retrieval(
    self, *, story_set_id: int
) -> tuple[StoryBibleEntryEntity, ...]:
    """All active entries of a story set — the structured retrieval pool."""
    # 按重要度降序取出,后续固定注入区直接按顺序截取即可。
    models = self.session.scalars(
        select(StoryBibleEntry)
        .where(
            StoryBibleEntry.story_set_id == story_set_id,
            StoryBibleEntry.status == "active",
        )
        .order_by(StoryBibleEntry.importance.desc(), StoryBibleEntry.id.asc())
    )
    return tuple(_to_entity(model) for model in models)

注意这一步实际取回了两份召回池:语义池(上面的向量 SQL,取 Top-K)喂给动态召回,结构化池(全量有效条目)喂给固定注入区。两份池子用同一套过滤条件(本故事集 + active),来源一致。

工序 3:四层召回,先固定、后动态

有了召回池,接下来决定"哪些记忆进入本集上下文"。分四层:

层内容来源
固定注入核心角色、世界与道具规则、安全规则结构化池,按重要度截取
最近发生最近剧情、未完成的线索结构化池,按时间截取
语义相关与本集想法相关的事件、道具、地点、关系、伏笔语义池,pgvector 检索
本集输入用户本次的创作要求不单独成区,见下

固定注入解决的是"绝对不能忘"的问题——核心设定不参与排序竞争,直接按重要度截取:

core_settings = self._fixed_section(
    entries, wanted=("character",), limit=self.settings.characters_limit
)
world_rules = self._fixed_section(
    entries,
    wanted=("world_rule", "item"),
    limit=self.settings.core_rules_limit,
    min_importance=70,
)
safety_rules = self._fixed_section(
    entries,
    wanted=("safety_rule", "style_rule"),
    limit=self.settings.core_rules_limit,
)

最近发生是剧情的连续性保证:

recent_events = self._recent_events(entries)
open_threads = self._open_threads(entries)

def _recent_events(
    self, entries: tuple[Any, ...], limit: int | None = None
) -> list[dict[str, Any]]:
    """最近剧情:事件条目按创建时间倒序取最近 N 条。

    用 created_at 而不是"分集号"排序,是因为条目创建时间天然
    跟随剧情推进,且不需要额外维护集数元数据。
    """
    limit = limit or self.settings.recent_events_limit
    events = [entry for entry in entries if entry.entry_type == "event"]
    events.sort(
        key=lambda entry: (entry.created_at or datetime.min.replace(tzinfo=UTC)),
        reverse=True,
    )
    return [self._to_item(entry) for entry in events[:limit]]

def _open_threads(self, entries: tuple[Any, ...]) -> list[dict[str, Any]]:
    """未完成线索:长期剧情的钩子,重要度 >= 30 才带上。

    线索的价值在于"提醒模型别忘了回收",所以宁缺毋滥——
    低重要度的氛围型线索不值得占用上下文预算。
    """
    threads = [
        entry
        for entry in entries
        if entry.entry_type == "open_thread" and entry.importance >= 30
    ]
    threads.sort(key=lambda entry: (-entry.importance, entry.id))
    return [self._to_item(entry) for entry in threads[: self.settings.open_threads_limit]]

语义检索只对动态条目(关系、地点、道具、事件、线索)做向量搜索——稳定设定已经在固定注入区里了,没必要再让它们挤占语义召回的名额。

本集输入比较特殊:用户的要求不单独成区,而是体现在两处——一是检索问题里包含本集想法(决定语义检索的方向),二是排序时的类型匹配加分(canonical_key 或标题命中查询词的条目 +0.1)。用户输入的原文本身就在提示词里,不依赖检索。

工序 4:混合排序、去重与组装

语义召回的候选不能只按向量距离排。项目里用多信号加权打分:

分数 = 语义(0.5) + 重要度(0.3) + 新鲜度(0.2) + 类型命中(+0.1) + 线索加成(+0.05)
# 工序 4:多信号混合排序。
ranked = self._hybrid_rank(
    candidates=candidates,
    query_terms=self._query_terms(query_text),
)
# 去重:语义命中的条目若已被固定分区选中,不再重复进入相关记忆。
relevant_memories = [
    item for item in ranked if item["id"] not in fixed_ids
][: self.settings.relevant_memories_limit]

def _hybrid_rank(
    self,
    *,
    candidates: dict[int, tuple[Any, float]],
    query_terms: set[str],
) -> list[dict[str, Any]]:
    now = datetime.now(UTC)
    weights = self.settings
    scored: list[tuple[float, dict[str, Any]]] = []
    for entry_id, (entry, semantic) in candidates.items():
        importance_norm = entry.importance / 100.0
        recency = self._recency(entry.created_at, now)
        type_bonus = self._type_match(entry, query_terms)
        score = (
            weights.weight_semantic * semantic
            + weights.weight_importance * importance_norm
            + weights.weight_recency * recency
            + type_bonus
            + (_OPEN_THREAD_BONUS if entry.entry_type == "open_thread" else 0.0)
        )
        item = self._to_item(entry)
        item["score"] = round(score, 4)
        scored.append((score, item))
    scored.sort(key=lambda pair: (-pair[0], pair[1]["id"]))
    return [item for _, item in scored]

几个设计点:

  • 新鲜度用指数衰减,半衰期 45 天,每过 45 天权重减半:
def _recency(created_at: Any, now: datetime) -> float:
    """新鲜度:指数衰减,半衰期 45 天(45 天前的记忆权重减半)。

    返回 (0, 1],越新越接近 1。用指数而不是线性衰减,
    是为了让"最近几集"明显领先,又不至于把一个月前的剧情一笔勾销。
    """
    if created_at is None:
        return 0.0
    try:
        age_days = max((now - created_at).total_seconds() / 86400.0, 0.0)
    except TypeError:
        return 0.0
    return math.exp(-age_days * math.log(2) / _RECENCY_HALF_LIFE_DAYS)
  • 去重的方向是"语义让位固定":同一条记忆如果已经被固定注入区选中,就从语义候选里剔除。固定注入区是"必须记住"的名单,不该被动态排序挤掉,也不该在上下文里出现两遍。

最后把检索结果组装成结构化的 memory_context,同时带出一份 retrieval_meta——记录这次检索用了什么策略、池子多大、查了多长,方便排查"为什么没召回":

used_ids = sorted(
    {
        item["id"]
        for section in (
            core_settings,
            world_rules,
            safety_rules,
            recent_events,
            open_threads,
            relevant_memories,
        )
        for item in section
    }
)
memory_context = {
    "core_settings": core_settings,
    "world_and_item_rules": world_rules,
    "safety_and_style_rules": safety_rules,
    "recent_events": recent_events,
    "relevant_memories": relevant_memories,
    "open_threads": open_threads,
}
retrieval_meta = {
    "strategy": strategy,
    "entry_count": len(entries),
    "dynamic_pool_size": len(dynamic_pool),
    "query_chars": len(query_text),
    "embedding_version": self.settings.embedding_version,
}
return {
    "memory_context": memory_context,
    "retrieved_memory_ids": used_ids,
    "retrieval_meta": retrieval_meta,
}

组装 Prompt 时不直接倾倒数据库记录,而是按固定分块输出——模型对【分区标题 + 列表】的遵守程度远高于一串混杂条目:

def format_memory_context(memory_context: dict[str, Any] | None) -> str:
    """
    不直接倾倒数据库记录,而是按固定分块输出——模型对
    【分区标题 + 列表】的遵守程度远高于一串混杂条目。
    空分区跳过,不输出空标题。
    """
    if not memory_context:
        return ""
    # 分区顺序即注入顺序:设定 → 规则 → 剧情 → 线索,由稳定到动态。
    sections: list[tuple[str, list[dict[str, Any]]]] = [
        ("系列核心设定", memory_context.get("core_settings") or []),
        ("世界与道具规则", memory_context.get("world_and_item_rules") or []),
        ("叙事与安全规则", memory_context.get("safety_and_style_rules") or []),
        ("最近剧情", memory_context.get("recent_events") or []),
        ("相关记忆", memory_context.get("relevant_memories") or []),
        ("未完成线索", memory_context.get("open_threads") or []),
    ]
    blocks: list[str] = []
    for title, items in sections:
        if not items:
            continue
        lines = [f"- {item['content']}" for item in items]
        blocks.append(f"【{title}】\n" + "\n".join(lines))
    if not blocks:
        return ""
    # 硬声明放在最前:这些是约束性事实,不是创作建议,不得推翻。
    header = "系列长期记忆(Story Bible,写作时必须保持一致,不得推翻):"
    return "\n\n" + header + "\n" + "\n\n".join(blocks)

注入效果(示意):

系列长期记忆(Story Bible,写作时必须保持一致,不得推翻):

【系列核心设定】
- 小兔子:善良、谨慎,害怕打雷,目前保管月光钥匙。

【世界与道具规则】
- 月光钥匙每个自然月只能开启一次星门。

【未完成线索】
- 小星星失踪前留下的银色羽毛,来历尚未解释。

两个细节值得强调:分区顺序由稳定到动态(设定 → 规则 → 剧情 → 线索),与"哪些更不能变"的优先级一致;最前面的硬声明把记忆定位成约束性事实而非创作建议,避免模型把它当成可以自由发挥的参考材料。

接入工作流

在 LangGraph 工作流里新增一个记忆检索节点,插在 analyze_requirements 与 plan_story 之间:

async def retrieve_story_memory_node(
    state: StoryGenerationWorkflowState,
) -> dict[str, Any]:
    """记忆检索节点:位于需求分析与故事规划之间。

    检索结果写入工作流状态并随快照持久化,供规划 / 生成 / 审稿 /
    修订四个节点共用同一份记忆,保证全程一致。
    """
    if state.get("memory_context"):
        # 恢复/重试时复用原召回结果,保证同一次任务上下文可重复。
        # 用户主动重新生成时才会重新检索。
        return {}
    if self.memory_retrieval is None:
        # 未启用记忆子系统:返回空上下文,生成流程照常走(退化行为)。
        return {
            "memory_context": {},
            "retrieved_memory_ids": [],
            "retrieval_meta": {"strategy": "unavailable"},
        }
    retrieval = self.memory_retrieval

    def _build() -> dict[str, Any]:
        # retrieve() 是同步 DB + HTTP 调用,丢进线程池避免阻塞事件循环。
        return retrieval.retrieve(
            story_set_id=story_set_id,
            episode_no=index,
            idea=idea,
            age_range=age_range,
            existing_story_count=len(reference_stories),
        )

    retrieved = await asyncio.to_thread(_build)
    return {
        "memory_context": retrieved["memory_context"],
        "retrieved_memory_ids": retrieved["retrieved_memory_ids"],
        "retrieval_meta": retrieved["retrieval_meta"],
    }

workflow = StoryGenerationWorkflow(
    nodes=StoryGenerationWorkflowNodes(
        analyze_requirements=analyze_requirements_node,
        retrieve_story_memory=retrieve_story_memory_node,
        plan_story=plan_story_node,
        draft_story=draft_story_node,
        rule_precheck=rule_precheck_node,
        review_story=review_story_node,
        revise_story=revise_story_node,
        finalize_story=finalize_story_node,
        reject_story=reject_story_node,
    ),
    checkpoint_writer=persist_checkpoint,
    max_review_rounds=DEFAULT_MAX_REVIEW_ROUNDS,
)

节点实现里有三个值得说的防御性设计:

  • 重试时复用召回结果:state 里已有 memory_context 就直接返回,保证同一次任务重试用的是同一份上下文;只有用户主动重新生成才重新检索。
  • 记忆子系统未启用时优雅退化:返回空上下文,生成流程照常走——RAG 是增强,不是依赖。
  • 同步调用丢进线程池:retrieve() 里是同步 DB + HTTP 调用,asyncio.to_thread 避免阻塞事件循环。

七、写入:没有写入规则,记忆不会更新

RAG 不只是"读旧记忆",还必须把新一集转化为未来可用的记忆,否则记忆库永远停在第一集。写入流程如下:

flowchart TD
    A[&#34;分集审核通过并保存&#34;] --> B[&#34;注册记忆提取任务&#34;]
    B --> C[&#34;数据库任务队列&#34;]
    C --> D[&#34;Worker 消费任务&#34;]
    D --> E[&#34;调用模型抽取结构化事实&#34;]
    E --> F{&#34;与现有记忆按 canonical_key 对比&#34;}
    F -->|&#34;没有相同 key&#34;| G[&#34;新建&#34;]
    F -->|&#34;key 相同但内容变化&#34;| H[&#34;替换,旧条目标记 superseded&#34;]
    F -->|&#34;内容完全相同&#34;| I[&#34;跳过,不入库&#34;]
    G --> J[&#34;以 candidate 状态入库&#34;]
    H --> J
    J --> K[&#34;确认后转为 active&#34;]
    J --> L[&#34;后台生成 Embedding&#34;]

7.1 触发时机:等内容稳定了再提取

记忆提取的时机,应当是待提取的内容相对稳定的时候。草稿改来改去时提取,只会提取出一堆马上就过时的记忆。故事机场景里选择在分集审核通过并保存之后触发任务注册:

self.task_repository.create_pending(
    task_type=EXTRACTION_TASK_TYPE,
    requested_by=requested_by,
    input_text=(
        f"提取故事集记忆:第 {episode.episode_no} 集"
        f"《{episode.title}》"
    ),
    request_options={
        "story_set_id": story_set.id,
        "episode_id": episode.id,
        "episode_no": episode.episode_no,
        "episode_title": episode.title,
        "story_set_title": story_set.title,
        "subject": story_set.subject,
        "description": story_set.description,
        "age_min": story_set.age_min,
        "age_max": story_set.age_max,
    },
    story_set_id=story_set.id,
    story_episode_id=episode.id,
    max_attempts=3,
)

延续项目一贯的做法:数据库表充当任务队列,独立 Worker 轮询消费,max_attempts=3 控制重试。

7.2 让模型输出结构化的事实清单

Worker 从队列取出任务后,调用模型从故事内容与上下文中抽取结构化关键信息,拆分出事实清单:

prompt = MEMORY_EXTRACTION_PROMPT_TEMPLATE.format(
    story_set_title=story_set_title or "未命名故事集",
    subject=subject or "未指定",
    description=description or "无",
    age_range=age_range or "全年龄",
    episode_no=episode_no,
    episode_title=episode_title,
    episode_content=episode_content[
        :_EPISODE_CONTENT_SNIPPET_CHARS
    ],
    existing_memory=format_existing_memory_for_extraction(active_entries),
)
self._attach_trace(task_id)
context = StoryAgentContext(
    user_id=user_id or 0,
    story_set_id=story_set_id,
    episode_id=story_episode_id,
    episode_no=episode_no,
    story_set_title=story_set_title,
    subject=subject,
    description=description,
    age_range=age_range,
)
agent = self.agent_factory()
result = await invoke_agent_with_guardrails(
    agent,
    {"messages": [HumanMessage(content=prompt)]},
    context=context,
    timeout_seconds=self.agent_timeout_seconds,
    run_name="storybox.memory_extraction",
    tags=["storybox", "memory_extraction"],
    metadata={
        "task_type": "memory_extraction",
        "ai_task_id": task_id,
        "user_id": user_id,
        "story_set_id": story_set_id,
        "episode_id": story_episode_id,
        "episode_no": episode_no,
    },
)

提取提示词有三个关键设计:把已有记忆喂给模型让它避免重复提取;用 canonical_key 约束"同一实体";用数量上限(new_facts ≤ 12、state_updates ≤ 8)防止一次性灌进一堆低质量记忆:

MEMORY_EXTRACTION_PROMPT_TEMPLATE = """请从下面这集新故事中提取 Story Bible 长期记忆。
故事集:{story_set_title}
故事集主题:{subject}
故事集简介:{description}
适合年龄:{age_range}
分集序号:第 {episode_no} 集
分集标题:{episode_title}
已有的长期记忆(canonical_key 相同的对象视为同一实体,不要重复提取):
{existing_memory}
分集正文:
<episode_content>{episode_content}</episode_content>

请返回 JSON:
{{
    "new_facts": [
        {{"entry_type": "event|item|location|character|relationship|world_rule|safety_rule|style_rule",
          "canonical_key": "如 item:moonlight-key,没有则省略",
          "title": "记忆标题(对象名或事件名)",
          "content": "一句可独立理解的事实,注明第几集",
          "importance": 0-100,
          "characters": ["涉及角色"]}}
    ],
    "state_updates": [
        {{"canonical_key": "被更新的既有记忆的 canonical_key",
          "title": "对象名称",
          "content": "当前最新状态(如:月光钥匙在第 X 集后由小兔子保管)",
          "importance": 0-100}}
    ],
    "resolved_threads": ["已解决的伏笔对应的 canonical_key 或标题"],
    "open_threads": [
        {{"title": "伏笔标题", "content": "伏笔内容与首次出现分集", "importance": 0-100}}
    ],
    "summary": "本次提取的一句话总结"
}}
只返回 JSON,不要返回其他内容。new_facts 不超过 12 条,state_updates 不超过 8 条。"""

注意:提示词里虽然对输出格式做了要求,但对模型的返回仍要做基础的格式校验——提示词约束不了模型百分百守约:

try:
    output = StoryMemoryExtractionOutput.model_validate(structured)
except (TypeError, ValueError) as error:
    raise StoryGenerationError("记忆提取 Agent 返回的数据格式不正确") from error

7.3 与现有记忆对比:新建、跳过、替换

拿到事实清单后,用 canonical_key 与现有条目对比,有三种结果:

  • 没有相同 key:新建;
  • 内容与现有条目完全相同:跳过(幂等,重试不会产生重复记忆);
  • 相同的 key 但内容变了:替换(旧条目标记 superseded,新条目带上 supersedes_entry_id)。
for fact in new_facts:
    from app.domain.story_bible import normalize_canonical_key
    canonical_key = normalize_canonical_key(fact.canonical_key)
    if canonical_key:
        if canonical_key in seen_keys:
            continue
        seen_keys.add(canonical_key)
        existing = active_by_key.get(canonical_key)
        if existing is not None and existing.content.strip() == fact.content.strip():
            duplicates.append(canonical_key)
            continue
    entry = self.bible_repository.create_entry(
        story_set_id=story_set_id,
        entry_type=fact.entry_type,
        canonical_key=canonical_key,
        title=fact.title,
        content=fact.content,
        meta=_entry_meta(fact),
        importance=fact.importance,
        status="candidate",
        source_episode_id=story_episode_id,
        source_content_hash=content_hash,
    )
    created_ids.append(entry.id)

for update in state_updates:
    canonical_key = normalize_canonical_key(update.canonical_key)
    if not canonical_key or canonical_key in seen_keys:
        continue
    seen_keys.add(canonical_key)
    existing = active_by_key.get(canonical_key)
    if existing is not None and existing.content.strip() == update.content.strip():
        duplicates.append(canonical_key)
        continue
    entry = self.bible_repository.create_entry(
        story_set_id=story_set_id,
        # 状态更新沿用被替代条目的类型,缺失时按 character 兜底。
        entry_type=(
            existing.entry_type if existing is not None else "character"
        ),
        canonical_key=canonical_key,
        title=update.title,
        content=update.content,
        meta=_entry_meta(update),
        importance=update.importance,
        status="candidate",
        source_episode_id=story_episode_id,
        source_content_hash=content_hash,
        supersedes_entry_id=existing.id if existing is not None else None,
    )
    created_ids.append(entry.id)
    if existing is not None:
        superseded_targets.append(
            {"candidate_entry_id": entry.id, "superseded_entry_id": existing.id}
        )

这里的关键是更新而不是无限追加。旧状态"月光钥匙由猫头鹰保管"、新事件"猫头鹰把钥匙交给小兔子"——不能把两条都保留为当前事实,否则模型检索到两条矛盾记忆,一致性比不记还糟。正确做法是把旧条目标记为 superseded、新建当前状态条目,并用 supersedes_entry_id 保留替代关系与来源分集,审计链路不断。

7.4 candidate:AI 提取的记忆不直接生效

此时条目的 status 是 candidate——候选状态,并没有正式生效。

这是刻意的:模型提取的结果不应该直接成为绝对事实。提取是"AI 说这集发生了这些事",而记忆库记录的是"经过确认的事实"。条目需要经过规则检查或人工在管理后台确认后,才会转为 active 参与检索;被判定有争议的进入 disputed,确认不成立的进入 rejected。

这一步看似多了一道工序,但它守住了整套系统的底线:记忆库里只有可信的事实。一旦错误设定以 active 身份进入检索池,它会被后续每一集引用,腐化的不只是这一集。

7.5 写入记忆时生成向量

candidate 确认生效后,后台为该条目生成 Embedding。这一步有几个必须注意的工程细节:

用于向量化的文本不必等于展示文本。 展示给模型看的是一句自然语言事实,但拿去计算向量的文本可以做规范化拼接,让语义信号更集中:

类型:道具
名称:月光钥匙
涉及角色:小兔子
事实:月光钥匙每个自然月只能开启一次星门
状态:当前有效

这种格式比只嵌入一句自然语言更稳定——类型和实体名这些强信号不会被叙事性表述稀释。

不要只保存向量,还要保存"向量的出生证明":原始文本、Embedding 模型名称、向量版本、生成时间。否则将来更换模型时,你无法判断库里的旧向量是否还兼容——这正是表结构里 embedding_model 和 embedding_version 两个字段存在的意义。

生成失败不能丢记忆。 Embedding 服务总有抖动的时候。条目文本先落库,向量状态标记为"待生成",由后台任务异步补齐;检索时遇到没有向量的条目就跳过(工序 2 的过滤条件已经处理了这种情况)。不要因为 Embedding 服务短暂故障而丢失记忆事实——文本是本体,向量只是索引。

向量维度由模型决定。 VECTOR(n) 的 n 要在迁移建表时按选用的 Embedding 模型确定;更换不同维度的模型通常需要新列或新表,新旧向量绝不能直接混合比较。

八、总结:工程边界与落地路线

回顾整套系统,有三条不能妥协的工程底线:

① 权限与数据隔离。 每次检索必须带 story_set_id 做所有权校验,调用方还必须拥有该故事集的查看 / 编辑 / AI 生成权限。向量索引只负责算距离,它不懂用户、租户、草稿与已发布状态——绝不能让 A 用户故事里的角色设定进入 B 用户的故事上下文。pgvector 的相似度索引也不能替代关系型索引,(story_set_id, status)、(story_set_id, entry_type)、(story_set_id, canonical_key) 这些组合索引要单独建。

② 成本只花在变化上。 Embedding 的调用点很多:事实提取、新增记忆向量化、每次生成前的查询向量化、连续性审稿。控制手段是增量:未修改条目的 Embedding 直接复用,相同任务的自动重试复用召回结果,核心规则的格式化文本可以缓存。永远不要"每生成一集就把全部历史重新向量化一遍"——新条目生成新向量、文本改变的重新生成、被替代的退出活跃检索、没变的保持不动。

③ 备份随 PostgreSQL 一起走,恢复要单独验证。 pgvector 数据就是 PostgreSQL 数据,pg_dump / pg_restore 会把记忆文本、元数据、向量一起备份。但恢复后要额外验证:vector 扩展是否已安装、维度是否一致、索引是否恢复、相似度查询能否执行。向量原则上可以从文本重新生成,但备份它依然值得——恢复快、省一次 Embedding 调用、保留了当时模型版本的检索行为。更换 Embedding 模型走双版本迁移:旧向量继续服务,后台生成新版本,离线评估检索效果后再切换,不要先覆盖旧向量再评估。

最后归结成一句话:系列故事真正需要的不是更长的 Prompt,而是一套能持续整理事实、检索相关设定、识别冲突并更新状态的长期记忆系统。 PostgreSQL 让记忆可信、可追溯,pgvector 让记忆可以按语义被找回,而 Story Bible 决定 AI 究竟应该记住什么。