给 AI 陪伴聊天做一个长期记忆微服务:架构与接入实践

Table of Contents

这篇文章梳理我做的一个长期记忆微服务:它是什么、解决什么问题、内部怎么把一段对话变成可检索的"记忆"、以及一个 AI 陪伴/角色扮演聊天后端是怎么把它接进主对话链路的。为避免泄露业务信息,文中去掉了产品名、内网地址与任何密钥,只保留架构与工程决策。

一、为什么要单独做一个记忆服务

AI 陪伴聊天的核心体验是"它记得我"。但 LLM 的上下文窗口是有限且易挥发的:一旦对话变长,早期的关键信息(用户的名字、偏好、两人之间发生过的重要事件、关系进展)就会被挤出窗口。把全部历史塞进 prompt 既不现实也不划算。

真正需要的是一个能长期沉淀、按需召回的外部记忆层:

  • 把对话里值得记住的原子事实抽出来、去重、消解冲突,持久化;
  • 在每一轮构建 prompt 时,只把当前最相关的少量记忆重新注入上下文。

这件事有两个特点决定了它值得独立成服务:一是写入很重(要跑 LLM 抽取、embedding、冲突消解),二是读取要快(在聊天主链路上,直接加到用户等待时间里)。把它从主后端剥离成独立微服务,就能让"重写入"异步化、让"快读取"单独优化,同时被多个上层业务复用。

二、整体架构:一个进程,两种角色,读写分离

记忆服务是一个独立部署的 Python 服务(FastAPI + Uvicorn,SQLModel/SQLAlchemy 2.0 async + asyncpg 连 PostgreSQL 16,pgvector 存向量,Kafka 做异步管道,LLM 走 OpenAI 兼容协议)。

它在同一个进程里扮演两种角色——API 服务器与 Kafka 消费者。进程启动时的 lifespan 里用 asyncio.create_task 把消费循环作为后台任务拉起(因此要求单 worker 运行,Prometheus 指标进程内共享)。

数据流被清晰地拆成"写"和"读"两条:

写入(异步,重):
  客户端 → POST /v1/memory/ingest → 生成 trace_id → 发布 Kafka(按 user:character 分区)→ 立即返回 trace_id
         Kafka 消费循环 → keyed 工作队列 → process_ingest → Postgres + 向量库 + LLM

读取(同步,快):
  客户端 → POST /v1/memory/retrieve → 向量召回 + 多因子打分 + rerank → 返回(不经 Kafka)
         GET  /v1/memory/summary   → 读缓存表,miss 时后台异步生成
         POST /v1/memory/delete    → 按消息区间软删除 + 写删除水位线

要点:写入完全经 Kafka 解耦,HTTP 只负责入队、毫秒级返回;检索是在线同步路径,有严格的延迟预算。这样上层聊天后端调用 ingest 从不阻塞对话,而 retrieve 的延迟被压在预算之内。

三、核心概念:mempoint

系统里记忆的最小单位叫 mempoint(记忆点),一条 mempoint 就是一个原子事实。关键字段:

  • type:记忆类型,六种——profile(偏好习惯)、identity(角色设定/名字)、facts(客观事实)、knowledge(领域知识)、event(重要事件)、relationship(关系动态)。
  • owner_type:归属,user / character / user-character(属于"两人之间")。
  • content:一句话的事实本身。
  • 三元作用域 user_id / character_id / session_id:所有记忆都挂在"用户—角色—会话"这个三元组下。
  • importance:1–100 的重要度。
  • source_trace_id + source_hash:幂等去重指纹。
  • extra:JSON,存这条记忆对应的消息区间 [min_msg_id, max_msg_id](后面删除、区间过滤都靠它)。
  • retrieve_count / first_retrieved_at / last_retrieved_at:使用统计,供 ACT-R 式打分用。
  • deleted_at:软删除。

向量单独存在一张一对一的表里(cosine 距离 + HNSW 索引)。此外还有几张支撑表:MemorySummary(按三元组唯一的摘要缓存)、ProcessedIngest(按 trace_id 的幂等 checkpoint)、DeleteWatermark(删除水位线墓碑,见后文)、以及成本记账表。

