当我们谈论 AI Agent 的"记忆",大多数人的第一反应是把所有历史对话塞进 Prompt 里。这个方案在 Agent 数量少、交互简单的时候可以凑合,但一旦进入多 Agent 长期运行的场景,它会很快崩掉——上下文窗口撑不住,Token 费用爆炸,而且模型在一堆流水账里根本找不到真正重要的信息。
这篇文章聊的是我们在一个多 Agent 项目里实现的记忆系统:基于 Gemini Embedding + pgvector 的语义检索记忆架构,以及这个过程中的几个关键工程决策。
一、问题是什么:为什么 Agent 需要"真正的记忆"
设想这样一个场景:
Agent 小王在 Tick 42 时,和 Agent 小李聊过一次,小李告诉他"市场最近在卖新鲜的鱼"。到了 Tick 200,小王路过市场附近,他还记得这件事吗?他会因此做出"去买鱼"的决策吗?
如果没有记忆系统,答案是"不会"。小王每次做决策时只有当前 Tick 的观察输入,Tick 42 发生的事对他来说就像从没存在过。
这就是多 Agent 系统里"记忆"问题的本质:Agent 需要在时间跨度很长的交互中,选择性地保留和调用过去的关键信息。
"选择性"这个词很关键。不是所有事情都值得记住——小王在 Tick 100 决定向右走一步,这种信息记住了也没用;但 Tick 42 和小李的对话,就是值得保留的"有意义的记忆"。
二、整体架构:写入与检索分离
我们的记忆系统分为两个完全独立的阶段:
每次 Tick 结束
│
▼
┌─────────────────────┐
│ 记忆写入(同步) │
│ 判断重要性 → 存库 │
│ (先不向量化) │
└──────────┬──────────┘
│ 后台任务
▼
┌─────────────────────┐
│ 向量化(异步) │
│ Gemini Embedding │
│ → 写入 pgvector │
└─────────────────────┘
下一次 Tick 开始
│
▼
┌─────────────────────┐
│ 记忆检索(同步) │
│ 当前观察 → Embedding│
│ → 余弦相似度检索 │
│ → 拼入 Prompt │
└─────────────────────┘
写入和检索解耦是这套系统最核心的设计决定。后面会详细解释为什么这样做。
三、记忆的数据模型
首先看记忆在数据库里长什么样:
go
type MemoryModel struct {
NPCID uint // 这条记忆属于哪个 Agent
Type string // "observation"(观察)/ "conversation"(对话)
Content string // 记忆内容,自然语言文本
Embedding pgvector.Vector // 1536 维特征向量(pgvector 存储)
Importance int // 重要度评分,1-10 分
Tick int64 // 在第几次心跳产生
IsCore bool // 是否是核心记忆(保留字段,用于未来扩展)
}
几个设计细节值得说一下:
为什么要有 Importance 字段?
最初我们想用 LLM 来给每条记忆打重要性分数——让模型判断"这件事有多重要"。这个思路本身没问题,但在实践中引入了两个副作用:额外的 LLM 调用(延迟 + 费用)和新的频控压力。
最终我们改成了基于规则的静态权重:
| 行为类型 | 权重 | 理由 |
|---|---|---|
talk(发起对话) |
8 分 | 社交互动,信息密度高 |
move(移动) |
4 分 | 日常行为,信息价值中等 |
stay(原地待机) |
不存储 | 无意义的游荡,过滤掉 |
这个"过滤 stay"的决定非常关键。如果每次 stay 都存一条"我决定原地休息",数据库会被大量低价值数据淹没,检索时的信噪比会急剧下降。保持数据库的信噪比,比存储更多数据更重要。
四、记忆写入:先存文本,后台再向量化
每次 Tick 结束后,Agent 的决策会被转化为自然语言记忆写入数据库:
go
func RecordNPCActionMemory(npc *models.NPCModel, decision *llm_ser.NPCDecision, tick int64) {
if decision.Action == "talk" {
memory := models.MemoryModel{
NPCID: npc.ID,
Type: "conversation",
Content: fmt.Sprintf("我对大家说:%s", decision.Speak),
Importance: 8,
Tick: tick,
}
// 注意:Omit("Embedding") —— 先不写向量,后台再处理
global.DB.Omit("Embedding").Create(&memory)
} else if decision.Action == "move" {
memory := models.MemoryModel{
NPCID: npc.ID,
Type: "observation",
Content: fmt.Sprintf("我决定移动到 (%d, %d)", decision.TargetX, decision.TargetY),
Importance: 4,
Tick: tick,
}
global.DB.Omit("Embedding").Create(&memory)
}
// stay 行为:直接忽略,不存储
}
注意这里的 Omit("Embedding")——我们刻意跳过了向量的写入,只存文本内容。
为什么这样设计?
调用 Gemini Embedding API 需要网络请求,有延迟(通常 200-500ms)。如果在 Tick 结束的主流程里同步等待向量化完成,会直接拖慢整个引擎的节奏。
所以我们把向量化放到了一个后台定时任务里处理:每隔一段时间,扫描数据库里 Embedding IS NULL 的记忆,批量向量化后回填。主流程和向量化完全解耦,互不阻塞。
五、向量化:Gemini Embedding 的工程细节
向量化这一步调用的是 gemini-embedding-001 模型,通过 OpenAI 兼容接口接入:
go
func GetEmbeddings(texts []string) ([][]float32, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cfg := global.Config.LLM.GetConfigByName(global.Config.LLM.EmbeddingModel)
client := llm.GetOpenAIClient(cfg.ApiKey, cfg.BaseURL)
req := openai.EmbeddingRequest{
Input: texts,
Model: openai.EmbeddingModel("gemini-embedding-001"),
}
res, err := client.CreateEmbeddings(ctx, req)
// ...
for _, item := range res.Data {
val := item.Embedding
// 强制截断到 1536 维,与数据库 vector(1536) 对齐
if len(val) > 1536 {
val = val[:1536]
}
embeddings = append(embeddings, val)
}
return embeddings, nil
}
有几个工程细节值得注意:
1. 为什么是 1536 维?
gemini-embedding-001 默认输出的维度可以高于 1536。我们在数据库侧定义的是 vector(1536),所以代码里做了一个强制截断。这是个工程妥协——维度越高,存储和检索的开销越大,1536 维对于这个场景的语义精度已经足够。
2. 批量接口而非单条调用
GetEmbeddings 接收的是 []string,支持批量向量化。后台任务每次拿出一批未向量化的记忆,一次 API 调用处理多条,而不是一条记忆发一次请求。这对控制 API 频控和降低延迟很关键。
3. 文本切片(Chunking)
对于较长的文本内容,代码里实现了一个带重叠的文本切片算法:
go
func ChunkText(text string, chunkSize int, overlap int) []string {
runes := []rune(text) // 用 rune 处理中文,避免字节截断
var chunks []string
for i := 0; i < len(runes); {
end := i + chunkSize
if end > len(runes) {
end = len(runes)
}
chunks = append(chunks, string(runes[i:end]))
if end == len(runes) {
break
}
i = end - overlap // 重叠 overlap 个字符,防止语义被截断
}
return chunks
}
重叠(overlap)的意义在于:如果一句话正好被切在边界上,两个相邻的 chunk 都会包含这句话的一部分,语义不会丢失。用 rune 而不是 byte 来切割,是处理中文的基本操作——一个中文字符是 3 个字节,按字节切会产生乱码。
六、记忆检索:用余弦相似度找到"最相关的过去"
检索是整个系统最有意思的部分。每次 Agent 准备做决策之前,系统会先把他当前的"观察"(环境描述)向量化,然后用这个向量去数据库里找最相关的历史记忆:
go
func RetrieveRelevantMemories(npcID uint, observation string) string {
// 1. 把当前观察转化为向量
embeddings, err := GetEmbeddings([]string{observation})
if err != nil || len(embeddings) == 0 {
return "" // 降级:获取失败就不带记忆,但不中断主流程
}
vector := pgvector.NewVector(embeddings[0])
// 2. 用余弦距离检索最相关的 5 条记忆
var memories []models.MemoryModel
global.DB.Where("npc_id = ? AND embedding IS NOT NULL", npcID).
Order(clause.OrderBy{
Expression: clause.Expr{
SQL: "embedding <=> ?",
Vars: []any{vector},
},
}).
Limit(5).
Find(&memories)
// 3. 拼成自然语言片段,插入 Prompt
result := "\n【你脑海中浮现出以下相关记忆】:\n"
for _, m := range memories {
result += fmt.Sprintf("- %s\n", m.Content)
}
return result
}
<=> 操作符是 pgvector 的余弦距离操作符,值越小代表越相似(余弦距离 = 1 - 余弦相似度)。这一行 SQL ORDER BY 就是整个语义检索的核心——PostgreSQL 会利用 pgvector 的 HNSW 或 IVFFlat 索引,在毫秒级内从海量向量中找到最近邻。
embedding IS NOT NULL 这个过滤条件很必要。刚写入的记忆还没有被后台任务向量化,如果不过滤,会把空向量也拿出来参与相似度计算,结果会错乱。
检索结果被格式化成自然语言片段,直接拼在 Prompt 的末尾:
【你脑海中浮现出以下相关记忆】:
- 我对大家说:市场附近好像在开庆典
- 我决定移动到 (15, 8)
- 我对大家说:今天天气不错,想去河边走走
Agent 的大模型在看到这段记忆时,就能在决策中体现出对过去事件的"记忆"。
七、降级处理:记忆系统不能成为单点故障
这是一个常被忽视但非常重要的工程细节。
记忆系统的每一步都有可能失败:Embedding API 超时、数据库查询慢、向量化任务积压……任何一个环节出问题,都不应该导致 Agent 无法完成当前 Tick 的决策。
我们的原则是:记忆是锦上添花,不是必要条件。
体现在代码上:
GetEmbeddings失败时,RetrieveRelevantMemories返回空字符串,而不是抛出 error- 空字符串拼入 Prompt 时相当于没有记忆,Agent 照常运行
- 后台向量化任务失败时,记录日志,下次定时任务继续重试,不影响主流程
go
embeddings, err := GetEmbeddings([]string{observation})
if err != nil || len(embeddings) == 0 {
global.Log.Sugar().Errorf("获取观察向量失败: %v", err)
return "" // 降级:不带记忆继续,主流程不受影响
}
这种"失败时优雅降级"的设计思路,在任何依赖外部 API 的系统里都值得遵守。
八、效果与反思
这套记忆系统在实际运行中,带来了一个意想不到的效果:Agent 之间形成了信息的自然传播。
小王在 Tick 50 说了"市场在卖新鲜的鱼",这条记忆被存入数据库并向量化。Tick 200 时,小李路过市场附近,他的当前观察里有"市场"这个语境,检索时就可能捞到小王那条记忆(如果他们之前有交互),或者他自己曾经观察到的相关记忆。
这不是我们刻意设计的,而是语义检索自然带来的涌现效果。"相关的记忆会在相关的场景下被唤起"——这和人类记忆的工作方式高度相似。
当然,这套系统也有明显的局限:
- 记忆没有衰减机制:现实里,久远的记忆会变得模糊,但我们目前对所有记忆一视同仁,只按相关性排序,不考虑时间衰减。
Tick字段已经记录了记忆的产生时间,未来可以在排序公式里引入时间权重。 - 记忆没有"反思"层:斯坦福小镇论文里提出的记忆架构有三层(观察、反思、计划),我们目前只实现了观察层,反思层(对多条记忆的高层总结)尚未引入。
- 向量索引需要调优:随着记忆数量增长,pgvector 的检索性能需要定期用
CREATE INDEX USING hnsw或ivfflat来优化,否则会退化为全表扫描。
结语
给 AI Agent 造一个记忆系统,本质上是在解决一个信息筛选与召回的工程问题。
Embedding 向量化 + pgvector 余弦检索,给了我们一个强大的语义相关性工具;写入与检索解耦、后台异步向量化、降级容错,保证了系统在多 Agent 高频运行下的稳定性;而基于规则的重要性过滤,则是用最低的成本维护了记忆库的信噪比。
技术选型没有对错,只有"在当前约束下是否合适"。如果你也在做类似的 Agent 记忆系统,希望这篇文章能给你一些参考。欢迎评论区交流。