news 2026/8/9 16:19:38

最简记忆:让 Agent 记住你的名字(第77篇-E63)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
最简记忆:让 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 序列化,实测它能完整保住ToolCallsToolCallID
  • Gob 的固定开销有多大:4 条消息 2647 字节,200 条消息才 15775 字节——每条从 662 字节降到 79 字节
  • Write整体覆盖不是追加:实测两个并发请求各写一条,最后只剩一条
  • 100 轮对话 = 200 条消息,每一轮都要全部读出来、拼进 prompt、再全部写回

一、Eino 里没有 memory 组件

先把这个事实说清楚,省得你去翻文档找。

eino/components/下面有modeltooldocumentembeddingindexerretrieverprompt——没有 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 一份消息列表。示例里给了inmemredis两种实现。

注意这个接口的定位——注释写的是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}_,err=m.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)iferr==sql.ErrNoRows{returnnil,nil// 新会话,不是错误}iferr!=nil{returnnil,err}returndecode(b)}

sql.ErrNoRows那行别漏:**新会话没有历史是正常状态,不该当成错误往上抛。**漏了这行,用户第一次说话就会收到 500。

序列化跟官方示例保持一致,用 Gob:

funcencode(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。每轮加两条(用户一条、助手一条)。这个线性增长就是第五节要讲的问题。


五、三个实验

实验 A:Gob 能不能扛住工具调用

对话历史里不只有文本。带工具调用的一轮长这样:assistant 发起tool_call,tool 返回结果,两者靠ToolCallID配对(第 7 篇讲过)。这个配对关系如果在序列化时丢了,下一轮请求就会被模型拒绝——它会看到一个没有对应结果的工具调用。

实测:

══════ 实验 A:Gob 往返会不会丢东西 ══════ 编码后 2647 字节,解回 4 条 ToolCalls / ToolCallID 完整保留? true system sys user 附近有什么川菜馆 assistant tool_call=search_restaurant({"cuisine":"川菜"}) tool 蜀香园

完整保留。Gob 处理 Go 结构体是原生的,嵌套结构、切片、字符串都不丢。

但注意那个字节数:4 条消息编码后 2647 字节。这几条消息的正文加起来不到 40 个字。为什么这么大?

因为Gob 会把类型定义一起写进流里。第一次编码[]*schema.Message时,它要描述清楚这个结构体有哪些字段、什么类型、嵌套了什么——这部分是固定开销。

对比实验 C 的数据就很清楚了:

消息数总字节平均每条
4 条2647662 字节
200 条1577579 字节

差了 8 倍。固定开销被摊薄了。

这意味着:如果你的会话普遍很短(几轮就结束),Gob 的元数据开销占比会非常难看。存一条 20 字的消息,实际占用几百字节。这种场景下 JSON 反而更省——它没有类型描述,但代价是你要自己保证字段能正确反序列化(schema.Message的字段都是导出的,JSON 可行)。

选哪个取决于你的会话长度分布。先量一量再决定,别照抄示例。

实验 B:Write是覆盖,不是追加

这个接口有个容易忽略的语义: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 在测试环境几乎撞不到(你不会手速快到同时发两条),但线上一定会发生:用户点了两次发送、前端重试、多设备同时在线。表现是「消息偶尔丢一条」,极难复现。

三种修法,代价递增:

  1. 乐观锁:表里加version列,WHERE version = $old,更新失败就重读重试。改动小,适合冲突少的场景
  2. 数据库端追加:不整体覆盖,改成UPDATE ... SET messages = messages || $new。要求存储格式支持追加(jsonb 数组可以,bytea 的 Gob 不行)
  3. 一条消息一行:彻底不用「整体覆盖」这个模型。代价是每次读要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_id+user_id,查询时带上,并且用行级安全(RLS)做兜底——不能只靠应用层记得加 WHERE 条件。第 11 篇讲过这套。

**② 并发覆盖。**实验 B 那个,上乐观锁或改成一条一行。

**③ 无限增长。**实验 C 那个,至少要有个上限。

**④ 没有过期清理。**会话结束后这行数据永远躺在库里。合规上通常有保留期限要求(比如 90 天),需要定时清理或者分区表按时间轮转。

**⑤ 内容是明文。**用户可能在对话里说出手机号、身份证号。生产上要么脱敏后再存,要么整列加密。这个话题在 Part 15。

⑥ 没有区分「短期」和「长期」。「我叫张伟」这件事,值得记一辈子;「帮我查报销截止时间」这句,会话结束就可以扔了。现在它们混在同一个数组里,同生同灭——要么一起留着浪费 token,要么一起删掉丢失用户画像

第六点就是下一篇的主题。


小结

  • Eino 没有官方 memory 组件(v0.9.13),官方示例自带一个三方法接口:Write/Read/QueryQuery是子串匹配不是向量检索
  • 记忆没有魔法:读出来、拼上去、写回去。模型 API 本来无状态,所谓记忆就是你把历史数组存在哪
  • 模型答对「你叫张伟」,是因为那句话就在本次请求的第 2 条消息里,不是它真的记住了
  • Gob 完整保留ToolCallsToolCallID,但固定开销大: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版本写的第三种。

对话内容中的「张伟」是虚构人物,无真实个人信息。

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

5步掌握AMD Ryzen处理器调试神器:SMUDebugTool完全指南

5步掌握AMD Ryzen处理器调试神器:SMUDebugTool完全指南 【免费下载链接】SMUDebugTool A dedicated tool to help write/read various parameters of Ryzen-based systems, such as manual overclock, SMU, PCI, CPUID, MSR and Power Table. 项目地址: https://g…

作者头像 李华
网站建设 2026/8/9 16:16:17

Minecraft地图嵌入游戏CG:三种技术方案与模组开发实战

1. 从标题拆解:这到底是个什么项目,解决了什么问题? 看到“我在MC建的bs2地图添加了游戏CG”这个标题,很多MC玩家和地图创作者的第一反应可能是好奇和兴奋。但更实际的问题是:这到底是怎么实现的?它解决了M…

作者头像 李华
网站建设 2026/8/9 16:14:08

Python实战:CNN图像识别从入门到部署

1. 项目概述:CNN图像识别实战入门 去年帮朋友做一个宠物品种识别小程序时,我重新审视了传统图像处理方法的局限性。当需要区分金毛和拉布拉多这种特征相似的犬种时,手工设计特征提取器简直是一场噩梦。这正是卷积神经网络(CNN)大显身手的场景…

作者头像 李华
网站建设 2026/8/9 16:14:06

AI Agent开发语言选型:TypeScript为何成为主流?

1. AI Agent开发语言选型现状解析 最近两年AI Agent开发领域出现了一个有趣的现象:Java、Rust、Go这些传统强类型语言在技术社区被频繁讨论,但实际生产环境中TypeScript却占据了主导地位。作为一名参与过多个AI Agent项目的全栈工程师,我想深…

作者头像 李华
网站建设 2026/8/9 16:12:27

大学生如何利用AI工具实现月入过万

1. AI时代的大学生财富机遇解析2023年ChatGPT的爆发让AI工具呈现井喷式增长,这个技术拐点正在创造全新的商业机会。我注意到一个有趣现象:在校大学生通过倒卖AI工具和服务,月入过万的案例越来越多。这本质上是一种"AI套利"行为——…

作者头像 李华
网站建设 2026/8/9 16:12:09

千笔与知文AI:本科生论文降重与文献管理工具对比

1. 本科生必备的AIGC降重工具横评:千笔VS知文AI作为一名长期关注学术写作工具的教育科技从业者,我注意到最近本科生群体中流传着两个被称为"论文救命神器"的AIGC工具——千笔和知文AI。这让我想起自己本科时通宵改论文格式的痛苦经历。现在的学…

作者头像 李华