四、写入流水线:extract → embed → resolve → write

process_ingest 把一次写入拆成有明确边界、各自独立计时的阶段:

  1. Checkpoint 幂等:查 ProcessedIngest,处理过就直接跳过。
  2. Extract 抽取:调 LLM,从多轮对话 transcript 里抽出候选 mempoint。提示词有几条硬规则——一条记忆只装一个原子事实、跟随输入语言不做翻译、强反幻觉;群聊场景用更激进的 exhaustive 提示词。超出条数上限时按 importance 降序保留 top-N,而不是按输出顺序截断。LLM 不可用时回退正则启发式。
  3. 去重(Phase A):算 source_hash = SHA1(type|owner_type|content|三元作用域),批内重复和 DB 已存在的直接跳过。
  4. 嵌入(Phase B):把一次抽取的所有候选一次 API 调用批量 embed
  5. 相似检索 + 近重复折叠(Phase C):对每个候选在其作用域内做向量 KNN,并对本批已接受的候选做批内近重复折叠,避免"同一次抽取出的两个兄弟都被创建"。
  6. 消解(Phase D):对超过相似阈值的候选,决定 skip / create / merge / delete(下一节详述)。
  7. 写入(Phase E):在单个事务里串行应用写入 + 向量 upsert。
  8. 摘要刷新:只有当内容真的变了,才在 commit 后异步调度摘要重建,绝不阻塞 worker。

这条流水线还踩过一个很值得记的性能坑:早期版本在只读 DB 阶段之后紧接着跑数秒级的 LLM 调用,而这期间一直握着同一个 DB 连接不放。高并发下就是典型的"连接池饥饿 × 慢外部调用"——30 个并发 ingest 抢十几个连接,一半 worker 拿不到连接干等,单条处理从 ~2s 劣化到 6–39s。修法是把每个只读阶段结束后立即 rollback 归还连接,让 LLM 调用期间不占 DB 连接;配合各阶段耗时埋点,吞吐提升约 7 倍。慢外部调用期间不要持有稀缺资源,这是最核心的一条工程教训。

五、Resolver:冲突消解怎么决策

新记忆和已有记忆撞车时怎么办?这是记忆系统最容易做脏的地方——处理不好,同一件事会存成十几条近义句。消解逻辑是一个"能本地判断就绝不打模型"的分层决策:

  1. 向量检索无匹配 → 直接 CREATE
  2. 最佳匹配距离 < 近重复阈值(0.12)→ SKIP(几乎相同,纯本地判断,不调 LLM;但仍会把消息区间信息合并进去)。
  3. 距离在"相似但不相同"区间(进入 resolver 的阈值 0.35)→ 才交给 LLM 决策 merge 还是 replace:
    • merge → 原地更新 content,importance 取两者较大值,合并消息区间;
    • replace → 软删旧的 + 插入新的综合内容。
  4. LLM 不可用 → 回退到启发式合并兜底。

两个工程上的关键优化:

  • 批处理 LLM 消解:把一次 ingest 里所有需要 LLM 判断的 (旧, 新) 对合并成批量调用,分块 + 信号量并发。CREATE/SKIP 全在本地判断,只有真正冲突的候选才打模型,显著降本降延迟。
  • 区间取并集而非"最新胜出":合并两条记忆时,消息区间取 [min, max]并集。这里有过教训——早期做的是窄化(新区间覆盖旧区间),结果约 7.3% 本应被删除的记忆变成了 no-op,因为删除时按区间匹配不上了。

六、检索:双路召回 + 多因子打分 + rerank

