news 2026/10/2 20:28:33

AI Agent 记忆与上下文工程实战(3):向量记忆与结构化记忆:长期记忆的写入路径

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AI Agent 记忆与上下文工程实战(3):向量记忆与结构化记忆:长期记忆的写入路径

问题背景

上一篇给摘要装了"分层冻结"的刹车,但被冻结的内容早已不在窗口里——它们要有去处,这就是长期记忆存储。本篇回答一个最容易被跳过的设计题:一条将要离场的信息,到底该存成向量还是存成结构化记录?很多团队的"记忆模块"从第一版起就只有一条路径:全部丢进向量库, embedding 一压了之。三个月后事故清单会教你做人——“预算上限五千元"召回成了"预算比较紧张”,用户上个月已改口的住址新旧两条同时注入提示词,模型挑了旧的。这两个事故的共同根源是存储形态选错:前者该走结构化(唯一当前值、精确回放),后者该走带有效期的结构化(事实会演化)。向量与结构化不是二选一的信仰,而是两类不同的查询负载:联想式召回走向量,断言式查询走结构化。本篇把两条路径的机制、能力边界、以及最重要的——写入路径的闸门设计——一次讲透。

两条路径的机制与边界

向量记忆的形态是:每条记忆一段文本加一枚 embedding 向量,检索时把查询也 embedding 化,按余弦相似度取 top-k。它天然适合"模糊语义联想":查询和记忆措辞完全不同也能牵上线——“帮我订交通方式"能召回"用户讨厌打车”。但边界同样清晰。第一,向量对数字、条件、否定不敏感:"上限五千元"与"上限八千元"的 embedding 几乎贴着,词面相似度反而最低,纯向量检索分不清哪个是当前值。第二,向量本身没有时间概念,一条去年三月的记忆和昨天的记忆在空间里无差别;要体现时近性,必须显式把衰减函数叠进打分。第三,top-k 是定长预算的懒汉方案——k 取小了漏召,取大了把无关记忆也拖进上下文,第二篇说过,无关内容就是负资产。

结构化记忆的形态是:主语-谓语-宾语式的事实记录,或者更工程化的说法——一张带时间戳的表。关键设计不是"用不用 SQL",而是有效期双字段:每条事实记valid_from与valid_to,事实变化时绝不 UPDATE 覆盖,而是"关闭旧区间、追加新区间"。这一个约定同时买到三样东西:当前视图查询(valid_to IS NULL)、任意时点回放(valid_from<=t AND (valid_to IS NULL OR valid_to>t))、以及完整的演化审计链。Zep 那类时序知识图谱的记忆单元就是这个名字带invalid_at字段的版本。它的短板在写入端:从对话里抽出"用户-居住在-张江"需要一个抽取步骤,抽取错误会被当成事实固化,所以结构化写入必须过校验闸门。

分流判据可以浓缩成三问:这条信息有唯一当前值吗(住址、预算、称呼)?回答需要精确复放或审计吗(金额、日期、承诺)?会被措辞完全不同的问题问到吗?前两问任一为"是"走结构化,第三问为"是"走向量,多数用户画像类信息两路并写——向量负责召回入口,结构化负责最终取值。而无论走哪条路,写入路径都要过同一道闸门:查重(同义记忆合并)、冲突检测(与现有有效断言比对)、来源登记(出自哪一轮对话,能不能回放核对)。写入闸门比检索算法重要,这是本篇最想传递的一句话。

实验一:纯标准库的向量记忆原型

下面用 Python 标准库实现一个最小向量记忆:文本切 2-gram 当词面嵌入(生产中替换为真实 embedding 模型),余弦相似度叠加时近指数衰减做综合打分,检索十条客服记忆。本机以确定性模拟演示机制,量级与排序规律可迁移,语义质量必须靠真实模型才能复现。

