拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Agent Memory架构实战:从Working Memory到Long-term Memory的完整设计

Agent Memory架构实战:从Working Memory到Long-term Memory的完整设计

1. 从“hindsight”说起:为什么我们需要给Agent装上“后视镜”

“hindsight”这个词本身很有意思,字面意思是“事后的洞察力”,也就是我们常说的“后见之明”。放在Agent Memory这个领域里,它指向一个非常具体且关键的问题:当LLM Agent完成一次任务后,它能不能记住自己刚才做了什么、为什么这么做、结果如何,并在下一次遇到类似场景时调用这些经验?

我接触过不少做Agent开发的团队,大家一开始都把精力放在工具调用、提示词工程、多轮对话管理上,但跑了一段时间后普遍会遇到同一个瓶颈——Agent像金鱼一样,每次对话都是“失忆”状态。用户昨天刚纠正过的错误,今天它又犯一遍;上周已经确认过的业务规则,这周重新问一遍它还是不知道。这不是模型能力的问题,而是记忆架构缺失的问题。

“hindsight”这个项目标题,本质上就是在解决Agent的长期记忆与经验回溯问题。它要做的不是简单的对话历史存储,而是让Agent具备“回头看”的能力:能够从过去的交互中提取结构化经验,能够在需要的时候检索到相关的历史决策,能够根据反馈调整未来的行为策略。这套东西做得好不好,直接决定了Agent是“一次性工具”还是“越用越聪明的助手”。

这篇文章适合三类人看:第一类是正在做Agent应用开发、被记忆问题困扰的工程师;第二类是对LLM Agent架构感兴趣、想了解记忆模块怎么设计的技术爱好者;第三类是用过Docker、MCP这些工具,想看看它们在Agent记忆场景下怎么配合落地的实践者。我会从架构设计、核心实现、实操部署、问题排查几个维度,把“hindsight”这类Agent Memory系统的完整面貌拆开来讲。

2. Agent Memory的核心架构:Working Memory与Long-term Memory怎么分工

2.1 为什么不能只靠上下文窗口

很多人第一反应是:现在LLM的上下文窗口都到128K甚至1M token了,直接把所有历史对话塞进去不就行了?这个思路在Demo阶段能跑通,但放到生产环境立刻崩掉。原因有三个:

成本问题。每次请求都把几万token的历史带上,按API计费模式算,一个月下来账单能吓死人。我见过一个客服Agent项目,没做记忆压缩之前,单次对话成本是做了记忆分层之后的17倍。

注意力稀释。上下文越长,模型对关键信息的注意力越容易被稀释。你把50轮对话塞进去,模型很可能“忘记”第3轮用户明确说过的偏好设置。这不是模型不行,是注意力机制本身的特性决定的。

时效性冲突。用户上周说“我喜欢简洁的回答”,这周说“给我详细解释一下”,如果两段记忆同等权重放在上下文里,模型很难判断该听哪个。

所以Agent Memory的第一条设计原则就是:分层。Working Memory负责当前会话的即时上下文,Long-term Memory负责跨会话的经验沉淀,两者通过检索机制按需桥接。

2.2 Working Memory的设计要点

Working Memory可以理解为Agent的“桌面”,当前任务需要的信息就摊在桌面上,任务结束就收走。它的核心挑战不是存储,而是压缩与摘要。

我一般建议采用滑动窗口加摘要的混合策略:保留最近N轮完整对话(N通常取5到10),更早的内容用LLM生成结构化摘要。摘要不是简单概括,而是提取出“用户意图、关键约束、已确认事实、待办事项”这几个字段。这样即使原始对话被丢弃,关键信息仍然以紧凑形式保留。

注意:摘要生成本身也要消耗token,所以不要每轮都重新生成全量摘要。我的做法是每5轮触发一次增量摘要,把新内容合并到已有摘要里,这样成本可控。

2.3 Long-term Memory的存储与检索

