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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Service interface {
AddSessionToMemory(ctx context.Context, s session.Session) error
SearchMemory(ctx context.Context, req *SearchRequest) (*SearchResponse, error)
}

type SearchRequest struct {
Query string
UserID string
AppName string
}

type SearchResponse struct {
Memories []Entry
}

type Entry struct {
ID string
Content *genai.Content
Author string
Timestamp time.Time
CustomMetadata map[string]any
}
  1. AddSessionToMemory:把一个 Session 里的事件抽取进 Memory。官方注释里也说明了,同一个 Session 生命周期里可以被添加多次。
  2. SearchMemory:根据查询条件检索记忆,返回相关的 Entry 列表。
  3. SearchRequest.Query:这次要查什么。
  4. SearchRequest.UserID:只查某个用户的记忆,避免用户之间串数据。
  5. SearchRequest.AppName:只查某个应用里的记忆,避免不同应用之间串数据。
  6. Entry.Content:记忆正文,类型是 *genai.Content,所以它不是只能存字符串,也能沿用 genai 的消息结构。
  7. Entry.Author:这条记忆是谁说的,常见是 usermodel 或某个 agent 名称。
  8. Entry.Timestamp:这条记忆发生的时间。
  9. Entry.CustomMetadata:自定义元数据,比如来源、标签、重要性、业务 ID。

另外在 Agent 运行时,agent.InvocationContext 里拿到的不是原始 memory.Service,而是更窄一点的 agent.Memory

1
2
3
4
type Memory interface {
AddSessionToMemory(context.Context, session.Session) error
SearchMemory(ctx context.Context, query string) (*memory.SearchResponse, error)
}

这里 SearchMemory 只需要传 query,因为 Runner 已经知道当前的 AppNameUserIDSessionID,会在内部帮你补进 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
2
sessionSvc := session.InMemoryService()
memorySvc := memory.InMemoryService()

然后把旧会话写入 Memory:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
previousResp, err := sessionSvc.Create(ctx, &session.CreateRequest{
AppName: "memory_app",
UserID: "u_1001",
SessionID: "sess_old",
})
if err != nil {
log.Fatal(err)
}

oldSession := previousResp.Session

event := session.NewEvent("manual-import")
event.Author = "user"
event.LLMResponse = model.LLMResponse{
Content: genai.NewContentFromText(
"我常用 Go 写后端,数据库一般选 MySQL。",
genai.RoleUser,
),
}

if err := sessionSvc.AppendEvent(ctx, oldSession, event); err != nil {
log.Fatal(err)
}

if err := memorySvc.AddSessionToMemory(ctx, oldSession); err != nil {
log.Fatal(err)
}

后面在新会话里搜索:

1
2
3
4
5
6
7
8
9
10
11
12
resp, err := memorySvc.SearchMemory(ctx, &memory.SearchRequest{
AppName: "memory_app",
UserID: "u_1001",
Query: "后端 数据库",
})
if err != nil {
log.Fatal(err)
}

for _, mem := range resp.Memories {
fmt.Println(mem.Author, mem.Timestamp, mem.Content)
}

这是最底层的用法。真正跑 Agent 时,一般会把它交给 Runner:

1
2
3
4
5
6
r, err := runner.New(runner.Config{
AppName: "memory_app",
Agent: rootAgent,
SessionService: sessionSvc,
MemoryService: memorySvc,
})

这样 InvocationContext 里就能拿到 Memory:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func myRun(ctx agent.InvocationContext) iter.Seq2[*session.Event, error] {
return func(yield func(*session.Event, error) bool) {
mem := ctx.Memory()
if mem == nil {
return
}

resp, err := mem.SearchMemory(ctx, "用户常用什么数据库")
if err != nil {
yield(nil, err)
return
}

for _, item := range resp.Memories {
fmt.Println("memory:", item.Author, item.Content)
}
}
}

如果你用的是 llmagent,官方已经提供了两个工具:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import (
"google.golang.org/adk/tool"
"google.golang.org/adk/tool/loadmemorytool"
"google.golang.org/adk/tool/preloadmemorytool"
)

llmAgent, err := llmagent.New(llmagent.Config{
Name: "memory_assistant",
Model: model,
Description: "能检索长期记忆的助手",
Instruction: "回答问题时,如果当前上下文不够,请使用长期记忆。",
Tools: []tool.Tool{
preloadmemorytool.New(),
loadmemorytool.New(),
},
})

这两个工具的区别很直接:

  • 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()。它是线程安全的内存实现,但逻辑非常轻量:

  1. AddSessionToMemory 会遍历 Session.Events()
  2. 只处理 event.LLMResponse.Content 不为空的事件。
  3. 只提取 Content.Parts 里的文本。
  4. 用空格切词,然后统一转小写。
  5. appName + userID + sessionID 存到内存 map 里。
  6. 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
2
3
4
5
6
7
8
9
10
11
12
13
type MemoryRecord struct {
ID string
AppName string
UserID string
SessionID string
EventID string
Author string
Text string
ContentJSON []byte
MetadataJSON []byte
Timestamp time.Time
CreatedAt time.Time
}

如果要做向量检索,再加:

1
2
3
4
type MemoryRecord struct {
// ...
Embedding []float32
}

不过实际落库时,Embedding 怎么存取决于你的数据库。MySQL 可能放 JSON 或单独向量表,PostgreSQL 可以用 pgvector,专门的向量数据库则通常会把向量和 metadata 一起写到 collection 里。

自定义服务的骨架大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
type MyMemoryService struct {
store MemoryStore
}