importmathimportrandom rng=random.Random(490)MEMORIES=["用户喜欢坐地铁通勤 不打车","用户住在徐家汇 靠近地铁站","项目预算上限五千元 不得超支","会议纪要 预算评审 决定砍掉方案B","用户是左撇子 用左手剪刀更顺手","用户养猫名叫煤球 对宠物毛发过敏","接口超时设置为三秒 失败重试两次","用户偏好周五下午开会 避开周一","代码评审通过 要求补充单元测试","用户饮食清淡 不吃香菜和花生",]defshingles(text):"""把文本切成 2-gram, 生产环境替换为真实 embedding 模型输出。"""cleaned="".join(cforcintextifcnotin" ,。")return[cleaned[i:i+2]foriinrange(len(cleaned)-1)]VOCAB=sorted({gforlineinMEMORIESforginshingles(line)})GID={g:ifori,ginenumerate(VOCAB)}defembed(text):vec=[0.0]*len(VOCAB)forginshingles(text):ifginGID:vec[GID[g]]+=1.0+rng.uniform(-0.05,0.05)norm=math.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_k=3):qv=embed(query)scored=[]fortext,vec,tinself.items:sim=cosine(qv,vec)recency=math.exp(-0.10*(t_now-t))# 时近指数衰减scored.append((sim+0.3*recency,sim,recency,text))scored.sort(reverse=True)returnscored[:top_k]mem=VectorMemory()fori,lineinenumerate(MEMORIES):mem.add(line,i+1)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):top=mem.search("预算上限 现在是多少",t_now,top_k=2)line=" | ".join("%s(总分%.3f)"%(text,total)fortotal,_,_,textintop)print(" t=%2d Top2: %s"%(t_now,line))print("\n纯相似度只看词面; 叠加时近项后, 新版本在召回上压过旧版本——")print("相关性*新鲜度的合成分, 正是各向量库'时间感知检索'的雏形。")

运行输出:

查询: 预算上限是多少 (当前时刻 t=10) 总分 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 | 用户饮食清淡 不吃香菜和花生 查询: 用户住在哪个地铁站附近 (当前时刻 t=10) 总分 0.820 = 相似度 0.685 + 0.3*时近 0.449 | 用户住在徐家汇 靠近地铁站 总分 0.430 = 相似度 0.000 + 0.3*时近 1.000 | 用户饮食清淡 不吃香菜和花生 总分 0.402 = 相似度 0.280 + 0.3*时近 0.407 | 用户喜欢坐地铁通勤 不打车 查询: 养宠物需要注意什么 (当前时刻 t=10) 总分 0.459 = 相似度 0.258 + 0.3*时近 0.670 | 用户养猫名叫煤球 对宠物毛发过敏 总分 0.300 = 相似度 0.000 + 0.3*时近 1.000 | 用户饮食清淡 不吃香菜和花生 总分 0.271 = 相似度 0.000 + 0.3*时近 0.905 | 代码评审通过 要求补充单元测试 同主题两条记忆先后写入时, 召回排序随时间翻转: t=11 Top2: 项目预算已上调至八千元 上限放宽(总分0.811) | 项目预算上限五千元 不得超支(总分0.632) t=20 Top2: 项目预算已上调至八千元 上限放宽(总分0.629) | 项目预算上限五千元 不得超支(总分0.552) t=40 Top2: 项目预算已上调至八千元 上限放宽(总分0.525) | 项目预算上限五千元 不得超支(总分0.505) 纯相似度只看词面; 叠加时近项后, 新版本在召回上压过旧版本—— 相关性*新鲜度的合成分, 正是各向量库'时间感知检索'的雏形。

三个观察。其一,三条查询的 top1 全部命中主题记忆,词面 2-gram 加时近项就能撑起基本可用的召回,换成真实 embedding 后差距只会更大——这验证了"相关性+时近"合成分这个骨架本身。其二,注意每条查询里都混进了"相似度 0.000"的纯时近蹭位者:最新写入的无关记忆靠时近项挤进 top-k。这就是给时近权重定系数的难点——它奖励新鲜,也放噪声进门,Generative Agents 论文用"recency、importance、relevance 三项加权"的打分式是同一个结构,系数必须用评测集调,不能拍。其三,看 t=40 那一行:新旧两条预算记忆的总分差距从 0.179 缩到 0.020,再过几十个时刻时近项抹平,谁排前面就纯看词面运气了。向量检索只能"倾向新值",永远做不到"保证新值"——需要保证的字段,交给下面的结构化路径。

实验二:结构化记忆的"作废+追加"写入路径

