从零到一搭建企业级智能问答系统:Ch14 · 三层记忆与断点续跑

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 读------读回来的只有没被压缩过的原始消息,那段摘要凭空消失。

修法两步:

  1. 建表 chat_summaries,字段含 user_id / session_id / summary / covers_from / covers_to / msg_count / importance;
  2. 读的时候优先回放摘要 :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),六步:

  1. 用户提问时,先在 task_queue 建一条 status=running 的任务;
  2. 每个节点执行完 ,把当前 state 序列化成 JSON,存进 task_checkpoints;
  3. 如果服务宕机 / 用户关客户端,task_queue 里那条还是 running;
  4. 下次登录,查 status=running 的任务;
  5. 读 task_checkpoints 里最后一条快照,把 state 恢复出来;
  6. "从中断的节点继续执行(不需要重新分类、检索)"。

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 内存。

现在有两处调用点:

  1. 正常路径:node_save_history 里(:1315);
  2. 缓存命中路径 ::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)

相关推荐
叮当猫_ddm1 小时前
vLLM 与 SGLang 并发实验:如何让性能数字可复现、可解释
llm
400分1 小时前
从零到「机械臂会放试管」:π0.5 VLA 真机部署完全指南(第二节)
人工智能·架构
滑翔的企鹅1 小时前
从真实系统看 CMS 与知识内容平台的架构演进
架构
guoqihan342svg1 小时前
你的编程 Agent 跑着跑着就「失忆」了?百万行仓库的上下文工程
agent
黑妹天下第一乖1 小时前
第 04 讲:阿加犀 AIMO 模型优化平台与 Model Farm 模型广场实战
人工智能·嵌入式硬件·矩阵·架构·iot
HLAIA光子1 小时前
RAG Chunks 切分优化后成本骤降 86%
后端·性能优化·架构
10年前端老司机2 小时前
LangChain 五种工具定义方式踩坑总结
python·langchain·llm
Dawson Zhu2 小时前
《Agentic Design Patterns》第 2 章导读:路由(Routing)
人工智能·语言模型·架构·aigc·agi
AINative软件工程3 小时前
LLM 多租户 Prompt Isolation 工程实践:为 100 个租户定制 Prompt,但别让它们互相污染
后端·llm