AI Agent 记忆与上下文工程实战(3):向量记忆与结构化记忆:长期记忆的写入路径
发布时间:2026/10/2 20:28:40来源:尧图网络
问题背景上一篇给摘要装了分层冻结的刹车但被冻结的内容早已不在窗口里——它们要有去处这就是长期记忆存储。本篇回答一个最容易被跳过的设计题一条将要离场的信息到底该存成向量还是存成结构化记录很多团队的记忆模块从第一版起就只有一条路径全部丢进向量库 embedding 一压了之。三个月后事故清单会教你做人——“预算上限五千元召回成了预算比较紧张”用户上个月已改口的住址新旧两条同时注入提示词模型挑了旧的。这两个事故的共同根源是存储形态选错前者该走结构化唯一当前值、精确回放后者该走带有效期的结构化事实会演化。向量与结构化不是二选一的信仰而是两类不同的查询负载联想式召回走向量断言式查询走结构化。本篇把两条路径的机制、能力边界、以及最重要的——写入路径的闸门设计——一次讲透。两条路径的机制与边界向量记忆的形态是每条记忆一段文本加一枚 embedding 向量检索时把查询也 embedding 化按余弦相似度取 top-k。它天然适合模糊语义联想查询和记忆措辞完全不同也能牵上线——“帮我订交通方式能召回用户讨厌打车”。但边界同样清晰。第一向量对数字、条件、否定不敏感上限五千元与上限八千元的 embedding 几乎贴着词面相似度反而最低纯向量检索分不清哪个是当前值。第二向量本身没有时间概念一条去年三月的记忆和昨天的记忆在空间里无差别要体现时近性必须显式把衰减函数叠进打分。第三top-k 是定长预算的懒汉方案——k 取小了漏召取大了把无关记忆也拖进上下文第二篇说过无关内容就是负资产。结构化记忆的形态是主语-谓语-宾语式的事实记录或者更工程化的说法——一张带时间戳的表。关键设计不是用不用 SQL而是有效期双字段每条事实记valid_from与valid_to事实变化时绝不 UPDATE 覆盖而是关闭旧区间、追加新区间。这一个约定同时买到三样东西当前视图查询valid_to IS NULL、任意时点回放valid_fromt AND (valid_to IS NULL OR valid_tot)、以及完整的演化审计链。Zep 那类时序知识图谱的记忆单元就是这个名字带invalid_at字段的版本。它的短板在写入端从对话里抽出用户-居住在-张江需要一个抽取步骤抽取错误会被当成事实固化所以结构化写入必须过校验闸门。分流判据可以浓缩成三问这条信息有唯一当前值吗住址、预算、称呼回答需要精确复放或审计吗金额、日期、承诺会被措辞完全不同的问题问到吗前两问任一为是走结构化第三问为是走向量多数用户画像类信息两路并写——向量负责召回入口结构化负责最终取值。而无论走哪条路写入路径都要过同一道闸门查重同义记忆合并、冲突检测与现有有效断言比对、来源登记出自哪一轮对话能不能回放核对。写入闸门比检索算法重要这是本篇最想传递的一句话。实验一纯标准库的向量记忆原型下面用 Python 标准库实现一个最小向量记忆文本切 2-gram 当词面嵌入生产中替换为真实 embedding 模型余弦相似度叠加时近指数衰减做综合打分检索十条客服记忆。本机以确定性模拟演示机制量级与排序规律可迁移语义质量必须靠真实模型才能复现。importmathimportrandom rngrandom.Random(490)MEMORIES[用户喜欢坐地铁通勤 不打车,用户住在徐家汇 靠近地铁站,项目预算上限五千元 不得超支,会议纪要 预算评审 决定砍掉方案B,用户是左撇子 用左手剪刀更顺手,用户养猫名叫煤球 对宠物毛发过敏,接口超时设置为三秒 失败重试两次,用户偏好周五下午开会 避开周一,代码评审通过 要求补充单元测试,用户饮食清淡 不吃香菜和花生,]defshingles(text):把文本切成 2-gram, 生产环境替换为真实 embedding 模型输出。cleaned.join(cforcintextifcnotin 。)return[cleaned[i:i2]foriinrange(len(cleaned)-1)]VOCABsorted({gforlineinMEMORIESforginshingles(line)})GID{g:ifori,ginenumerate(VOCAB)}defembed(text):vec[0.0]*len(VOCAB)forginshingles(text):ifginGID:vec[GID[g]]1.0rng.uniform(-0.05,0.05)normmath.sqrt(sum(v*vforvinvec))or1.0return[v/normforvinvec]defcosine(a,b):returnsum(x*yforx,yinzip(a,b))classVectorMemory:def__init__(self):self.items[]# (text, vec, t_written)defadd(self,text,t):self.items.append((text,embed(text),t))defsearch(self,query,t_now,top_k3):qvembed(query)scored[]fortext,vec,tinself.items:simcosine(qv,vec)recencymath.exp(-0.10*(t_now-t))# 时近指数衰减scored.append((sim0.3*recency,sim,recency,text))scored.sort(reverseTrue)returnscored[:top_k]memVectorMemory()fori,lineinenumerate(MEMORIES):mem.add(line,i1)forq,t_nowin[(预算上限是多少,10),(用户住在哪个地铁站附近,10),(养宠物需要注意什么,10)]:print(查询: %s (当前时刻 t%d)%(q,t_now))fortotal,sim,rec,textinmem.search(q,t_now):print( 总分 %5.3f 相似度 %5.3f 0.3*时近 %5.3f | %s%(total,sim,rec,text))print()print(同主题两条记忆先后写入时, 召回排序随时间翻转:)mem.add(项目预算已上调至八千元 上限放宽,11)fort_nowin(11,20,40):topmem.search(预算上限 现在是多少,t_now,top_k2)line | .join(%s(总分%.3f)%(text,total)fortotal,_,_,textintop)print( t%2d Top2: %s%(t_now,line))print(\n纯相似度只看词面; 叠加时近项后, 新版本在召回上压过旧版本——)print(相关性*新鲜度的合成分, 正是各向量库时间感知检索的雏形。)运行输出查询: 预算上限是多少 (当前时刻 t10) 总分 0.646 相似度 0.497 0.3*时近 0.497 | 项目预算上限五千元 不得超支 总分 0.323 相似度 0.158 0.3*时近 0.549 | 会议纪要 预算评审 决定砍掉方案B 总分 0.300 相似度 0.000 0.3*时近 1.000 | 用户饮食清淡 不吃香菜和花生 查询: 用户住在哪个地铁站附近 (当前时刻 t10) 总分 0.820 相似度 0.685 0.3*时近 0.449 | 用户住在徐家汇 靠近地铁站 总分 0.430 相似度 0.000 0.3*时近 1.000 | 用户饮食清淡 不吃香菜和花生 总分 0.402 相似度 0.280 0.3*时近 0.407 | 用户喜欢坐地铁通勤 不打车 查询: 养宠物需要注意什么 (当前时刻 t10) 总分 0.459 相似度 0.258 0.3*时近 0.670 | 用户养猫名叫煤球 对宠物毛发过敏 总分 0.300 相似度 0.000 0.3*时近 1.000 | 用户饮食清淡 不吃香菜和花生 总分 0.271 相似度 0.000 0.3*时近 0.905 | 代码评审通过 要求补充单元测试 同主题两条记忆先后写入时, 召回排序随时间翻转: t11 Top2: 项目预算已上调至八千元 上限放宽(总分0.811) | 项目预算上限五千元 不得超支(总分0.632) t20 Top2: 项目预算已上调至八千元 上限放宽(总分0.629) | 项目预算上限五千元 不得超支(总分0.552) t40 Top2: 项目预算已上调至八千元 上限放宽(总分0.525) | 项目预算上限五千元 不得超支(总分0.505) 纯相似度只看词面; 叠加时近项后, 新版本在召回上压过旧版本—— 相关性*新鲜度的合成分, 正是各向量库时间感知检索的雏形。三个观察。其一三条查询的 top1 全部命中主题记忆词面 2-gram 加时近项就能撑起基本可用的召回换成真实 embedding 后差距只会更大——这验证了相关性时近合成分这个骨架本身。其二注意每条查询里都混进了相似度 0.000的纯时近蹭位者最新写入的无关记忆靠时近项挤进 top-k。这就是给时近权重定系数的难点——它奖励新鲜也放噪声进门Generative Agents 论文用recency、importance、relevance 三项加权的打分式是同一个结构系数必须用评测集调不能拍。其三看 t40 那一行新旧两条预算记忆的总分差距从 0.179 缩到 0.020再过几十个时刻时近项抹平谁排前面就纯看词面运气了。向量检索只能倾向新值永远做不到保证新值——需要保证的字段交给下面的结构化路径。实验二结构化记忆的作废追加写入路径用标准库 sqlite3 建一张带有效区间的事实表模拟两次演化事件第 18 轮用户搬家、第 30 轮预算上调验证同一张表如何同时回答现在与当时。importsqlite3 dbsqlite3.connect(:memory:)db.execute( CREATE TABLE fact ( id INTEGER PRIMARY KEY, subject TEXT, predicate TEXT, object TEXT, valid_from INTEGER, valid_to INTEGER -- 逻辑时间: 对话轮次 ))db.executemany(INSERT INTO fact VALUES (NULL,?,?,?,?,?),[(用户,居住在,徐家汇,1,None),(用户,过敏源,花生,1,None),(项目,预算上限,五千元,1,None),(用户,称呼,王工,1,None),])defas_of(t):return{sp:ofors,p,oindb.execute(SELECT subject,predicate,object FROM fact WHERE valid_from? AND (valid_to IS NULL OR valid_to?),(t,t))}defon_event(subject,predicate,new_obj,at):写入路径核心: 关闭旧事实的有效区间, 追加新事实, 历史不被覆盖。olddb.execute(SELECT id, object FROM fact WHERE subject? AND predicate? AND valid_to IS NULL,(subject,predicate)).fetchall()forfid,old_objinold:db.execute(UPDATE fact SET valid_to? WHERE id?,(at,fid))print( 事件 t%d: 作废 [%s%s%s] (valid_to%d)%(at,subject,predicate,old_obj,at))db.execute(INSERT INTO fact VALUES (NULL,?,?,?,?,?),(subject,predicate,new_obj,at,None))db.commit()print( 事件 t%d: 新增 [%s%s%s] (valid_from%d)%(at,subject,predicate,new_obj,at))print( 写入路径: 两条演化事件 )on_event(用户,居住在,张江,18)on_event(项目,预算上限,六千五百元,30)print(\n 读取路径: 同一张表既答现在也答当时 )fortin(10,25,35):viewas_of(t)print(t%2d 时点视图: 居住在%s, 预算上限%s, 过敏源%s%(t,view.get(用户居住在),view.get(项目预算上限),view.get(用户过敏源)))ndb.execute(SELECT COUNT(*) FROM fact).fetchone()[0]curdb.execute(SELECT COUNT(*) FROM fact WHERE valid_to IS NULL).fetchone()[0]print(\n表内 %d 条记录, 当前有效 %d 条, 已作废 %d 条——版本链完整保留%(n,cur,n-cur))print(\n对照: 若用覆盖式更新(UPDATE object新值), t25 回放当时预算是多少)print(将只能查到六千五百元(第 30 轮的值穿越到了第 25 轮)——时间线错乱的根源。)运行输出 写入路径: 两条演化事件 事件 t18: 作废 [用户居住在徐家汇] (valid_to18) 事件 t18: 新增 [用户居住在张江] (valid_from18) 事件 t30: 作废 [项目预算上限五千元] (valid_to30) 事件 t30: 新增 [项目预算上限六千五百元] (valid_from30) 读取路径: 同一张表既答现在也答当时 t10 时点视图: 居住在徐家汇, 预算上限五千元, 过敏源花生 t25 时点视图: 居住在张江, 预算上限五千元, 过敏源花生 t35 时点视图: 居住在张江, 预算上限六千五百元, 过敏源花生 表内 6 条记录, 当前有效 4 条, 已作废 2 条——版本链完整保留 对照: 若用覆盖式更新(UPDATE object新值), t25 回放当时预算是多少 将只能查到六千五百元(第 30 轮的值穿越到了第 25 轮)——时间线错乱的根源。t25 这一行是整篇的戏眼居住已是新值第 18 轮生效预算还是旧值第 30 轮才变——同一时刻、不同字段、各自正确这是当前视图历史回放两个查询负载共存的最小证明。全部代价只是两条纪律演化走作废追加、绝不用 UPDATE 覆盖对象值。多出来的两条作废记录不是垃圾是审计资产用户质疑它什么时候开始认为我住张江时直接查 valid_from。反过来看覆盖式更新的对照组一旦要回答上个月我们说的预算是多少时间线已经损毁只能拿新值硬答而且答得理直气壮——这类错误的恶劣之处在于不可发现。常见陷阱一是把一切写进向量库省事数字与否定在 embedding 里失真“不超过五千元和超过五千元的向量距离近到令人绝望唯一值字段必须有结构化归宿。二是向量库当真相源向量条目没有版本与有效期同一事实的三四条历史变体同时召回模型随机采信一条——症状是 Agent选择性失忆又选择性固执”。三是抽取闸门失守结构化写入完全依赖模型抽取把我觉得可以再看看别的方案固化成预算上限无限制抽取要带置信度低置信落候选区等复核而不是直接生效。四是时近权重当成时间线给检索分加 recency 项只是倾向新值不解决冲突消解——旧值该在写入时作废而不是靠打分压制。五是只有写入没有门查重缺失导致同一偏好存成十条近似向量检索 top-k 全被一个观点的复读占满挤掉了其他真正相关的记忆。落地清单写入前先分流有唯一当前值/需审计的进结构化事实表模糊联想类进向量库画像类两路并写且以结构化取值为准事实表用 valid_from/valid_to 双时间轴演化一律关闭旧区间追加新区间禁止覆盖对象值所有写入过三道闸门与现有有效断言做冲突检测、同义查重、登记来源轮次指针向量检索打分相关性时近重要性三项系数用固定探针集回归测试每次调整跑一遍低置信抽取进候选区不直接生效候选区定期合并复核防止噪声长期沉淀存进去只是开始同一条记忆检索时机不同、注入消息结构的方式不同对回答质量的改变天差地别——早注入的会被后续对话淹没随手注入的会把中段污染成垃圾场。下一篇《AI Agent 记忆与上下文工程实战4记忆检索时机注入方式如何影响回答质量》把什么时候查、查完放哪里做成可量化的对照实验。参考来源Park et al., Generative Agents: Interactive Simulacra of Human Behaviorhttps://arxiv.org/abs/2304.03442Packer et al., MemGPT: Towards LLMs as Operating Systemshttps://arxiv.org/abs/2310.08560Zep Documentation, Agent Memory Conceptshttps://help.getzep.com/GitHub, getzep/graphiti时序知识图谱记忆层https://github.com/getzep/graphitiGitHub, facebookresearch/faiss向量近邻检索库https://github.com/facebookresearch/faissPython 官方文档, sqlite3 — SQL database enginehttps://docs.python.org/3/library/sqlite3.html团队采购 AI 编程工具的可以看云大使专享Qoder 企业版、Token Plan 团队版等 专享入口
网站建设高端定制企业官网