type MemoryStore interface {
Upsert(ctx context.Context, record MemoryRecord) error
Search(ctx context.Context, appName, userID, query string) ([]MemoryRecord, error)
}

func (s *MyMemoryService) AddSessionToMemory(ctx context.Context, sess session.Session) error {
for event := range sess.Events().All() {
text := extractText(event.LLMResponse.Content)
if text == "" {
continue
}

record := MemoryRecord{
ID: event.ID,
AppName: sess.AppName(),
UserID: sess.UserID(),
SessionID: sess.ID(),
EventID: event.ID,
Author: event.Author,
Text: text,
Timestamp: event.Timestamp,
}

if err := s.store.Upsert(ctx, record); err != nil {
return err
}
}
return nil
}

func (s *MyMemoryService) SearchMemory(ctx context.Context, req *memory.SearchRequest) (*memory.SearchResponse, error) {
records, err := s.store.Search(ctx, req.AppName, req.UserID, req.Query)
if err != nil {
return nil, err
}

resp := &memory.SearchResponse{}
for _, r := range records {
resp.Memories = append(resp.Memories, memory.Entry{
ID: r.ID,
Author: r.Author,
Timestamp: r.Timestamp,
Content: genai.NewContentFromText(r.Text, genai.Role(r.Author)),
})
}
return resp, nil
}

extractText 可以按你的业务来写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func extractText(content *genai.Content) string {
if content == nil {
return ""
}

var b strings.Builder
for _, part := range content.Parts {
if part == nil || part.Text == "" {
continue
}
if b.Len() > 0 {
b.WriteByte(' ')
}
b.WriteString(part.Text)
}
return b.String()
}

这就是 Memory 持久化的关键:上层仍然满足 memory.Service,下层你自己决定用普通数据库、全文检索还是向量检索。

自定义Memory的添加内容

内置实现会把 Session 里所有有文本内容的事件都提取进去。这很简单,但不一定符合业务。

比如用户说:

今天随便聊聊,帮我写个 demo。

这句话未必值得长期记忆。真正有价值的可能是:

我更喜欢 Go,数据库优先用 MySQL,前端不想用太重的框架。

所以生产环境里最好不要粗暴保存所有事件,而是自己做“记忆抽取”。

一个简单办法是用 CustomMetadata 标记哪些事件应该进长期记忆:

1
2
3
4
5
6
7
8
9
event := session.NewEvent("profile-update")
event.Author = "user"
event.LLMResponse = model.LLMResponse{
Content: genai.NewContentFromText("我偏好 Go + MySQL 的后端技术栈。", genai.RoleUser),
CustomMetadata: map[string]any{
"memory": true,
"memory_type": "preference",
},
}

然后在自定义 AddSessionToMemory 里过滤:

1
2
3
4
5
6
7
func shouldRemember(event *session.Event) bool {
if event == nil {
return false
}
v, ok := event.CustomMetadata["memory"].(bool)
return ok && v
}

更进一步,可以先让模型做一次摘要或结构化抽取,再把摘要写入 Memory:

1
2
3
4
type UserPreferenceMemory struct {
Topic string `json:"topic"`
Value string `json:"value"`
}

这样 Memory 里存的不是原始闲聊,而是更干净的长期事实:

1
2
3
4
{
"topic": "backend_stack",
"value": "用户偏好 Go + MySQL"
}

我的建议是:原始对话放 Session,长期事实放 Memory。Memory 不是垃圾桶,越克制越好用。

自定义Memory的查找功能

内置查找是关键词匹配。如果你要更像真实产品,一般会换成下面几种方式之一:

  • SQL LIKE:最简单,但召回质量一般。
  • 全文索引:适合日志、问答、短文本。
  • BM25:适合传统文本检索。
  • 向量检索:适合语义召回。
  • 混合检索:BM25 + 向量召回,再用 rerank 排序。

接口层仍然不用变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func (s *MyMemoryService) SearchMemory(ctx context.Context, req *memory.SearchRequest) (*memory.SearchResponse, error) {
candidates, err := s.vectorStore.Search(ctx, VectorSearchRequest{
AppName: req.AppName,
UserID: req.UserID,
Query: req.Query,
TopK: 8,
})
if err != nil {
return nil, err
}

resp := &memory.SearchResponse{}
for _, c := range candidates {
resp.Memories = append(resp.Memories, memory.Entry{
ID: c.ID,
Content: genai.NewContentFromText(c.Text, genai.Role(c.Author)),
Author: c.Author,
Timestamp: c.Timestamp,
CustomMetadata: c.Metadata,
})
}
return resp, nil
}

这里最重要的是隔离条件:

1
2
AppName = req.AppName
UserID = req.UserID

Memory 是用户级长期记忆,如果检索时不加用户隔离,很容易把 A 用户的偏好返回给 B 用户。这类 bug 在 Agent 场景里很危险。

最后总结

理解 adk-go 的 Memory,重点记住这几句话:

  1. Memory 是跨 Session 的长期检索,不是当前 Session 的聊天历史。
  2. v1.0/1.1.x 的核心接口仍然是 AddSessionToMemorySearchMemory
  3. Runner 只负责把 MemoryService 包进运行上下文,不会自动帮你决定什么时候写入长期记忆。
  4. 内置 InMemoryService 是内存关键词版,适合 demo,不适合生产。
  5. 真正可用的 Memory 通常需要你自己实现持久化、抽取策略和检索排序。

所以 Memory 这层最核心的价值,不是“多一个存储服务”,而是给 Agent 提供了一个标准接口:过去哪些信息值得被想起,以及当前问题该想起哪几条。