四层 Memory Protocol
前两天讲了 Agent 怎么"不说错"(Critic)、"不崩不烧钱"(闸门);今天讲它怎么"记事"。Agent 怎么记住过去的对话、历史故障、团队知识?四层记忆各司其职,靠 Protocol 抽象做到"换存储不动业务代码"。
Agent 为什么需要记忆
大模型本身是"无状态"的——每次调用它都不记得上一次说了什么。但一个好用的 Agent 需要三种"记性":
- 记住这次对话:多轮追问要接得上(这是"工作记忆");
- 记住历史经验:这个故障以前遇到过吗、当时怎么解决的("情景记忆");
- 记住团队知识:相关的 SOP、运维手册、架构拓扑("语义记忆")。
本框架把这些抽象成四层 Memory Protocol,代码在 packages/ai-trust-toolkit/src/ai_trust_toolkit/memory/。每一层是一个文件,职责清晰。
四层记忆总图
① Working 工作记忆
会话内 · 短期当前这次执行的 state,多轮之间接得上。就是 Day 03 讲的 checkpointer。
② Episodic 情景记忆
历史 case · 具体经验过去处理过的一个个具体故障 case。新故障来了先召回相似历史。
③ Semantic 语义记忆
通用知识 · SOP团队沉淀的 SOP、runbook、拓扑等通用知识,供 RAG 检索。
④ 向量底座
②③ 共用的存储情景和语义记忆都建立在同一个"向量库"之上,负责相似度检索。
metadata.type 字段区分。向量检索:让机器"按意思找"
情景/语义记忆的核心是"按语义相似度找",而不是"按关键词精确匹配"。比如查"服务 A 变慢",它应该能召回"service-A 响应延迟升高"这种意思相近但用词不同的历史 case。这靠向量检索(vector search)实现:
"服务A变慢"→ Embedder
转成向量→ [0.12, -0.8, ...]
一串数字→ 向量库
找最近邻→ 相似历史
Top-K case
向量库:存这些向量、并能快速找出"跟给定向量最接近的 K 条"。相似度常用"余弦相似度"衡量。
所以一次记忆召回 = "把查询文本 embed 成向量 → 在向量库里搜最近的几条 → 返回对应的原文"。
"支付服务响应变慢" → Embedder 转成向量 [0.12,-0.8,…] → 向量库找最近邻 top_k=3 →命中历史 case c-874「payment-svc 连接池被调小导致 P99 飙升」(用词完全不同,但"意思"相近)。关键字匹配会因为"变慢"≠"P99飙升"而漏掉它;向量检索因为语义相近照样能翻出来。
向量库里存的每一条叫 Record(vector_store.py:17),是最基础的数据结构:
@dataclass
class Record: # vector_store.py:17
id: str # 唯一主键(如 case_id "abc-2025-04-15")
text: str # 原文(召回后要塞进 prompt 的那段)
embedding: list[float] # 文本转成的向量(一串浮点数)
metadata: dict[str, Any] = field(default_factory=dict)
# 常用 metadata:type(case/sop)、service、alert_type、created_at
id + text + embedding三件套:主键、原文、向量。搜的时候比 embedding,返回的时候给 text。metadata.type最关键的一个 metadata 字段:情景记忆(type=case)和语义记忆(type=sop)共用同一个向量库,全靠这个字段区分(见 L07)。Protocol 解耦:换厂商不动业务
问题来了:本地开发想用"内存里的假向量库 + 假 embedder"(快、免费、可复现),生产想用"PostgreSQL+pgvector + Ollama 真 embedder"。怎么做到切换存储时业务代码一行都不用改?
答案是 Python 的 Protocol(协议/接口)——只约定"必须有哪些方法",不关心具体实现。业务只依赖协议,跑时注入哪个实现都行。真身在 vector_store.py:28:
@runtime_checkable # vector_store.py:28
class VectorStore(Protocol):
async def search(self, query_embedding, top_k=3, filter=None,
min_score=-1.0) -> list[tuple[Record, float]]:
"""返回 [(record, similarity), ...] 按相似度降序。"""
...
async def add(self, records: list[Record]) -> None: ...
async def delete(self, ids: list[str]) -> int: ...
async def count(self, filter=None) -> int: ...
class ...(Protocol)继承 Protocol=声明"这是个协议"。方法体全是 ...(省略号)——只写签名、不写实现。它规定"想当 VectorStore 必须有这 4 个方法"。@runtime_checkable允许运行时用 isinstance(x, VectorStore) 检查某对象是否满足协议。没它 Protocol 只能给类型检查器用。search 返回 list[tuple[Record, float]]约定返回值是 (记录, 相似度) 的列表、按相似度降序。任何实现都必须遵守这个形状。extends?
Protocol 是"鸭子类型 + 静态检查":InMemoryVectorStore 和 PgVectorStore 压根不用继承 VectorStore,只要方法签名对得上,就自动算"满足协议"。好处是零侵入——将来接第三方的向量库(比如别人写的、你改不了源码的类),只要它的方法长得对,不用改它一行就能塞进来。抽象基类则强制"必须继承我",第三方类没法用。这就是"结构化子类型"胜过"名义子类型"的地方。本地/测试
InMemoryVectorStore(vector_store.py:58,dict + 余弦)MockEmbedder(embedder.py:26,SHA256 切片,确定性、可复现)生产
PgVectorStore(vector_store.py:112,PG16 + pgvector,ivfflat/hnsw 索引)BgeM3Embedder(embedder.py:58,接 Ollama,1024 维)两种实现都满足同一个 VectorStore / Embedder 协议,所以业务节点拿到的接口一模一样。PgVectorStore 还做了防注入——表名走白名单校验(vector_store.py:149)。
👶 小白:本地用假的 MockEmbedder(SHA256 切片),那测出来的相似度不就是"假"的吗,能信?
👨🏫 老师:测试要的不是"语义多准",而是可复现——同样输入永远得同样向量,好让断言稳定、不联网、不花钱、几秒跑完。语义准不准那是生产环境 BgeM3 的事。就像消防演习用的是训练弹:目的是验证"流程跑得通",不是真炸。两个实现满足同一个协议,所以流程一模一样。
内存实现走读:InMemoryVectorStore
测试/本地用的实现 InMemoryVectorStore(vector_store.py:58)——用一个 dict 当数据库,纯 Python、0 外部依赖。最能说明"向量搜索到底在算什么"的是它的 search(vector_store.py:87):
class InMemoryVectorStore: # vector_store.py:58
def __init__(self):
self._records: dict[str, Record] = {} # 就是个字典当库
async def search(self, query_embedding, top_k=3, filter=None, min_score=-1.0):
results = []
for r in self._records.values():
if filter and not _match_filter(r, filter): # 先按 metadata 过滤
continue
score = _cosine_similarity(query_embedding, r.embedding) # 算余弦相似度
if score < min_score: # 低于阈值丢掉
continue
results.append((r, score))
results.sort(key=lambda x: x[1], reverse=True) # 按相似度降序
return results[:top_k] # 取前 K 条
遍历全部 records内存实现就是暴力全扫:每条都算一次相似度。数据量小无所谓,量大就得靠 L06 的数据库索引。_match_filter先按 metadata 精确匹配过滤(如只要 type=case),再算相似度。省得给不相关的记录白算。_cosine_similarity核心数学:算两个向量的"夹角余弦"。方向越一致值越接近 1,即"意思越像"。min_score 边界低于阈值直接丢。默认 -1.0(余弦下限)=不过滤;业务要"至少像到某程度才召回"就调高它。sort + [:top_k]降序排,取最像的前 K 条返回。余弦相似度的真实实现(vector_store.py:367)也很朴素——点积除以两个模长:
def _cosine_similarity(a, b): # vector_store.py:367
if not a or not b or len(a) != len(b):
return 0.0 # ← 边界:空/长度不齐直接判 0
dot = sum(x * y for x, y in zip(a, b, strict=True))
na = sum(x * x for x in a) ** 0.5
nb = sum(y * y for y in b) ** 0.5
if not na or not nb:
return 0.0 # ← 边界:零向量除零保护
return dot / (na * nb)
生产实现走读:PgVectorStore
生产用 PgVectorStore(vector_store.py:112)——PostgreSQL 16+ 加 pgvector 扩展。它和内存实现满足同一个协议、方法签名一模一样,但内部是真数据库。先看构造函数里的两处安全校验(vector_store.py:144-150):
def __init__(self, dsn, *, table="vector_records", dim=768,
index_type="ivfflat", ...): # vector_store.py:133
if dim <= 0:
raise ValueError(f"dim 必须 > 0 · got {dim}")
if index_type not in ("ivfflat", "hnsw"):
raise ValueError(f"index_type 必须 ivfflat 或 hnsw · got {index_type!r}")
# 防 SQL 注入 · table 名严格限白名单字符
if not table.replace("_", "").isalnum(): # vector_store.py:149
raise ValueError(f"table 名只接受 [A-Za-z0-9_] · got {table!r}")
%s 只能占值、不能占表名/列名),只能字符串拼接进 SQL。而拼接就有SQL 注入风险——万一表名来自不可信输入,可能被注入恶意语句。所以这里用 isalnum() 白名单把关:只允许字母数字和下划线。而查询里的向量、filter 等"值"仍然走 %s 占位符(见下),由驱动安全转义。"能占位的占位、不能占位的白名单"是防注入的标准姿势。它的 search(vector_store.py:287)把相似度计算下推给数据库,用 pgvector 的 <=> 距离算子 + 索引,不再全扫:
async def search(self, query_embedding, top_k=3, filter=None, min_score=-1.0):
if len(query_embedding) != self.dim: # ← 边界:维度不符直接报错(不像内存版返回0)
raise ValueError("query_embedding dim mismatch ...")
if filter:
sql = ("SELECT id, text, embedding, metadata, "
"1 - (embedding <=> %s::vector) AS sim " # 1 - 余弦距离 = 相似度
"FROM {table} WHERE metadata @> %s::jsonb " # @> = jsonb 包含,走 GIN 索引
"ORDER BY embedding <=> %s::vector LIMIT %s")
args = (query_embedding, json.dumps(filter), query_embedding, top_k)
...
len != dim → raise生产实现严格校验维度——对不上就报错,而不是像内存版那样返回 0。因为生产环境维度不匹配是配置错误,早报早发现,别悄悄给个假结果。embedding <=> %spgvector 的距离算子,配合 ivfflat/hnsw 索引,让数据库用索引找最近邻,不用把百万条全扫一遍。metadata @> %s::jsonbjsonb "包含"操作符,配 GIN 索引,快速按 type 等过滤。%s 占位符所有"值"都走占位符,驱动负责转义——和上面表名白名单一起构成防注入两道防线。init() 里 import psycopg_pool 失败会抛清晰的 ImportError("...跑 uv sync --extra postgres")(vector_store.py:170),告诉你缺哪个依赖;② 没先 await store.init() 就用,_ensure_pool() 抛 RuntimeError("PgVectorStore 未 init()...")(vector_store.py:256)。都是"早失败 + 给出可操作的错误信息",而不是让你对着一个莫名其妙的 NoneType 报错发懵。情景记忆 & 语义记忆走读
这两层都是薄薄一层封装——持有一个 VectorStore + 一个 Embedder,把"embed 再搜/存"的样板包好。
Episodic 情景记忆(episodic.py)
EpisodicStore(episodic.py:16)是个 dataclass,recall(episodic.py:39)读、writeback(episodic.py:58)写:
@dataclass
class EpisodicStore: # episodic.py:16
vector_store: VectorStore # 依赖协议,不认具体实现
embedder: Embedder
record_type: str = "case" # 本层固定 type=case
async def recall(self, query, top_k=3, extra_filter=None, min_score=-1.0):
[embedding] = await self.embedder.embed([query]) # ① 查询转向量
filter = {"type": self.record_type} # ② 只搜 type=case!
if extra_filter: filter.update(extra_filter)
return await self.vector_store.search(query_embedding=embedding,
top_k=top_k, filter=filter, min_score=min_score)
async def writeback(self, case_id, text, metadata=None):
[embedding] = await self.embedder.embed([text]) # 原文转向量
meta = {"type": self.record_type} # 打上 type=case 标记
if metadata: meta.update(metadata)
await self.vector_store.add([Record(id=case_id, text=text,
embedding=embedding, metadata=meta)])
filter={"type":"case"}召回时强制只搜 case——这就是"和语义记忆共用一个库、靠 type 区分"的落地点。历史故障绝不会翻出 SOP。[embedding] = await ...embed([query])embed 接收列表、返回列表,这里用解包语法取出唯一一条。查询和写入都要先转向量。writeback 打 type 标记写入时也盖上 type=case,这样它才只会被 recall 搜到。docstring 明确要求:调用前先过 Day 08 的 should_writeback 守门。Semantic 语义记忆(semantic.py)
SemanticStore(semantic.py:16)和情景记忆共用同一个向量库,seed(semantic.py:40)离线灌库、lookup(semantic.py:60)在线检索:
async def lookup(self, query, record_type=None, top_k=3,
extra_filter=None, min_score=-1.0): # semantic.py:60
[embedding] = await self.embedder.embed([query])
filter = {}
if record_type: filter["type"] = record_type # 传 "sop" 就只搜 SOP
if extra_filter: filter.update(extra_filter)
return await self.vector_store.search(query_embedding=embedding, top_k=top_k,
filter=filter or None, min_score=min_score)
record_type 参数和 Episodic 写死 case 不同,语义记忆按需传 type:lookup(q, "sop") 查手册、"topology" 查拓扑。一个库装多种知识。filter or None没传 type 时 filter 是空字典 {},{} or None 变成 None=不过滤,全库搜。rag 节点干的就是 lookup。vector_store,只靠一条 metadata.type 区分:Episodic 写死 type=case,Semantic 按 type=sop/topology 检索。一套底座、两种用法——这正是 Day 06 讲的"渐进抽象"省下的重复。工作记忆:checkpointer 工厂
第①层工作记忆就是 Day 03 讲的 checkpointer(存档器),管"会话内 state + 断点续跑"。框架把后端选择做成工厂 make_checkpointer_from_env()(checkpointer.py:24),按环境变量自动挑:
def make_checkpointer_from_env(): # checkpointer.py:24
redis_url = os.environ.get("REDIS_URL")
if redis_url:
log.info("...REDIS_URL 设但需 lifespan 集成 · 降级 memory...") # 见下方诚实注释
pg_dsn = os.environ.get("CHECKPOINTER_PG_DSN")
if pg_dsn:
log.info("checkpointer · backend=postgres")
try:
saver = make_checkpointer("postgres", url=pg_dsn)
...
return saver
except ImportError as e:
log.warning("postgres backend 不可用(%s)· 降级 memory ...", e)
return make_checkpointer("memory") # 兜底:内存版
底层 make_checkpointer(backend)(checkpointer.py:70)是真正 new 出实例的地方,三种后端惰性 import:
def make_checkpointer(backend="memory", *, url=None, **kwargs): # checkpointer.py:70
if backend == "memory":
from langgraph.checkpoint.memory import InMemorySaver
return InMemorySaver(**kwargs)
if backend == "redis":
if not url: raise ValueError("redis backend 需要 url='redis://...'")
try:
from langgraph.checkpoint.redis import RedisSaver
except ImportError as e:
raise ImportError("redis backend 需要 `pip install langgraph-checkpoint-redis`") from e
return RedisSaver.from_conn_string(url, **kwargs)
... # postgres 同理
raise ValueError(f"未知 backend: {backend!r}(支持 memory/redis/postgres)")
惰性 importredis/postgres 的库只在真用到时才 import。这样只装内存版的环境不会因为缺 redis 库而报错——按需付费。ImportError → 友好提示缺库时抛的错直接告诉你该 pip install 什么,不让你猜。from_env 逐级降级REDIS_URL → CHECKPOINTER_PG_DSN → memory,任何一级不可用就往下退,保证永远能返回一个能用的 checkpointer。又是"依赖倒置"套路——同一份业务代码,本地用内存、生产用 Redis/PG,靠环境变量切换,业务 builder.compile(checkpointer=...) 一行不改。
checkpointer.py:44 说明 langgraph-checkpoint-redis 0.4+ 的 from_conn_string 返回的是需要 lifespan with-block 管理的上下文管理器,当前简化方案暂时降级到内存版、真 redis 跨进程持久化留作 follow-up。框架把"还没完全做好的地方"如实写进注释,而不是假装完美——读代码时看到这种注释要留意:它标的是"现状"而非"终态"。
sre-rca 串联 + 小结 + 动手
回到 Day 05 的 sre-rca,四层记忆在它的图里各就各位:
compile 时挂工作记忆
make_checkpointer_from_env() → 支持这次分析多轮接续、断点续跑。
recall 节点用情景记忆
EpisodicStore.recall() 捞出相似的历史故障 case,给后面的综合当参考。
rag 节点用语义记忆
SemanticStore.lookup(type="sop") 检索相关运维手册。
writeback 节点写回情景记忆
过了 should_writeback 守门后,把这次成功的分析存进去,成为未来的"历史经验"。
# builder.py 里的组装(本地默认全走内存 mock,测试友好)
store = EpisodicStore(vector_store=InMemoryVectorStore(),
embedder=MockEmbedder())
checkpointer = make_checkpointer_from_env()
graph = builder.compile(checkpointer=checkpointer)
🧠 今天你应该能回答
- 四层记忆分别是什么、各管什么?(工作/情景/语义/向量底座)
- 向量检索为什么能"按意思找"?(Embedder 把文本转向量,找最近邻)
- 为什么用 Protocol 而不是抽象基类?(结构化子类型、零侵入,第三方类不改也能塞进来)
- 内存版和 pgvector 版对脏输入的态度差别?(内存版返回 0 不崩、生产版严格校验维度报错)
- pgvector 为何表名白名单、值用占位符?(表名不能占位只能拼、拼要防注入;值走 %s 转义)
- 情景和语义记忆的区别?(Episodic 写死 type=case vs Semantic 按 type 检索,共用一个向量库)
- 写回守门 + 情景记忆怎么形成"越用越聪明又不学坏"的闭环?
✋ 动手
# 1. 看 memory 模块清单
sed -n '1,39p' packages/ai-trust-toolkit/src/ai_trust_toolkit/memory/__init__.py
# 2. 读向量库 Protocol + 两种实现
sed -n '17,120p' packages/ai-trust-toolkit/src/ai_trust_toolkit/memory/vector_store.py
# 3. 读情景/语义记忆
cat packages/ai-trust-toolkit/src/ai_trust_toolkit/memory/episodic.py
cat packages/ai-trust-toolkit/src/ai_trust_toolkit/memory/semantic.py
# 4. 读 checkpointer 工厂(含诚实降级注释)
sed -n '24,105p' packages/ai-trust-toolkit/src/ai_trust_toolkit/memory/checkpointer.py
# 5. 跑记忆单测
uv run pytest packages/ai-trust-toolkit/tests/test_memory.py -q