Agent长期记忆架构实战:Qdrant + SQLite WAL + MemoryProvider 三层设计 1. 为什么给 Agent 加长期记忆这件事远比想象中麻烦做过 Agent 项目的人大概都有过这种体验单轮对话里模型表现得像个天才一旦把时间线拉长到几十轮、跨天、跨会话它立刻变成一个失忆症患者。你上周告诉它你的项目代号叫夜莺这周它一脸茫然地问你夜莺是什么。这不是模型能力问题而是记忆架构问题。我最近在给一个自研的 Hermes Agent 做记忆层重构目标很明确让它拥有可持久化、可迁移的长期记忆。所谓可持久化就是进程重启、机器换掉、容器重建之后记忆还在所谓可迁移就是记忆能打包带走从一台机器搬到另一台从一个环境迁到另一个环境而不是被死死绑在某个运行时里。这个需求听起来简单但真正动手就会发现坑一个接一个。最直觉的方案是全都塞进向量库结果发现元数据查询、时间过滤、状态更新这些操作在纯向量库里做起来极其别扭另一个极端是全都塞进关系库结果语义检索又做不了。折腾了几轮之后我最终落地的方案是Qdrant SQLite WAL MemoryProvider三层结构Qdrant 负责语义召回SQLite开启 WAL 模式负责结构化元数据与状态MemoryProvider 作为统一抽象层把两者粘起来对上暴露干净的接口。这套东西适合谁看如果你正在做 Agent、聊天机器人、个人知识助手或者任何需要记住用户的应用并且已经过了玩具阶段、开始认真考虑持久化和迁移那这篇内容应该能帮你少走不少弯路。下面我会把选型逻辑、表结构设计、写入链路、召回策略、迁移方案以及我踩过的那些坑一条条拆开讲。2. 三层架构的选型逻辑为什么不是一个库打天下2.1 纯向量库的边界在哪里一开始我也想过偷懒直接上 Qdrant 一个库搞定所有事。Qdrant 的 payload 机制确实支持存任意 JSON 元数据也能做过滤看起来什么都能塞。但实际用下来问题集中在三个地方。第一是更新语义。记忆不是只写不改的一条记忆可能从待确认变成已确认从活跃变成归档甚至被用户手动删除。在向量库里做这种状态机式的更新你得先按 ID 查、再改 payload、再 upsert链路长且容易出并发问题。而这类操作在关系库里就是一句UPDATE。第二是事务与一致性。记忆写入往往涉及写元数据 写向量两步如果中间挂了就会出现元数据有、向量没有的脏状态。向量库大多不提供跨存储的分布式事务你得自己在应用层补偿。第三是迁移成本。Qdrant 的 snapshot 机制迁移整个 collection 是可行的但如果你想做只迁移某个用户的记忆只迁移某段时间的记忆纯向量库的粒度控制就很别扭。而 SQLite 文件本身就是一个可拷贝的实体配合导出逻辑迁移粒度可以做到任意细。所以结论是向量库擅长相似度召回不擅长状态管理。把状态管理硬塞进去短期能跑长期一定还债。2.2 SQLite 为什么必须开 WAL选 SQLite 存元数据很多人第一反应是SQLite 并发不行。这个印象其实来自默认的 rollback journal 模式写操作会锁整个数据库文件读也被阻塞。但开启WALWrite-Ahead Logging之后情况完全不同。WAL 模式的核心机制是写操作先追加到-wal文件读操作继续读主库文件读写互不阻塞。对于 Agent 这种读多写少、但写入频繁且要求低延迟的场景WAL 几乎是必选项。我实测下来开启 WAL 后记忆写入的 P99 延迟从原来的几十毫秒降到了个位数毫秒级别而且并发读基本不受写入影响。开启方式很简单但有几个参数必须一起调PRAGMA journal_mode WAL; PRAGMA synchronous NORMAL; PRAGMA busy_timeout 5000; PRAGMA wal_autocheckpoint 1000;这里synchronous NORMAL是关键取舍WAL 模式下 NORMAL 意味着只在 checkpoint 时真正 fsync崩溃时最多丢失最后一个 checkpoint 之后的写入但不会损坏数据库。对记忆场景来说丢几条最新记忆可以接受换来的是写入速度的大幅提升。如果你做的是金融级场景那就老老实实FULL。注意WAL 模式依赖共享内存如果数据库放在网络文件系统上WAL 可能无法正常工作。记忆库一定要放在本地磁盘。2.3 MemoryProvider 抽象层到底抽象了什么有了 Qdrant 和 SQLite为什么还要加一层 MemoryProvider因为上层业务不应该关心记忆存在哪。今天用 Qdrant明天可能换成本地 FAISS今天用 SQLite明天可能换成 Postgres。如果业务代码里到处是qdrant_client.search(...)和sqlite3.execute(...)那迁移就是灾难。MemoryProvider 的职责边界我定义为四条统一写入add_memory(content, metadata)一次调用内部完成向量化、双写、事务协调。统一召回recall(query, filters, top_k)一次调用内部完成语义检索 元数据过滤 结果融合。统一生命周期update_memory、archive_memory、delete_memory管理记忆状态。统一迁移export_memories、import_memories提供可序列化的迁移接口。抽象层不是越多越好而是刚好覆盖变化点。向量库和关系库是变化点所以抽象记忆的业务语义比如什么算重要记忆是稳定的所以不抽象直接放在 Provider 内部策略里。3. 记忆的数据模型一条记忆到底该长什么样3.1 记忆的字段设计在动手写代码前我花了整整一个下午只做一件事设计记忆的字段。因为字段设计错了后面所有查询、迁移、召回都会跟着错。最终定下来的核心字段如下字段名类型作用是否进向量库 payloadmemory_idTEXT (UUID)全局唯一标识是contentTEXT记忆正文是也是向量化源memory_typeTEXT类型fact/preference/event/summary是importanceREAL重要度 0~1是created_atINTEGER创建时间戳毫秒是updated_atINTEGER更新时间戳是last_access_atINTEGER最后召回时间是access_countINTEGER被召回次数是statusTEXTactive/archived/deleted是source_sessionTEXT来源会话 ID是user_idTEXT归属用户是tagsTEXT (JSON)标签数组是embedding_versionTEXT向量模型版本是这里有几个设计决策值得展开说。为什么 importance 是 REAL 而不是枚举因为记忆的重要性是连续的而且会随时间衰减。用 0~1 的浮点数方便做加权排序也方便后续用公式动态调整。枚举高/中/低在召回排序时太粗糙。为什么要有 last_access_at 和 access_count这是为了做记忆衰减。一条记忆如果长期不被召回说明它对当前对话不重要可以降低权重甚至归档。这两个字段让遗忘变成可计算的策略而不是拍脑袋。为什么 embedding_version 要单独存因为向量模型会升级。当你从某个旧模型换到新模型时旧向量和新向量不在同一空间混在一起检索会出大问题。存版本号就能在迁移或升级时精准定位哪些记忆需要重新向量化。3.2 SQLite 表结构与索引元数据表我建了两张主表memories和标签关联表memory_tags。标签单独建表是为了支持高效的标签过滤避免每次都在 JSON 里做字符串匹配。CREATE TABLE memories ( memory_id TEXT PRIMARY KEY, content TEXT NOT NULL, memory_type TEXT NOT NULL, importance REAL DEFAULT 0.5, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_access_at INTEGER, access_count INTEGER DEFAULT 0, status TEXT DEFAULT active, source_session TEXT, user_id TEXT NOT NULL, embedding_version TEXT, vector_synced INTEGER DEFAULT 0 ); CREATE INDEX idx_user_status ON memories(user_id, status); CREATE INDEX idx_created ON memories(created_at); CREATE INDEX idx_importance ON memories(importance DESC);注意那个vector_synced字段这是双写一致性的关键。写入时先在 SQLite 落库并标记vector_synced 0然后写 Qdrant成功后回写为 1。如果中间挂了后台有个补偿任务扫描vector_synced 0的记录重新同步。这样即使 Qdrant 短暂不可用记忆也不会丢只是暂时检索不到。3.3 Qdrant collection 的配置取舍Qdrant 这边collection 的配置直接影响召回质量和资源占用。我的配置是这样的from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams, PayloadSchemaType client QdrantClient(path./qdrant_data) # 本地模式便于迁移 client.create_collection( collection_namehermes_memory, vectors_configVectorParams(size1024, distanceDistance.COSINE), )几个关键点距离用 COSINE 而不是 EUCLID因为文本向量通常做过归一化余弦相似度对长度不敏感更符合语义相似度的直觉。向量维度 1024是当前主流中文 embedding 模型的常见输出维度具体按你用的模型调整。更重要的是 payload 索引。Qdrant 默认对 payload 字段不做索引过滤时会全扫描性能很差。必须显式建索引client.create_payload_index( collection_namehermes_memory, field_nameuser_id, field_schemaPayloadSchemaType.KEYWORD, ) client.create_payload_index( collection_namehermes_memory, field_namestatus, field_schemaPayloadSchemaType.KEYWORD, ) client.create_payload_index( collection_namehermes_memory, field_namecreated_at, field_schemaPayloadSchemaType.INTEGER, )我踩过的坑是一开始没建user_id索引多用户场景下召回延迟随数据量线性增长到几万条时单次召回要几百毫秒。建完索引后稳定在十几毫秒。4. 写入链路一次 add_memory 背后发生了什么4.1 完整写入流程拆解add_memory看起来是一个简单调用内部其实是一条精心编排的流水线。我把它拆成七步参数校验与归一化检查 content 非空、user_id 存在把 tags 统一成小写去重。生成 memory_id用 UUID4保证全局唯一且无序避免暴露写入顺序。计算 importance如果调用方没传走默认策略见 4.2。SQLite 落库插入主表vector_synced 0同时写标签关联表。整个过程包在一个事务里。向量化调用 embedding 模型把 content 转成向量。这一步是耗时大头通常几十到几百毫秒。写 Qdrant带上完整 payload用 memory_id 作为 point id。回写同步标记把vector_synced更新为 1。这个顺序的核心原则是先保证数据不丢再保证可检索。SQLite 落库成功就意味着记忆已经安全了即使后面向量化失败补偿任务也能兜底。4.2 importance 的默认计算策略importance 不该是拍脑袋给的常数。我的默认策略综合了三个信号def compute_importance(content, memory_type, metadata): base { fact: 0.7, preference: 0.8, event: 0.5, summary: 0.6, }.get(memory_type, 0.5) # 长度信号过短的信息通常信息量低 length_factor min(len(content) / 100.0, 1.0) * 0.2 # 显式标记信号调用方明确说这很重要 explicit 0.3 if metadata.get(explicit_important) else 0.0 return min(base length_factor explicit, 1.0)这套策略不是最优解但它把类型先验 内容长度 显式信号三个维度都考虑进去了比固定值强很多。实际跑下来用户偏好类记忆preference天然获得较高权重符合直觉。4.3 双写一致性怎么保证双写一致性是这套架构里最容易出问题的地方。我总结了三种失败场景和对策失败场景后果对策SQLite 成功Qdrant 失败记忆存在但检索不到补偿任务扫描 vector_synced0 重试SQLite 失败Qdrant 未执行无影响事务回滚直接报错两者都成功回写标记失败重复同步同步操作幂等用 memory_id 做 upsert补偿任务的实现很简单一个定时任务每 30 秒扫一次def sync_pending_memories(provider, batch_size100): rows provider.sqlite.query( SELECT * FROM memories WHERE vector_synced 0 LIMIT ?, (batch_size,) ) for row in rows: try: provider._upsert_vector(row) provider.sqlite.execute( UPDATE memories SET vector_synced 1 WHERE memory_id ?, (row[memory_id],) ) except Exception as e: logger.warning(fsync failed for {row[memory_id]}: {e})提示补偿任务一定要做幂等。Qdrant 的 upsert 天然幂等同 ID 覆盖所以重复同步不会产生脏数据。5. 召回策略怎么让 Agent 想起该想起的事5.1 语义召回 元数据过滤的组合拳召回不是简单的向量相似度 top_k。真实场景里你几乎总是带着条件召回某个用户的、状态为 active 的、最近 30 天的记忆。所以召回是过滤 语义排序的组合。Qdrant 支持在 search 时带 filter把元数据过滤下推到向量检索阶段避免先召回再过滤导致的召回不足。比如from qdrant_client.models import Filter, FieldCondition, MatchValue, Range results client.search( collection_namehermes_memory, query_vectorquery_embedding, query_filterFilter( must[ FieldCondition(keyuser_id, matchMatchValue(valueuser_id)), FieldCondition(keystatus, matchMatchValue(valueactive)), FieldCondition(keycreated_at, rangeRange(gtecutoff_ts)), ] ), limittop_k * 3, # 多召回一些后面重排 )注意limittop_k * 3这个技巧向量召回的结果还要经过重排见 5.2多召回一些候选能提升最终质量。5.2 重排把 importance 和时间衰减揉进去纯语义相似度有个问题它只看内容像不像不看这条记忆重不重要是不是最近才提过。所以我在召回后加了一层重排综合三个因子import math def rerank(candidates, now_ts, alpha0.6, beta0.25, gamma0.15): scored [] for c in candidates: semantic c.score # 0~1 importance c.payload[importance] age_days (now_ts - c.payload[created_at]) / 86400000 recency math.exp(-age_days / 30.0) # 30天半衰期 final alpha * semantic beta * importance gamma * recency scored.append((final, c)) scored.sort(keylambda x: x[0], reverseTrue) return [c for _, c in scored]这里的权重alpha0.6, beta0.25, gamma0.15是我调了几轮的结果。语义相似度仍然是主导但重要度和新鲜度能起到微调作用。半衰期设 30 天意味着一个月前的记忆新鲜度衰减到约 0.37符合近期对话更相关的直觉。5.3 召回后的副作用更新访问统计召回不是只读操作。每次召回后我会异步更新被命中记忆的last_access_at和access_countdef touch_memories(provider, memory_ids): now int(time.time() * 1000) provider.sqlite.executemany( UPDATE memories SET last_access_at ?, access_count access_count 1 WHERE memory_id ?, [(now, mid) for mid in memory_ids] )这个副作用有两个价值一是为后续的遗忘策略提供数据长期不访问的记忆可以降权二是让用户能看到哪些记忆最常被用到。但要注意异步执行别让它拖慢召回主链路。6. 迁移方案把记忆从一台机器搬到另一台6.1 迁移的两种粒度迁移需求分两种全量迁移换机器、换环境和增量迁移只搬某个用户、某段时间。两种粒度用同一套导出格式只是过滤条件不同。导出格式我选了 JSONL每行一个 JSON 对象原因是可读、可流式处理、对超大记忆集友好。每条记录包含元数据 向量def export_memories(provider, output_path, user_idNone, since_tsNone): query SELECT * FROM memories WHERE status ! deleted params [] if user_id: query AND user_id ? params.append(user_id) if since_ts: query AND created_at ? params.append(since_ts) rows provider.sqlite.query(query, params) with open(output_path, w, encodingutf-8) as f: for row in rows: vector provider._get_vector(row[memory_id]) record {**row, vector: vector} f.write(json.dumps(record, ensure_asciiFalse) \n)6.2 导入时的向量版本校验导入不是简单地把数据塞回去。前面提到embedding_version导入时必须校验如果导出方的向量模型版本和导入方不一致向量不能直接用必须重新向量化。def import_memories(provider, input_path, current_embedding_version): with open(input_path, encodingutf-8) as f: for line in f: record json.loads(line) if record[embedding_version] ! current_embedding_version: # 版本不匹配重新向量化 record[vector] provider.embed(record[content]) record[embedding_version] current_embedding_version provider._insert_raw(record)这个校验看起来多余但实际救过我一次某次升级 embedding 模型后做迁移如果没校验新旧向量混在一起召回结果会莫名其妙地差而且极难排查。6.3 迁移过程中的一致性保障迁移期间源库可能还在写入。为了保证一致性我的做法是迁移前记录一个时间戳T。导出created_at T的所有记忆。迁移完成后再导出created_at T的增量。导入方按 memory_id upsert天然去重。这套全量 增量的组合避免了迁移期间锁库也避免了数据丢失。对于记忆这种允许最终一致的场景完全够用。7. 踩坑实录那些文档里不会写的教训7.1 WAL 文件膨胀导致磁盘告警WAL 模式有个副作用-wal文件会持续增长直到触发 checkpoint。默认的wal_autocheckpoint 1000意味着每 1000 页约 4MB触发一次。但在高频写入场景下如果读事务长期不结束checkpoint 会被阻塞WAL 文件可能涨到几百 MB。我遇到过一次磁盘告警排查发现-wal文件涨到了 800MB。解决办法是加一个定期手动 checkpointdef periodic_checkpoint(conn, interval_sec300): while True: time.sleep(interval_sec) conn.execute(PRAGMA wal_checkpoint(TRUNCATE);)TRUNCATE模式会在 checkpoint 后把 WAL 文件截断到零彻底释放空间。但注意别太频繁否则影响写入性能5 分钟一次比较合适。7.2 Qdrant 本地模式的并发陷阱Qdrant 的本地模式path...很方便适合单机部署和迁移。但它有个限制同一时刻只能有一个进程访问同一个路径。我一开始在多个 worker 里各自初始化 client结果第二个进程直接报锁冲突。解决方案有两个要么用 Qdrant 的服务端模式独立进程 HTTP/gRPC要么在应用内做单例所有 worker 共享一个 client。我选了后者因为迁移时只需要拷贝一个目录简单。7.3 向量维度不匹配的静默失败有一次我换了 embedding 模型维度从 768 变成 1024但忘了重建 collection。结果写入时报错还算好的更坑的是某些客户端会静默截断或填充导致向量空间错乱召回结果完全不可用。教训是collection 创建时就把维度写进配置启动时校验。如果模型维度和 collection 维度不一致直接启动失败别让它带病运行。7.4 时间戳精度问题SQLite 存时间戳我用的是毫秒整数。但有一次跨语言调用时某个客户端传了秒级时间戳导致记忆的created_at比实际早了 1000 倍召回时新鲜度计算全乱。后来我在 Provider 入口加了归一化小于某个阈值的当作秒乘以 1000。def normalize_ts(ts): if ts 1e12: # 秒级时间戳 return int(ts * 1000) return int(ts)这种防御性代码看起来丑但能省掉大量排查时间。8. 性能实测与调优参数8.1 关键指标实测在约 5 万条记忆、单用户 5000 条的数据规模下我测了一组数据操作P50P95P99add_memory含向量化45ms120ms210msrecalltop_k1012ms28ms55msupdate_memory3ms8ms15msexport1万条1.2s1.8s2.5s向量化是写入的瓶颈占了 80% 以上时间。如果对写入延迟敏感可以把向量化改成异步先落 SQLite 返回后台慢慢向量化。代价是刚写入的记忆短时间内检索不到需要业务上能接受。8.2 值得调的参数清单参数默认值建议值影响synchronousFULLNORMAL写入速度提升 3~5 倍wal_autocheckpoint10001000太大导致 WAL 膨胀busy_timeout05000避免并发写直接报错recall limit 倍数13提升重排质量recency 半衰期-30 天按业务调整8.3 什么时候该考虑换 PostgresSQLite 不是银弹。如果你的场景满足以下任意一条就该考虑换 Postgres pgvector多机部署需要共享记忆库写入 QPS 持续超过 1000需要复杂的 SQL 分析查询需要行级权限控制但对绝大多数单机 Agent 场景SQLite WAL 的性能和可靠性完全够用而且迁移简单到拷贝文件。9. 记忆衰减与归档让 Agent 学会忘记9.1 为什么必须做遗忘记忆只增不减迟早会拖垮系统。一方面是存储成本另一方面是召回质量当库里全是陈年旧事语义检索的噪声会越来越大。所以遗忘不是可选项是必需品。我的遗忘策略分三级降权长期不访问的记忆importance 按时间衰减。归档importance 低于阈值且超过 N 天未访问status 改为 archived不再参与默认召回。删除用户显式删除或归档超过 M 天后清理。9.2 衰减公式与执行def decay_importance(provider, now_ts, half_life_days90): rows provider.sqlite.query( SELECT memory_id, importance, last_access_at, created_at FROM memories WHERE status active ) for row in rows: last row[last_access_at] or row[created_at] age_days (now_ts - last) / 86400000 factor math.exp(-age_days / half_life_days) new_importance row[importance] * factor if new_importance 0.1: provider.sqlite.execute( UPDATE memories SET status archived WHERE memory_id ?, (row[memory_id],) ) else: provider.sqlite.execute( UPDATE memories SET importance ? WHERE memory_id ?, (new_importance, row[memory_id]) )这个任务每天跑一次就够。半衰期 90 天意味着一条记忆三个月不碰权重降到约 0.37符合记忆随时间淡化的直觉。注意衰减任务一定要在低峰期跑并且分批处理避免一次性锁库太久。10. 把记忆层做成可复用的组件10.1 接口设计的稳定性原则MemoryProvider 的接口我刻意保持极简只有六个方法add、recall、update、archive、delete、export/import。接口越少越稳定越容易替换底层实现。一个反直觉的经验不要过早暴露底层细节。我一开始把vector_synced、embedding_version这些字段暴露在接口返回值里结果上层业务开始依赖它们后来想改内部实现就束手束脚。后来我把这些字段收进内部对外只返回业务语义的字段。10.2 配置化与多环境适配不同环境开发、测试、生产对记忆层的要求不同。开发环境可能用内存模式生产用持久化。我用一个配置对象统一管理dataclass class MemoryConfig: sqlite_path: str ./memory.db qdrant_path: str ./qdrant_data qdrant_url: str None # 非空则用服务端模式 embedding_model: str default embedding_dim: int 1024 recall_top_k: int 10 rerank_alpha: float 0.6 rerank_beta: float 0.25 rerank_gamma: float 0.15这样切换环境只改配置不动代码。迁移时也只需要把sqlite_path和qdrant_path指向新位置。10.3 监控与可观测性记忆层是沉默的基础设施出问题时往往已经影响用户体验了。所以我加了几个关键指标上报写入成功率、补偿任务积压量召回延迟 P50/P95/P99各状态记忆数量分布WAL 文件大小其中补偿任务积压量最有用。一旦这个数持续增长说明 Qdrant 出问题了能提前告警而不是等用户反馈Agent 失忆了。11. 关于这套方案我个人的几点体会整套记忆层跑了大半年从最初的能存能查到现在的可持久化、可迁移、可衰减中间返工了好几次。最大的体会是记忆系统的难点不在存储而在策略。存哪里、怎么存都是工程问题有标准答案但什么该记、什么该忘、召回时怎么排序这些是策略问题没有标准答案只能根据业务不断调。另一个体会是迁移能力要从第一天就设计。我见过太多项目记忆和运行时绑死想换机器时发现根本搬不走。把记忆做成文件 可序列化格式迁移就退化成一次文件拷贝这是最省心的设计。最后分享一个小技巧如果你不确定 importance 和衰减参数怎么设先别调让系统跑两周把召回日志和用户反馈收集起来用真实数据反推参数。拍脑袋设的参数往往和真实需求差得很远。这套 Qdrant SQLite WAL MemoryProvider 的组合本质上是用最小的复杂度换来了最大的灵活性和可迁移性对单机 Agent 场景来说性价比相当高。