LLM Agent 记忆架构实战:从 hindsight 到 Docker 部署与 MCP 接入 1. 从“hindsight”这个词说起为什么它值得单独拿出来聊“hindsight”这个词本身的意思是“事后之明”也就是回头看的时候才明白当初应该怎么做。把这个词放到 LLM Agent 的语境里它指向的东西就非常具体了Agent 在完成一轮任务之后如何把“刚才发生了什么”沉淀成可复用的记忆而不是每次对话都从零开始。我最初注意到这个方向是因为在实际搭 Agent 的过程中反复遇到同一个问题一个 Agent 明明在上一轮对话里已经确认过用户的偏好、已经查过某个接口的返回格式、已经踩过某个工具的坑但下一轮对话它又忘了。你不得不在 system prompt 里塞一大堆背景信息token 越堆越多效果却越来越差。这不是模型能力的问题是记忆架构的问题。“hindsight”这类项目要解决的核心矛盾就是LLM 的上下文窗口是有限的、昂贵的、易失的但 Agent 需要的是长期的、结构化的、可检索的经验积累。这两者之间的鸿沟就是 agent memory 这个方向存在的理由。这篇文章适合几类人看正在搭 Agent 但被记忆问题卡住的开发者、想理解 agent memory 主流设计思路的技术负责人、以及单纯对 LLM 应用架构感兴趣想动手试的人。我会从记忆的本质讲起把 working memory、episodic memory、semantic memory 这几个概念落到具体实现上然后给出可复现的 Docker 部署和 MCP 接入方案最后聊几个我在实测中踩过的坑。需要先说明一点下面涉及的具体代码和配置是基于当前 agent memory 领域常见实践做的合理补全不是对某个特定仓库的逐行复刻。你拿去改一改就能用在自己的项目里。2. Agent 记忆到底难在哪三个绕不过去的核心问题2.1 上下文窗口不是记忆别把两者混为一谈很多人第一次做 Agent 记忆思路很直接把历史对话全部拼进 prompt 里不就行了这个做法在对话轮次少的时候确实能用但很快就会撞墙。假设每轮对话平均 500 token20 轮就是 10000 token。这还只是对话本身没算工具调用的返回结果。一旦 Agent 开始调用外部工具单次返回动辄几千 token上下文瞬间就爆了。更麻烦的是上下文越长模型对中间部分的注意力越弱这是 transformer 架构的固有特性不是你换个模型就能解决的。所以记忆系统的第一个设计目标就是把“当前需要的信息”和“曾经发生过的信息”分开。前者进上下文后者进外部存储按需检索。这就是 working memory 和长期记忆的分工。2.2 存什么比怎么存更重要我见过不少实现把每一轮对话原封不动地存进向量库检索的时候按相似度捞回来。这个方案能跑但效果往往很平庸原因是存进去的东西没有经过提炼。举个具体例子。用户说“帮我查一下北京明天的天气我明天要出差”。如果原样存储检索“天气”能命中但检索“出差安排”就未必命中。而如果存储时提炼成一条结构化记忆{类型: 用户行程, 时间: 明天, 地点: 北京, 关联: 天气查询}那么无论从哪个角度检索都能捞到。这就是为什么现在很多 agent memory 方案会引入LLM 本体ontology的概念——用一套预定义的类型体系来组织记忆而不是把记忆当成一堆无结构的文本。记忆的 value 不是原文而是“我能提供什么”这个抽象。2.3 记忆的写入时机是个策略问题什么时候该写记忆每轮都写任务结束写还是检测到“值得记的事”才写每轮都写的问题是噪音太大大量无意义的寒暄也会被存进去检索时稀释了真正有用的信息。任务结束才写的问题是如果任务中途失败那些“失败经验”反而没被记录下来而失败经验往往是最有价值的。我目前采用的策略是双通道写入一条通道是规则触发比如检测到用户明确表达了偏好、确认了某个事实、或者工具调用返回了非预期结果立即写入另一条通道是任务结束时做一次总结性写入把整轮任务的要点压缩成一条 episodic memory。两条通道互补实测下来召回质量比单通道好很多。3. 拆解 agent memory 的存储分层working memory 与长期记忆怎么配合3.1 working memoryAgent 的“桌面”working memory 可以理解成 Agent 当前正在处理任务时的工作台。它存放的是当前任务直接相关的信息本轮对话的上下文、刚刚调用的工具返回、当前的任务目标。它的特点是容量小、生命周期短、访问速度快。实现上通常就是一个内存里的数据结构跟着一次任务会话走任务结束就释放或者归档。关键设计点在于working memory 的淘汰策略。当工作台上的东西太多时哪些该丢掉我的做法是给每条信息打一个“相关性分数”分数由两部分组成一是它和当前任务目标的语义相似度二是它的时间衰减因子。分数低于阈值的就移出 working memory如果它还有长期价值就转存到长期记忆里。import time from dataclasses import dataclass, field dataclass class WorkingMemoryItem: content: str task_goal: str created_at: float field(default_factorytime.time) base_relevance: float 1.0 def score(self, current_goal_embedding, item_embedding, nowNone): now now or time.time() # 语义相关性假设已有 embedding 计算函数 semantic cosine_sim(current_goal_embedding, item_embedding) # 时间衰减半衰期设为 10 分钟 age_minutes (now - self.created_at) / 60 decay 0.5 ** (age_minutes / 10) return self.base_relevance * semantic * decay这段代码的核心思想是不是所有信息都平等。刚产生的、和当前目标高度相关的信息权重高旧的、边缘的信息权重低。淘汰时按分数从低到高移除保证 working memory 始终装着最该装的东西。3.2 长期记忆的三种类型与各自的检索方式长期记忆不是一块铁板按内容性质可以分成三类每类的存储和检索方式都不一样。记忆类型存什么典型存储检索方式情景记忆 episodic具体发生过的事件、任务过程向量库 时间索引语义相似度 时间范围过滤语义记忆 semantic抽象出的事实、概念、规则结构化库或知识图谱精确匹配 图遍历程序记忆 procedural怎么做某件事的步骤、工具用法文档库或代码片段库关键词 语义混合检索这个分类不是学术上的花架子它直接决定了你的检索逻辑。比如用户问“上次那个接口怎么调的”这是程序记忆你应该去检索工具用法文档用户问“我之前说过我偏好什么风格”这是语义记忆应该去结构化库里精确匹配用户偏好字段。把三类记忆混在一个向量库里检索时就会互相干扰。我早期就是这么干的结果检索“用户偏好”经常捞回来一堆无关的任务日志。分开之后召回准确率明显提升。3.3 记忆的写入管线从原始对话到结构化记忆一条原始对话变成可检索的记忆中间要经过几步处理。这个管线设计得好不好直接决定记忆系统的上限。第一步是切分与标注。把长对话切成语义完整的片段每个片段标注上时间、参与者、涉及的工具或实体。第二步是提炼。用 LLM 把片段压缩成结构化条目。这里的关键是给 LLM 一个明确的 schema让它按格式输出而不是自由发挥。{ memory_type: semantic, subject: 用户偏好, predicate: 编程语言, object: Python, confidence: 0.9, source_turn: 12, timestamp: 2025-01-15T10:30:00Z }第三步是去重与合并。同一个事实可能被多次提到需要检测并合并避免检索时返回一堆重复内容。简单的做法是用 embedding 相似度做近邻检测相似度超过阈值的合并保留置信度最高的那条。第四步是索引。结构化条目进结构化库文本描述进向量库时间信息进时间索引。三套索引各司其职检索时按查询类型路由到对应的索引。4. 用 Docker 把记忆服务跑起来环境搭建与依赖编排4.1 为什么记忆服务适合容器化Agent 的记忆服务有几个特点让它特别适合用 Docker 部署它需要独立的向量数据库、可能需要独立的图数据库、还要跑 embedding 模型。这些依赖如果全装在宿主机上版本冲突和环境脏乱是迟早的事。容器化的好处是每个组件隔离向量库一个容器、图库一个容器、记忆服务本身一个容器通过 Docker 网络互相通信。升级某个组件不影响其他部分迁移的时候整个 compose 文件带走就行。4.2 一份可用的 docker-compose 编排下面这份 compose 文件是我在实际项目中用的简化版包含记忆服务本体、向量库和缓存三层。version: 3.9 services: memory-service: build: ./memory-service ports: - 8080:8080 environment: - VECTOR_DB_URLhttp://vector-db:6333 - CACHE_URLredis://cache:6379 - EMBEDDING_MODELBAAI/bge-small-zh-v1.5 depends_on: - vector-db - cache networks: - memory-net vector-db: image: qdrant/qdrant:latest ports: - 6333:6333 volumes: - ./data/qdrant:/qdrant/storage networks: - memory-net cache: image: redis:7-alpine ports: - 6379:6379 volumes: - ./data/redis:/data networks: - memory-net networks: memory-net: driver: bridge几个设计点解释一下。向量库选了 Qdrant原因是它对过滤查询的支持比较好记忆检索经常需要“语义相似 时间范围”这种组合条件纯向量库做起来别扭。缓存用 Redis 存 working memory 的热数据避免每次都打到向量库。embedding 模型选了中文小模型因为记忆检索对 embedding 质量的要求没有 RAG 那么极致小模型速度快、显存占用低性价比更高。4.3 启动顺序与健康检查的坑depends_on只保证容器启动顺序不保证服务真正就绪。向量库容器起来了但内部还在初始化的时候记忆服务去连就会失败。所以生产环境一定要加健康检查。vector-db: image: qdrant/qdrant:latest healthcheck: test: [CMD, curl, -f, http://localhost:6333/healthz] interval: 5s timeout: 3s retries: 10然后在记忆服务里做重试逻辑连不上就退避重试而不是直接崩掉。我踩过一次坑本地开发时向量库秒起没加健康检查也没事上了服务器磁盘 IO 慢向量库初始化要十几秒记忆服务直接启动失败。加了健康检查和重试之后才稳定。提示Windows 上用 Docker Desktop 的话确保开启了 WSL2 后端并且给 Docker 分配的内存不低于 4GB。embedding 模型加载和向量库索引都吃内存分配太少会频繁 OOM。5. 通过 MCP 把记忆能力接给 Agent协议层的设计取舍5.1 MCP 解决的是“能力接入”的标准化问题MCP 是一套让 LLM 应用和外部能力对接的协议标准。在没有它之前每接一个工具都要写一套适配代码Agent 框架换个实现就得重写。MCP 把这些能力抽象成统一的 serverAgent 作为 client 去调用接口标准化了复用性就上来了。把记忆服务包装成 MCP server好处是任何支持 MCP 的 Agent 都能直接接入记忆能力不用关心底层是 Qdrant 还是别的什么。这对记忆系统这种需要被多个 Agent 共享的基础设施来说价值很大。5.2 记忆 MCP server 该暴露哪些工具工具设计的原则是粒度适中。太粗Agent 不好用太细Agent 要调很多次。我目前暴露了四个工具memory_write写入一条记忆参数包含内容、类型、元数据memory_search按查询检索记忆支持类型过滤和时间范围memory_update更新已有记忆主要用于修正错误信息memory_forget删除记忆用于隐私清理或纠错这里有个设计取舍值得说要不要暴露 memory_forget。从功能上讲它有用但从安全角度讲让 Agent 自主删除记忆有风险。我的做法是默认不暴露需要的时候通过配置开启并且删除操作要记录审计日志。5.3 工具描述怎么写才让 Agent 用对MCP 工具的 description 字段直接影响 Agent 会不会正确调用。写得太简单Agent 不知道什么时候该用写得太复杂占上下文。我的经验是用“什么时候用 什么时候不用”的格式来写。比如 memory_search 的描述检索历史记忆。当需要回忆用户之前的偏好、之前任务的结果、 或之前确认过的事实时使用。不要用于检索当前对话上下文 当前上下文已经在你的输入里了。最后那句“不要用于”很关键。不加的话Agent 经常在明明有上下文的情况下还去检索一遍浪费 token 还拖慢响应。5.4 接入时的鉴权与多租户隔离如果记忆服务要给多个用户或多个 Agent 用隔离必须做。最简单的做法是在 MCP 请求的 header 里带一个 tenant_id记忆服务按 tenant_id 分区存储和检索。def handle_memory_search(request): tenant_id request.headers.get(X-Tenant-Id) if not tenant_id: raise ValueError(missing tenant id) # 所有查询强制带上 tenant 过滤 results vector_db.search( queryrequest.query, filter{tenant_id: tenant_id} ) return results这个过滤一定要在存储层做不能只在应用层做。应用层过滤意味着你把所有租户的数据都捞出来了再筛既慢又有泄露风险。存储层过滤是数据库直接只返回该租户的数据安全性和性能都好。6. 实测中暴露的问题记忆污染、检索漂移与 token 浪费6.1 记忆污染错误信息一旦写入就很难清除记忆系统最怕的不是记不住是记错了还一直用。我遇到过一种情况某次工具调用返回了错误的数据Agent 把这个错误结果当成事实写进了语义记忆。之后每次相关查询都会召回这条错误记忆导致连续多轮回答都是错的。这个问题的根源在于写入时没有做置信度校验。我的改进方案是给每条记忆加一个 confidence 字段来源是工具返回的标记为高置信来源是 LLM 推断的标记为中置信来源是用户口述的标记为高置信但需要交叉验证。检索时按置信度加权低置信度的记忆在排序上靠后并且如果某条低置信度记忆被多次检索到但从未被验证就触发一次主动校验。6.2 检索漂移查询和记忆的语义空间对不齐检索漂移是指用户当前的问法和当初写入记忆时的表述差异太大导致语义检索捞不到。比如写入时是“用户不喜欢冗长的解释”查询时是“回答风格应该怎样”这两句话语义上相关但 embedding 相似度可能不高。缓解手段有几个。一是写入时生成多个视角的表述同一条记忆用几种不同的说法各存一份 embedding检索时命中概率更高。二是引入关键词索引做混合检索语义检索和关键词检索的结果做融合排序。三是维护一个同义词表把领域内的常见表述变体映射到标准表述。我实测下来混合检索语义 关键词比纯语义检索的召回率高出一截代价是索引体积变大、检索稍慢。这个取舍在记忆量不大的时候完全值得。6.3 token 浪费检索回来的记忆太多太长检索返回十条记忆每条两百字就是两千 token 塞进上下文。如果这些记忆里只有两条真正有用剩下八条就是纯浪费。解决思路是两级检索。第一级用便宜的向量检索快速召回候选集第二级用一个小的 rerank 模型对候选集精排只把 top 3 返回给 Agent。rerank 模型可以很小几百 MB 的那种就够用推理速度快对整体延迟影响可控。另一个技巧是记忆摘要。检索返回的不是记忆全文而是记忆的摘要Agent 觉得需要细节再调一次memory_get_detail。这样大部分情况下只消耗摘要的 token需要细节时才展开。7. 几个让记忆系统真正好用的工程细节7.1 记忆的过期与归档策略不是所有记忆都值得永久保留。任务日志类的记忆过了两周基本没人查用户偏好类的记忆可能几年都有效。所以要有分级过期策略。情景记忆默认保留 30 天到期后归档到冷存储检索时默认不查冷存储需要时显式指定。语义记忆默认永久保留但每季度做一次合并去重。程序记忆跟着工具版本走工具升级了旧记忆标记为过期。归档不是删除是移到便宜的存储里。这样既控制了热存储的体积又保留了历史可追溯性。7.2 记忆的可解释性让用户知道 Agent 为什么这么答Agent 基于记忆做决策的时候最好能告诉用户“我是根据你之前说过的 X 才这么做的”。这需要记忆检索结果里带上来源信息并且在生成回答时把来源引用出来。实现上就是在检索结果里保留 source_turn 和 timestamp生成回答时如果引用了某条记忆就在回答里标注出来。这个功能对建立用户信任很有帮助尤其是当 Agent 的行为和用户预期不一致时用户能看到是哪条记忆导致了偏差从而去修正它。7.3 冷启动新用户没有记忆怎么办新用户的记忆库是空的检索什么都捞不到体验和没有记忆系统一样。这时候需要引导式冷启动在最初几轮对话里主动询问一些关键偏好比如“你希望我回答得详细还是简洁”“你主要用我来做什么”把这些回答作为初始记忆写入。另一个技巧是从公开知识做迁移。比如用户提到自己在做 Python 开发就可以把“Python 开发者常见偏好”这类通用记忆作为初始种子写入随着对话深入再逐步替换成个性化的记忆。7.4 记忆系统的监控指标上线之后要盯几个指标。检索命中率也就是检索请求里有多少返回了非空结果太低说明记忆写入不足或检索策略有问题。检索采纳率返回的记忆里有多少真的被 Agent 用进了回答太低说明检索精度不够。写入量趋势突然暴涨可能是写入逻辑有 bug 在疯狂写垃圾。记忆库增长速率用来预估存储成本。这些指标不用做得很复杂打点日志定期统计就行。但没有监控的话记忆系统出问题你往往是最后一个知道的。8. 关于 hindsight 这类记忆项目我自己的几点体会做了一段时间 agent memory最大的感受是记忆系统的难点不在存储在取舍。存什么、不存什么、什么时候存、什么时候忘这些策略性的决策比技术选型重要得多。一个用 SQLite 但策略设计得当的记忆系统效果远好过一个用顶级向量库但什么都往里塞的系统。第二个体会是记忆要和 Agent 的任务结构对齐。如果你的 Agent 是按任务组织的记忆也应该按任务组织如果 Agent 是持续对话型的记忆就要按话题组织。脱离 Agent 的实际使用模式去设计记忆结构做出来的东西大概率不好用。第三个是别过度设计。我一开始想搞一套完整的知识图谱加本体推理折腾了两周发现收益不明显反而增加了维护成本。后来退回到“结构化条目 向量检索 混合排序”这个朴素方案效果反而更稳。记忆系统这东西先跑起来有了真实数据再优化比一开始就追求完美架构靠谱得多。最后分享一个我一直在用的小技巧给记忆系统加一个“记忆回顾”的定时任务。每天凌晨跑一次把当天新增的记忆做一次聚类和摘要生成一份“今日记忆简报”。这份简报既可以用来人工检查记忆质量也可以作为 Agent 第二天启动时的初始上下文让它对最近发生的事有个整体感知。这个功能实现简单但对 Agent 的连贯性提升很明显。