Day 09 / 共 20 天 · 第 2 周 可信底座

四层 Memory Protocol

前两天讲了 Agent 怎么"不说错"(Critic)、"不崩不烧钱"(闸门);今天讲它怎么"记事"。Agent 怎么记住过去的对话、历史故障、团队知识?四层记忆各司其职,靠 Protocol 抽象做到"换存储不动业务代码"。

📍 你在 20 天里的位置(第 2 周:可信底座)
D06 toolkit 总览 D07 Critic D08 失败闸门 D09 Memory D10 评测
💡 用一个类比先兜住今天(延续「盖楼/物业」世界观) 大模型天生"失忆"——每次调用都不记得上次说过啥。四层记忆就像物业的档案体系①工作记忆=手上这张正在填的报修工单(这次会话内的临时便签,办完就归档);②情景记忆=历史报修案例库("这故障去年修过没?当时咋修的");③语义记忆=物业的操作手册/SOP(通用知识,相对稳定);④向量底座=档案室那套"按意思找"的检索系统(②③共用它,不是按标题精确翻,而是按意思翻——"服务A变慢"能翻到"响应延迟升高")。记住"临时工单 / 案例库 / 操作手册 / 智能检索",四层就清楚了。
L01

Agent 为什么需要记忆

大模型本身是"无状态"的——每次调用它都不记得上一次说了什么。但一个好用的 Agent 需要三种"记性":

  • 记住这次对话:多轮追问要接得上(这是"工作记忆");
  • 记住历史经验:这个故障以前遇到过吗、当时怎么解决的("情景记忆");
  • 记住团队知识:相关的 SOP、运维手册、架构拓扑("语义记忆")。

本框架把这些抽象成四层 Memory Protocol,代码在 packages/ai-trust-toolkit/src/ai_trust_toolkit/memory/。每一层是一个文件,职责清晰。

L02

四层记忆总图

① Working 工作记忆

会话内 · 短期

当前这次执行的 state,多轮之间接得上。就是 Day 03 讲的 checkpointer。

memory/checkpointer.py

② Episodic 情景记忆

历史 case · 具体经验

过去处理过的一个个具体故障 case。新故障来了先召回相似历史。

memory/episodic.py

③ Semantic 语义记忆

通用知识 · SOP

团队沉淀的 SOP、runbook、拓扑等通用知识,供 RAG 检索。

memory/semantic.py

④ 向量底座

②③ 共用的存储

情景和语义记忆都建立在同一个"向量库"之上,负责相似度检索。

memory/vector_store.py
为什么分这么细? 因为三种记性的"读写模式"完全不同:工作记忆随会话生灭;情景记忆是"具体案例",越攒越多;语义记忆是"提炼过的知识",相对稳定。分层后每层能独立选合适的存储和策略。而②③底层其实共用一套向量检索(第④层),只靠一个 metadata.type 字段区分。
🍼 记忆口诀(四层记忆)「单(工作记忆·这次工单)、案(情景记忆·历史案例)、册(语义记忆·操作手册)、检(向量底座·按意思检索)」——前三层是"记什么",第四层是"怎么找"。
L03

向量检索:让机器"按意思找"

情景/语义记忆的核心是"按语义相似度找",而不是"按关键词精确匹配"。比如查"服务 A 变慢",它应该能召回"service-A 响应延迟升高"这种意思相近但用词不同的历史 case。这靠向量检索(vector search)实现:

一段文本
"服务A变慢"
Embedder
转成向量
[0.12, -0.8, ...]
一串数字
向量库
找最近邻
相似历史
Top-K case
Embedder(嵌入器):把一段文本"翻译"成一串数字(向量),意思越相近的文本,向量在空间里越靠近。
向量库:存这些向量、并能快速找出"跟给定向量最接近的 K 条"。相似度常用"余弦相似度"衡量。

所以一次记忆召回 = "把查询文本 embed 成向量 → 在向量库里搜最近的几条 → 返回对应的原文"。

