adk-go-memory
adk-go Memory详解
Memory 负责是“跨对话线程以后还能想起什么”。
在 adk-go 里,Memory 不是自动替你保存所有历史的魔法盒。它更像一个长期记忆服务接口:你把某个 Session 里的事件喂进去,后面再按用户问题把相关记忆查出来,交给 Agent 使用。
所以先把边界说清楚:
- Session 解决短期上下文,也就是同一个
sessionID内的历史、状态和事件。 - Memory 解决长期检索,也就是同一个用户跨多个 Session 的历史知识。
- Runner 不会默认把每个 Session 自动写进 Memory,你需要在合适时机调用
AddSessionToMemory。 - adk-go v1.0.0 内置的
memory.InMemoryService()只是内存版关键词检索,不是生产级向量数据库。
如果你只是想让 Agent 接着上一轮聊,优先用 Session。只有当你希望“新开一个 Session 之后,Agent 还能检索过去的有用信息”,才需要 Memory。
Memory的核心接口
adk-go 的长期记忆接口在 google.golang.org/adk/memory 包里。核心接口很小:
1 | type Service interface { |
AddSessionToMemory:把一个 Session 里的事件抽取进 Memory。官方注释里也说明了,同一个 Session 生命周期里可以被添加多次。SearchMemory:根据查询条件检索记忆,返回相关的Entry列表。SearchRequest.Query:这次要查什么。SearchRequest.UserID:只查某个用户的记忆,避免用户之间串数据。SearchRequest.AppName:只查某个应用里的记忆,避免不同应用之间串数据。Entry.Content:记忆正文,类型是*genai.Content,所以它不是只能存字符串,也能沿用 genai 的消息结构。Entry.Author:这条记忆是谁说的,常见是user、model或某个 agent 名称。Entry.Timestamp:这条记忆发生的时间。Entry.CustomMetadata:自定义元数据,比如来源、标签、重要性、业务 ID。
另外在 Agent 运行时,agent.InvocationContext 里拿到的不是原始 memory.Service,而是更窄一点的 agent.Memory:
1 | type Memory interface { |
这里 SearchMemory 只需要传 query,因为 Runner 已经知道当前的 AppName、UserID 和 SessionID,会在内部帮你补进 memory.SearchRequest。
Memory什么时候用
Memory 的使用时机:
第一类是“写入时机”:
- 一段会话结束后,把整个 Session 添加到 Memory。
- 用户明确说了一条偏好,比如“以后回答我都用中文”,可以把包含这条信息的 Session 或摘要写入 Memory。
- Agent 完成一个阶段性任务后,把总结事件写进 Session,再把 Session 同步到 Memory。
- 后台定时任务扫描最近完成的 Session,异步做记忆抽取和索引。
第二类是“读取时机”:
- 新 Session 开始时,用户问到了过去的信息。
- Agent 需要根据历史偏好回答,比如用户的城市、技术栈、常用模型。
- 当前 Session 的上下文不够,需要跨 Session 查找过去说过的内容。
注意一点:Memory 不应该替代 Session。当前对话还在同一个 Session 里时,Session 的事件流就是最直接的上下文。Memory 更适合“换了一个 Session 之后还能找回来”的场景。
Memory如何使用
最小使用方式就是准备两个服务:
1 | sessionSvc := session.InMemoryService() |
然后把旧会话写入 Memory:
1 | previousResp, err := sessionSvc.Create(ctx, &session.CreateRequest{ |
后面在新会话里搜索:
1 | resp, err := memorySvc.SearchMemory(ctx, &memory.SearchRequest{ |
这是最底层的用法。真正跑 Agent 时,一般会把它交给 Runner:
1 | r, err := runner.New(runner.Config{ |
这样 InvocationContext 里就能拿到 Memory:
1 | func myRun(ctx agent.InvocationContext) iter.Seq2[*session.Event, error] { |
如果你用的是 llmagent,官方已经提供了两个工具:
1 | import ( |
这两个工具的区别很直接:
preload_memory:每次 LLM 请求前自动用当前用户输入查 Memory,然后把结果拼进系统指令。load_memory:作为一个函数工具暴露给模型,由模型在需要时主动调用,入参是query。
我的建议是:如果你希望每次都尽量带上相关记忆,用 preload_memory;如果你希望模型自己判断什么时候查记忆,用 load_memory;如果你在做严肃业务流程,不想把“是否查记忆”的决策交给模型,就自己在 Agent 或 Tool 里直接调用 ctx.Memory().SearchMemory(...)。
内置InMemoryService做了什么
adk-go v1.0/1.1.x 内置的 Memory 实现仍然只有 memory.InMemoryService()。它是线程安全的内存实现,但逻辑非常轻量:
AddSessionToMemory会遍历Session.Events()。- 只处理
event.LLMResponse.Content不为空的事件。 - 只提取
Content.Parts里的文本。 - 用空格切词,然后统一转小写。
- 按
appName + userID + sessionID存到内存 map 里。 SearchMemory同样对 query 空格切词,只要 query 里的词和记忆文本里的词有交集,就算命中。
所以它的特点也很明显:
- 进程重启后记忆全部丢失。
- 不做向量检索。
- 不做语义召回。
- 不做中文分词。
- 同一个 Session 再次
AddSessionToMemory时,会用当前 Session 的事件列表覆盖旧的那份内存记录。
这对 demo 和单测很方便,但生产环境一般不够。尤其是中文内容,如果你写的是“我喜欢后端开发”,而 query 是“后端”,内置实现因为只按空格切词,未必能按你预期命中。
Memory的持久化
Session 有官方的 session/database 可以接 GORM,也有 session/vertexai 可以接 Vertex AI Agent Engine Session;但 Memory 在 v1.0/1.1.x 这一层仍然没有对应的 memory/database 包。也就是说,如果你要长期持久化 Memory,常规做法是自己实现 memory.Service。
工程上可以拆成两层:
- Session 持久化:保存原始会话历史,用
session/database。 - Memory 持久化:保存抽取后的长期记忆,用自定义
memory.Service,底层可以是 MySQL、PostgreSQL、Redis、Elasticsearch、Milvus、pgvector、Qdrant 等。
表结构可以先按这个思路设计:
1 | type MemoryRecord struct { |
如果要做向量检索,再加:
1 | type MemoryRecord struct { |
不过实际落库时,Embedding 怎么存取决于你的数据库。MySQL 可能放 JSON 或单独向量表,PostgreSQL 可以用 pgvector,专门的向量数据库则通常会把向量和 metadata 一起写到 collection 里。
自定义服务的骨架大概是这样:
1 | type MyMemoryService struct { |
extractText 可以按你的业务来写:
1 | func extractText(content *genai.Content) string { |
这就是 Memory 持久化的关键:上层仍然满足 memory.Service,下层你自己决定用普通数据库、全文检索还是向量检索。
自定义Memory的添加内容
内置实现会把 Session 里所有有文本内容的事件都提取进去。这很简单,但不一定符合业务。
比如用户说:
今天随便聊聊,帮我写个 demo。
这句话未必值得长期记忆。真正有价值的可能是:
我更喜欢 Go,数据库优先用 MySQL,前端不想用太重的框架。
所以生产环境里最好不要粗暴保存所有事件,而是自己做“记忆抽取”。
一个简单办法是用 CustomMetadata 标记哪些事件应该进长期记忆:
1 | event := session.NewEvent("profile-update") |
然后在自定义 AddSessionToMemory 里过滤:
1 | func shouldRemember(event *session.Event) bool { |
更进一步,可以先让模型做一次摘要或结构化抽取,再把摘要写入 Memory:
1 | type UserPreferenceMemory struct { |
这样 Memory 里存的不是原始闲聊,而是更干净的长期事实:
1 | { |
我的建议是:原始对话放 Session,长期事实放 Memory。Memory 不是垃圾桶,越克制越好用。
自定义Memory的查找功能
内置查找是关键词匹配。如果你要更像真实产品,一般会换成下面几种方式之一:
- SQL
LIKE:最简单,但召回质量一般。 - 全文索引:适合日志、问答、短文本。
- BM25:适合传统文本检索。
- 向量检索:适合语义召回。
- 混合检索:BM25 + 向量召回,再用 rerank 排序。
接口层仍然不用变:
1 | func (s *MyMemoryService) SearchMemory(ctx context.Context, req *memory.SearchRequest) (*memory.SearchResponse, error) { |
这里最重要的是隔离条件:
1 | AppName = req.AppName |
Memory 是用户级长期记忆,如果检索时不加用户隔离,很容易把 A 用户的偏好返回给 B 用户。这类 bug 在 Agent 场景里很危险。
最后总结
理解 adk-go 的 Memory,重点记住这几句话:
- Memory 是跨 Session 的长期检索,不是当前 Session 的聊天历史。
- v1.0/1.1.x 的核心接口仍然是
AddSessionToMemory和SearchMemory。 - Runner 只负责把
MemoryService包进运行上下文,不会自动帮你决定什么时候写入长期记忆。 - 内置
InMemoryService是内存关键词版,适合 demo,不适合生产。 - 真正可用的 Memory 通常需要你自己实现持久化、抽取策略和检索排序。
所以 Memory 这层最核心的价值,不是“多一个存储服务”,而是给 Agent 提供了一个标准接口:过去哪些信息值得被想起,以及当前问题该想起哪几条。