retrieve 是在线路径,既要相关又要快。它的召回—排序分三层:

  1. 双路召回:一路语义向量召回(query 先 embed,非对称 embedding 给 query 加 Instruct: 前缀),另一路按 importance DESC 取高价值记忆作兜底(防止纯语义漏掉重要但表述不同的记忆),两路按 id 去重合并。
  2. 多因子加权打分:每个候选按加权和排序,权重可配、可关:
    • 语义相似(1 − 向量距离,权重最高)
    • 词法重叠(含中文 n-gram 切分)
    • 重要度
    • 时间衰减(recency)
    • 消息区间重叠
    • 类型权重(relationship / event 最高,identity 最低)
    • ACT-R 式使用度(可选):借鉴认知科学的记忆激活模型——“最近用过"按时间线性衰减、“用得多"用 log1p(count) 对数饱和,越常被召回的记忆越容易再次浮现。
    • 外加几个微调项:非身份 query 命中身份记忆时惩罚、意图匹配加成等。
  3. 交叉编码器 rerank:对 top-K 用 rerank 模型重打分,final = base × 0.5 + relevance × 0.5。但有多重短路——没有 query、预算不足、或 top1 与 top2 分差够大(已经很有把握)时直接跳过。任何一步失败都降级到 base 排序,永不抛异常。

最后按分数排序,受 limitmax_tokens 双重裁剪。检索完还会异步用一条 bulk UPDATE 更新使用统计,失败也不影响返回。此外支持按消息区间硬过滤,用于会话分支场景下追溯祖先会话的记忆。

七、上层聊天后端怎么接进来

记忆服务对外就是四个 REST 端点(ingest / retrieve / summary / delete)。上层的 AI 陪伴聊天后端(Go + Gin)用一个轻量 client 封装它们,默认 1.5s 超时,全链路透传 X-Trace-ID。接入的精妙之处主要在写入的攒批异步读取的 prompt-cache 友好注入

写入:攒批 + 异步 + 多层去重

不是每轮对话都立刻写记忆,而是**攒够若干轮(默认 4 轮)**才固化一次:

  • 每完成一轮对话,异步把这轮的引用 RPush 进 Redis pending 队列(用 hash-tag 保证同会话落同分片),并用双 hash 做去重(已 ingest / 已在 pending)。
  • 攒够一批后,拿分布式 flush 锁,把多轮拼成 transcript 调 ingest
  • 一个后台 sweeper 每隔几分钟兜底:把接近 TTL 过期、还没攒满一批的残留强制固化,防止用户长时间沉默导致记忆丢失。
  • 用户改写历史(编辑、回溯、清空、分支)时,走 delete 按消息区间删除相交记忆,并清理未固化的 pending;分支时把父会话的 pending 迁移到新分支。

整条写入路径失败只告警、不阻塞主对话——记忆是锦上添花,绝不能拖垮聊天。

读取:双层记忆 + 保护 prompt cache

上层在每轮构建 prompt 时注入两种记忆,而且刻意区分注入位置来保护 LLM 的 prompt caching:

  • 稳定摘要:调 summary 拿一段长期摘要,作为独立 system 消息注入到 history 之前。它按"边界对齐"做进程内缓存——只要历史窗口的边界没跨过对齐步长,就复用同一份摘要,从而让这段前缀在多轮之间保持稳定、不破坏 prompt cache
  • 动态记忆:只有当历史窗口被截断(说明真有历史掉出上下文)时,才调 retrieve 召回当前最相关的少量 mempoint,并把它们拼到最后一条 user 消息的末尾——而不是放进 system 块。原因很直接:这部分每轮都在变,若放进 system 前缀会让缓存整体 miss。

预算上,长期记忆总共只占上下文的一小部分(约 20%,且有绝对上限),再在摘要与动态记忆之间二次分配,超预算就二分裁剪。分支会话则沿 parent 链并发向多个作用域召回,对祖先会话用区间硬过滤防止召回越界记忆。这套"注入位置的讲究"背后是一整套保护前缀缓存的设计,值得单独展开(见下一节)。

还有一个产品化的细节:记忆功能的读写开关由 AB 实验驱动,而不是一个全局配置直接放行——含 holdout 硬闸、fail-closed(分组判断失败时宁可这轮没记忆,也不绕过实验)。全局开关只作为叠加在上面的急停总闸。