Long-term Memory是“hindsight”真正发挥价值的地方。它要解决的是:当新任务到来时,如何从海量历史经验中找到最相关的那几条。

这里涉及三个关键决策:

存什么。不是所有对话都值得长期保存。我的经验是只存三类内容:用户明确纠正过的错误、成功完成复杂任务的决策路径、用户显式表达的偏好。其他日常闲聊、中间过程全部丢弃。

怎么存。向量数据库是标配,但纯向量检索有个坑——它擅长语义相似,不擅长精确匹配。比如用户问“上次那个订单号是多少”,向量检索可能返回一堆语义相近但订单号不对的记录。所以实际系统里通常是向量检索加结构化过滤双路并行:先用元数据(时间范围、用户ID、任务类型)缩小范围,再做语义排序。

怎么取。检索回来的记忆不能直接塞进上下文,需要做相关性重排序。我常用的是“LLM as judge”模式:让模型对检索结果打分,只保留置信度高的。虽然多了一次LLM调用,但能显著降低噪声干扰。

2.4 MCP在记忆系统中的角色

MCP(Model Context Protocol)在这里扮演的是标准化接口层的角色。你可以把它理解成Agent和外部工具之间的“USB协议”——不管后面接的是数据库、文件系统还是API,Agent都通过统一的协议去调用。

在Agent Memory场景下,MCP的价值在于把记忆的读写操作标准化。比如定义一个memory_store工具和一个memory_retrieve工具,Agent不需要知道底层用的是Redis还是PostgreSQL,只需要按MCP格式发请求就行。这样换存储后端的时候,Agent侧的代码几乎不用改。

提示:MCP是软件协议层面的概念,和硬件接口协议不是一回事。它的核心是定义模型与工具之间的通信格式,让不同厂商的工具能以统一方式接入。

3. 核心细节解析:从Token三元组到记忆生命周期管理

3.1 理解LLM的Token三元组:Key、Query、Value

热词里有一条说得挺形象:“LLM的token三个点:key我是谁、query我在找什么、value我能提供什么”。这其实就是注意力机制的本质。在Agent Memory系统里,这个机制被放大到了记忆检索层面。

每条记忆在存入时,需要生成三个维度的表示:

  • Key(我是谁):这条记忆是关于什么的?通常用摘要向量表示。
  • Query(我在找什么):当前任务需要什么信息?用当前对话的意图向量表示。
  • Value(我能提供什么):这条记忆的具体内容,包括原始文本、结构化字段、时间戳等。

检索时计算Query和Key的相似度,返回对应的Value。听起来简单,但实际调优时,Key的生成质量直接决定检索准确率。我的经验是Key不要用原始文本的embedding,而要用LLM生成的“记忆标题”的embedding,这样语义更集中。

3.2 记忆的生命周期:写入、索引、检索、衰减、淘汰

一套完整的记忆系统需要管理记忆的完整生命周期:

阶段操作关键考量
写入从对话中提取值得记忆的内容提取策略决定信噪比
索引生成向量和元数据索引质量决定检索上限
检索根据当前上下文召回相关记忆重排序策略决定精度
衰减根据时间和使用频率降低权重避免过时信息干扰
淘汰删除低价值记忆控制存储成本和噪声

衰减机制特别值得展开说。我一般用指数衰减加使用频率加权:记忆的权重 = 基础权重 × exp(-λ × 天数) × (1 + 使用次数 × α)。λ取0.01到0.05之间,α取0.1左右。这样一条三个月前从未被使用的记忆,权重会降到很低,但不会完全消失;而一条经常被调用的记忆,即使时间久远也能保持较高权重。

3.3 记忆冲突的处理策略

实际系统里经常遇到记忆冲突:用户上周说“预算控制在5000以内”,这周说“预算可以到8000”。两条记忆都存着,检索时都返回了,Agent该听谁的?

