Ch14 · 三层记忆与断点续跑
覆盖提交 :
f51f19f(011,多层记忆机制一次引入) ·a046981(028,P0 记忆系统止血------身份贯通 / 原子取号 / 缓存历史空洞修复) ·04e290e(030,断点恢复修复) 代码位置 :GitHub 搜索lingluo1hao / enterprise-ai→memory_store.py(class MySQLMemoryStore:145·_json_default:92·_json_object_hook:115·save_message:365·load_messages:404·clear_messages:469·save_checkpoint:490·load_latest_checkpoint:547·get_unfinished_tasks:941·save_summary:1012·mark_interrupted_tasks:1075)·langgraph_rag_agent.py(HISTORY_MAX_TURNS:124·HISTORY_COMPRESS_TURNS:129·_active_context:670·_append_history:1244·_compress_history:2392)·advanced_rag_agent.py(class CacheManager:246)·rag_web_server.py(_derive_session_id:63) 难度 :★★★★☆ 阶段 :Day 3 -- Day 8 关键词:三层记忆 / 滑动窗口 / 摘要压缩 / 缓存空洞 / 原子取号 / 断点快照 / 反序列化 / 降级模式
本章导读
"AI 记不住上下文"这句话,通常有三层误会。
第一层误会最流行:以为是窗口不够大 。可是这个项目的上下文窗口约 32K tokens,一轮问答顶多占几百------窗口根本不是瓶颈。
第二层误会有点道理:历史越堆越长,得压缩。项目确实压缩了:超过 8 轮(16 条消息)就把旧消息交给模型压成一段摘要。这一步没问题。
第三层误会才是本集的主角:你以为每一轮都写进了历史,其实不是。 有一段路径------缓存命中的那一轮------写的是 return cached,在"写历史"那个节点之前就返回了。于是那一轮问答永远不存在于历史里。
后果是这样的:
你问 A(缓存没命中)→ 正常入库。 你问 B(缓存命中了 )→ 直接返回,这一轮没写进历史 。 你问 C:"刚才说的 B 再展开讲讲" → 模型看到的历史里根本没有 B。
而这一切不报错、不打日志、界面完全正常。 你会以为模型"记性差",其实它从来没见过那条消息。
这一集就是要把这件事讲清楚:先看清"三层记忆"到底是几个模块(答案:三个,而且互不相干 ),再讲长对话的两道处理,然后讲三个把记忆整坏的 P0 bug,最后讲断点续跑------以及它一个非常反直觉的真相:它做的是"重放",不是"接着跑"。
本集学习目标
| # | 目标 | 达标标准 |
|---|---|---|
| 1 | 说清"三层记忆"是哪三层、归谁管 | 能指出 L1 是 langgraph_rag_agent.py:670 的一个字典 、L2 是 memory_store.py:145 的一个类 、L3 是 advanced_rag_agent.py:246 的 CacheManager ;并能说出三者之间没有任何一致性协议 |
| 2 | 说出长对话的两道处理与参数 | ① 滑动窗口 limit=50;② 摘要压缩:超 16 条触发 (HISTORY_MAX_TURNS = 8 × 2)、保留 12 条 (HISTORY_COMPRESS_TURNS = 6 × 2) |
| 3 | 讲出三个 P0 记忆 bug 的现象 / 根因 / 修法 | ① 缓存命中 → 该轮永不入库 ;② 摘要只存内存 、重启即失;③ msg_order 并发撞号 |
| 4 | 画出断点续跑六步,并指出第 6 步与代码不符 | 能指出 resume() 的实际动作是 invoke(restored_state),图从 START 重放 ------状态恢复 ≠ 执行恢复 |
📐 理论基石:记忆不是一种东西,是三种(P0-05 §五「记忆三型」)
认知科学把记忆分成三类,P0-05 把它们和工程落点对上了:
| 类型 | 存什么 | 本项目落点 |
|---|---|---|
| 短期记忆 | 当前对话上下文 | 上下文窗口 |
| 长期记忆 | 跨会话的用户 / 项目知识 | 向量库 + 摘要压缩 |
| 程序性记忆 | "怎么做某类任务"的流程 | Playbook / 经验缓存 |
P0-05 §五 的末段直接点名了本章:
「Ch10 的「三层记忆与断点续跑」,就是这三类在工程上的实现:短期=上下文,长期=摘要落库,程序性=可复用流程。丢失长期记忆的 Agent 每次都「从零开始」,体验差。」
⚠️ 引文里的 "Ch10" 是 P0 文档写作时的编号,引文保持原样不动 。本集编号以 课程表_第06集起.md 为准:第 14 集 · 三层记忆与断点续跑 · 30 min。
带一句话进第一部分 :这张表是"应该长成什么样"。真去代码里看,会发现三种记忆的载体,压根不在一个地方。
第一部分 · "三层记忆"的真相:三个互不相干的模块
1.1 模块自己说自己是"第二层"
打开 memory_store.py,文件开头的 docstring 就给了架构(:10-16):
css
三层记忆架构:
Layer 1 内存(_active_context) --- 最快,重启丢失(已有,保留)
Layer 2 MySQL(本模块) --- 持久化,重启不丢(★新增)
Layer 3 Redis(CacheManager) --- Q&A 缓存(已有,保留)
看着挺规整。但"三层"这个词会骗人 ------它暗示有一个统一的东西分成三层。实际不是。
1.2 三个模块,分别在三个不同的文件里
| 层 | 是什么 | 在哪儿 |
|---|---|---|
| L1 内存 | 一个字典,不是类 | langgraph_rag_agent.py:670:self._active_context: Dict[str, List[Dict]] = {} |
| L2 MySQL | 一个类 | memory_store.py:145:class MySQLMemoryStore: |
| L3 Redis | 另一个文件里的类 | advanced_rag_agent.py:246:class CacheManager: |
L1 连类都不是 ------它就是 LangGraphRAGApp 的一个实例属性,字典而已。三个模块没有共同的基类、没有共同的接口、没有任何一个"记忆总闸"。
1.3 它们之间没有一致性协议
这是本部分最该带走的一句话。三个具体的证据:
证据一:清了历史,缓存还在。 clear_messages() 的实现只有一条 SQL(memory_store.py:469-489):
python
cursor.execute(
"DELETE FROM chat_messages WHERE user_id = %s AND session_id = %s",
(user_id, session_id),
)
它只删 MySQL 那一份 。L1 内存字典和 L3 Redis 缓存原封不动 ------用户"清空对话"之后,再问同一个问题,缓存还会命中,还能给出旧答案。
证据二:L1 和 L2 的先后顺序,是手写的。 写入靠 _append_history(langgraph_rag_agent.py:1244)里一段普通代码:先算压缩、再有条件地写 MySQL、最后写内存。没有事务------如果写 MySQL 那一步抛异常,内存那一份已经更新了,两边就不一致。
证据三:Redis 那一层只有 TTL。 它没有主动失效机制,只能等过期时间自己到。
结论 :不是"三层记忆需要增强",而是三个模块各写各的 。当你听到"我做了三层记忆架构"时,值得追问一句:这三层之间,谁来保证一致?
第一部分小结 · 对照自查
- 能说出 L1 / L2 / L3 各自的类型 (字典 / 类 / 类)和文件位置吗?
- 能举出至少一个 "模块间不一致"的具体现象吗?(
clear_messages()不碰 Redis) - "三层记忆"这个说法,哪里会让人误解?(它暗示统一,实际是三个独立模块)
第二部分 · 长对话怎么不撑爆窗口:两道处理
2.1 第一道:滑动窗口,只取最近 50 条
load_messages()(memory_store.py:404)的签名是:
python
def load_messages(self, session_id: str, limit: int = 50, user_id: int = 0) -> List[Dict]:
SQL 是 ORDER BY msg_order DESC LIMIT 50,取完再 reversed() 翻回正序。这道处理解决"读"的问题------不管历史多长,读出来最多 50 条。
2.2 第二道:摘要压缩,触发线 16 条、保留 12 条
第二道解决"写"的问题。两个常量(langgraph_rag_agent.py:124 和 :129):
python
HISTORY_MAX_TURNS = 8 # 超过 8 轮触发压缩
HISTORY_COMPRESS_TURNS = 6 # 压缩后保留最近 6 轮
换算一下(一轮 = 一问一答 = 2 条消息):
| 量 | 计算 | 值 |
|---|---|---|
| 触发线 | HISTORY_MAX_TURNS × 2 |
16 条消息 |
| 保留条数 | HISTORY_COMPRESS_TURNS × 2 |
12 条消息 |
| 被压缩的 | 16 − 12 | 4 条(最早的) |
触发点在 _append_history 里(:1261):每追加一轮就数一下,len(ctx) > 16 就压。
2.3 摘要长什么样、放在哪
压缩函数是 _compress_history(:2392)。它的返回结构是这句话最关键的部分:
python
return [{"role": "system", "content": f"历史摘要: {summary}"}] + recent
摘要不是"替换"历史,而是作为一条 system 消息,接在最近 12 条消息的最前面。 最终喂给模型的消息序列是:
csharp
[system] 历史摘要: ...... ← 压缩出来的
[user] 第 13 轮的问题
[assistant] 第 13 轮的回答
...(共 12 条真实消息)
2.4 ★ 一个反直觉的修正:摘要不替代原文
load_messages() 里有一段注释,是这一部分最值钱的东西(memory_store.py:439-441):
始终返回最近 limit 条(不再用 covers_to 屏蔽 ,否则历史会被越压越短,重启后只剩最后一轮)。摘要仅作为"前情提要"前缀,不替代原始消息本身。
这句话在纠正一个很自然的错误设计 :既然有摘要了,那摘要覆盖过的那批原始消息是不是就不用读了?
不是。 如果按 covers_to 去屏蔽("摘要盖到第 40 条,那第 40 条之前的就别读了"),会产生一个越用越短的雪球 :每次压缩都把"可读范围"往后推一截,重启几次之后,历史里只剩最后一轮。
正确做法:摘要当"前情提要"用,原文照读。两个都留着。
第二部分小结 · 对照自查
- 两道处理分别解决"读"还是"写"?(窗口解决读,压缩解决写)
- 触发线是多少条、保留多少条、一次压掉几条?(16 / 12 / 4)
- 摘要用什么角色、放在什么位置?(
system,放最前) - 为什么不按
covers_to屏蔽原始消息?(会越压越短,重启后只剩最后一轮)
第三部分 · 三个 P0 记忆 bug:记忆是坏的
3.1 先看清单
| # | bug | 现象 | 严重度 |
|---|---|---|---|
| ① | 缓存命中造成历史空洞 | 引用"刚才说的"时模型看不见 | fatal |
| ② | 摘要只存内存、重启即失 | 压缩白做 | fatal |
| ③ | msg_order 并发撞号 |
同会话消息顺序错乱 | fatal |
三个都 fatal,而且三个都不报错。
3.2 Bug ①:缓存命中,这一轮就不写历史了
原代码的形状是这样的:
python
cached = self.cache.get(question)
if cached:
return cached # ← 直接返回
问题出在"返回"这两个字的位置。 这个 return 在创建任务之前、整张图之前 。而"把这一轮写进历史"这件事,是由图里的 node_save_history 节点(间接走 _append_history)做的。
于是:缓存命中的这一轮,永远走不到那个节点。
推演一遍:
| 轮次 | 缓存 | 写历史了吗 | 模型下一轮能看到吗 |
|---|---|---|---|
| 问 A | 未命中 | ✅ 写了 | 能 |
| 问 B | 命中 | ❌ 没写 | 不能 |
| 问 C:"刚才的 B 再说说" | --- | --- | 历史里没有 B |
修复只有一行------在返回之前,先把这一轮写进历史 (langgraph_rag_agent.py:2745):
python
# P0 止血 3.3:缓存只跳过「推理」,不跳过「记忆」。
# 否则命中缓存的这一轮永不入库,对话历史出现空洞(模型看不到刚问过的内容)。
self._append_history(session_id, question, cached, user_id=self.user, cached=True)
return cached
"缓存只跳过推理,不跳过记忆"------这句话值得抄在本子上。它的普适形式是:
凡是"为了加速而提前返回"的路径,都要问一句:这条路上,有没有本该发生的副作用被跳过了?
3.3 Bug ②:摘要只活在内存里
第二部分的压缩逻辑没问题,问题在压缩产物存在哪。
压缩发生在 _append_history 里,产物写进了 ctx------而 ctx 是 L1 内存字典 。压缩完没落库。 于是:服务一重启,L1 清空,load_messages() 从 MySQL 读------读回来的只有没被压缩过的原始消息,那段摘要凭空消失。
修法两步:
- 建表
chat_summaries,字段含user_id / session_id / summary / covers_from / covers_to / msg_count / importance; - 读的时候优先回放摘要 :
load_messages()先查最新一条摘要,把它 prepend 成system消息,再接原始消息。
代码上就是这两句(memory_store.py:428-441 区间):
python
if summary:
messages = [{"role": "system", "content": summary}] + messages
3.4 Bug ③:msg_order 并发撞号
第三个别看它小,它是并发版的经典错误。原逻辑是"先查再写":
sql
请求 1:SELECT MAX(msg_order) → 17
请求 2:SELECT MAX(msg_order) → 17 ← 同时读到 17
请求 1:INSERT ... msg_order = 18
请求 2:INSERT ... msg_order = 18 ← 撞了
根因 :取号和写入是两步,两步之间别人可以插进来。
修法是把两步合成一条 SQL,让数据库自己保证原子性:
python
"INSERT INTO chat_messages (...) "
"SELECT %s, %s, %s, %s, COALESCE(MAX(msg_order), 0) + 1 "
"FROM chat_messages WHERE user_id = %s AND session_id = %s"
MAX(...) + 1 在 SELECT 里算,取号与插入是同一条语句 ,中间没有缝。同样手法也用在断点快照的 checkpoint_order 上(save_checkpoint:490-546)。
3.5 三个 bug 的共同点
它们都不抛异常。
- ① 历史空洞 → 模型答得"好像有点道理",但少了上下文;
- ② 摘要丢失 → 重启后"感觉 AI 变笨了";
- ③ 顺序错乱 → 消息还在,只是顺序不对。
没有任何一条会在日志里留下红字。 这也是"记忆系统"这类模块最难写的地方:它的失效方式是"表现变差",不是"报错"。
第三部分小结 · 对照自查
- 三个 bug 分别用一句话说清"少了什么"?(历史 / 摘要 / 顺序)
- Bug ① 的修复为什么只要一行?(把被跳过的副作用补回来)
- Bug ③ 为什么"一条 SQL"就能解决?(原子性)
- 三个 bug 的共同点是什么?(都不报错)
第四部分 · 断点续跑:四张表、六步、一个反直觉的真相
4.1 表其实不止三张
模块 docstring 写的是"三张 MySQL 表"(memory_store.py:18-22):chat_messages / task_checkpoints / task_queue。
但 _verify_schema() 实际校验的清单是 6 张 (:258-259):
| 表 | 干什么 | docstring 提了吗 |
|---|---|---|
chat_messages |
对话历史 | ✅ |
task_checkpoints |
断点快照 | ✅ |
task_queue |
任务状态 | ✅ |
chat_summaries |
摘要 | ❌(P0 之后才加的) |
prompt_templates |
提示词 | ❌ |
admin_users |
账号 | ❌ |
这是一处典型的"文档漂移" :代码往前走了,文件头那段介绍停在原地。读代码时,注释和 docstring 只能当线索,不能当结论。
4.2 断点续跑的六步
docstring 给了完整流程(:20-29),六步:
- 用户提问时,先在
task_queue建一条status=running的任务; - 每个节点执行完 ,把当前 state 序列化成 JSON,存进
task_checkpoints; - 如果服务宕机 / 用户关客户端,
task_queue里那条还是running; - 下次登录,查
status=running的任务; - 读
task_checkpoints里最后一条快照,把 state 恢复出来; - "从中断的节点继续执行(不需要重新分类、检索)"。
4.3 ★ 但第 6 步,和代码不一致
第 6 步说"从中断的节点继续执行"。代码不是这么干的。
resume() 的实际动作是:恢复 state → graph.invoke(restored_state, {"recursion_limit": 50})。
而在这个项目里,compile() 是不带参数 的------没有挂检查点器。也就是说:
图拿到恢复后的 state,然后从
START重新开始跑。
每一轮要跑的节点,从头再来一遍 。所谓"不需要重新分类、检索"------分类和检索恰恰是会重跑的(它们只是拿着已经填好的 state,算得快一点)。
正确说法是两句话:
| 说法 | 对不对 |
|---|---|
| "状态恢复了" | ✅ 对------state 里的字段确实被捞回来了 |
| "从断点继续执行" | ❌ 不对------图还是从起点跑 |
| "状态恢复 ≠ 执行恢复" | ✅ 这才是准确的那句 |
4.4 快照里存的"形状",有个坑
save_checkpoint() 要存的是整份 state。而 state 里装着 retrieved_docs------它的元素是 (Document, score) 元组。
Document 对象不能直接 json.dumps;元组也不行(JSON 里没有元组)。
所以模块写了两个适配器(memory_store.py:92 和 :115)做打标记再还原:
python
# 存的时候:Document → dict,tuple → list,都打上 __type__ 标记
{"__type__": "Document", "page_content": "...", "metadata": {...}}
{"__type__": "tuple", "items": [...]}
读的时候再用 _json_object_hook 把标记还原回 Document 和 tuple。
4.5 不还原成元组会怎样
load_latest_checkpoint() 的 docstring 把这个后果写得很直白(:558-560):
否则后续节点访问
d[0].page_content会报'str' object has no attribute。
为什么会报这个错? 因为如果元组被还原成了普通 list(或者更糟,被降级成了字符串),后面的节点仍然按"取第 0 个元素再取 .page_content"去写:
- 还原对了 →
d[0]是Document,.page_content有值; - 还原成字符串 →
d[0]是那个字符串的第 0 个字符 ,字符当然没有.page_content→ 报错。
这是一类很典型的坑:序列化的难点从来不在"存下去",而在"读回来之后,还是原来那个类型吗"。
第四部分小结 · 对照自查
- 表实际有几张?docstring 说几张?(6 / 3------文档漂移)
- 六步里第几步与代码不符?不符在哪?(第 6 步;图从
START重放) - 快照为什么需要
__type__标记?(Document和元组都不能直接进 JSON) - 反序列化时"类型没还原"和"直接报错",哪个更危险?(前者:不报错,但后面某个节点才炸)
第五部分 · 容错、隔离与边界
5.1 MySQL 挂了会怎样:降级,不阻断
docstring 的容错策略写得很明确(memory_store.py:31-33):
如果 MySQL 不可用,自动降级为内存模式(与旧版行为一致),打印警告但不阻断服务。所有方法都有 try-except 兜底。
落地方式是一个布尔开关 self.available,加上三套内存 fallback 容器:
| fallback | 对应表 |
|---|---|
_fallback_history |
chat_messages |
_fallback_checkpoints |
task_checkpoints |
_fallback_tasks |
task_queue |
available = False 时,所有方法走内存分支。代价是重启就丢------但对"服务能起来"这件事来说,这个交换是划算的。
这里能看出本集一个被迫的设计选择 :因为数据不出内网 ,记忆只能落在自建的 MySQL 里(不能用云托管的记忆服务),所以"MySQL 挂了怎么办 "就成了一个必须自己回答的问题------降级模式不是加分项,是自建路线的必答题。
5.2 服务重启时,谁去把 running 改掉
宕机时正在跑的任务,状态停在 running。没人来得及改它。 所以启动时要主动扫一遍------mark_interrupted_tasks(session_id)(:1075):
sql
UPDATE task_queue SET status = 'interrupted'
WHERE session_id = %s AND status = 'running'
传 None 就标记所有会话。为什么要有这一步? 因为"running"和"interrupted"对用户是两种不同的提示------一个说"正在跑",一个说"上次中断了"。状态名要如实。
5.3 为什么查询要查两种状态
get_unfinished_tasks()(:941)的 SQL 条件是:
sql
WHERE user_id = %s AND session_id = %s
AND status IN ('running', 'interrupted')
为什么两个都要查? 两种情况都代表"没跑完",只是原因不同:
| 状态 | 含义 |
|---|---|
running |
服务突然宕机,还没来得及标记(重启扫库之前) |
interrupted |
已经被 mark_interrupted_tasks 标记过 |
只查其中一个,就会漏掉另一类。
5.4 ★ 隔离:别人的断点,恢复不了
同一个方法里,WHERE 的两个条件值得单独拎出来:
sql
WHERE user_id = %s AND session_id = %s
user_id 这一条,是一道安全线。 没有它,按 session_id 查(或者干脆按 task_id 查)就可能让 A 用户恢复 B 用户的断点任务 ------而断点快照里装着对方检索出来的文档内容 。这不是"体验问题",是数据越权。
同理,load_messages / clear_messages / save_message 全部 带 user_id。
5.5 会话身份从哪来
会话 ID 不是随便生成的字符串,而是按用户 + 角色派生 的(rag_web_server.py:63-73):
python
return f"web:{safe_role}:{safe_user}"
注意顺序是 role 在前、user 在后 。派生之前还做了两件事:把非字母数字字符替换成 _(防注入 / 防奇怪字符),并且截断长度(user 32 位、role 16 位)。
为什么要按用户派生? 因为如果所有人共用一个写死的会话 ID(比如 "web_session"),那所有人的历史会混在一起。
5.6 一处"列建了但没用"
chat_summaries 表里有 covers_from 列,但 save_summary() 写进去的时候,这个位置硬编码传的是 0 (memory_store.py:1035-1039):摘要表记了"覆盖到哪"(covers_to),却没记"从哪开始覆盖"。
这不是 bug------是没落地。 列建了、没人用、也没人因此出错。之所以要讲它,是因为它和 4.1 的"文档说三张表、实际六张"是同一类现象:表结构往前走了一步,语义没跟上。 读数据库的时候,"这个列存在"和"这个列有意义"是两件事。
5.7 本集的边界
- 跨会话的长期记忆召回,没做。
chat_summaries里预留了embedding列,但没填 ,语义检索也没接。所以"用户三个月前问过 X"这件事,现在还召回不到。 - 本集不涉及意图识别、限流、网关------那些是后面几集的事。
第五部分小结 · 对照自查
- MySQL 不可用时,服务会怎样?(降级内存模式,打印警告,不阻断)
- 为什么查询要同时查
running和interrupted?(两种都代表没跑完,只查一个会漏) WHERE user_id = %s这一条防的是什么?(越权恢复别人的断点)- "列存在"和"列有意义"是不是一回事?(不是------
covers_from恒为 0)
踩坑总表
| # | 坑 | 现象 | 根因 | 严重度 |
|---|---|---|---|---|
| 1 | 缓存命中跳过写历史 | 引用"刚才说的"时模型看不见 | return cached 早于写历史节点 |
fatal |
| 2 | 摘要只存内存 | 重启后压缩白做 | 压缩产物没落库 | fatal |
| 3 | msg_order 并发撞号 |
同会话消息顺序错乱 | 取号与写入分两步、非原子 | fatal |
| 4 | 把断点恢复当成"接着跑" | 分类、检索其实重跑了一遍 | invoke(restored_state),图从 START 起 |
moderate |
| 5 | 快照少了类型标记 | 后续节点报 'str' object has no attribute |
元组 / Document 反序列化没还原 |
moderate |
| 6 | docstring 说"三张表" | 按三张去找 | 文档停在 P0 之前 | minor |
| 7 | clear_messages() 不清 Redis |
清了历史,缓存还在 | 模块间无一致性协议 | minor |
对应解法速查
python
# 坑 1:提前返回的路径,先把副作用补上
self._append_history(session_id, question, cached, user_id=self.user, cached=True)
return cached # langgraph_rag_agent.py:2745
# 坑 3:取号与写入合成一条原子 SQL
"INSERT INTO chat_messages (...) SELECT %s, ..., COALESCE(MAX(msg_order), 0) + 1 "
"FROM chat_messages WHERE user_id = %s AND session_id = %s"
# 坑 5:序列化打标记 + 反序列化还原(不能只做一半)
{"__type__": "tuple", "items": [...]} # memory_store.py:92 存
tuple(d.get("items", [])) # memory_store.py:115 读
# 坑 4:恢复时问一句"图从哪个节点开始跑"
graph.invoke(restored_state, {"recursion_limit": 50}) # 从 START 重放
学习目标回顾
- ✓ 目标 1 · 说清三层记忆是哪三层、归谁管 ------你拿下了:L1 是字典(
:670)、L2 是类(:145)、L3 是另一个文件的CacheManager(:246),而且三者没有一致性协议。 - ✓ 目标 2 · 说出长对话的两道处理与参数 ------你拿下了:窗口
limit=50;压缩16 条触发、保留 12 条。 - ✓ 目标 3 · 讲出三个 P0 记忆 bug ------你拿下了:历史空洞 / 摘要不落库 / 并发撞号,三个都不报错。
- ✓ 目标 4 · 画出六步并指出第 6 步不符 ------你拿下了:状态恢复 ≠ 执行恢复 ,图从
START重放。
知识点卡片
【知识点】提前返回的路径,最容易漏掉副作用
return cached本身没错,错在它早于 "写历史"那个节点。缓存只跳过推理,不跳过记忆。 检查方法:把所有return/continue找出来,逐个问"这条路上,本该发生的事都发生了吗?"【知识点】上下文压缩的正确姿势:摘要当前情提要,不替代原文
摘要用
system角色放最前,原始消息照读 。如果按"摘要覆盖范围"去屏蔽原文,会得到一个越用越短的历史------重启几次之后只剩最后一轮。【知识点】取号和写入,必须在同一条 SQL 里
"先
SELECT MAX(...)再INSERT"在单线程下永远对,一并发就撞 。INSERT ... SELECT COALESCE(MAX(x), 0) + 1把两步合成一步,把原子性交给数据库。【知识点】序列化的难点在"读回来还是不是原来那个类型"
Document/ 元组不能直接进 JSON,要打__type__标记。忘了还原不会在存的时候报错 ------它会在后面某个节点 以'str' object has no attribute的形式炸出来。【知识点】"三层记忆"的追问:谁保证一致?
三个模块、三个文件、没有共同基类、没有事务、没有统一失效。下次听到"我做了三层记忆",就问这一句。
练习题
基础题
第 1 题:三层记忆里,哪一层是"字典"而不是"类"?它在哪个文件、哪个变量名?
答案
L1 内存层 ,在 langgraph_rag_agent.py:670:
python
self._active_context: Dict[str, List[Dict]] = {} # Layer 1: 内存加速
它是 LangGraphRAGApp 的实例属性,不是独立模块------这也正是"三层记忆其实是三个各写各的模块"这条结论的第一个证据。
第 2 题 :用户的对话进行到第 17 轮(已触发压缩)后,load_messages() 返回的消息列表,第一条是什么角色、内容大致是什么?
答案
第一条是 system 角色 ,内容是被压缩出来的摘要(形如 历史摘要: ......)。
因为 load_messages() 会先查 chat_summaries 里最新一条摘要,然后:
python
if summary:
messages = [{"role": "system", "content": summary}] + messages
后面接的仍然是最近 50 条原始消息 ------摘要只是"前情提要",不替代原文。
进阶题
第 3 题 :项目里除了 node_save_history,还有没有别的路径会写对话历史?如果要新增一条写历史的路径,必须同时改哪几处,才能不出现"历史空洞"?
答案
写历史统一收敛到 _append_history() (langgraph_rag_agent.py:1244)------它一次干三件事:压缩判断 → 写 L2 MySQL → 写 L1 内存。
现在有两处调用点:
- 正常路径:
node_save_history里(:1315); - 缓存命中路径 :
:2745------这正是 Bug ① 的修复,即"缓存只跳过推理,不跳过记忆"。
新增路径时必须同时改三处:
- 写 L2(MySQL)------否则重启丢;
- 写 L1(内存)------否则同一进程内后续轮次读不到;
- 压缩判断 + 摘要落库------否则摘要只在内存里(Bug ② 的形态)。
最稳的做法不是"记得改三处",而是让所有路径都走 _append_history 这一个入口。
思考题
第 4 题 :断点恢复时,如果元组 (Document, score) 被还原成了字符串而不是元组,为什么这比"直接报错"更危险?
答案
因为报错会立刻暴露,而类型错不会。
- 如果
load_latest_checkpoint()直接抛异常 → 恢复失败,当场就知道; - 如果它"成功"返回了一个字符串(或者 list 里套字符串)→ 恢复流程看起来一切正常 ,
state也填好了,图继续往下跑......
直到某个后续节点 执行 d[0].page_content 才炸:
d[0]取到的是字符串的第 0 个字符;- 字符没有
.page_content→'str' object has no attribute。
这时的报错点,离真正的病因(反序列化没还原类型)已经隔了很远 ------排查时你会盯着那个节点看,而问题其实在几层之前的 _json_object_hook 里。
这类"延迟暴露的类型错误",是所有序列化 / 缓存 / 断点功能的共同风险 :它们让错误跨越了时间边界,把"当场失败"变成了"下次启动才失败"。
本章小结
| 收获 | 内容 |
|---|---|
| 一个真相 | "三层记忆"是三个互不相干的模块 :L1 字典(langgraph_rag_agent.py:670)、L2 类(memory_store.py:145)、L3 CacheManager(advanced_rag_agent.py:246),无一致性协议 |
| 两道处理 | 读:滑动窗口 limit=50;写:摘要压缩(16 条触发 / 保留 12 条 ),摘要以 system 放最前,不替代原文 |
| 三个 bug | ① 缓存命中 → 历史空洞 ;② 摘要只存内存 ;③ msg_order 并发撞号 。三个都不报错 |
| 一个反直觉 | 断点续跑做的事是重放 :invoke(restored_state),图从 START 重跑------状态恢复 ≠ 执行恢复 |
| 一处文档漂移 | docstring 说"三张表",_verify_schema 实际校验 6 张 |
| 一处没落地 | covers_from 列存在但恒为 0------"列存在"不等于"列有意义" |
| 一条安全线 | 所有查询都带 WHERE user_id = %s------别人的断点恢复不了 |
| 一条规律 | 记忆系统的失效方式是**"表现变差",不是"报错"** |
下一章 :第 15 集 · 意图识别:从词表到语义路由 ------ 本集讲的 session_id 隔离,下一集会继续用;而"用户这句话到底想干什么",要靠三代演进出来的判别链。
本章的接口 :本章没有改变任何检索与生成的质量 ------一行提示词也没改,一个算法也没动。它改变的是"AI 还记不记得住刚才发生了什么 "。这也是它容易被跳过的原因:记忆不产出答案,只决定答案有没有上下文 。三个 P0 bug 修完之后,系统的回答质量没有提高 ------它只是不再凭空丢掉一段对话。
📌 两处口径说明 : ① 必须带的口径边界 :本章对"缓存命中会不会真的发生"讲的是机制 (命中时这条路径会跳过写历史),不是历史断言 ("线上发生过多少次")------本集没有可用的 run 日志,历史结论无法证明。 ② 可能变化的引用 :HISTORY_MAX_TURNS/HISTORY_COMPRESS_TURNS是两个可调常量,本章按当前值 8 / 6 讲;改动后结论(16 条触发、保留 12 条)需按新值重算。
导航 :返回总目录 · 上一篇:LangGraph 状态图(v2 第 13 集) · 下一篇:意图识别:从词表到语义路由(v2 第 15 集,编号见 课程表_第06集起.md)