1. 从“hindsight”这个词说起:为什么记忆是 Agent 最被低估的能力
“hindsight”这个词本身很有意思,字面意思是“事后的洞察力”,也就是我们常说的“后见之明”。放在 LLM Agent 的语境里,它指向一个非常具体且关键的问题:Agent 能不能记住过去发生过的事情,并且在后续决策中真正用上这些记忆?
大多数人搭 Agent 的时候,第一反应是接工具、调 API、写 prompt。这些当然重要,但真正决定一个 Agent 能不能从“一次性问答机器”进化成“持续工作的助手”的,是它的记忆系统。我见过太多项目,工具链搭得很漂亮,MCP 接了一堆,但 Agent 每次对话都像失忆一样从头开始,用户体验直接崩掉。
结合热搜词里出现的agent memory、working memory、MCP、Docker这些关键词,可以判断这个方向的核心诉求是:如何给 LLM Agent 构建一套可靠的记忆存储与检索机制,并且通过 MCP 协议和容器化部署让它真正跑起来。
这篇文章适合谁看?如果你正在做 Agent 相关的开发,或者你已经在用 MCP 接各种工具但发现 Agent “记不住事”,那这篇内容就是写给你的。我会从记忆的本质讲起,拆解 working memory 和 long-term memory 的区别,然后落到 MCP 协议怎么承载记忆读写,最后给出 Docker 环境下的完整部署思路和我自己踩过的坑。
注意:本文讨论的“记忆”指的是 Agent 在运行过程中对上下文、历史交互、任务状态的存储与调用,不涉及任何用户隐私数据的采集或跨境传输问题。
2. Agent 记忆的本质:不是存下来就完事了
2.1 Working Memory 和 Long-term Memory 的分界线在哪
很多人一上来就说“我要给 Agent 加记忆”,但没想清楚加的是哪种记忆。这里必须先把这个概念理清楚,否则后面架构设计一定跑偏。
Working memory(工作记忆)类比人的短期记忆,它承载的是当前任务执行过程中需要临时保持的信息。比如 Agent 正在帮你订机票,它需要记住你说了出发城市、目的地、时间、舱位偏好,这些信息在任务完成之前必须一直可用。Working memory 的典型特征是:生命周期短、容量有限、读写频率极高。
Long-term memory(长期记忆)类比人的长期记忆,它跨越会话存在。比如 Agent 上次帮你订机票时你说了“我偏好靠窗座位”,这次你再让它订票,它应该能主动想起这个偏好。长期记忆的特征是:持久化存储、容量大、检索需要索引和排序。
我自己的经验是,大部分 Agent 项目失败不是因为长期记忆没做好,而是工作记忆根本没管好。你想想,一个 Agent 连当前对话里三轮之前说过的约束都记不住,你给它加再多长期记忆也没用,因为它在单次任务里就已经开始胡言乱语了。
所以正确的顺序是:先把 working memory 做扎实,再考虑 long-term memory 的持久化和检索。
2.2 为什么简单的 context window 堆叠不够用
有人会说,现在 LLM 的 context window 都到 128K 甚至 1M token 了,我把所有历史都塞进去不就行了?
这个想法在 demo 阶段没问题,但一到生产环境就会撞墙。原因有三个:
第一,成本。每次请求都把全部历史塞进去,token 消耗是线性增长的。一个跑了 50 轮对话的 Agent,每轮都带上全部历史,你的 API 账单会教你做人。
第二,注意力稀释。这是更隐蔽的问题。当 context 里塞了大量无关历史时,LLM 对关键信息的注意力会被稀释。你会发现 Agent 开始忽略你在第 3 轮说的关键约束,因为它在第 47 轮的时候已经“看不过来”了。
第三,一致性问题。历史信息之间可能矛盾。你第 5 轮说“预算 5000”,第 20 轮说“预算可以放宽到 8000”,如果两句话都在 context 里,LLM 可能随机选一个。你需要一个机制来标记哪条信息是最新的、有效的。
所以记忆系统的核心不是“存”,而是选择性地存、有策略地取。这就引出了后面要讲的存储结构和检索策略。
2.3 记忆的三个核心维度:谁、找什么、能提供什么
热搜词里有一条特别有意思:“llm的token三个点key我是谁、query我在找什么、value我能提供什么”。这其实是在用 key-value 的思维来理解记忆。
我把它展开一下,Agent 记忆系统里每一条记忆记录,本质上都包含三个维度的信息:
| 维度 | 含义 | 示例 |
|---|---|---|
| 身份标识(Who) | 这条记忆属于谁、在什么场景下产生 | 用户A的偏好、任务B的中间状态 |
| 检索意图(What) | 当前需要什么类型的信息 | 用户偏好、历史决策、工具调用结果 |
| 内容载荷(Value) | 实际存储的信息本体 | “偏好靠窗座位”、“上次用的是MySQL 8.0” |
这个三元组看起来简单,但实际设计的时候,最难的是“检索意图”这一层。因为 Agent 在运行时,它自己往往不知道“我现在需要什么记忆”。你不能指望 LLM 每次都精准地说出“我需要检索用户偏好类记忆”。
我的做法是:在 Agent 的决策循环里,显式地插入一个记忆检索步骤。在每次调用 LLM 之前,先用当前 query 去记忆库里做一次相似度检索,把 top-k 相关记忆注入到 context 里。这样就不依赖 LLM 自己“想起来”要检索记忆。
3. MCP 协议在记忆系统里扮演什么角色
3.1 MCP 不是硬件协议,它是 Agent 和外部能力之间的标准接口
热搜词里有人问“mcp 是软件协议 硬件协议那个概念叫什么来着”,这里先澄清一下:MCP(Model Context Protocol)是一个软件层的通信协议,它定义的是 LLM 应用和外部工具/数据源之间的交互标准。跟硬件协议(比如 I2C、SPI 那种)完全是两个层面的东西。
MCP 的核心价值在于解耦。在没有 MCP 之前,你每接一个工具就要写一套适配代码,工具换了接口你就得改 Agent 代码。有了 MCP 之后,工具方按照协议暴露能力,Agent 方按照协议调用能力,双方不需要知道对方内部怎么实现的。
放到记忆系统里,MCP 的意义是:记忆存储可以作为一个独立的 MCP Server 存在,Agent 通过标准的 MCP 接口来读写记忆,而不是把记忆逻辑硬编码在 Agent 里面。
3.2 把记忆做成 MCP Server 的架构思路
我实际用过的一种架构是这样的:
Agent (MCP Client) | |-- MCP Protocol (stdio / SSE) | Memory MCP Server | |-- Working Memory (in-memory / Redis) |-- Long-term Memory (vector DB / SQLite) |-- Retrieval Engine (embedding + rerank)Agent 在需要存取记忆的时候,通过 MCP 协议发送请求。Memory Server 负责实际的存储、索引、检索逻辑。这样做的好处是:
- 记忆层可以独立升级:你今天用 SQLite 存,明天想换向量数据库,Agent 侧代码不用动。
- 多个 Agent 可以共享记忆层:如果你有多个 Agent 协作,它们可以连同一个 Memory Server。
- 调试方便:记忆的读写有独立的日志和监控,出问题好定位。
具体到 MCP 的工具定义,Memory Server 通常会暴露这几个 tool:
{ "tools": [ { "name": "store_memory", "description": "存储一条记忆", "inputSchema": { "type": "object", "properties": { "content": {"type": "string"}, "memory_type": {"type": "string", "enum": ["working", "long_term"]}, "metadata": {"type": "object"} } } }, { "name": "retrieve_memory", "description": "根据查询检索相关记忆", "inputSchema": { "type": "object", "properties": { "query": {"type": "string"}, "top_k": {"type": "integer", "default": 5}, "memory_type": {"type": "string"} } } } ] }这个设计的关键点是memory_type 参数。Agent 在存的时候要明确这是工作记忆还是长期记忆,因为两者的存储策略和过期策略完全不同。
3.3 MCP 连接方式的选择:stdio 还是 SSE
MCP 支持多种传输方式,最常见的是 stdio 和 SSE。在记忆系统这个场景下,我的选择建议是:
stdio适合本地开发、单机部署。Memory Server 和 Agent 跑在同一台机器上,通过标准输入输出通信。优点是简单、延迟低、不需要网络配置。缺点是没法跨机器共享。
SSE(Server-Sent Events)适合分布式部署。Memory Server 作为独立的 HTTP 服务运行,多个 Agent 可以通过网络连接。优点是灵活、可扩展。缺点是需要处理网络异常、认证、并发等问题。
我自己的项目里,开发阶段用 stdio,上线之后切到 SSE。切换的时候只需要改 MCP Client 的连接配置,Memory Server 本身的逻辑不用动,这就是协议标准化的好处。
提示:如果你用 SSE 方式部署 Memory Server,一定要注意并发写入的问题。多个 Agent 同时写同一条记忆的元数据时,需要加锁或者用乐观并发控制,否则会出现数据覆盖。
4. Docker 环境下的记忆服务部署实操
4.1 为什么记忆服务值得容器化
热搜词里 Docker 相关的词出现频率极高:docker安装、docker desktop、docker网络不通、windows安装docker、ubuntu安装docker并运行python环境……这说明大量开发者在这个环节卡过。
记忆服务容器化的理由很直接:
- 环境一致性:向量数据库、embedding 模型、Python 依赖版本这些东西,在不同机器上装出来的结果可能不一样。容器化之后,开发机和服务器跑的是同一个镜像。
- 资源隔离:embedding 计算是吃内存的,如果你把它和 Agent 跑在同一个进程里,Agent 的响应延迟会被拖累。分开容器部署,可以独立限制资源。
- 持久化清晰:记忆数据是需要持久化的,用 Docker volume 挂载出来,容器重建数据不丢。
4.2 一个可用的 docker-compose 配置拆解
下面是我实际在用的一个配置,做了简化但核心结构保留:
version: "3.8" services: memory-server: build: ./memory-server ports: - "8080:8080" environment: - MEMORY_BACKEND=sqlite - EMBEDDING_MODEL=all-MiniLM-L6-v2 - WORKING_MEMORY_TTL=3600 - LONG_TERM_DB_PATH=/data/memory.db volumes: - memory-data:/data healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s timeout: 10s retries: 3 redis: image: redis:7-alpine ports: - "6379:6379" volumes: - redis-data:/data command: redis-server --appendonly yes volumes: memory-data: redis-data:几个关键点解释一下:
MEMORY_BACKEND=sqlite是给小型项目用的,零配置、单文件、够用。如果你记忆量上到百万级,换成 PostgreSQL + pgvector 或者专门的向量数据库。
WORKING_MEMORY_TTL=3600是工作记忆的过期时间,单位秒。一小时没有访问的工作记忆自动清理。这个值要根据你的任务时长来调,任务平均跑 10 分钟的话,TTL 设 1800 就够了。
healthcheck这个别省。记忆服务挂了但 Agent 不知道,会导致 Agent 一直重试或者静默失败。有了 healthcheck,编排层可以自动重启不健康的容器。
Redis 单独拆出来是因为工作记忆的读写频率太高,用 Redis 做缓存层比直接读写 SQLite 快一个数量级。长期记忆落 SQLite,工作记忆走 Redis,这个组合在小规模场景下性价比最高。
4.3 Docker 网络不通这个坑,我踩过三次
热搜词里“docker网络不通”这个词让我很有共鸣。记忆服务部署最常见的翻车场景就是:Agent 容器连不上 Memory Server 容器。
排查链路我总结成这样:
第一步,确认两个容器在同一个 network 里。docker-compose 默认会创建一个 bridge network,所有 service 都在里面。但如果你是手动docker run的,默认用的是 bridge 网络,容器之间只能用 IP 通信,不能用服务名。
第二步,确认端口映射对不对。容器内部的 8080 端口,映射到宿主机的 8080,这个没问题。但如果你在 Agent 容器里用localhost:8080去连,那肯定连不上,因为 localhost 指的是 Agent 容器自己。要用服务名memory-server:8080。
第三步,确认防火墙和 SELinux。在 Linux 上跑的时候,宿主机的防火墙规则可能拦截了容器间的流量。SELinux 开启的情况下,还需要给 volume 挂载加:z或:Z标签。
第四步,看 DNS 解析。docker exec -it agent-container nslookup memory-server看看能不能解析出 IP。解析不了就是 network 配置问题。
这四步走完,99% 的网络问题都能定位。
4.4 Windows 上跑 Docker Desktop 的额外注意事项
热搜词里有“windows安装docker”和“virtualization support not detected docker desktop failed to start”,这个错误太经典了。
Windows 上跑 Docker Desktop,底层依赖 WSL2 或者 Hyper-V。出现 “virtualization support not detected” 通常是两个原因:
- BIOS 里没开虚拟化:Intel VT-x 或 AMD-V 需要在 BIOS 里手动启用。这个跟操作系统无关,是硬件层面的开关。
- Hyper-V 和 WSL2 冲突:如果你之前装过 VirtualBox 或者 VMware,它们可能占用了 Hyper-V 层。需要在“启用或关闭 Windows 功能”里确认 Hyper-V 和“虚拟机平台”都勾选了。
另外,Windows 上跑记忆服务有个性能坑:文件系统 IO 慢。WSL2 访问 Windows 文件系统的速度远低于访问 Linux 原生文件系统。所以 SQLite 数据库文件一定要放在 WSL2 的文件系统里(比如/home/user/data),不要放在/mnt/c/...下面。我实测过,同样的写入操作,放在/mnt/c下比放在 WSL2 原生路径下慢 5 到 10 倍。
5. 记忆检索策略:存进去容易,取出来对才是难点
5.1 纯向量检索的三个失效场景
很多人做记忆检索,第一反应就是 embedding + 向量相似度。这个方案在 demo 阶段效果不错,但生产环境会遇到几个典型失效场景:
场景一:时间敏感型查询。用户问“我上次说的预算是多少”,向量检索可能召回三条不同时间说的预算,但它不知道哪条是最新的。这时候需要时间衰减因子,越新的记忆权重越高。
场景二:精确匹配需求。用户问“我的订单号是多少”,这是一个精确查找,不是语义相似度问题。向量检索可能召回一堆语义相近但订单号不对的记忆。这时候需要关键词索引做兜底。
场景三:多跳推理。用户问“我上次订的那个航班,用的哪张信用卡”,这需要先找到航班记录,再从航班记录关联到支付记录。纯向量检索做不了这种关联查询,需要图结构或者关系型索引。
我的做法是混合检索:向量检索负责语义召回,关键词索引负责精确匹配,时间衰减负责排序,三者加权融合。具体权重根据你的场景调,我一般用 0.6 向量 + 0.3 关键词 + 0.1 时间衰减作为起点。
5.2 记忆的写入策略:什么时候该存,什么时候不该存
检索策略重要,但写入策略更基础。垃圾进,垃圾出,如果存了一堆无用记忆,检索再厉害也白搭。
我的写入原则是三条:
第一,显式约束必须存。用户在对话中明确表达的偏好、限制、要求,这些必须存。比如“我不要红色”、“预算不超过 5000”、“用 Python 不用 Java”。
第二,工具调用结果选择性存。不是所有工具返回都要存。查询类工具的结果(比如“今天天气怎么样”)存了没意义,下次问的时候重新查就行。但配置类、状态类的结果要存,比如“数据库连接串是 xxx”、“任务 ID 是 xxx”。
第三,中间推理过程不存。Agent 的思考链(chain of thought)不需要存。这些是过程性信息,对后续决策没有直接价值,存了反而污染检索结果。
具体实现上,我会在 Memory Server 里加一个write filter层,用规则 + 小模型判断一条信息是否值得存储。规则负责硬性过滤(比如长度小于 10 个字符的不存),小模型负责语义判断(比如判断这是偏好还是闲聊)。
5.3 记忆冲突的处理:当新旧信息矛盾时怎么办
这是记忆系统里最棘手的问题之一。用户先说“预算 5000”,后说“预算改成 8000”,两条记忆都在库里,检索的时候召回哪条?
我的处理方案是版本化 + 失效标记:
每条记忆有一个valid_from和valid_until字段。新记忆写入时,如果检测到与旧记忆冲突(同一主体、同一属性、不同值),就把旧记忆的valid_until设为当前时间,新记忆的valid_from设为当前时间。检索的时候只召回valid_until为空或者大于当前时间的记忆。
冲突检测怎么做?简单场景用规则:同一用户 + 同一属性 key + 不同 value = 冲突。复杂场景需要 LLM 判断,比如“预算 5000”和“别超过 8000”是不是冲突。这个判断可以异步做,不阻塞主流程。
注意:冲突检测不要做得太激进。有些信息看起来矛盾但实际上不矛盾,比如“我喜欢红色”和“我讨厌红色衣服”,这是两个不同维度的偏好。误判冲突会导致记忆被错误失效。
6. 实测中遇到的几个非典型问题
6.1 Embedding 模型的冷启动延迟
第一次调用 embedding 模型的时候,模型需要加载到内存,这个延迟可能达到几秒甚至十几秒。如果你的 Memory Server 是在请求到来时才懒加载模型,第一个请求会超时。
解决方案是在容器启动时预热模型。在 Dockerfile 的 entrypoint 里加一步:启动后先跑一次 dummy embedding,把模型加载到内存,然后再开始接受请求。这样第一个真实请求的延迟就正常了。
6.2 工作记忆的并发读写
Agent 可能是多线程的,或者你有多个 Agent 实例共享同一个 Memory Server。工作记忆的并发读写如果不加控制,会出现脏读和丢失更新。
我的做法是:工作记忆的写操作走 Redis 的原子操作,用HSET而不是先HGET再HSET。读操作直接用HGETALL。如果需要更复杂的原子性,用 Redis 的 Lua 脚本或者事务。
长期记忆的写入频率低,用数据库的事务就够了。但要注意 SQLite 的写锁是库级别的,并发写入会串行化。如果写入量大,换 PostgreSQL。
6.3 记忆检索的延迟预算
Agent 的响应时间预算是有限的。如果记忆检索占了 500ms,LLM 推理占了 2s,用户感知到的延迟就是 2.5s。记忆检索的延迟必须控制在总预算的 10% 到 20% 以内。
优化手段有几个:
- 缓存高频检索:同样的 query 在短时间内重复出现,直接返回缓存结果。
- 异步预取:在 Agent 开始思考的时候,就并行发起记忆检索,不要等 LLM 说“我需要记忆”才去查。
- 限制 top_k:召回 5 条和召回 50 条,延迟差好几倍,但效果可能差不多。从 5 开始调,不够再加。
我实测下来,在 SQLite + 本地 embedding 模型的配置下,单次检索延迟可以压到 50ms 以内。如果换成远程 embedding API,延迟会到 200-500ms,这时候缓存就很重要了。
6.4 记忆数据的备份和迁移
记忆是 Agent 的核心资产,丢了就没了。Docker volume 虽然比容器内存储可靠,但也不是绝对安全。
我的备份策略是:每天定时把 SQLite 文件导出成 JSON 快照,存到对象存储。恢复的时候从快照导入。这个方案简单粗暴但有效,比搞复杂的数据库复制靠谱。
迁移的时候注意 embedding 维度要一致。如果你换了 embedding 模型,维度从 384 变成 768,旧的向量索引就废了,需要全量重新计算。所以选 embedding 模型的时候要慎重,尽量选一个稳定的、长期维护的。
7. 关于这套方案的一些个人体会
我从去年开始在自己的项目里迭代这套记忆系统,最大的感受是:记忆系统的复杂度不在于技术,而在于产品决策。
什么叫产品决策?就是你要决定 Agent 记住什么、忘记什么、什么时候主动回忆、什么时候假装不知道。这些决策没有标准答案,取决于你的用户预期和使用场景。
比如一个客服 Agent,它应该记住用户的历史问题,但不应该在用户没提的时候主动说“你上次也问过这个问题”。而一个个人助理 Agent,主动回忆反而是加分项。
技术层面,MCP 协议确实让记忆服务的集成变得干净了很多。以前我要在每个 Agent 里写一套记忆读写逻辑,现在抽成一个 MCP Server,所有 Agent 共用。Docker 化之后,部署和迁移也省心了。
如果你现在正在做 Agent 项目,我的建议是:先把 working memory 用 Redis 管起来,把当前任务的上下文一致性做扎实。这一步的投入产出比最高。长期记忆可以后面再加,不急。
最后分享一个我调试记忆系统时常用的小技巧:在 Memory Server 里加一个/debug/dump接口,把当前所有记忆以人类可读的格式输出出来。每次 Agent 行为异常的时候,先 dump 一下记忆库,看看它到底记住了什么、没记住什么。十次有八次,问题就出在记忆的写入或检索环节,而不是 LLM 本身。