我的处理策略是时间优先加显式覆盖:新记忆默认覆盖旧记忆,但如果旧记忆被标记为“长期有效”(比如用户说“这是公司规定”),则保留并标注冲突。检索时如果发现冲突,把两条都返回给LLM,让模型根据当前上下文判断。实测下来,LLM处理这种冲突的能力比规则引擎强得多。

注意:不要试图用规则解决所有冲突,规则越复杂越容易出bug。把判断权交给模型,但要在提示词里明确告诉它“如果发现记忆冲突,优先采用时间更近的,除非旧记忆被标记为长期有效”。

3.4 与Docker的配合:容器化部署记忆服务

Agent Memory服务通常需要独立部署,因为它要维护向量数据库、缓存、持久化存储等多个组件。Docker Compose是这里的最佳搭档。

一个典型的docker-compose.yml结构大概是这样的:

version: '3.8' services: memory-api: build: ./memory-api ports: - "8080:8080" environment: - VECTOR_DB_URL=http://vector-db:6333 - REDIS_URL=redis://redis:6379 depends_on: - vector-db - redis vector-db: image: qdrant/qdrant:latest ports: - "6333:6333" volumes: - ./data/qdrant:/qdrant/storage redis: image: redis:7-alpine ports: - "6379:6379" volumes: - ./data/redis:/data

这样一套跑起来,记忆服务就有了独立的API层、向量存储层和缓存层。Docker的网络配置让这几个容器通过服务名互相访问,不需要暴露额外端口到宿主机。

4. 实操过程:从零搭建一套Agent Memory系统

4.1 环境准备与依赖安装

先确认基础环境。Windows用户建议用WSL2,因为Docker Desktop在WSL2下的性能明显好于Hyper-V后端。安装Docker Desktop时如果遇到“Virtualization support not detected”报错,通常是BIOS里没开虚拟化,进BIOS把Intel VT-x或AMD-V打开就行。

安装完Docker Desktop后,验证一下:

docker --version docker compose version

两个命令都能正常输出版本号,说明环境OK。

接下来拉取必要的镜像:

docker pull qdrant/qdrant:latest docker pull redis:7-alpine docker pull python:3.11-slim

4.2 记忆服务的核心代码实现

记忆服务的API层我用FastAPI写,核心就三个接口:写入记忆、检索记忆、更新记忆权重。

from fastapi import FastAPI from pydantic import BaseModel from typing import List, Optional import uuid import time app = FastAPI() class MemoryItem(BaseModel): content: str memory_type: str # "correction", "preference", "procedure" user_id: str metadata: Optional[dict] = {} class RetrieveQuery(BaseModel): query: str user_id: str top_k: int = 5 @app.post("/memory/write") async def write_memory(item: MemoryItem): memory_id = str(uuid.uuid4()) # 生成记忆标题用于索引 title = generate_memory_title(item.content) # 存入向量数据库 vector_db.upsert( collection_name="agent_memory", points=[{ "id": memory_id, "vector": embed(title), "payload": { "content": item.content, "type": item.memory_type, "user_id": item.user_id, "created_at": time.time(), "access_count": 0, "metadata": item.metadata } }] ) return {"memory_id": memory_id, "status": "stored"} @app.post("/memory/retrieve") async def retrieve_memory(query: RetrieveQuery): query_vector = embed(query.query) results = vector_db.search( collection_name="agent_memory", query_vector=query_vector, query_filter={ "must": [{"key": "user_id", "match": {"value": query.user_id}}] }, limit=query.top_k * 2 # 多召回一些用于重排序 ) # 重排序:结合相似度、时间衰减、使用频率 reranked = rerank(results, query.query) # 更新访问计数 for r in reranked[:query.top_k]: vector_db.set_payload( collection_name="agent_memory", payload={"access_count": r.payload["access_count"] + 1}, points=[r.id] ) return {"memories": reranked[:query.top_k]}

generate_memory_title这个函数很关键,它用LLM把原始内容压缩成一句话标题,比如把“用户说以后回答不要用表格,用纯文本就行”压缩成“用户偏好:纯文本回答,禁用表格”。这样生成的向量语义更集中,检索准确率能提升不少。

