[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"article-50":3},{"id":4,"title":5,"title_en":6,"abstract":7,"abstract_en":8,"content":9,"content_en":10,"category":11,"banner_id":12,"banner_path":13,"tags":14,"is_recommend":18,"prev_article":19,"next_article":23,"created_at":27},50,"用 Go + Gemini Embedding + pgvector 给 AI Agent 造一个\"会遗忘的大脑","Building a Scalable AI Agent Memory System with Golang, Gemini Embeddings, and pgvector","> 当我们谈论 AI Agent 的\"记忆\"，大多数人的第一反应是把所有历史对话塞进 Prompt "," AI Agent Memory System, Golang AI Agent, Gemini Embeddings, pgvector PostgreSQL, Semantic Search Go, GORM pgvector, Vector Database Go","> 当我们谈论 AI Agent 的\"记忆\"，大多数人的第一反应是把所有历史对话塞进 Prompt 里。这个方案在 Agent 数量少、交互简单的时候可以凑合，但一旦进入多 Agent 长期运行的场景，它会很快崩掉——上下文窗口撑不住，Token 费用爆炸，而且模型在一堆流水账里根本找不到真正重要的信息。\n>\n> 这篇文章聊的是我们在一个多 Agent 项目里实现的记忆系统：**基于 Gemini Embedding + pgvector 的语义检索记忆架构**，以及这个过程中的几个关键工程决策。\n\n---\n\n## 一、问题是什么：为什么 Agent 需要\"真正的记忆\"\n\n设想这样一个场景：\n\nAgent 小王在 Tick 42 时，和 Agent 小李聊过一次，小李告诉他\"市场最近在卖新鲜的鱼\"。到了 Tick 200，小王路过市场附近，他还记得这件事吗？他会因此做出\"去买鱼\"的决策吗？\n\n如果没有记忆系统，答案是\"不会\"。小王每次做决策时只有当前 Tick 的观察输入，Tick 42 发生的事对他来说就像从没存在过。\n\n这就是多 Agent 系统里\"记忆\"问题的本质：**Agent 需要在时间跨度很长的交互中，选择性地保留和调用过去的关键信息**。\n\n\"选择性\"这个词很关键。不是所有事情都值得记住——小王在 Tick 100 决定向右走一步，这种信息记住了也没用；但 Tick 42 和小李的对话，就是值得保留的\"有意义的记忆\"。\n\n---\n\n## 二、整体架构：写入与检索分离\n\n我们的记忆系统分为两个完全独立的阶段：\n\n```\n每次 Tick 结束\n      │\n      ▼\n┌─────────────────────┐\n│   记忆写入（同步）    │\n│  判断重要性 → 存库   │\n│  （先不向量化）      │\n└──────────┬──────────┘\n           │ 后台任务\n           ▼\n┌─────────────────────┐\n│   向量化（异步）     │\n│  Gemini Embedding   │\n│  → 写入 pgvector    │\n└─────────────────────┘\n\n下一次 Tick 开始\n      │\n      ▼\n┌─────────────────────┐\n│   记忆检索（同步）   │\n│  当前观察 → Embedding│\n│  → 余弦相似度检索    │\n│  → 拼入 Prompt      │\n└─────────────────────┘\n```\n\n**写入和检索解耦**是这套系统最核心的设计决定。后面会详细解释为什么这样做。\n\n---\n\n## 三、记忆的数据模型\n\n首先看记忆在数据库里长什么样：\n\n```go\ntype MemoryModel struct {\n    NPCID      uint            \u002F\u002F 这条记忆属于哪个 Agent\n    Type       string          \u002F\u002F \"observation\"（观察）\u002F \"conversation\"（对话）\n    Content    string          \u002F\u002F 记忆内容，自然语言文本\n    Embedding  pgvector.Vector \u002F\u002F 1536 维特征向量（pgvector 存储）\n    Importance int             \u002F\u002F 重要度评分，1-10 分\n    Tick       int64           \u002F\u002F 在第几次心跳产生\n    IsCore     bool            \u002F\u002F 是否是核心记忆（保留字段，用于未来扩展）\n}\n```\n\n几个设计细节值得说一下：\n\n**为什么要有 `Importance` 字段？**\n\n最初我们想用 LLM 来给每条记忆打重要性分数——让模型判断\"这件事有多重要\"。这个思路本身没问题，但在实践中引入了两个副作用：额外的 LLM 调用（延迟 + 费用）和新的频控压力。\n\n最终我们改成了**基于规则的静态权重**：\n\n| 行为类型 | 权重 | 理由 |\n|----------|------|------|\n| `talk`（发起对话） | 8 分 | 社交互动，信息密度高 |\n| `move`（移动） | 4 分 | 日常行为，信息价值中等 |\n| `stay`（原地待机） | 不存储 | 无意义的游荡，过滤掉 |\n\n这个\"过滤 stay\"的决定非常关键。如果每次 stay 都存一条\"我决定原地休息\"，数据库会被大量低价值数据淹没，检索时的信噪比会急剧下降。**保持数据库的信噪比，比存储更多数据更重要。**\n\n---\n\n## 四、记忆写入：先存文本，后台再向量化\n\n每次 Tick 结束后，Agent 的决策会被转化为自然语言记忆写入数据库：\n\n```go\nfunc RecordNPCActionMemory(npc *models.NPCModel, decision *llm_ser.NPCDecision, tick int64) {\n    if decision.Action == \"talk\" {\n        memory := models.MemoryModel{\n            NPCID:      npc.ID,\n            Type:       \"conversation\",\n            Content:    fmt.Sprintf(\"我对大家说：%s\", decision.Speak),\n            Importance: 8,\n            Tick:       tick,\n        }\n        \u002F\u002F 注意：Omit(\"Embedding\") —— 先不写向量，后台再处理\n        global.DB.Omit(\"Embedding\").Create(&memory)\n    } else if decision.Action == \"move\" {\n        memory := models.MemoryModel{\n            NPCID:      npc.ID,\n            Type:       \"observation\",\n            Content:    fmt.Sprintf(\"我决定移动到 (%d, %d)\", decision.TargetX, decision.TargetY),\n            Importance: 4,\n            Tick:       tick,\n        }\n        global.DB.Omit(\"Embedding\").Create(&memory)\n    }\n    \u002F\u002F stay 行为：直接忽略，不存储\n}\n```\n\n注意这里的 `Omit(\"Embedding\")`——我们**刻意跳过了向量的写入**，只存文本内容。\n\n为什么这样设计？\n\n调用 Gemini Embedding API 需要网络请求，有延迟（通常 200-500ms）。如果在 Tick 结束的主流程里同步等待向量化完成，会直接拖慢整个引擎的节奏。\n\n所以我们把向量化放到了一个**后台定时任务**里处理：每隔一段时间，扫描数据库里 `Embedding IS NULL` 的记忆，批量向量化后回填。主流程和向量化完全解耦，互不阻塞。\n\n---\n\n## 五、向量化：Gemini Embedding 的工程细节\n\n向量化这一步调用的是 `gemini-embedding-001` 模型，通过 OpenAI 兼容接口接入：\n\n```go\nfunc GetEmbeddings(texts []string) ([][]float32, error) {\n    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n    defer cancel()\n\n    cfg := global.Config.LLM.GetConfigByName(global.Config.LLM.EmbeddingModel)\n    client := llm.GetOpenAIClient(cfg.ApiKey, cfg.BaseURL)\n\n    req := openai.EmbeddingRequest{\n        Input: texts,\n        Model: openai.EmbeddingModel(\"gemini-embedding-001\"),\n    }\n\n    res, err := client.CreateEmbeddings(ctx, req)\n    \u002F\u002F ...\n\n    for _, item := range res.Data {\n        val := item.Embedding\n        \u002F\u002F 强制截断到 1536 维，与数据库 vector(1536) 对齐\n        if len(val) > 1536 {\n            val = val[:1536]\n        }\n        embeddings = append(embeddings, val)\n    }\n    return embeddings, nil\n}\n```\n\n有几个工程细节值得注意：\n\n**1. 为什么是 1536 维？**\n\n`gemini-embedding-001` 默认输出的维度可以高于 1536。我们在数据库侧定义的是 `vector(1536)`，所以代码里做了一个强制截断。这是个工程妥协——维度越高，存储和检索的开销越大，1536 维对于这个场景的语义精度已经足够。\n\n**2. 批量接口而非单条调用**\n\n`GetEmbeddings` 接收的是 `[]string`，支持批量向量化。后台任务每次拿出一批未向量化的记忆，一次 API 调用处理多条，而不是一条记忆发一次请求。这对控制 API 频控和降低延迟很关键。\n\n**3. 文本切片（Chunking）**\n\n对于较长的文本内容，代码里实现了一个带重叠的文本切片算法：\n\n```go\nfunc ChunkText(text string, chunkSize int, overlap int) []string {\n    runes := []rune(text)  \u002F\u002F 用 rune 处理中文，避免字节截断\n    var chunks []string\n    for i := 0; i \u003C len(runes); {\n        end := i + chunkSize\n        if end > len(runes) {\n            end = len(runes)\n        }\n        chunks = append(chunks, string(runes[i:end]))\n        if end == len(runes) {\n            break\n        }\n        i = end - overlap  \u002F\u002F 重叠 overlap 个字符，防止语义被截断\n    }\n    return chunks\n}\n```\n\n重叠（overlap）的意义在于：如果一句话正好被切在边界上，两个相邻的 chunk 都会包含这句话的一部分，语义不会丢失。用 `rune` 而不是 `byte` 来切割，是处理中文的基本操作——一个中文字符是 3 个字节，按字节切会产生乱码。\n\n---\n\n## 六、记忆检索：用余弦相似度找到\"最相关的过去\"\n\n检索是整个系统最有意思的部分。每次 Agent 准备做决策之前，系统会先把他当前的\"观察\"（环境描述）向量化，然后用这个向量去数据库里找最相关的历史记忆：\n\n```go\nfunc RetrieveRelevantMemories(npcID uint, observation string) string {\n    \u002F\u002F 1. 把当前观察转化为向量\n    embeddings, err := GetEmbeddings([]string{observation})\n    if err != nil || len(embeddings) == 0 {\n        return \"\"  \u002F\u002F 降级：获取失败就不带记忆，但不中断主流程\n    }\n\n    vector := pgvector.NewVector(embeddings[0])\n\n    \u002F\u002F 2. 用余弦距离检索最相关的 5 条记忆\n    var memories []models.MemoryModel\n    global.DB.Where(\"npc_id = ? AND embedding IS NOT NULL\", npcID).\n        Order(clause.OrderBy{\n            Expression: clause.Expr{\n                SQL:  \"embedding \u003C=> ?\",\n                Vars: []any{vector},\n            },\n        }).\n        Limit(5).\n        Find(&memories)\n\n    \u002F\u002F 3. 拼成自然语言片段，插入 Prompt\n    result := \"\\n【你脑海中浮现出以下相关记忆】：\\n\"\n    for _, m := range memories {\n        result += fmt.Sprintf(\"- %s\\n\", m.Content)\n    }\n    return result\n}\n```\n\n**`\u003C=>` 操作符**是 pgvector 的余弦距离操作符，值越小代表越相似（余弦距离 = 1 - 余弦相似度）。这一行 SQL ORDER BY 就是整个语义检索的核心——PostgreSQL 会利用 pgvector 的 HNSW 或 IVFFlat 索引，在毫秒级内从海量向量中找到最近邻。\n\n**`embedding IS NOT NULL`** 这个过滤条件很必要。刚写入的记忆还没有被后台任务向量化，如果不过滤，会把空向量也拿出来参与相似度计算，结果会错乱。\n\n检索结果被格式化成自然语言片段，直接拼在 Prompt 的末尾：\n\n```\n【你脑海中浮现出以下相关记忆】：\n- 我对大家说：市场附近好像在开庆典\n- 我决定移动到 (15, 8)\n- 我对大家说：今天天气不错，想去河边走走\n```\n\nAgent 的大模型在看到这段记忆时，就能在决策中体现出对过去事件的\"记忆\"。\n\n---\n\n## 七、降级处理：记忆系统不能成为单点故障\n\n这是一个常被忽视但非常重要的工程细节。\n\n记忆系统的每一步都有可能失败：Embedding API 超时、数据库查询慢、向量化任务积压……任何一个环节出问题，都不应该导致 Agent 无法完成当前 Tick 的决策。\n\n我们的原则是：**记忆是锦上添花，不是必要条件**。\n\n体现在代码上：\n- `GetEmbeddings` 失败时，`RetrieveRelevantMemories` 返回空字符串，而不是抛出 error\n- 空字符串拼入 Prompt 时相当于没有记忆，Agent 照常运行\n- 后台向量化任务失败时，记录日志，下次定时任务继续重试，不影响主流程\n\n```go\nembeddings, err := GetEmbeddings([]string{observation})\nif err != nil || len(embeddings) == 0 {\n    global.Log.Sugar().Errorf(\"获取观察向量失败: %v\", err)\n    return \"\"  \u002F\u002F 降级：不带记忆继续，主流程不受影响\n}\n```\n\n这种\"失败时优雅降级\"的设计思路，在任何依赖外部 API 的系统里都值得遵守。\n\n---\n\n## 八、效果与反思\n\n这套记忆系统在实际运行中，带来了一个意想不到的效果：**Agent 之间形成了信息的自然传播**。\n\n小王在 Tick 50 说了\"市场在卖新鲜的鱼\"，这条记忆被存入数据库并向量化。Tick 200 时，小李路过市场附近，他的当前观察里有\"市场\"这个语境，检索时就可能捞到小王那条记忆（如果他们之前有交互），或者他自己曾经观察到的相关记忆。\n\n这不是我们刻意设计的，而是语义检索自然带来的涌现效果。\"相关的记忆会在相关的场景下被唤起\"——这和人类记忆的工作方式高度相似。\n\n当然，这套系统也有明显的局限：\n\n- **记忆没有衰减机制**：现实里，久远的记忆会变得模糊，但我们目前对所有记忆一视同仁，只按相关性排序，不考虑时间衰减。`Tick` 字段已经记录了记忆的产生时间，未来可以在排序公式里引入时间权重。\n- **记忆没有\"反思\"层**：斯坦福小镇论文里提出的记忆架构有三层（观察、反思、计划），我们目前只实现了观察层，反思层（对多条记忆的高层总结）尚未引入。\n- **向量索引需要调优**：随着记忆数量增长，pgvector 的检索性能需要定期用 `CREATE INDEX USING hnsw` 或 `ivfflat` 来优化，否则会退化为全表扫描。\n\n---\n\n## 结语\n\n给 AI Agent 造一个记忆系统，本质上是在解决一个**信息筛选与召回**的工程问题。\n\nEmbedding 向量化 + pgvector 余弦检索，给了我们一个强大的语义相关性工具；写入与检索解耦、后台异步向量化、降级容错，保证了系统在多 Agent 高频运行下的稳定性；而基于规则的重要性过滤，则是用最低的成本维护了记忆库的信噪比。\n\n技术选型没有对错，只有\"在当前约束下是否合适\"。如果你也在做类似的 Agent 记忆系统，希望这篇文章能给你一些参考。欢迎评论区交流。\n","> - **Target Keywords:** AI Agent Memory System, Golang AI Agent, Gemini Embeddings, pgvector PostgreSQL, Semantic Search Go, GORM pgvector, Vector Database Go.\n> - **Meta Description:** A practical guide on building a resilient, long-term memory system for AI agents using Go, Gemini Embeddings, and pgvector. Learn how to implement semantic search, handle rate limits, and design for graceful degradation.\n\nWhen people talk about giving AI agents \"memory,\" their first instinct is often to dump all past conversation history directly into the LLM prompt. While this naive approach works for simple demos with a single agent, it quickly falls apart in multi-agent, long-running systems. The context window overflows, API costs skyrocket, and the model struggles to find relevant information amidst a mountain of irrelevant logs.\n\nTo solve this, we built a production-grade long-term memory system for our AI agent simulation platform. In this post, we’ll dive deep into our engineering architecture using **Golang**, **Gemini Embeddings (`gemini-embedding-001`)**, and **pgvector (PostgreSQL)**, and discuss the key engineering decisions we made along the way.\n\n---\n\n## 1. The Core Problem: Why Do Agents Need \"Real Memory\"?\n\nImagine this scenario in a virtual agent town:\n\n*At Tick 42, Agent Alice talks to Agent Bob. Bob mentions, \"The market nearby is selling fresh fish.\" At Tick 200, Alice passes by the market. Does she remember what Bob said? Does she decide to buy fish based on that past interaction?*\n\nWithout a dedicated memory system, she won't. When making decisions, Alice only receives observations from the current tick. Everything that happened at Tick 42 is lost to her.\n\nThis is the essence of the memory problem in multi-agent systems: **agents must selectively retain and retrieve critical information over long-term timelines.**\n\nThe word *\"selectively\"* is key. Not every event is worth remembering. If Alice decides to take one step to the right at Tick 100, storing that observation is useless noise. However, her conversation with Bob at Tick 42 is a high-value memory that must be preserved.\n\n---\n\n## 2. System Architecture: Decoupling Writes from Retrieval\n\nTo keep our execution loop highly responsive, we decoupled memory writes from vector retrieval:\n\n```\nEnd of Current Tick\n        │\n        ▼\n┌───────────────────────┐\n│ Synchronous Write     │\n│ - Grade Importance    │\n│ - Store Text to DB    │\n│   (Skip Embeddings)   │\n└──────────┬────────────┘\n           │ Asynchronous Background Job\n           ▼\n┌───────────────────────┐\n│ Vectorization Pipeline│\n│ - Call Gemini API     │\n│ - Backfill pgvector   │\n└───────────────────────┘\n\nStart of Next Tick\n        │\n        ▼\n┌───────────────────────┐\n│ Memory Retrieval      │\n│ - Vectorize Query     │\n│ - Cosine Distance Search│\n│ - Inject into Prompt  │\n└───────────────────────┘\n```\n\nThis decoupled architecture is the most critical design decision we made to maintain high performance. Let's break down why and how we implemented each stage.\n\n---\n\n## 3. Designing the Memory Data Model\n\nLet's look at how memory is structured in our database using GORM:\n\n```go\ntype MemoryModel struct {\n    NPCID      uint            `gorm:\"index;not null\" json:\"npc_id\"`\n    Type       string          `gorm:\"size:32;not null\" json:\"type\"`      \u002F\u002F \"observation\" or \"conversation\"\n    Content    string          `gorm:\"type:text;not null\" json:\"content\"` \u002F\u002F Raw memory text\n    Embedding  pgvector.Vector `gorm:\"type:vector(1536)\" json:\"-\"`        \u002F\u002F 1536-dimensional vector\n    Importance int             `json:\"importance\"`                        \u002F\u002F Importance rating (1-10)\n    Tick       int64           `json:\"tick\"`                              \u002F\u002F World tick when generated\n    IsCore     bool            `json:\"is_core\"`                           \u002F\u002F Core memory flag\n}\n```\n\n### Why we rate memory `Importance`\nInitially, we considered using an LLM to dynamically grade the importance of every event. While conceptually sound, this introduced significant downsides: extra LLM API calls (adding latency and costs) and severe rate-limiting pressures.\n\nInstead, we switched to **rule-based static weights**:\n\n| Action Type | Importance Score | Rationale |\n|-------------|------------------|-----------|\n| `talk` (initiating conversation) | 8 | Social interaction; high information density. |\n| `move` (navigating map) | 4 | Routine activity; moderate information value. |\n| `stay` (idle \u002F waiting) | *Filtered out* | Trivial action; filtered to prevent database bloat. |\n\nFiltering out `stay` events is crucial. If we stored every \"I decided to rest\" event, our database would be flooded with low-value records, degrading semantic search quality. **Maintaining a high signal-to-noise ratio in your vector database is far more important than storing everything.**\n\n---\n\n## 4. Writing Memory: Text First, Vectorize Later\n\nAt the end of a world tick, the engine converts agent decisions into natural language memories and writes them to PostgreSQL:\n\n```go\nfunc RecordNPCActionMemory(npc *models.NPCModel, decision *llm_ser.NPCDecision, tick int64) {\n    if decision.Action == \"talk\" {\n        memory := models.MemoryModel{\n            NPCID:      npc.ID,\n            Type:       \"conversation\",\n            Content:    fmt.Sprintf(\"I said to everyone: %s\", decision.Speak),\n            Importance: 8,\n            Tick:       tick,\n        }\n        \u002F\u002F Omit the \"Embedding\" field—we do not vectorize synchronously\n        global.DB.Omit(\"Embedding\").Create(&memory)\n    } else if decision.Action == \"move\" {\n        memory := models.MemoryModel{\n            NPCID:      npc.ID,\n            Type:       \"observation\",\n            Content:    fmt.Sprintf(\"I decided to move to (%d, %d)\", decision.TargetX, decision.TargetY),\n            Importance: 4,\n            Tick:       tick,\n        }\n        global.DB.Omit(\"Embedding\").Create(&memory)\n    }\n}\n```\n\nNotice the use of `global.DB.Omit(\"Embedding\").Create(&memory)`. We intentionally skip generating embeddings synchronously. \n\nCalling the Gemini Embedding API over the network introduces latency (typically 200–500ms). If we waited for this call during the main tick loop, the entire simulation engine would stall. Instead, a **background cron worker** periodically queries records where `embedding IS NULL`, batch-vectorizes them, and updates the database.\n\n---\n\n## 5. Vectorization: Running Gemini Embeddings in Go\n\nTo generate vectors, we call the Google Gemini Embedding model (`gemini-embedding-001`) via an OpenAI-compatible client wrapper:\n\n```go\nfunc GetEmbeddings(texts []string) ([][]float32, error) {\n    if len(texts) == 0 {\n        return nil, nil\n    }\n\n    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n    defer cancel()\n\n    cfg := global.Config.LLM.GetConfigByName(global.Config.LLM.EmbeddingModel)\n    client := llm.GetOpenAIClient(cfg.ApiKey, cfg.BaseURL)\n\n    req := openai.EmbeddingRequest{\n        Input: texts,\n        Model: openai.EmbeddingModel(\"gemini-embedding-001\"),\n    }\n\n    res, err := client.CreateEmbeddings(ctx, req)\n    if err != nil {\n        return nil, fmt.Errorf(\"batch embedding generation failed: %v\", err)\n    }\n\n    var embeddings [][]float32\n    for _, item := range res.Data {\n        val := item.Embedding\n        \u002F\u002F Truncate to 1536 dimensions to match database schema\n        if len(val) > 1536 {\n            val = val[:1536]\n        }\n        embeddings = append(embeddings, val)\n    }\n\n    return embeddings, nil\n}\n```\n\n### Engineering Trade-offs & Techniques:\n1. **Dimension Alignment:** The `gemini-embedding-001` model can output dimensions higher than 1536. We define our database column as `vector(1536)` and truncate the array accordingly. 1536 dimensions provide the perfect balance between semantic precision and database query performance.\n2. **Batch Processing:** The `GetEmbeddings` function takes a slice of strings (`[]string`). Our background job processes multiple memories in a single API call, reducing HTTP round-trip overhead and mitigating rate-limit exhaustion.\n3. **Text Chunking for Long Inputs:** For long-form text, we implement a sliding window chunking algorithm using Go's `rune` type to prevent character corruption when cutting UTF-8 strings:\n\n```go\nfunc ChunkText(text string, chunkSize int, overlap int) []string {\n    runes := []rune(text)\n    var chunks []string\n    for i := 0; i \u003C len(runes); {\n        end := i + chunkSize\n        if end > len(runes) {\n            end = len(runes)\n        }\n        chunks = append(chunks, string(runes[i:end]))\n        if end == len(runes) {\n            break\n        }\n        i = end - overlap \u002F\u002F Slide back to preserve context across chunks\n    }\n    return chunks\n}\n```\n\n---\n\n## 6. Memory Retrieval: Cosine Distance Search via pgvector\n\nWhen an agent needs to make a decision, we take their current observation (their immediate sensory input), vectorize it, and search the pgvector database for the top 5 most semantically similar memories:\n\n```go\nfunc RetrieveRelevantMemories(npcID uint, observation string) string {\n    \u002F\u002F 1. Vectorize the current observation query\n    embeddings, err := GetEmbeddings([]string{observation})\n    if err != nil || len(embeddings) == 0 {\n        global.Log.Sugar().Errorf(\"Failed to generate embedding for query: %v\", err)\n        return \"\" \u002F\u002F Graceful degradation: return empty if embedding fails\n    }\n\n    vector := pgvector.NewVector(embeddings[0])\n\n    \u002F\u002F 2. Perform Cosine Distance search via pgvector\n    var memories []models.MemoryModel\n    err = global.DB.Where(\"npc_id = ? AND embedding IS NOT NULL\", npcID).\n        Order(clause.OrderBy{\n            Expression: clause.Expr{\n                SQL:  \"embedding \u003C=> ?\",\n                Vars: []any{vector},\n            },\n        }).Limit(5).Find(&memories).Error\n\n    if err != nil || len(memories) == 0 {\n        return \"\"\n    }\n\n    \u002F\u002F 3. Format results to inject into the LLM Prompt\n    result := \"\\n[You recall the following relevant memories from the past]:\\n\"\n    for _, m := range memories {\n        result += fmt.Sprintf(\"- %s\\n\", m.Content)\n    }\n\n    return result\n}\n```\n\n*   **The `\u003C=>` Operator:** This is pgvector’s operator for **Cosine Distance** (1 minus Cosine Similarity). A smaller value means the memory is more semantically relevant. By combining this query with an HNSW or IVFFlat index on the `embedding` column in PostgreSQL, we get sub-millisecond search times.\n*   **The `embedding IS NOT NULL` Filter:** This is critical. Freshly created memories that haven't been processed by the background vectorization worker have `NULL` embeddings. Filtering them out avoids query exceptions and ensures we only search valid vectors.\n\n---\n\n## 7. Resilience: Designing for Graceful Degradation\n\nA key engineering lesson when working with external AI APIs is that **everything will eventually fail**. The embedding API might time out, the database connection might saturate, or you might hit rate limits.\n\nOur memory retrieval system is designed with a **fail-safe** mechanism:\n* If the embedding generation fails, we log the error but do not halt the simulation tick.\n* `RetrieveRelevantMemories` simply returns an empty string. The agent's prompt compiles without historical memory and executes normally.\n* If the background vectorization worker fails, it retries on the next run cycle without affecting the live simulation thread.\n\nMemory should always be treated as a **progressive enhancement** to agent behavior, never as a single point of failure.\n\n---\n\n## 8. Summary & Key Takeaways\n\nBuilding this memory system taught us that managing AI agent memory is, at its core, a classic engineering problem of data indexing, performance optimization, and fault tolerance:\n\n1. **Decouple CPU\u002FIO-bound work:** Generate embeddings asynchronously via background tasks to keep your simulation ticks fast and deterministic.\n2. **Prioritize Signal over Noise:** Use smart rules or lightweight heuristics to filter out low-value events before saving them to your vector database.\n3. **Graceful Degradation is Mandatory:** Never let a failed embedding API call bring down your entire application logic.\n\nBy choosing Go for performance, Gemini for embeddings, and pgvector for native relational\u002Fvector integration, we created a robust, easy-to-scale architecture that lets our agents reference past events naturally while keeping system latency minimal.\n\n*Are you building a memory system for your AI agents? Let us know your thoughts or any optimization tricks you use in the comments below!*\n","AI",39,"https:\u002F\u002Fblog4-1316398321.cos.ap-nanjing.myqcloud.com\u002Fblog5\u002F20260703205119__手势.png",[15,16,17],"GO","Agent","Gemini",false,{"id":20,"title":21,"title_en":22},49,"Go + Gemini 多模型并发：让不同大脑同台竞技，以及差点打爆 API 的那段经历","Go + Gemini Multi-Model Concurrency: Putting Different “Brains” Head-to-Head—and the Time We Almost Crashed the API",{"id":24,"title":25,"title_en":26},51,"驯服大模型的输出：如何在 Go 中可靠地解析 LLM 返回的 JSON","Taming LLM Outputs: How to Reliably Parse JSON in Golang","2026-06-30T20:02:10.943403+08:00"]