八、把 LLM 前缀缓存吃满

上一节说动态记忆"要拼到最后一条 user 消息末尾、不能进 system 块”——这句话背后是这套系统里我最满意的一块设计。它值得单独讲,因为记忆注入天然是前缀缓存的敌人:记忆每轮都在变,而前缀缓存要求 prompt 前缀逐字节稳定。

约束:前缀一变,后面全 miss

支持 prompt caching 的 LLM,只有当 messages 的前缀逐字节相同时才命中缓存;任何靠前的内容一变,它后面的所有缓存全部失效、按原价重算。对一个每轮都要塞记忆、塞提醒、塞字数指令的陪伴聊天 prompt 来说,稍不留神就是每轮全 miss。

解法是把整个 prompt 沿时间轴切成三个稳定性等级,并严格按"稳定在前、易变在后"排布:

  1. 绝对稳定前缀:system 模板、世界书、pin memory、稳定摘要——放最前面,用缓存断点罩住。
  2. 准稳定的 history 窗口:用"步长对齐"让窗口头部只偶尔跳一次。
  3. 每轮必变的内容:动态召回记忆、reminder、字数指令、assistant prefill——一律后置到所有缓存断点之后

记忆放在哪:一个进前缀,一个进末尾

  • 稳定摘要注入成独立 system 消息,位置在角色 system 模板/世界书之后、history 之前,和稳定前缀一起被缓存断点覆盖。它能放进前缀的前提是它"很少变”(下面的 boundary 机制保证)。
  • 动态记忆只往最后一条 user 消息的末尾拼,而且只在末尾三条消息里从后往前找第一条 user 来拼,绝不回头改历史中段。原因是最关键的一环:转换成模型原生格式时,所有 system 消息都会被提升到顶层 system 块,而缓存断点就打在顶层 system 块的末尾。动态记忆若进 system,它每轮变化的内容就落在断点之前,把整个稳定前缀连同 history 的缓存全部污染掉;拼进末尾 user 消息,它就落在所有断点之后——本轮正常计费,下一轮从 DB 重读 history 时根本不含这块,前缀完全不受影响。(拼接时还带一个 header 声明"以下是召回的长期记忆、不是用户当前输入",防止模型把它当成用户说的话。)

两个"步进式稳定"的对齐步长

只把易变内容后置还不够——摘要和 history 窗口本身也会随对话推进而漂移,漂移一次就 miss 一次。这里用了两个独立的对齐步长把漂移"量子化":

  • 摘要 boundary 对齐,步长 5:取 history 窗口最旧一条消息的 sequence,向下取整到 5 的倍数作为 boundary。摘要的进程内缓存 key 就是 {用户, 角色, 会话, boundary, 预算}。history 窗口每轮都在向前滑(最旧 seq 在涨),但 boundary 只有当最旧 seq 跨过下一个 5 的倍数时才跳变——大约每 2~3 轮才刷新一次摘要,其余轮次拿到的是逐字节相同的同一份。既不会每轮都变(那样前缀天天失效),也不会长期陈旧。
  • history 窗口粘性裁剪,步长 10:按 token 预算裁完 history 后,再把窗口起始 sequence 对齐到 10 的倍数(一轮对话占 2 个 sequence,即约每 5 轮才移动一次窗口头部)。否则窗口头部每轮随 token 预算漂移,history 这段前缀每轮 miss。

两个步长是独立的(摘要 5、history 10),但都是同一个"步进式稳定"的思路:用少量的新鲜度换前缀的稳定复用。甚至还有个细节——动态记忆预算没用满也不回填给 history,就是为了不让 history boundary 每轮抖动。

缓存断点打在"倒数第三条"

在模型原生格式里显式设了三个缓存断点:最后一个 system block(罩住整个稳定前缀)、倒数第三条 message、以及最后一个 tool 定义。断点为什么是倒数第三条而不是倒数第一?因为最后一条 user 消息每轮会被追加动态记忆和 reminder(每轮变),倒数第二条也带每轮变的内容——把断点退到倒数第三条,正好把所有每轮变化的东西隔在断点之后,让断点之前的一切稳定复用。

