1. 从一次真实的踩坑说起:为什么我要去翻 TencentDB Agent Memory 的源码
事情的起因很简单。我在做一个基于 Codex 的本地 Agent 项目,想让它在多轮对话里记住用户之前说过的偏好,比如“我习惯用 Python 而不是 Node”“数据库统一走 PostgreSQL”“日志级别默认 WARN”。最开始我用的是最土的办法——把历史对话拼成一个超长的 prompt 塞进上下文。跑了两天就崩了:token 消耗爆炸,响应延迟从 1.2 秒涨到 8 秒,而且一旦对话轮次超过 30 轮,模型开始出现“记忆错乱”,把三天前的一个临时变量当成当前任务的约束条件。
这时候我意识到,Agent Memory 不是“把历史记录塞进 prompt”这么简单的事。它本质上是一个独立的存储与检索子系统,需要解决三个核心问题:写什么、怎么存、怎么取。而 TencentDB 在这块做了一套相对完整的 Agent Memory 架构,我决定直接读源码,搞清楚它的设计逻辑,顺便验证一个我一直在怀疑的事情——Codex 这类工具在接入外部记忆系统时,存在一个结构性的硬冲突。
这篇文章就是我读源码 + 实际接入测试的完整记录。适合正在做 Agent 记忆系统、或者准备把 Codex 接入自有存储层的朋友参考。我会把架构拆开讲,把冲突点标出来,把踩过的坑和绕过去的办法都写清楚。
2. TencentDB Agent Memory 的整体架构设计思路
2.1 为什么不是简单的向量数据库加一层封装
很多人第一次听到“Agent Memory”会下意识觉得:不就是把对话 embedding 一下存进向量库,需要的时候做相似度检索吗?我一开始也是这么想的,直到我把 TencentDB Agent Memory 的模块依赖图拉出来,才发现它的分层比想象中复杂得多。
它的核心设计思路可以概括为四层分离:
- 接入层(Ingestion Layer):负责接收来自 Agent 的原始交互数据,做初步的格式归一化和敏感信息过滤。
- 记忆抽象层(Memory Abstraction Layer):这是整个架构里最关键的一层,它把原始数据拆成三类记忆——工作记忆(Working Memory)、情景记忆(Episodic Memory)、语义记忆(Semantic Memory)。这个分类不是拍脑袋定的,后面我会详细讲它的依据。
- 存储引擎层(Storage Engine Layer):底层用的是 TencentDB 自身的多模存储能力,结构化数据走关系表,非结构化走向量索引,时序特征走列存。
- 检索与召回层(Retrieval Layer):对外暴露统一的 recall 接口,内部做多路召回 + 重排序。
这个分层的好处在于:每一层可以独立替换。比如你不想用它的向量索引,可以换成自己搭的 FAISS;你不想用它的记忆分类策略,可以在抽象层做自定义。这种设计在工程上很务实,因为 Agent 场景的需求变化太快,耦合太紧的系统活不过三个月。
2.2 三类记忆的划分依据与实际含义
我重点看了记忆抽象层的实现。它把记忆分成三类,这个划分直接对应了 Agent 运行时的不同时间尺度:
工作记忆对应的是当前任务上下文,生命周期最短,通常只在一个 session 内有效。源码里它的存储结构是一个带 TTL 的 KV 表,默认过期时间是 30 分钟。这里有个细节值得注意:工作记忆的写入是同步阻塞的,因为 Agent 下一步动作可能立刻就要读它。
情景记忆对应的是“发生过什么”,比如用户在某次对话里提到了某个偏好、某个任务失败了、某个工具调用返回了异常。它的存储是追加写的日志结构,带时间戳和 session_id 索引。读取时主要按时间范围 + 语义相似度做混合检索。
语义记忆对应的是从多次交互中提炼出来的稳定知识,比如“这个用户偏好简洁回答”“这个项目的技术栈是 Java + MySQL”。它的写入是异步的,需要经过一个提炼过程,通常由后台任务完成。
提示:三类记忆的写入时机和同步策略完全不同,这是很多人在接入时最容易搞混的地方。如果你把语义记忆也做成同步写入,Agent 的响应延迟会直接翻倍。
2.3 存储引擎的选型逻辑
底层存储这块,源码里能看到它做了明确的读写分离。工作记忆走的是内存 + 本地持久化,追求的是低延迟;情景记忆走的是分布式 KV,追求的是写入吞吐;语义记忆走的是向量索引 + 关系表,追求的是检索质量。
我实测下来,这个选型逻辑是合理的。但有一个隐藏成本:三套存储意味着三套运维。如果你是在本地做单机 Agent,这套架构会显得过重。我的建议是,单机场景可以只保留工作记忆 + 语义记忆两层,情景记忆直接用本地文件追加写,等规模上来了再迁移。
3. 核心细节解析:记忆的写入、提炼与召回
3.1 写入路径的完整链路
我把写入路径的源码从头跟了一遍,完整链路是这样的:
- Agent 产生一次交互(用户输入 + 模型输出 + 工具调用结果)
- 接入层做格式归一化,统一成内部 schema
- 敏感信息过滤,这里用的是规则 + 轻量分类器
- 路由到记忆抽象层,根据内容类型决定写入哪类记忆
- 工作记忆直接写 KV,情景记忆追加写日志,语义记忆进入提炼队列
- 写入完成后返回一个 memory_id,供后续引用
这里有个设计我觉得很聪明:memory_id 是全局唯一的,跨三类记忆通用。这意味着你拿到一个 memory_id,可以反查它属于哪类记忆、原始内容是什么、被哪些后续记忆引用过。这个引用链在调试 Agent 行为时非常有用。
3.2 语义记忆的提炼过程
语义记忆的提炼是整个架构里最复杂的部分。源码里它走的是一个两阶段流程:
第一阶段是候选提取。系统会定期扫描情景记忆,找出高频出现的模式。比如用户连续三次要求“回答简短一点”,这就会被标记为候选。
第二阶段是置信度评估。候选模式会经过一个打分函数,综合考虑出现频次、时间衰减、跨 session 一致性。只有超过阈值的候选才会被写入语义记忆。
这个阈值在源码里默认是 0.75,我建议根据你的场景调整。如果你的 Agent 面向的是高频短交互场景(比如客服),可以降到 0.6;如果是低频长交互(比如代码助手),建议提到 0.85,避免把偶然偏好当成稳定偏好。
3.3 召回策略的多路融合
召回层用的是三路并行 + 重排序的结构:
- 第一路:基于 memory_id 的精确查找,用于已知引用的场景
- 第二路:基于时间窗口的范围检索,用于“最近发生过什么”的场景
- 第三路:基于向量相似度的语义检索,用于“相关记忆是什么”的场景
三路结果合并后,会经过一个重排序模型,最终返回 top-k。这里的 k 默认是 5,源码里有个细节:工作记忆的权重系数是 1.5,情景记忆是 1.0,语义记忆是 0.8。这个权重设计的意思是,越接近当前上下文的记忆越重要。
我实测下来,这个权重在代码助手场景下偏保守。如果你做的是需要长期一致性的任务(比如持续几天的项目开发),建议把语义记忆的权重提到 1.2 左右,否则 Agent 会“忘记”之前确立的约定。
4. Codex 接入记忆系统的硬冲突:问题到底出在哪
4.1 冲突的表象:endpoint 不匹配
我第一次尝试把 Codex 接到这套记忆系统时,遇到的是最直接的问题:Codex 的请求走的是/responses端点,而记忆系统的接入层默认监听的是/memory/ingest。这两个接口的 schema 完全不兼容。
Codex 发过来的请求体里,对话历史是嵌套在messages数组里的,每条消息带role和content。而记忆系统期望的是扁平的interaction对象,带session_id、turn_index、raw_input、raw_output这些字段。
我一开始想的是写个适配层做转换,但转换过程中发现了一个更深的问题。
4.2 冲突的根源:状态管理模型的差异
Codex 的设计假设是无状态请求。每次请求都携带完整的对话历史,服务端不保存任何状态。这是为了水平扩展和容错。
而 Agent Memory 的设计假设是有状态会话。它期望客户端只发送增量信息,由记忆系统来维护完整的状态。
这两个假设是根本矛盾的。你要么让 Codex 每次发全量历史(那记忆系统就退化成了一个纯存储,失去了状态管理的意义),要么让 Codex 只发增量(那你就得改 Codex 的客户端逻辑,而它本身不提供这个能力)。
注意:这不是配置问题,是架构层面的冲突。任何试图通过“改个 endpoint”或“加个中间件”来解决的方案,都只能解决表象,解决不了根源。
4.3 我尝试过的三种绕行方案
方案一:全量历史 + 记忆系统做去重。让 Codex 照常发全量历史,记忆系统在写入时做去重,只保留新增部分。这个方案能跑通,但代价是每次请求的 payload 都很大,网络传输和解析开销高。我实测在 50 轮对话后,单次请求体到了 200KB 左右。
方案二:客户端拦截 + 增量改写。在 Codex 客户端和记忆系统之间加一个代理,拦截请求,把全量历史替换成增量。这个方案技术上可行,但维护成本高,而且 Codex 版本升级时容易失效。
方案三:双写模式。Codex 保持无状态,记忆系统独立维护一份完整状态。两边各存各的,通过 memory_id 做关联。这个方案最稳,但存储成本翻倍,而且存在一致性问题。
我最后选的是方案三的变体:Codex 侧只保留最近 5 轮对话,更早的历史由记忆系统按需召回后注入。这样既控制了 payload 大小,又保证了长期记忆的可用性。
5. 实操过程:从零搭一套可用的接入方案
5.1 环境准备与依赖确认
我用的环境是 Python 3.11 + 本地部署的 TencentDB 实例。依赖这块有几个坑要注意:
- 向量索引依赖的底层库对 glibc 版本有要求,CentOS 7 默认版本偏低,需要手动升级
- 记忆系统的 SDK 目前只提供了 Python 和 Java 两个版本,其他语言需要走 HTTP 接口
- 本地部署时,工作记忆的 TTL 清理任务默认是 60 秒跑一次,高频场景下建议调到 10 秒
5.2 接入层的适配代码
适配层的核心逻辑是把 Codex 的请求转换成记忆系统能识别的格式。关键代码如下:
def adapt_codex_request(codex_payload, session_id): messages = codex_payload.get("messages", []) # 只取最后一条用户消息作为当前输入 current_input = "" for msg in reversed(messages): if msg["role"] == "user": current_input = msg["content"] break # 历史消息作为情景记忆的补充 history = [ {"role": m["role"], "content": m["content"]} for m in messages[:-1] ] return { "session_id": session_id, "turn_index": len(messages), "raw_input": current_input, "history_context": history, "timestamp": int(time.time()) }这段代码的关键点是把当前输入和历史上下文分开处理。当前输入走工作记忆,历史上下文走情景记忆。这样召回时可以根据需要分别取用。
5.3 召回结果的注入策略
召回结果怎么注入回 Codex 的请求,这是另一个需要仔细设计的地方。我的做法是在 system prompt 后面追加一个记忆区块:
def inject_memory(codex_payload, recalled_memories): memory_block = "\n".join([ f"[{m['type']}] {m['content']}" for m in recalled_memories ]) system_msg = { "role": "system", "content": f"以下是相关历史记忆,供参考:\n{memory_block}" } # 插入到 system 消息之后 codex_payload["messages"].insert(1, system_msg) return codex_payload这里有个细节:记忆区块要标注类型。我实测发现,如果不标注类型,模型会把工作记忆和语义记忆混为一谈,导致行为不稳定。标注之后,模型能区分“这是当前任务的临时约束”和“这是长期偏好”。
5.4 完整链路的联调记录
联调时我按这个顺序逐步验证:
- 先单独测写入:发一条消息,确认三类记忆都正确落库
- 再单独测召回:手动构造查询,确认三路召回都能返回结果
- 然后测端到端:发消息 → 写入 → 下一轮召回 → 注入 → 模型响应
- 最后测边界:空历史、超长历史、并发写入
第三步跑通的时候,我观察到响应延迟从纯 Codex 的 1.2 秒涨到了 2.1 秒。其中写入占 0.3 秒,召回占 0.4 秒,注入和序列化占 0.2 秒。这个开销在可接受范围内,但如果你的场景对延迟敏感,可以考虑把写入改成异步。
6. 常见问题与排查技巧实录
6.1 记忆召回不准确的三类原因
我踩过的坑里,召回不准是最常见的。总结下来有三类原因:
| 问题现象 | 根本原因 | 解决方向 |
|---|---|---|
| 召回结果与当前任务无关 | 向量索引的 embedding 模型与场景不匹配 | 换用领域微调过的 embedding 模型 |
| 该召回的记忆没召回 | 时间衰减系数设置过激 | 调低衰减速率,或对语义记忆关闭衰减 |
| 召回了过时的记忆 | 缺少时效性过滤 | 在重排序阶段加入时间惩罚项 |
第一类问题最隐蔽。我一开始用的是通用 embedding 模型,在代码场景下召回质量很差。换成代码领域微调过的模型后,召回准确率从 62% 提到了 81%。
6.2 写入冲突与并发问题
并发写入时我遇到过一次数据覆盖。原因是工作记忆的 KV 表用的是session_id作为主键,同一 session 的并发写入会互相覆盖。解决办法是在写入前加一个基于 session_id 的分布式锁,或者改用session_id + turn_index作为复合主键。
提示:如果你用的是单机部署,直接用本地锁就行,不用上分布式锁,省得引入额外依赖。
6.3 记忆膨胀的治理
跑了一周后,我发现语义记忆表膨胀得很快,从最初的几十条涨到了三千多条。排查后发现是提炼阈值设得太低,把很多一次性偏好也写进去了。
治理办法有两个:一是提高提炼阈值,二是加一个定期的记忆合并任务,把相似度高的语义记忆合并成一条。我两个都做了,表大小稳定在了 400 条左右。
6.4 常见问题速查表
| 症状 | 排查第一步 | 常见修复 |
|---|---|---|
| Agent 响应变慢 | 看写入是否同步阻塞 | 改异步写入 |
| 记忆丢失 | 检查 TTL 配置 | 调长过期时间 |
| 记忆错乱 | 检查注入格式 | 加类型标注 |
| 存储增长过快 | 统计各类记忆条数 | 提高提炼阈值 |
| 召回为空 | 检查索引是否建立 | 重建向量索引 |
7. 我对这套架构的实际体会
读源码这件事,最大的价值不是学会怎么用,而是理解为什么这么设计。TencentDB Agent Memory 的三层记忆划分,本质上是在解决“不同时间尺度的信息需要不同的存储和检索策略”这个问题。这个思路可以迁移到很多场景,不只是 Agent。
Codex 接入的硬冲突,说到底是一个状态归属权的问题。无状态客户端和有状态服务端之间的边界在哪里,这个边界怎么划,直接决定了你的架构复杂度。我现在的做法是把边界划在“最近 5 轮”这个位置,再往前的交给记忆系统。这个数字不是拍脑袋定的,是我测了 3 轮、5 轮、10 轮之后,在延迟和记忆连续性之间找到的平衡点。
最后分享一个小技巧:调试记忆系统时,一定要把 memory_id 打到日志里。Agent 行为异常时,顺着 memory_id 反查它的来源和引用链,比盲目看对话历史高效得多。我靠这一招定位过好几次“Agent 为什么突然改变行为”的问题,每次都能在十分钟内找到根因。