4.3 记忆提取的提示词设计

从对话中提取值得记忆的内容,提示词设计直接决定信噪比。我用的模板大概是这样的:

你是一个记忆提取器。从以下对话中提取值得长期记忆的信息。 只提取以下三类: 1. 用户明确纠正的错误(correction) 2. 用户表达的偏好(preference) 3. 成功完成复杂任务的步骤(procedure) 不要提取:日常问候、中间推理过程、模型自己的解释。 输出格式为JSON数组,每个元素包含: - content: 记忆内容(一句话) - type: correction/preference/procedure - confidence: 0-1之间的置信度 对话内容: {conversation}

实测下来,这个提示词能把信噪比控制在可接受范围内。confidence低于0.6的直接丢弃,不存入长期记忆。

4.4 与MCP的对接

如果Agent框架支持MCP,可以把记忆服务包装成MCP工具。核心是定义一个工具描述文件:

{ "name": "agent_memory", "description": "读写Agent长期记忆", "tools": [ { "name": "memory_write", "description": "写入一条长期记忆", "parameters": { "content": {"type": "string", "description": "记忆内容"}, "type": {"type": "string", "enum": ["correction", "preference", "procedure"]} } }, { "name": "memory_retrieve", "description": "检索相关长期记忆", "parameters": { "query": {"type": "string", "description": "检索查询"}, "top_k": {"type": "integer", "default": 5} } } ] }

Agent在需要的时候调用这两个工具,不需要关心底层存储细节。这样即使以后把Qdrant换成Milvus,Agent侧完全无感。

4.5 完整部署流程

把代码和配置准备好之后,部署流程如下:

  1. 在项目根目录创建docker-compose.yml,内容参考3.4节的配置。
  2. 创建memory-api目录,放入FastAPI代码和requirements.txt。
  3. 创建Dockerfile:
FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
  1. 执行docker compose up -d启动所有服务。
  2. 用docker compose logs -f memory-api查看日志,确认服务正常启动。
  3. 用curl测试写入和检索接口:
curl -X POST http://localhost:8080/memory/write \ -H "Content-Type: application/json" \ -d '{"content":"用户偏好纯文本回答","memory_type":"preference","user_id":"user_001"}' curl -X POST http://localhost:8080/memory/retrieve \ -H "Content-Type: application/json" \ -d '{"query":"回答格式偏好","user_id":"user_001","top_k":3}'

如果两个接口都正常返回,说明基础链路通了。

5. 常见问题与排查技巧实录

5.1 记忆检索不准确怎么办

这是最高频的问题。排查顺序建议从索引质量开始查起。

先看记忆标题生成得怎么样。如果标题太泛(比如“用户说了一些话”),向量检索肯定不准。改进方法是优化generate_memory_title的提示词,要求标题必须包含具体实体和动作。

再看检索时的过滤条件。如果只按user_id过滤,范围太大,噪声自然多。可以加上时间范围过滤,比如只检索最近30天的记忆,或者按memory_type过滤,当前任务需要偏好类记忆就只查preference类型。

最后看重排序策略。如果相似度分数普遍在0.7以下,说明要么索引有问题,要么查询向量和记忆向量不在同一语义空间。检查embedding模型是否一致,写入和检索必须用同一个模型。

5.2 Docker网络不通的排查

Docker Compose环境下,容器之间通过服务名通信。如果memory-api连不上vector-db,先确认两点:

第一,depends_on只保证启动顺序,不保证服务就绪。vector-db启动可能需要几秒钟,memory-api如果启动太快会连接失败。解决办法是在memory-api里加重试逻辑,或者用healthcheck:

vector-db: image: qdrant/qdrant:latest healthcheck: test: ["CMD", "curl", "-f", "http://localhost:6333/health"] interval: 5s retries: 5

第二,确认端口配置。容器内部通信走的是容器端口(比如6333),不是宿主机映射端口。memory-api里配置的VECTOR_DB_URL应该是http://vector-db:6333,而不是http://localhost:6333。