记忆服务侧的配合:read-through + 后台重建

前缀能稳定,前提是"同一 boundary 窗口内,每次取到的摘要文本必须逐字节一致"。这由记忆服务侧的摘要缓存保证:

  • 读路径绝不同步跑 LLMsummary 命中缓存表就直接返回;miss 时也不同步调 summarizer(那会阻塞聊天、撑爆超时),而是返回一个默认值并起后台任务重建,下次读就免费命中。
  • 后台重建折叠中间态:重建按会话 key 去重,如果重建期间又有新 ingest 把 key 标脏,就丢弃这次结果继续重算,只把最终态落库。高频写入期间不会反复抖动摘要内容。
  • 只在内容真变时才重算:一次 ingest 只有真的产生了 create/merge/replace,才在事务提交后异步调度摘要刷新;否则旧摘要原样保留。

两侧配合形成闭环:记忆服务决定"摘要内容何时变",聊天后端的 boundary 步长决定"何时去取新内容",最终让注入 LLM 的前缀在多轮之间逐字节稳定,把 prompt 前缀缓存的命中率吃到最满。

九、Kafka 消费:从堆积到追平生产

写入走 Kafka 异步这条路,一开始踩了个很典型的坑:消费能力远跟不上生产,消息大量堆积,consumer lag 一路发散。这块的演进过程很有代表性,分三个阶段。

阶段 0:串行消费,一条坏消息卡死全队列

最初的消费循环就是最朴素的写法——async for msg in consumer 一次拉一条,所有用户的消息挤在同一条 lane 上,逐条 await process_ingest()。问题在于 process_ingest 是一条几秒级的 LLM 流水线(抽取 + embedding + 消解),event loop 在处理当前这条时根本没法碰下一条,等于严格串行。更糟的是失败处理是"原地 sleep(2) 无限重试":一次评测流量洪峰灌进来,就能把后面所有消息堵死约 10 分钟,lag 直接发散。

阶段 1 & 2:按分区并行,但被分区数绑死

第一次改造把消费改成按分区并行getmany 批量拉取后 asyncio.gather 并发处理各个分区,producer 开始按 用户:角色 做 partition key(保证同一用户流有序落同一分区)。紧接着又做了 fetch/process 解耦——poll 循环只负责把消息塞进每个分区的常驻队列,不再等处理完成。

这两步确实把并行度从 1 拉了上来,但都有个共同的天花板:处理并行度死死绑在分区数(12)上。要提并发就得加分区,而分区不能随便减、还牵动有序性。并发信号量当时也只有 12。雪上加霜的是,并发一上去,max_overflow=10 的连接池根本不够用,反而因为 QueuePool 超时造成新一轮的堆积卡死。

阶段 3:keyed work queue,并发与分区彻底解耦

最终的形态是把"每分区一个串行 worker"换成 keyed work queue

  • 每个 用户:角色 key 一条串行 lane,lane 按需创建、排空即退出。同 key 串行(保住消解里 supersede/DELETE 的顺序依赖),异 key 并行
  • 处理并行度从分区数解耦出来,改由一个全局信号量封顶——上限从 12 提到 100,单 pod 处理并发直接涨了约 8 倍,且不再受 12 个分区的约束。
  • offset 改成入队即提交(at-most-once),配一个 500 条的内存队列做反压:队列满了 poll 循环自然被 backpressure 住,而不是把内存撑爆。重复投递由幂等索引兜底。
  • 失败有限重试 3 次后丢弃并记录完整 payload,单条坏消息再也卡不死整条 lane。
  • 配套把连接池 max_overflow 从 10 提到 100,确保连接数能覆盖 ingest 并发——这条和前面那次"连接池饥饿"复盘是同一个教训的两面:并发模型和资源池必须一起调,只调一个反而更糟

