一、为什么系列故事需要"长期记忆"
在故事机项目里,故事有两个维度:一个是故事内容本身,一个是系列故事集。小朋友的想象力是无穷的------也许你给他一只小马,他就能顺着编出一整个"小马宝莉"式的系列故事。
系列故事集与单篇故事不太一样。单篇故事可以任意、天马行空、不受约束;故事集就必须有一些约束条件,否则很容易产生一致性问题。一个连贯、情节走向合理的故事集,理论上需要一个"总编辑"来把控全局。这个总编辑需要长期记住:
- 主角叫什么、长什么样;
- 角色之间是什么关系;
- 故事世界遵循什么规则;
- 哪些事件已经发生过;
- 角色拥有什么物品和能力;
- 哪些设定绝对不能被后续分集推翻;
- ......
常见的一致性问题有这几类:
- 第一集主角叫小美,第三集变成了小帅;
- 第四集和第五集的故事内容大面积相似;
- 道具的能力在不同分集里发生了变化;
- 角色关系前后不一致。
我们用大模型帮我们创作故事,但大模型本身没有记忆功能------每次调用本质上都是一次全新的上下文。所以要给故事生成 Agent 加上一套长期记忆。用一个公式概括:
本集创作要求 + 传入上下文 = 模型本轮能够使用的记忆。
本文通过分享故事集记忆的实践过程,介绍 Agent 长期记忆的作用以及如何构建它。要解决的问题包括:
- Story Bible 应保存哪些信息;
- 如何把故事长期记忆存进 PostgreSQL;
- 如何使用 pgvector 检索相关记忆;
- 如何在生成新分集时组装 RAG 上下文;
- 如何在新故事生成后更新长期记忆;
- 如何处理冲突、过期、重试与人工修订。
二、故事机已有的记忆能力:V1 的"穷办法"
在第一版故事机里,我用"上下文 + 已有故事摘要 + 提示词约束"的方式给故事机加了一个初步的记忆能力。生成上下文包括:故事集标题、故事主题、故事设定与简介、目标年龄段、本集创作想法、已有分集摘要等信息。提示词模板如下(模板较长,重点看第 5、6 条约束):
python
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 生成,提取规则是逐级兜底:
- 优先取分集摘要
description; - 如果没有摘要,取分集创建时的想法提示词
idea_prompt; - 如果以上均无,取故事正文前 200 个字符。
python
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 保存文本的向量表示,并按相似度查询:
为什么不只用关键词搜索,或者只用向量数据库?
- 为什么不能只用关键词搜索? 两句话表达不同但语义相似("钥匙放在小兔子那里" vs "月光钥匙由小兔子保管"),关键词完全关联不上。记忆召回恰恰是语义问题。
- 为什么不能只用向量数据库? 查询不止于语义查询。"这条记忆属于哪个故事集""是否已失效""来自哪一集"------这些精确的结构化关联,还是需要关系数据库来管。
所以最终的分工是:
PostgreSQL 负责判断这条记忆属于谁、是否有效、来自哪里;pgvector 负责判断这条记忆与本次创作有多相关。
五、存储:一张表 + 一条粒度原则
5.1 表结构
Story Bible 的全部记忆放在一张 story_bible_entries 表里:
sql
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_rule |
character |
canonical_key |
逻辑对象的唯一标识。不同的表达可能是同一个逻辑对象,靠它关联、防重复 | item:moonlight-key |
content |
记忆内容,保存可直接给模型阅读的自然语言事实 | 月光钥匙由小兔子保管,每个自然月只能开启一次星门。 |
metadata |
结构化属性:涉及角色、道具归属、出自第几集等 | 见下方 JSON |
importance |
重要程度,0~100 | 80 |
status |
记忆状态:有效(active)/ 已被取代(superseded)/ 有争议(disputed)/ 已归档(archived) |
active |
source_episode_id |
来源分集 ID,标识这条记忆是从哪一集提炼的 | 1 |
metadata 的实际结构:
json
{
"character_names": ["小兔子"],
"item_name": "月光钥匙",
"current_owner": "小兔子",
"usage_limit": "每月一次",
"episode_no": 3
}
importance 在项目里按分数段划分:
| 分段 | 含义 |
|---|---|
| 90~100 | 不可违反的核心设定 |
| 70~89 | 重要角色或连续剧情事实 |
| 40~69 | 普通历史事件 |
| 低于 40 | 氛围和细节信息 |
5.2 粒度原则:按事实单元组织,而不是按文档切块
行业里通用的文档切分方式有很多:按固定字符数、按固定 token 数、按段落、语义切分、LLM 驱动切分......但记忆库不是 RAG 知识库,照搬"切块"思路会踩坑------任何片段切法都容易把一个情节切得支离破碎,事实变得不完整、不清晰。
这个场景里的记忆粒度应该更抽象一层:以"事实单元"来组织。每条记忆应满足以下约束:
- 能被独立理解,不依赖上下文;
- 构成一个完整事实;
- 指明涉及的实体;
- 包含必要的时间与分集信息;
- 能判断它现在是否仍然有效。
在这个粒度下,记忆库里一定要存三类信息:
- 分集摘要:帮助模型理解整体剧情走向;
- 原子事实:精确维持一致性(主角是谁、钥匙归谁、规则是什么);
- 未完成线索:推进长期剧情的钩子。
六、读取:一次检索要经过四道工序
读取是整套系统里最复杂的一段。整体流程分四道工序:
工序 1:构造检索问题,生成查询向量
检索问题不是"把本集想法原样丢进去",而是拼上分集序号和年龄段,让查询带上"这是第几集、给多大孩子看"的语境,使检索问题带上比较完整的约束条件:
python
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。
python
try:
query_vector = self.embedding_client.embed([query_text])[0]
except EmbeddingError as error:
# Embedding 服务不可用时不能让生成流程挂掉:
# 记忆照常保存,检索降级为纯结构化策略(见 retrieval_meta.strategy)。
...
工序 2:结构化过滤,先缩小范围再算相似度
相似度计算是有成本的,计算之前先把范围缩小,能减少大量无谓的比较。项目里的过滤条件有三个:
- 只看本故事集------别的故事集的记忆与本次创作毫无关系;
- 只看有效记录 ------已经过期或失效(
superseded/archived)的记忆不参与计算; - 向量必须已就位 ------
embedding为空或状态不是ready的条目,算了也是白算。
过滤条件和相似度检索写在同一条 SQL 里:
python
"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"
同时,固定注入区还需要一份结构化召回池------本故事集下所有有效条目,按重要度降序取出:
python
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 检索 |
| 本集输入 | 用户本次的创作要求 | 不单独成区,见下 |
固定注入解决的是"绝对不能忘"的问题------核心设定不参与排序竞争,直接按重要度截取:
python
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,
)
最近发生是剧情的连续性保证:
python
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:混合排序、去重与组装
语义召回的候选不能只按向量距离排。项目里用多信号加权打分:
scss
分数 = 语义(0.5) + 重要度(0.3) + 新鲜度(0.2) + 类型命中(+0.1) + 线索加成(+0.05)
python
# 工序 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 天权重减半:
python
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------记录这次检索用了什么策略、池子多大、查了多长,方便排查"为什么没召回":
python
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 时不直接倾倒数据库记录,而是按固定分块输出------模型对【分区标题 + 列表】的遵守程度远高于一串混杂条目:
python
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)
注入效果(示意):
text
系列长期记忆(Story Bible,写作时必须保持一致,不得推翻):
【系列核心设定】
- 小兔子:善良、谨慎,害怕打雷,目前保管月光钥匙。
【世界与道具规则】
- 月光钥匙每个自然月只能开启一次星门。
【未完成线索】
- 小星星失踪前留下的银色羽毛,来历尚未解释。
两个细节值得强调:分区顺序由稳定到动态 (设定 → 规则 → 剧情 → 线索),与"哪些更不能变"的优先级一致;最前面的硬声明把记忆定位成约束性事实而非创作建议,避免模型把它当成可以自由发挥的参考材料。
接入工作流
在 LangGraph 工作流里新增一个记忆检索节点,插在 analyze_requirements 与 plan_story 之间:
python
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 不只是"读旧记忆",还必须把新一集转化为未来可用的记忆,否则记忆库永远停在第一集。写入流程如下:
7.1 触发时机:等内容稳定了再提取
记忆提取的时机,应当是待提取的内容相对稳定 的时候。草稿改来改去时提取,只会提取出一堆马上就过时的记忆。故事机场景里选择在分集审核通过并保存之后触发任务注册:
python
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 从队列取出任务后,调用模型从故事内容与上下文中抽取结构化关键信息,拆分出事实清单:
python
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)防止一次性灌进一堆低质量记忆:
python
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 条。"""
注意:提示词里虽然对输出格式做了要求,但对模型的返回仍要做基础的格式校验------提示词约束不了模型百分百守约:
python
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)。
python
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。这一步有几个必须注意的工程细节:
用于向量化的文本不必等于展示文本。 展示给模型看的是一句自然语言事实,但拿去计算向量的文本可以做规范化拼接,让语义信号更集中:
text
类型:道具
名称:月光钥匙
涉及角色:小兔子
事实:月光钥匙每个自然月只能开启一次星门
状态:当前有效
这种格式比只嵌入一句自然语言更稳定------类型和实体名这些强信号不会被叙事性表述稀释。
不要只保存向量,还要保存"向量的出生证明" :原始文本、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 究竟应该记住什么。