我给自己的 Go LLM Agent 框架写了套"生产级记忆系统",三天狗粮吃出两个真 Bug
从一个尴尬的 Bug 说起
我维护一个 Go 实现的多协议 LLM Agent 框架 tt-agent(OpenAI/Anthropic/Gemini 协议适配 + ReAct 循环 + 工作流编排)。某天回头看代码,发现一个 embarrassing 的问题:
默认的会话记忆是纯内存的。
进程一重启,所有用户的对话历史全没了。多实例部署?两个 Pod 各记各的,用户上一句在 A 实例、下一句到 B 实例,上下文当场失忆。
这是很多 LLM 应用的通病:Demo 阶段 demo 得飞起,上生产第一件事就是记忆这块补丁摞补丁。于是我决定把记忆系统当成一等公民重做一遍——这篇文章讲的就是这套系统的设计决策、真实性能数据,以及我自己吃狗粮吃出来的两个真 Bug。
先看架构:分层,但每层只为一个失败模式负责
┌────────────────────────────────┐
│ core.Memory 接口 │
│ Add / Recent / Clear / Split │
└───────────────┬────────────────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
┌────────▼────────┐ ┌─────────▼─────────┐ ┌───────▼────────┐
│ memorystore.Store│ │ snapshot.Store │ │ ltm.Memory │
│ 预算/截断/摘要语义 │ │ 快照 + 安全回滚 │ │ 长期记忆(向量) │
└────────┬────────┘ └─────────┬─────────┘ └───────┬────────┘
│ │ │
└──────────┬──────────┴──────────┬──────────┘
│ Driver 契约 │
(只存取字节,不理解任何业务语义)
│
┌───────────────┼───────────────┐
│ │ │
┌────▼────┐ ┌──────▼──────┐ ┌─────▼─────┐
│ Redis │ │ SQLite │ │ PostgreSQL │
└─────────┘ └─────────────┘ └───────────┘
三个关键设计决策:
决策一:驱动只存取字节,语义全部上收。 预算截断、系统消息保留、tool_calls 配对这些容易跑偏的逻辑,全部由 memorystore.Store 统一实现,四种后端构造上就不可能行为不一致。怎么保证的?一套共享契约测试跑在全部后端上——Redis 的测试挂了,不用看代码就知道是驱动层的锅。
决策二:一个 Redis 驱动喂两个视图。 会话记忆和快照(时间旅行)包同一个驱动,共享存储,跨进程锁自动生效:
driver := redis.New(client, redis.Options{Prefix: "myapp:", SessionTTL: 24 * time.Hour})
mem := memorystore.New(driver, memorystore.Options{Observer: bus})
snaps := snapshot.New(driver, snapshot.Options{MaxPerSession: 20})
决策三:加密挂在编解码层,不在驱动层。 换一个 EncryptedCodec,四个后端全部自动获得 AES-256-GCM 静态加密,密钥永远不进驱动代码。实测开销 ~0.5µs/条——基本免费。
时间旅行:回滚一个"说错话"的 Agent
LLM 应用有个独特需求:模型某轮抽风说了不该说的,你希望把会话回滚到那之前,就像 git revert。
快照 ──► 对话继续 ──► 模型说错话 ──► RollbackSafe(快照)
│
┌──────────────┤
▼ ▼
恢复到快照 自动留存"安全快照"
(坏轮次也保着,后悔还能再回滚)
跨进程怎么防两个 Pod 同时回滚互相踩?三种共享后端各有方案:
| 后端 | 锁实现 | 崩溃安全 |
|---|---|---|
| Redis | SET NX PX + token 校验释放 | 租约 TTL 自动回收 |
| PostgreSQL | 事务级咨询锁 | 连接断开锁自动消散 |
| SQLite | 锁表 + 租约 | 过期租约下一获取者回收 |
每一种都有测试真刀真枪验证过——Postgres 那个是测试里直接 pg_terminate_backend 杀后端进程验证锁回收的。
长期记忆:不改进模型,只改这一轮的上下文
会话记忆管"这次对话",长期记忆管"一直记得的事"(用户是素食者、对花生过敏、养了只猫叫煤球)。
vecStore, _ := pgvector.New(pool, pgvector.Options{Dim: 768, Schema: "agent_ltm"})
mem := ltm.New(vecStore, ltm.Options{
Embedder: embeddings.NewOpenAICompatible(ollamaURL, "ollama", "nomic-embed-text"),
Extractor: extractor.New(chatLLM, extractor.Options{Lenient: true}),
})
mem.Remember(ctx, "user-42", "用户对花生严重过敏", nil) // 显式写入
mem.Learn(ctx, "user-42", conversation) // LLM 从对话里抽事实
facts, _ := mem.Recall(ctx, "user-42", "推荐午餐", 5) // 向量召回
prompt := ltm.Prompt(facts) // 渲染成提示词注入
三个生产级细节:
-
向量失败自动降级关键词——远程 embedding 服务挂了,事实不丢,召回照常(质量下降但不中断)
-
命名空间硬隔离——
user-42的召回构造上不可能带出user-43的任何事实,有专门的跨命名空间泄露测试 -
事件绝不携带消息内容——观测系统只发计数和 ID,webhook 配错 URL 也不会把对话泄出去
真实性能数据(Intel Ultra 9 185H 实测)
| 操作 | Redis | SQLite | PostgreSQL |
|---|---|---|---|
| 追加一条消息 | 122µs | 458µs | 1,584µs |
| 读取最近 1000 条 | 2,908µs | 2,159µs | 3,857µs |
| 修剪 1000 条 | 1,957µs | 606µs | 1,049µs |
| 长期记忆 | 100 条事实 | 1,000 条 | 5,000 条 |
|---|---|---|---|
| 关键词召回 | 71µs | 989µs | 6,322µs |
| 向量召回(pgvector) | — | 1,184µs | 8,382µs |
(Redis 数字基于 miniredis 无网络往返;真 Redis 会多一个 RTT)
并发语义是测试背书的:16 并发连接 × 10 条消息打到同一会话,160 条零丢失;双路同时安全回滚,会话锁串行化,最终状态精确一致。
狗粮时刻:三天吃出两个真 Bug
我专门写了一个消费者项目(Gin 架构的完整 API 服务)来吃自己的狗粮,全链路打真实基础设施(真 Redis、真 pgvector、真 DeepSeek/智谱 GLM 双厂商)。结果第一天就咬出两个框架级 Bug:
Bug 1:SystemPrompt 静默丢失。 配置一路从 HTTP handler → agent loop 传到底,最后组装请求时忘了注入。所有设了系统提示词的用户,实际一个都没生效。为什么之前没发现?单测都只断言"传参正确",没人看真正发出去的请求。
Bug 2:抽取器硬编码模型名。 事实抽取模块发请求时写死 Model: "extractor"。宽松的服务商忽略未知模型名,DeepSeek 严格校验直接 400:The supported API model names are deepseek-flash, deepseek-v4-pro, but you passed extractor。宽松环境下所有测试全绿——第一次真实调用当场爆炸。
两个 Bug 的共同点:只有真实消费者 + 真实厂商 + 端到端断言才抓得出来。这也是我给所有做框架的人的建议:你的 dogfood 项目应该比你想象的更早开始。
后来问自己"支持 Redis Cluster 吗",查代码发现键没加 hash tag,Lua 修剪脚本在分片集群必炸 CROSSSLOT——第三个坑。已修(v1.3.0),但这件事说明:不问不查,用户会替你发现。
质量凭据
不是"我觉得能上生产",是这些:
270+ 测试函数、27 个基准,-race 全绿
跨后端契约套件:四后端行为一致性是构造保证
故障路径测试:启动降级 / 运行中断网 / 同端口重启自愈
CI:push/PR 全量测试(-race + 真 pgvector 服务容器)
JSON wire 契约金样本:改一个 tag,CI 当场红给你看
双厂商实测:DeepSeek 15/15 + GLM 15/15 端到端
版本纪律:semver、Release 说明、破坏性变更显著标注
快速开始
go get github.com/Lookfukc/tt-agent@v1.3.0
llm, _ := adapters.NewLLM(cfg) // 任意 OpenAI 兼容厂商
mem := driver.Memory(memorystore.Options{})
loop := agent.NewLoop(llm, tools.NewRegistry(), mem, agent.Config{
Model: "deepseek-chat",
SystemPrompt: "你是一个简洁的助手",
})
msg, usage, _ := loop.Run(ctx, sessionID, "你好")
会话记忆换后端只改一行(Redis ↔ SQLite ↔ PostgreSQL),业务语义零变化。双语 README(2600+ 行)有完整的生产装配指南、参数速查表和三个实战坑预警。
诚实的时间表
还没做好的:Anthropic embedding 适配器未对线上端点实测(地区限制,README 如实标注);零外部用户——所有"生产级"的凭证来自我自己的消费者项目和测试,还没有陌生人的生产环境替我锤过它。
项目在这里:github.com/Lookfukc/tt… 。如果你在做 Go 的 LLM 应用,欢迎来提 issue——被真实需求锤打是框架变好的唯一捷径。
- 掘金:《我给自己的 LLM 框架重写了记忆系统,然后三天吃出两个真 Bug》