一句话概括这条演进线:并行度不该由分区数决定。分区只用来分散 fetch、保住同用户有序,真正的处理并发交给"按业务 key 分 lane + 全局信号量"来控制——生产端有多快,消费端就能按需铺开多少条 lane 去追。

十、贯穿始终的工程决策

除了上面的架构,几个"生产环境才会疼"的点也一并做了:

  • 幽灵记忆竞态与删除水位线。异步系统里存在一种竞态:一次 ingest 在"删除发生前"抽取、却在"删除发生后"落库,把已删消息的记忆"复活"了。解法是删除时无论是否命中都写一条水位线墓碑,ingest 在开始前和写入前两次检查:凡候选的源消息区间与"本消息入队之后签发的"水位线重叠,就丢弃。水位线惰性清理。
  • 多级幂等。trace_id checkpoint + (source_trace_id, source_hash) 部分唯一索引 + INSERT ... ON CONFLICT DO NOTHING + 批内去重 + resolver 的 SKIP,层层设防。
  • 优雅降级贯穿全链路。embedding 三级 provider 兜底(主→备→确定性 hash 兜底)、rerank 永不抛异常、LLM 失败回退启发式、检索 embedding 超时降级为非语义检索、成本写入失败只告警。没有任何外部依赖的失败会把主流程打成 500。
  • HTTP/2 跨区连接复用调优。embedding/rerank 客户端开 h2 并把 keepalive 从默认 5s 调到 55s——否则跨区连接在流量间隙里就断了,每次都要重新握手,尾延迟能飙到数百毫秒。还专门加了"复用 vs 新握手"的分类指标来观测这件事。
  • 观测即产品。检索做了漏斗式归因——每个候选在哪一步、因什么原因被丢弃,都落成指标;成本按场景/操作/trace_id 三级聚合;日志里保留业务原文(抽取出的记忆、匹配内容、合并结果、消解理由),而不仅仅是计数。定位"整体慢但不知慢在哪"时,各阶段耗时埋点是最有效的手段。
  • 可插拔向量后端。用同一接口路由 pgvector 与外部向量库,向量库只存向量 + 过滤字段,数据仍在 PG,KNN 之后回 PG 补全并过滤软删除。

十一、这套记忆系统到底做对了什么

回到最初的问题——它做了什么、好在哪:

  1. 把"重写入"和"快读取"彻底分开:写入经 Kafka 全异步、绝不阻塞对话;读取有严格延迟预算并逐层优化。
  2. 记忆是被治理过的,不是流水账:抽取原子化 → 指纹去重 → 向量近重复折叠 → LLM 冲突消解,且"能本地判断绝不打模型、要打就批量打",兼顾质量与成本。
  3. 召回融合了认知科学:语义 + 词法 + 重要度 + 时间衰减 + 使用频率(ACT-R)+ 类型权重 + 交叉编码器 rerank,每一维都可调可关。
  4. 对上层友好,且把前缀缓存吃满:四个干净的 REST 端点;接入方用攒批异步写,读取则靠"稳定摘要进前缀 + 动态记忆拼末尾 + 两个对齐步长(摘要 5、history 10)+ 缓存断点退到倒数第三条 + 记忆服务侧 read-through 后台重建"这一整套配合,把记忆无缝织进对话而不破坏 prompt 前缀缓存、不拖慢响应。
  5. 消费并发扛得住生产:写入管道从串行消费(一条坏消息卡死全队列)→ 按分区并行(被分区数绑死)→ keyed work queue(同 key 串行、异 key 并行、并发由全局信号量从 12 提到 100),彻底把处理并行度从分区数解耦,才让消费追平生产、消化掉堆积。
  6. 为生产而生:多级幂等、删除水位线防幽灵记忆、全链路优雅降级、跨区连接复用、连接池与并发一起调、漏斗式可观测、成本记账——这些才是让它在真实流量下稳定跑起来的东西。

一句话总结:记忆系统的难点从来不是"存下来",而是"存得干净、召得准、扛得住、还不拖慢主链路"。 这四件事,才是这套系统真正在解决的问题。