📝 举个例子:一次"按意思找"的召回 查询 "支付服务响应变慢" → Embedder 转成向量 [0.12,-0.8,…] → 向量库找最近邻 top_k=3 →命中历史 case c-874「payment-svc 连接池被调小导致 P99 飙升」(用词完全不同,但"意思"相近)。
关键字匹配会因为"变慢"≠"P99飙升"而漏掉它;向量检索因为语义相近照样能翻出来。

向量库里存的每一条叫 Recordvector_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)。
L04

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]]约定返回值是 (记录, 相似度) 的列表、按相似度降序。任何实现都必须遵守这个形状。
💡 设计取舍①:为什么用 Protocol,而不是定义一个抽象基类让两个实现去 extends Protocol 是"鸭子类型 + 静态检查"InMemoryVectorStorePgVectorStore 压根不用继承 VectorStore,只要方法签名对得上,就自动算"满足协议"。好处是零侵入——将来接第三方的向量库(比如别人写的、你改不了源码的类),只要它的方法长得对,不用改它一行就能塞进来。抽象基类则强制"必须继承我",第三方类没法用。这就是"结构化子类型"胜过"名义子类型"的地方。
本地/测试
InMemoryVectorStorevector_store.py:58,dict + 余弦)
MockEmbedderembedder.py:26,SHA256 切片,确定性、可复现
生产
PgVectorStorevector_store.py:112,PG16 + pgvector,ivfflat/hnsw 索引)
BgeM3Embedderembedder.py:58,接 Ollama,1024 维)

两种实现都满足同一个 VectorStore / Embedder 协议,所以业务节点拿到的接口一模一样。PgVectorStore 还做了防注入——表名走白名单校验(vector_store.py:149)。

Protocol 解耦:业务只认接口,实现随环境换 业务节点 只调 VectorStore.search/add VectorStore 协议(只约定方法) 本地:InMemoryVectorStore + MockEmbedder 生产:PgVectorStore + BgeM3Embedder
图注:业务只依赖 VectorStore 协议;本地注入内存实现、生产注入 pgvector 实现,业务代码一行不改。
这就是"依赖倒置":业务不依赖具体的数据库,而是依赖一个抽象接口;具体用哪个数据库,由启动时的配置决定。测试快、生产稳,两全其美——这也是为什么本框架的测试能几秒跑完、不联网。

👶 小白:本地用假的 MockEmbedder(SHA256 切片),那测出来的相似度不就是"假"的吗,能信?

👨‍🏫 老师:测试要的不是"语义多准",而是可复现——同样输入永远得同样向量,好让断言稳定、不联网、不花钱、几秒跑完。语义准不准那是生产环境 BgeM3 的事。就像消防演习用的是训练弹:目的是验证"流程跑得通",不是真炸。两个实现满足同一个协议,所以流程一模一样。

L05

内存实现走读:InMemoryVectorStore

测试/本地用的实现 InMemoryVectorStorevector_store.py:58)——用一个 dict 当数据库,纯 Python、0 外部依赖。最能说明"向量搜索到底在算什么"的是它的 searchvector_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)
⚠️ 两处边界值得注意:长度不齐返回 0(维度对不上不该崩,判"完全不像"最安全);零向量返回 0(避免除以 0 的崩溃)。这些"防御性 0"让内存实现在脏输入下也不会抛异常——和生产实现严格校验维度形成对比(见 L06)。
控制流:search 内部五步流水线 遍历全部 records filter按 metadata cosine算相似度 min_score阈值过滤 sort取 top_k
图注:内存版 search 就是"全扫→过滤→算分→卡阈值→排序取前 K"这条流水线。
L06

生产实现走读:PgVectorStore

生产用 PgVectorStorevector_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}")
💡 设计取舍②:为什么表名要白名单校验,参数却用占位符? 因为 SQL 里表名不能用参数占位符%s 只能占值、不能占表名/列名),只能字符串拼接进 SQL。而拼接就有SQL 注入风险——万一表名来自不可信输入,可能被注入恶意语句。所以这里isalnum() 白名单把关:只允许字母数字和下划线。而查询里的向量、filter 等"值"仍然走 %s 占位符(见下),由驱动安全转义。"能占位的占位、不能占位的白名单"是防注入的标准姿势。

