最简记忆:让 Agent 记住你的名字(第77篇-E63) 系列「企业级 AI Agent 实现拆解」E63 篇Part 14 记忆篇第一章。上一篇收完了 RAG——那解决的是「Agent 懂业务」。这篇开始讲记忆解决的是「Agent 记得你」。先给一个可能让你意外的事实Eino 框架里没有官方的 memory 组件。记忆不是框架帮你做的事是你自己拼出来的。这篇就把这十几行拼装代码拆开看。先分清楚记忆和 RAG 不是一回事两者都是「存东西、查东西、拼进 prompt」非常容易混。但它们回答的是不同的问题RAG知识库记忆存的是什么公司文档、产品手册——所有人共享这个用户说过的话——一人一份谁写进去的管理员上传离线索引用户自己每轮对话实时产生怎么取语义检索取最相关的几片按会话 ID 取通常全都要过期了怎么办文档改版就重新索引越久远越不重要要衰减、要淘汰答错的后果答案不准可能泄露别人的隐私最后一行是记忆最要命的地方RAG 检索错了只是答得不好记忆串了是事故——把 A 用户的对话内容拼进了 B 用户的 prompt。所以这一 Part 从第一篇起就要把「隔离」这件事放在心上。读完这篇你会知道Eino 没有官方 memory 组件官方示例里那个MemoryStore接口只有三个方法记忆的本质就三步读出来、拼上去、写回去没有任何框架魔法一个 40 行的 PostgreSQL 实现以及每轮实际发给模型的消息列表长什么样官方示例用 Gob 而不是 JSON 序列化实测它能完整保住ToolCalls和ToolCallIDGob 的固定开销有多大4 条消息 2647 字节200 条消息才 15775 字节——每条从 662 字节降到 79 字节Write是整体覆盖不是追加实测两个并发请求各写一条最后只剩一条100 轮对话 200 条消息每一轮都要全部读出来、拼进 prompt、再全部写回一、Eino 里没有 memory 组件先把这个事实说清楚省得你去翻文档找。eino/components/下面有model、tool、document、embedding、indexer、retriever、prompt——没有 memory。仓库里能搜到两个远程分支叫feat/auto_memory_mw说明官方在做但截至v0.9.13没有合进主干。那记忆怎么办看官方示例eino-examples/flow/agent/react/memory_example/它自己定义了一个接口// MemoryStore persists and restores short-term conversation history.typeMemoryStoreinterface{Write(ctx context.Context,sessionIDstring,msgs[]*schema.Message)errorRead(ctx context.Context,sessionIDstring)([]*schema.Message,error)Query(ctx context.Context,sessionID,textstring,limitint)([]*schema.Message,error)}三个方法一个 session 一份消息列表。示例里给了inmem和redis两种实现。注意这个接口的定位——注释写的是short-term conversation history短期对话历史。它管的是「这一轮会话说过什么」不是「这个用户是谁」。后者要到第 78 篇讲三层设计时才出现。还要注意Query这个方法。示例的实现是子串匹配// Query performs a simple substring search on message contents for the session.ifstrings.Contains(strings.ToLower(m.Content),q){不是向量检索。这是记忆和 RAG 的又一处分野——会话历史通常几十上百条直接扫一遍就行上向量库是杀鸡用牛刀。等到记忆多到需要语义检索时那已经是长期记忆的范畴了。二、记忆的本质读出来、拼上去、写回去看官方示例是怎么把记忆接进 agent 的一共就三行关键代码prev,_:store.Read(ctx,sessionID)// ① 读出历史eff:append(prev,schema.UserMessage(turn))// ② 拼上本轮输入// ... 调用 agent拿到回复 ...store.Write(ctx,sessionID,updated)// ③ 整体写回没有中间件没有自动注入没有框架魔法。就是你自己从数据库读一个数组、往后面 append、再存回去。这件事值得强调因为很多人会觉得「记忆」是个高深功能。它不是。大模型的 API 本来就是无状态的——你每次调用都得把完整的对话历史发过去模型才知道之前说了什么。所谓「记忆」就是你在两次 API 调用之间把这个历史数组存在哪里。存在进程内存里 → 重启就没了存在 Redis 里 → 跨进程能共享存在 PostgreSQL 里 → 能持久化、能查询、能跟其他业务数据一起做事务第三种就是这篇要写的。三、40 行的 PostgreSQL 实现表结构极简CREATETABLEsessions(session_idtextPRIMARYKEY,messages byteaNOTNULL,updated_at timestamptzNOTNULLDEFAULTnow());一个会话一行整个消息列表序列化成一个bytea。写func(m*pgMemory)Write(ctx context.Context,sessionIDstring,msgs[]*schema.Message)error{b,err:encode(msgs)iferr!nil{returnerr}_,errm.db.ExecContext(ctx, INSERT INTO sessions (session_id, messages) VALUES ($1, $2) ON CONFLICT (session_id) DO UPDATE SET messages EXCLUDED.messages, updated_at now(),sessionID,b)returnerr}读func(m*pgMemory)Read(ctx context.Context,sessionIDstring)([]*schema.Message,error){varb[]byteerr:m.db.QueryRowContext(ctx,SELECT messages FROM sessions WHERE session_id $1,sessionID).Scan(b)iferrsql.ErrNoRows{returnnil,nil// 新会话不是错误}iferr!nil{returnnil,err}returndecode(b)}sql.ErrNoRows那行别漏**新会话没有历史是正常状态不该当成错误往上抛。**漏了这行用户第一次说话就会收到 500。序列化跟官方示例保持一致用 Gobfuncencode(msgs[]*schema.Message)([]byte,error){varbuf bytes.Bufferiferr:gob.NewEncoder(buf).Encode(msgs);err!nil{returnnil,err}returnbuf.Bytes(),nil}为什么是 Gob 不是 JSON下一节实测。四、跑三轮看消息列表怎么攒起来三轮对话第三轮问「那我叫什么名字来着」。助手的回复是我写死的没有 API Key重点看每轮实际发给模型的消息列表══════ 第 1 轮 ══════ 用户输入我叫张伟在财务部 ① 读出历史0 条 ② 发给模型2 条 [0] system 你是一个简洁的助手请在多轮对话中保持上下文。 [1] user 我叫张伟在财务部 ③ 写回历史2 条 ══════ 第 2 轮 ══════ 用户输入帮我查一下报销的截止时间 ① 读出历史2 条 ② 发给模型4 条 [0] system 你是一个简洁的助手请在多轮对话中保持上下文。 [1] user 我叫张伟在财务部 [2] assistant 好的张伟有什么可以帮你的 [3] user 帮我查一下报销的截止时间 ③ 写回历史4 条 ══════ 第 3 轮 ══════ 用户输入那我叫什么名字来着 ① 读出历史4 条 ② 发给模型6 条 [0] system 你是一个简洁的助手请在多轮对话中保持上下文。 [1] user 我叫张伟在财务部 [2] assistant 好的张伟有什么可以帮你的 [3] user 帮我查一下报销的截止时间 [4] assistant 费用发生后 30 天内提交超期不予受理。 [5] user 那我叫什么名字来着 ③ 不调 LLM。但 Query(张伟) 在历史里命中 2 条 —— 名字确实带进去了 user 我叫张伟在财务部 assistant 好的张伟有什么可以帮你的第三轮那个[1] user 我叫张伟在财务部就是全部的秘密。模型能答对「你叫张伟」不是因为它记住了什么而是因为那句话就明明白白写在这次请求的第二条消息里。你把它读出来拼进去了它就记得你不拼它就忘了。系统提示词里那句「请在多轮对话中保持上下文」也不是魔法——它只是让模型愿意去引用前面的内容前提仍然是那些内容得在消息列表里。注意消息数的增长2 → 4 → 6。每轮加两条用户一条、助手一条。这个线性增长就是第五节要讲的问题。五、三个实验实验 AGob 能不能扛住工具调用对话历史里不只有文本。带工具调用的一轮长这样assistant 发起tool_calltool 返回结果两者靠ToolCallID配对第 7 篇讲过。这个配对关系如果在序列化时丢了下一轮请求就会被模型拒绝——它会看到一个没有对应结果的工具调用。实测══════ 实验 AGob 往返会不会丢东西 ══════ 编码后 2647 字节解回 4 条 ToolCalls / ToolCallID 完整保留 true system sys user 附近有什么川菜馆 assistant tool_callsearch_restaurant({cuisine:川菜}) tool 蜀香园完整保留。Gob 处理 Go 结构体是原生的嵌套结构、切片、字符串都不丢。但注意那个字节数4 条消息编码后 2647 字节。这几条消息的正文加起来不到 40 个字。为什么这么大因为Gob 会把类型定义一起写进流里。第一次编码[]*schema.Message时它要描述清楚这个结构体有哪些字段、什么类型、嵌套了什么——这部分是固定开销。对比实验 C 的数据就很清楚了消息数总字节平均每条4 条2647662 字节200 条1577579 字节差了 8 倍。固定开销被摊薄了。这意味着如果你的会话普遍很短几轮就结束Gob 的元数据开销占比会非常难看。存一条 20 字的消息实际占用几百字节。这种场景下 JSON 反而更省——它没有类型描述但代价是你要自己保证字段能正确反序列化schema.Message的字段都是导出的JSON 可行。选哪个取决于你的会话长度分布。先量一量再决定别照抄示例。实验 BWrite是覆盖不是追加这个接口有个容易忽略的语义Write(sessionID, msgs)是用 msgs 整体替换这个会话的历史不是往后追加。单线程没问题。但用户在两个标签页同时发消息呢══════ 实验 B两个请求同时写会怎样 ══════ 两个请求各追加 1 条期望 3 条实际 2 条 user 第 0 条 user 来自请求 A → Write 是整体覆盖后写的赢先写的那条消息消失了期望 3 条实际 2 条请求 B 的消息凭空消失了。过程是这样的请求 A读出 [第0条] → 拼成 [第0条, A] → 写回 请求 B读出 [第0条] → 拼成 [第0条, B] → 写回两个请求都基于同一份旧历史做了追加然后各自整体写回——后写的那个把先写的覆盖了。这是典型的「读-改-写」竞态lost update。这个 bug 在测试环境几乎撞不到你不会手速快到同时发两条但线上一定会发生用户点了两次发送、前端重试、多设备同时在线。表现是「消息偶尔丢一条」极难复现。三种修法代价递增乐观锁表里加version列WHERE version $old更新失败就重读重试。改动小适合冲突少的场景数据库端追加不整体覆盖改成UPDATE ... SET messages messages || $new。要求存储格式支持追加jsonb 数组可以bytea 的 Gob 不行一条消息一行彻底不用「整体覆盖」这个模型。代价是每次读要ORDER BY seq拼装**生产上基本都会走到第 3 种。**一条一行之后你才能做分页加载、单条删除用户撤回、按时间范围查询、只读最近 N 条——这些用 blob 存法一个都做不了。实验 C历史会一直长下去══════ 实验 C历史长度与体积 ══════ 100 轮对话 200 条消息4184 个字符 Gob 编码后 15775 字节库里存了 15775 字节 → 每一轮都要把这 200 条全部读出来、拼进 prompt、再整体写回100 轮对话不算多——一个客服会话聊半小时就有了。但此时每一轮都要从数据库读 15KB、反序列化 200 条消息每一轮都要把这 200 条塞进 prompt 发给模型每一轮都要重新序列化 200 条、写回 15KB第二条是真正的痛点。4184 个字符大约是 3000 个 token中文粗算每轮对话你都要为这 3000 个 token 付一次钱而且用户问的可能只是「谢谢」。更糟的是它会撞上上下文窗口的硬上限。到那时请求会直接报错——而报错发生在你已经付了检索和序列化成本之后。这个问题有三种解法分别是后面三篇的主题裁剪只带最近 N 轮第 83 篇摘要把早期对话压缩成一段摘要第 82 篇分层把「用户是谁」从「说过什么」里抽出来单独存不占对话历史的位置第 78、81 篇六、这个最简版还差什么按严重程度排① 没有用户隔离——最致命。我们的 key 只有session_id。如果 session ID 是可猜的比如自增数字换一个 ID 就能读到别人的对话。记忆比 RAG 更需要隔离因为里面是用户亲口说的话。生产上至少要表里存tenant_iduser_id查询时带上并且用行级安全RLS做兜底——不能只靠应用层记得加 WHERE 条件。第 11 篇讲过这套。**② 并发覆盖。**实验 B 那个上乐观锁或改成一条一行。**③ 无限增长。**实验 C 那个至少要有个上限。**④ 没有过期清理。**会话结束后这行数据永远躺在库里。合规上通常有保留期限要求比如 90 天需要定时清理或者分区表按时间轮转。**⑤ 内容是明文。**用户可能在对话里说出手机号、身份证号。生产上要么脱敏后再存要么整列加密。这个话题在 Part 15。⑥ 没有区分「短期」和「长期」。「我叫张伟」这件事值得记一辈子「帮我查报销截止时间」这句会话结束就可以扔了。现在它们混在同一个数组里同生同灭——要么一起留着浪费 token要么一起删掉丢失用户画像。第六点就是下一篇的主题。小结Eino 没有官方 memory 组件v0.9.13官方示例自带一个三方法接口Write/Read/QueryQuery是子串匹配不是向量检索记忆没有魔法读出来、拼上去、写回去。模型 API 本来无状态所谓记忆就是你把历史数组存在哪模型答对「你叫张伟」是因为那句话就在本次请求的第 2 条消息里不是它真的记住了Gob 完整保留ToolCalls和ToolCallID但固定开销大4 条消息平均每条 662 字节200 条时降到 79 字节。短会话多的场景要量一量再选序列化格式Write是整体覆盖实测两个并发请求各追加一条最后只剩一条。线上表现是「偶尔丢消息」且极难复现生产上迟早要改成一条消息一行否则分页、撤回、按时间查询全做不了100 轮对话 200 条消息 每轮多花 3000 token裁剪 / 摘要 / 分层是后面三篇的主题最缺的是用户隔离记忆串了不是答得不好是隐私事故下一篇讲三层记忆设计user / agent / session 为什么要分三层「我叫张伟」和「帮我查报销时间」这两句话凭什么区别对待以及分层之后读写路径怎么变。代码状态说明全部输出真机运行、原样粘贴。数据库是本地 PostgreSQL 18.4全程在临时 schemae77demo内操作跑完DROP SCHEMA e77demo CASCADE未触碰业务表。没有调用任何 LLM无 API Key。三轮对话里助手的回复是我写死的常量重点在于展示「每轮实际发给模型的消息列表」——那部分是真实拼装出来的。接口定义与 Gob 序列化引自eino-examples/flow/agent/react/memory_example/PostgreSQL 实现是我照着示例的inmem/redis版本写的第三种。对话内容中的「张伟」是虚构人物无真实个人信息。