当我们谈论 AI Agent 的"记忆",大多数人的第一反应是把所有历史对话塞进 Prompt
用 Go + Gemini Embedding + pgvector 给 AI Agent 造一个"会遗忘的大脑
发布时间: 2026-06-30 (21 days ago)
GOAgentGemini

当我们谈论 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 hnswivfflat 来优化,否则会退化为全表扫描。

结语

给 AI Agent 造一个记忆系统,本质上是在解决一个信息筛选与召回的工程问题。

Embedding 向量化 + pgvector 余弦检索,给了我们一个强大的语义相关性工具;写入与检索解耦、后台异步向量化、降级容错,保证了系统在多 Agent 高频运行下的稳定性;而基于规则的重要性过滤,则是用最低的成本维护了记忆库的信噪比。

技术选型没有对错,只有"在当前约束下是否合适"。如果你也在做类似的 Agent 记忆系统,希望这篇文章能给你一些参考。欢迎评论区交流。