5.3 记忆膨胀导致成本失控

跑了一段时间后,记忆库越来越大,检索变慢,存储成本上升。这时候需要做记忆淘汰。

我的策略是每周跑一次清理任务:删除access_count为0且创建时间超过90天的记忆;删除confidence低于0.5的记忆;对同一用户的同类记忆做去重合并。清理任务本身也用Docker Cron Job跑,不占用主服务资源。

提示:淘汰之前先备份。我吃过亏,有一次清理脚本写错了条件,把用户明确标记为“长期有效”的记忆也删了,导致Agent行为异常。后来加了备份步骤,清理前先导出到冷存储。

5.4 常见问题速查表

问题现象可能原因排查方向解决方案
检索返回空过滤条件太严检查user_id和type过滤放宽过滤条件或增加召回数量
检索结果不相关索引质量差检查记忆标题生成优化标题提示词
服务启动失败依赖服务未就绪查看容器日志加healthcheck和重试
记忆冲突新旧记忆同时召回检查时间戳和权重启用时间衰减和冲突标记
成本过高记忆无淘汰统计记忆总量和调用量启用淘汰策略和摘要压缩
写入失败向量维度不匹配检查embedding模型统一写入和检索的模型

5.5 几个踩过的坑

第一个坑是embedding模型不一致。有一次升级了embedding模型,但只更新了检索侧,写入侧还是旧模型,导致新写入的记忆检索不到。后来在代码里加了模型版本校验,不匹配直接报错。

第二个坑是摘要生成死循环。摘要生成本身调用LLM,如果摘要内容又触发了新的记忆提取,会无限循环。解决办法是在提取提示词里明确排除“摘要内容”这个来源。

第三个坑是Docker volume权限问题。Qdrant容器写入宿主机目录时,如果目录权限不对会启动失败。Linux下用chown -R 1000:1000 ./data/qdrant解决,Windows下一般没这个问题。

5.6 性能调优的几个方向

如果记忆服务响应变慢,可以从这几个方向优化:

批量写入。不要一条一条写,攒够10条批量upsert,Qdrant的批量写入性能比单条高一个数量级。

索引预热。服务启动后先跑一次全量索引加载,避免第一次检索时冷启动。

缓存热点记忆。用Redis缓存最近被频繁访问的记忆,减少向量数据库查询次数。缓存过期时间设短一点,比如5分钟,保证一致性。

异步写入。记忆写入不需要同步等待结果,可以丢到消息队列里异步处理。这样Agent的主流程不会被写入操作阻塞。

6. 记忆系统的扩展方向与个人实践体会

这套架构跑通之后,扩展方向其实挺多的。比如可以加一个记忆可视化面板,让用户看到Agent记住了什么、哪些记忆被调用了、哪些记忆在冲突。这对调试和建立信任都很有帮助。

还可以做跨Agent记忆共享。多个Agent共用一套记忆库,但通过命名空间隔离。这样客服Agent学到的用户偏好,推荐Agent也能用上,用户体验更连贯。

另外记忆的主动遗忘也值得研究。不是所有记忆都值得保留,有些敏感信息用户可能希望被遗忘。提供一个“忘记这条”的接口,既是功能也是合规需要。

我自己在实际操作中的体会是:Agent Memory系统最难的不是技术实现,而是信噪比的平衡。存太多,检索噪声大;存太少,Agent又不够聪明。这个平衡点没有标准答案,需要根据具体业务场景反复调。我的建议是先跑起来,用真实数据观察哪些记忆被调用了、哪些从来没被用过,然后根据数据调整提取策略和淘汰策略。迭代几轮之后,系统会慢慢收敛到一个比较舒服的状态。

最后分享一个小技巧:在记忆的metadata里加一个source字段,记录这条记忆是从哪次对话、哪个任务里提取的。排查问题的时候,能直接追溯到原始上下文,比只看记忆内容高效得多。

返回列表