用标准库 sqlite3 建一张带有效区间的事实表,模拟两次演化事件(第 18 轮用户搬家、第 30 轮预算上调),验证同一张表如何同时回答"现在"与"当时"。

importsqlite3 db=sqlite3.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{s+p: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):"""写入路径核心: 关闭旧事实的有效区间, 追加新事实, 历史不被覆盖。"""old=db.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):view=as_of(t)print("t=%2d 时点视图: 居住在=%s, 预算上限=%s, 过敏源=%s"%(t,view.get("用户居住在"),view.get("项目预算上限"),view.get("用户过敏源")))n=db.execute("SELECT COUNT(*) FROM fact").fetchone()[0]cur=db.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=新值), t=25 回放'当时预算是多少'")print("将只能查到六千五百元(第 30 轮的值穿越到了第 25 轮)——时间线错乱的根源。")

运行输出:

=== 写入路径: 两条演化事件 === 事件 t=18: 作废 [用户居住在=徐家汇] (valid_to=18) 事件 t=18: 新增 [用户居住在=张江] (valid_from=18) 事件 t=30: 作废 [项目预算上限=五千元] (valid_to=30) 事件 t=30: 新增 [项目预算上限=六千五百元] (valid_from=30) === 读取路径: 同一张表既答'现在'也答'当时' === t=10 时点视图: 居住在=徐家汇, 预算上限=五千元, 过敏源=花生 t=25 时点视图: 居住在=张江, 预算上限=五千元, 过敏源=花生 t=35 时点视图: 居住在=张江, 预算上限=六千五百元, 过敏源=花生 表内 6 条记录, 当前有效 4 条, 已作废 2 条——版本链完整保留 对照: 若用'覆盖式更新'(UPDATE object=新值), t=25 回放'当时预算是多少' 将只能查到六千五百元(第 30 轮的值穿越到了第 25 轮)——时间线错乱的根源。

t=25 这一行是整篇的戏眼:居住已是新值(第 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 Behavior:https://arxiv.org/abs/2304.03442
  • Packer et al., MemGPT: Towards LLMs as Operating Systems:https://arxiv.org/abs/2310.08560
  • Zep Documentation, Agent Memory Concepts:https://help.getzep.com/
  • GitHub, getzep/graphiti(时序知识图谱记忆层):https://github.com/getzep/graphiti
  • GitHub, facebookresearch/faiss(向量近邻检索库):https://github.com/facebookresearch/faiss
  • Python 官方文档, sqlite3 — SQL database engine:https://docs.python.org/3/library/sqlite3.html

团队采购 AI 编程工具的可以看云大使专享:Qoder 企业版、Token Plan 团队版等 专享入口

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 20:27:12

ESP32双协议网关:WiFi与BLE融合的智能家居实战

1. 项目概述&#xff1a;用ESP32把WiFi和BLE捏合到一套智能家居里家里设备多起来之后&#xff0c;我最大的痛点不是“缺一个遥控器”&#xff0c;而是为了控制不同东西装了五六个App&#xff1a;灯的App、插座App、加湿器App、体脂秤App&#xff0c;界面各不相同&#xff0c;数…

作者头像 李华
网站建设 2026/10/2 20:26:28

全速域PMSM无感FOC控制:高频注入与滑模观测器的工程实现

1. 项目解读与全速域无感控制选型1.1 这个版本到底在解决什么问题搞电机控制的兄弟看到这个工程名应该会心一笑。B1.1版本&#xff0c;全速域永磁同步电机无感控制&#xff0c;低速段用高频注入做转子初始位置辨识&#xff0c;中高速段交给滑模观测器SMO&#xff0c;中间用权重…

作者头像 李华
网站建设 2026/10/2 20:25:38

kimi、GLM、deepseek 模型对比:用 TaoToken 统一 Key 跑通三模型配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 20:25:34

STM32参考设计查找指南:官方渠道与开源平台使用经验

做嵌入式这几年&#xff0c;我越来越觉得&#xff0c;STM32项目最值钱的东西不是代码&#xff0c;而是那套“已经有人验证过的电路和程序”。很多人拿到一个新需求&#xff0c;第一反应是打开数据手册从头啃&#xff0c;或者在群里问“这个模块怎么接”&#xff0c;其实最高效的…

作者头像 李华