云计算百科
云计算领域专业知识百科平台

最简记忆:让 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 和 ToolCallID
  • Gob 的固定开销有多大: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.
type MemoryStore interface {
Write(ctx context.Context, sessionID string, msgs []*schema.Message) error
Read(ctx context.Context, sessionID string) ([]*schema.Message, error)
Query(ctx context.Context, sessionID, text string, limit int) ([]*schema.Message, error)
}

三个方法,一个 session 一份消息列表。 示例里给了 inmem 和 redis 两种实现。

注意这个接口的定位——注释写的是 short-term conversation history(短期对话历史)。它管的是「这一轮会话说过什么」,不是「这个用户是谁」。后者要到第 78 篇讲三层设计时才出现。

还要注意 Query 这个方法。示例的实现是子串匹配:

// Query performs a simple substring search on message contents for the session.
if strings.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 实现

表结构极简:

CREATE TABLE sessions (
session_id text PRIMARY KEY,
messages bytea NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);

一个会话一行,整个消息列表序列化成一个 bytea。

写:

func (m *pgMemory) Write(ctx context.Context, sessionID string, msgs []*schema.Message) error {
b, err := encode(msgs)
if err != nil {
return err
}
_, 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)
return err
}

读:

func (m *pgMemory) Read(ctx context.Context, sessionID string) ([]*schema.Message, error) {
var b []byte
err := m.db.QueryRowContext(ctx,
`SELECT messages FROM sessions WHERE session_id = $1`, sessionID).Scan(&b)
if err == sql.ErrNoRows {
return nil, nil // 新会话,不是错误
}
if err != nil {
return nil, err
}
return decode(b)
}

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

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

func encode(msgs []*schema.Message) ([]byte, error) {
var buf bytes.Buffer
if err := gob.NewEncoder(&buf).Encode(msgs); err != nil {
return nil, err
}
return buf.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 条 2647 662 字节
200 条 15775 79 字节

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

三种修法,代价递增:

  • 乐观锁:表里加 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_id + user_id,查询时带上,并且用行级安全(RLS)做兜底——不能只靠应用层记得加 WHERE 条件。第 11 篇讲过这套。

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

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

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

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

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

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


    小结

    • Eino 没有官方 memory 组件(v0.9.13),官方示例自带一个三方法接口:Write / Read / Query,Query 是子串匹配不是向量检索
    • 记忆没有魔法:读出来、拼上去、写回去。模型 API 本来无状态,所谓记忆就是你把历史数组存在哪
    • 模型答对「你叫张伟」,是因为那句话就在本次请求的第 2 条消息里,不是它真的记住了
    • Gob 完整保留 ToolCalls 和 ToolCallID,但固定开销大:4 条消息平均每条 662 字节,200 条时降到 79 字节。短会话多的场景要量一量再选序列化格式
    • Write 是整体覆盖:实测两个并发请求各追加一条,最后只剩一条。线上表现是「偶尔丢消息」且极难复现
    • 生产上迟早要改成一条消息一行,否则分页、撤回、按时间查询全做不了
    • 100 轮对话 = 200 条消息 = 每轮多花 3000 token,裁剪 / 摘要 / 分层是后面三篇的主题
    • 最缺的是用户隔离:记忆串了不是答得不好,是隐私事故

    下一篇讲三层记忆设计:user / agent / session 为什么要分三层,「我叫张伟」和「帮我查报销时间」这两句话凭什么区别对待,以及分层之后读写路径怎么变。


    代码状态说明:

    全部输出真机运行、原样粘贴。数据库是本地 PostgreSQL 18.4,全程在临时 schema e77demo 内操作,跑完 DROP SCHEMA e77demo CASCADE,未触碰业务表。

    没有调用任何 LLM(无 API Key)。三轮对话里助手的回复是我写死的常量,重点在于展示「每轮实际发给模型的消息列表」——那部分是真实拼装出来的。

    接口定义与 Gob 序列化引自 eino-examples/flow/agent/react/memory_example/,PostgreSQL 实现是我照着示例的 inmem / redis 版本写的第三种。

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

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 最简记忆:让 Agent 记住你的名字(第77篇-E63)
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!