它的 searchvector_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 占位符所有"值"都走占位符,驱动负责转义——和上面表名白名单一起构成防注入两道防线。
🤔 边界:pgvector 没装、或 store 没 init 会怎样?两处真实处理:① 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 报错发懵。
🍼 两个实现一句话对比内存版=暴力全扫 + 防御性返回 0(图省事、图不崩,测试用);生产版=数据库索引 + 严格校验报错(图快、图早发现错,线上用)。但它俩方法签名完全相同,所以业务代码换谁都不用改——这就是 L04 Protocol 的威力。
L07

情景记忆 & 语义记忆走读

这两层都是薄薄一层封装——持有一个 VectorStore + 一个 Embedder,把"embed 再搜/存"的样板包好。

Episodic 情景记忆(episodic.py

EpisodicStoreepisodic.py:16)是个 dataclass,recallepisodic.py:39)读、writebackepisodic.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

SemanticStoresemantic.py:16)和情景记忆共用同一个向量库seedsemantic.py:40)离线灌库、lookupsemantic.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 不同,语义记忆按需传 typelookup(q, "sop") 查手册、"topology" 查拓扑。一个库装多种知识。
filter or None没传 type 时 filter 是空字典 {}{} or None 变成 None=不过滤,全库搜。
RAG(检索增强生成):调 LLM 前先从知识库检索相关资料、塞进 prompt。让模型基于"真实资料"回答而非凭记忆瞎编——也是一种防幻觉手段。sre-rca 的 rag 节点干的就是 lookup
⚠️ 小白常误以为情景记忆和语义记忆是"两个数据库"。看代码就明白它俩共用同一个 vector_store,只靠一条 metadata.type 区分:Episodic 写死 type=case,Semantic 按 type=sop/topology 检索。一套底座、两种用法——这正是 Day 06 讲的"渐进抽象"省下的重复。
L08

工作记忆: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。框架把"还没完全做好的地方"如实写进注释,而不是假装完美——读代码时看到这种注释要留意:它标的是"现状"而非"终态"。

L09

sre-rca 串联 + 小结 + 动手

回到 Day 05 的 sre-rca,四层记忆在它的图里各就各位:

1

compile 时挂工作记忆

make_checkpointer_from_env() → 支持这次分析多轮接续、断点续跑。

2

recall 节点用情景记忆

EpisodicStore.recall() 捞出相似的历史故障 case,给后面的综合当参考。

3

rag 节点用语义记忆

SemanticStore.lookup(type="sop") 检索相关运维手册。

4

writeback 节点写回情景记忆

过了 should_writeback 守门后,把这次成功的分析存进去,成为未来的"历史经验"。

# builder.py 里的组装(本地默认全走内存 mock,测试友好)
store = EpisodicStore(vector_store=InMemoryVectorStore(),
                      embedder=MockEmbedder())
checkpointer = make_checkpointer_from_env()
graph = builder.compile(checkpointer=checkpointer)
闭环:情景记忆形成了一个自我改进的闭环——每处理一次故障就沉淀一条经验,下次遇到相似的先召回参考。加上 Day 08 的写回守门(只沉淀"通过质检的"),这个闭环不会被错误经验污染。这就是"越用越聪明"又"不会学坏"。

🧠 今天你应该能回答

  • 四层记忆分别是什么、各管什么?(工作/情景/语义/向量底座)
  • 向量检索为什么能"按意思找"?(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
明天预告 · Day 10:第 2 周收官。Agent 质量怎么量化、怎么防止"改一版就偷偷变差"?Day 10 讲 多维评测 Eval——golden set 5 维打分、在线评测、回归检测,以及 CI 里不达标自动拦合并。
← Day 08 失败闸门 Day 10 · 多维评测 Eval →