
1. 从“hindsight”说起为什么我们需要给Agent装上“后视镜”“hindsight”这个词本身很有意思字面意思是“事后的洞察力”也就是我们常说的“后见之明”。放在LLM Agent的语境下它指向一个非常具体且关键的问题Agent如何记住过去发生过的事情并在后续决策中有效地调用这些记忆如果你最近在折腾Agent相关的项目大概率会遇到这样的场景你精心搭建了一个基于LLM的对话助手第一轮对话它表现得聪明得体但聊到第五轮、第十轮它开始忘记前面说过的关键信息甚至自相矛盾。你告诉它“我对花生过敏”三轮之后它给你推荐了一道宫保鸡丁。这不是模型不够强而是记忆机制缺失导致的典型症状。hindsight这个项目标题结合agent memory、LLM、MCP、Docker这几个关键词核心指向的就是为LLM Agent构建一套可持久化、可检索、可管理的记忆系统。它要解决的问题不是“让模型更聪明”而是“让模型记住该记住的忘掉该忘掉的在需要的时候准确调用”。这套东西适合谁如果你正在做以下任何一件事这篇文章都值得你花时间读完基于LLM开发对话系统被“上下文窗口不够用”折磨过想让Agent跨会话记住用户偏好、历史决策、任务状态在探索MCP协议想把记忆能力做成一个可插拔的服务用Docker部署过服务但对“记忆存储”这一层还没有清晰方案听说过a-memguard这类记忆安全框架想理解它到底在防什么我会从整体设计思路讲到具体实操包括记忆的存储结构、检索策略、MCP集成方式、Docker部署细节以及我在实际搭建过程中踩过的坑。文章不会堆砌术语每个技术选择我都会解释“为什么这么做”以及“不这么做会怎样”。2. 核心设计思路拆解Agent记忆到底该怎么存2.1 为什么传统上下文窗口不够用先把这个前提说清楚。LLM的上下文窗口本质上是一块临时工作区它的特点是容量有限、线性增长、会话结束即清空。你可以把它想象成一张白板每轮对话都在上面写字写满了就得擦掉旧的。问题是擦掉哪些、保留哪些模型自己说了不算。目前主流的应对方式有三种滑动窗口只保留最近N轮对话。简单粗暴但会丢失早期关键信息。摘要压缩把历史对话压缩成一段摘要。能省空间但摘要过程本身会丢失细节而且摘要质量不稳定。外部记忆存储把对话历史、事实、偏好等写入外部存储需要时检索回来。这是hindsight这类项目采用的核心思路。前两种方式都是在“窗口内做文章”第三种是“把窗口外挂”。hindsight显然走的是第三条路而且结合MCP关键词来看它很可能把记忆能力封装成了一个独立的MCP服务让Agent通过标准协议来读写记忆。2.2 记忆的分层模型Working Memory与Long-term Memory人的记忆不是铁板一块Agent的记忆也不应该是一锅粥。我在实际项目中会把记忆分成至少两层Working Memory工作记忆当前会话内的短期上下文生命周期短读写频繁容量小。它对应的是LLM的上下文窗口加上一个轻量的会话缓存。这一层的关键是“快”不能每次读写都去查数据库。Long-term Memory长期记忆跨会话持久化的信息包括用户偏好、历史事实、任务结果、决策记录等。这一层的关键是“准”检索出来的内容必须和当前query高度相关否则就是噪音。hindsight这个命名暗示了它的核心价值在第二层——事后回看的能力。Agent在做出决策后把决策依据、结果、反馈写入长期记忆下次遇到类似场景时通过检索把相关历史调出来形成“后见之明”。这里有一个容易被忽略的设计点记忆不是越多越好。我见过一些项目把所有对话原文一股脑塞进向量数据库结果检索出来的内容又长又杂反而干扰了模型判断。好的记忆系统需要做写入时的过滤和读取时的排序这两件事比存储本身更重要。2.3 为什么选择MCP作为集成方式MCPModel Context Protocol本质上是一套让LLM与外部工具/数据源交互的标准协议。你可以把它理解成“AI世界的USB接口”——不管背后是数据库、API还是本地文件只要实现了MCPAgent就能用统一的方式调用。把记忆系统做成MCP服务好处很直接解耦记忆的存储和检索逻辑独立于Agent本体换Agent不用重写记忆层。可复用同一个记忆服务可以同时给多个Agent使用。可替换今天用SQLite存明天换PostgreSQL只要MCP接口不变上层无感知。可组合记忆服务可以和其他MCP服务比如搜索、代码执行串联形成更复杂的工作流。从热搜词里看到playwright mcp、chrome devtools mcp、burp suite mcp server这些说明MCP生态正在快速扩张。在这个时间点把记忆能力MCP化是一个顺势而为的选择。2.4 Docker在其中的角色Docker出现在关键词里说明这个项目大概率提供了容器化的部署方式。对于记忆服务来说Docker解决的是环境一致性和依赖隔离问题。记忆服务通常需要一个数据库SQLite/PostgreSQL/Redis一个向量检索引擎可选用于语义检索一个MCP Server进程可能的Embedding模型服务这些东西如果裸装在宿主机上版本冲突和配置漂移会让你痛不欲生。Docker Compose一把梭把数据库、向量引擎、MCP Server编排在一起docker compose up就能跑起来这才是现代项目该有的样子。3. 记忆系统的核心细节与实操要点3.1 记忆的数据结构设计Key-Query-Value三元组热搜词里有一条很关键的信息llm的token三个点key我是谁、query我在找什么、value我能提供什么。这其实是在用通俗的方式解释记忆检索的三元组结构Key我是谁记忆的标识维度比如用户ID、会话ID、时间戳、主题标签。它决定了“这条记忆属于谁、属于哪个场景”。Query我在找什么检索时的查询条件通常是当前对话的语义向量或关键词。它决定了“我现在需要什么信息”。Value我能提供什么记忆的实际内容可以是一段文本、一个事实、一条决策记录。它决定了“我能给Agent提供什么”。在实际存储中我通常会把一条记忆设计成这样的结构{ memory_id: uuid, user_id: user_001, session_id: sess_20250101, memory_type: preference, content: 用户对花生过敏, embedding: [0.12, -0.34, ...], metadata: { created_at: 2025-01-01T10:00:00Z, last_accessed: 2025-01-01T12:00:00Z, access_count: 3, confidence: 0.95, source: user_explicit }, tags: [health, dietary] }这里有几个设计决策值得展开memory_type字段把记忆分类偏好、事实、任务、反馈等检索时可以按类型过滤。比如用户问“我之前说过什么饮食禁忌”就只检索preference和health相关的记忆。embedding字段用于语义检索。不是所有记忆都需要embedding但涉及自然语言内容的记忆有embedding才能做“意思相近”的匹配而不是死板的关键词匹配。metadata里的access_count和last_accessed这两个字段用于记忆衰减。长期不被访问的记忆可以降低检索权重甚至归档。这模拟了人类记忆的“用进废退”。confidence字段记忆的置信度。用户明确说“我对花生过敏”是0.95模型从对话中推断出“用户可能不喜欢甜食”可能只有0.6。检索时高置信度的记忆优先返回。3.2 写入策略什么时候该记什么时候不该记这是我在实际项目中最先踩坑的地方。一开始我让Agent把每轮对话都写入记忆结果数据库迅速膨胀检索质量急剧下降。后来我总结了一套写入过滤规则必须写入的内容用户明确陈述的偏好、事实、约束“我住在北京”、“我不吃辣”、“项目截止日期是3月15日”任务的关键决策和结果“选择了方案B因为成本更低”用户的纠正和反馈“不对应该是这样……”谨慎写入的内容模型的推测和假设需要标注低置信度临时性的中间状态除非任务跨会话重复信息写入前先做去重检查不写入的内容寒暄和无关闲聊已经过期的临时信息敏感个人信息除非有明确的加密和权限控制写入前去重是一个容易被忽略的步骤。我的做法是在写入前先用embedding在现有记忆中做一次相似度检索如果相似度超过阈值比如0.92就不新增而是更新已有记忆的last_accessed和confidence。3.3 检索策略不只是向量相似度很多人一提到记忆检索就想到向量数据库但纯向量检索有几个问题语义漂移query的embedding和记忆的embedding可能因为表述差异而匹配不上。时效性缺失向量相似度不考虑时间三个月前的记忆和昨天的记忆可能得分一样。类型混淆用户问“我的偏好”可能检索出一堆“事实”类记忆。我的做法是混合检索综合多个信号打分信号权重说明向量相似度0.5语义匹配程度时间衰减0.2越近的记忆得分越高访问频率0.15经常被调用的记忆更重要类型匹配0.1query意图与memory_type的匹配度置信度0.05高置信度记忆优先最终得分 0.5×相似度 0.2×时间衰减 0.15×频率 0.1×类型 0.05×置信度这个权重不是固定的需要根据你的场景调。比如做客服Agent时效性权重可以调高做知识库Agent相似度权重可以调高。检索返回的结果也不是越多越好。我通常限制返回Top 5-8条并且做去重和摘要——如果多条记忆讲的是同一件事合并成一条再返回给LLM。3.4 MCP接口设计记忆服务的对外契约既然要做成MCP服务就需要定义清晰的工具接口。我设计的记忆MCP Server暴露以下工具{ tools: [ { name: memory_write, description: 写入一条新记忆, parameters: { content: string, 记忆内容, memory_type: string, 记忆类型, user_id: string, 用户标识, session_id: string, 会话标识, confidence: number, 置信度0-1, tags: array, 标签列表 } }, { name: memory_search, description: 检索相关记忆, parameters: { query: string, 查询内容, user_id: string, 用户标识, memory_type: string, 可选按类型过滤, top_k: number, 返回条数默认5 } }, { name: memory_update, description: 更新已有记忆, parameters: { memory_id: string, 记忆ID, content: string, 可选新内容, confidence: number, 可选新置信度 } }, { name: memory_forget, description: 删除或归档记忆, parameters: { memory_id: string, 记忆ID, hard_delete: boolean, 是否物理删除 } } ] }这几个接口覆盖了记忆的增删改查全生命周期。memory_forget这个接口特别重要——用户有权要求“忘掉”某些信息系统也需要主动清理过期记忆。注意MCP工具的description字段会直接影响LLM调用工具的准确性。描述要写得具体、无歧义参数说明要包含类型和示例。4. 从零搭建Docker化部署与MCP集成实操4.1 环境准备与Docker Compose编排假设你在Ubuntu 22.04或Windows 11WSL2上操作先确保Docker和Docker Compose已安装。Windows用户如果遇到virtualization support not detected报错需要在BIOS里开启虚拟化支持并在“启用或关闭Windows功能”中勾选Hyper-V和“虚拟机平台”。我的项目目录结构是这样的hindsight/ ├── docker-compose.yml ├── mcp-server/ │ ├── Dockerfile │ ├── requirements.txt │ └── src/ │ ├── main.py │ ├── memory_store.py │ └── embedding.py ├── db/ │ └── init.sql └── .envdocker-compose.yml的内容version: 3.8 services: postgres: image: postgres:16-alpine environment: POSTGRES_DB: hindsight POSTGRES_USER: hindsight POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - pgdata:/var/lib/postgresql/data - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql ports: - 5432:5432 healthcheck: test: [CMD-SHELL, pg_isready -U hindsight] interval: 5s timeout: 5s retries: 5 qdrant: image: qdrant/qdrant:latest volumes: - qdrant_data:/qdrant/storage ports: - 6333:6333 mcp-server: build: ./mcp-server environment: DB_HOST: postgres DB_PORT: 5432 DB_NAME: hindsight DB_USER: hindsight DB_PASSWORD: ${DB_PASSWORD} QDRANT_HOST: qdrant QDRANT_PORT: 6333 EMBEDDING_MODEL: ${EMBEDDING_MODEL} ports: - 8080:8080 depends_on: postgres: condition: service_healthy qdrant: condition: service_started volumes: pgdata: qdrant_data:这里我用了PostgreSQL Qdrant的组合PostgreSQL存结构化记忆和元数据Qdrant存向量做语义检索。为什么不用SQLite因为记忆服务通常需要并发读写SQLite在高并发下会成为瓶颈。为什么不用RedisRedis适合做缓存但持久化和复杂查询能力不如PostgreSQL。4.2 数据库表结构设计init.sql里定义核心表CREATE TABLE memories ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id VARCHAR(128) NOT NULL, session_id VARCHAR(128), memory_type VARCHAR(64) NOT NULL, content TEXT NOT NULL, confidence FLOAT DEFAULT 1.0, tags TEXT[], metadata JSONB DEFAULT {}, created_at TIMESTAMPTZ DEFAULT NOW(), last_accessed TIMESTAMPTZ DEFAULT NOW(), access_count INT DEFAULT 0, is_archived BOOLEAN DEFAULT FALSE ); CREATE INDEX idx_memories_user ON memories(user_id); CREATE INDEX idx_memories_type ON memories(memory_type); CREATE INDEX idx_memories_created ON memories(created_at DESC); CREATE INDEX idx_memories_tags ON memories USING GIN(tags);Qdrant的collection在应用启动时自动创建不需要手动建表。4.3 MCP Server核心逻辑实现memory_store.py里实现写入和检索的核心逻辑import uuid from datetime import datetime, timezone from typing import List, Optional import asyncpg from qdrant_client import QdrantClient from qdrant_client.models import PointStruct, Filter, FieldCondition, MatchValue class MemoryStore: def __init__(self, pg_pool, qdrant_client, embedding_fn): self.pg pg_pool self.qdrant qdrant_client self.embed embedding_fn self.collection memories async def write(self, user_id: str, content: str, memory_type: str, session_id: Optional[str] None, confidence: float 1.0, tags: Optional[List[str]] None) - str: # 去重检查 existing await self._find_similar(user_id, content, threshold0.92) if existing: await self._touch(existing[id]) return existing[id] memory_id str(uuid.uuid4()) embedding await self.embed(content) # 写入PostgreSQL await self.pg.execute( INSERT INTO memories (id, user_id, session_id, memory_type, content, confidence, tags) VALUES ($1, $2, $3, $4, $5, $6, $7) , memory_id, user_id, session_id, memory_type, content, confidence, tags or []) # 写入Qdrant self.qdrant.upsert( collection_nameself.collection, points[PointStruct( idmemory_id, vectorembedding, payload{ user_id: user_id, memory_type: memory_type, content: content, confidence: confidence, created_at: datetime.now(timezone.utc).isoformat() } )] ) return memory_id async def search(self, user_id: str, query: str, memory_type: Optional[str] None, top_k: int 5) - List[dict]: query_vec await self.embed(query) # 构建Qdrant过滤条件 must_conditions [ FieldCondition(keyuser_id, matchMatchValue(valueuser_id)) ] if memory_type: must_conditions.append( FieldCondition(keymemory_type, matchMatchValue(valuememory_type)) ) # 向量检索多召回一些用于后续重排 results self.qdrant.search( collection_nameself.collection, query_vectorquery_vec, query_filterFilter(mustmust_conditions), limittop_k * 3 ) # 混合重排 scored [] now datetime.now(timezone.utc) for r in results: payload r.payload created datetime.fromisoformat(payload[created_at]) days_old (now - created).days time_decay 1.0 / (1.0 days_old * 0.05) # 获取访问频率 row await self.pg.fetchrow( SELECT access_count FROM memories WHERE id $1, r.id ) freq_score min(row[access_count] / 10.0, 1.0) if row else 0.0 final_score ( 0.5 * r.score 0.2 * time_decay 0.15 * freq_score 0.1 * (1.0 if memory_type and payload[memory_type] memory_type else 0.5) 0.05 * payload.get(confidence, 1.0) ) scored.append((final_score, r)) scored.sort(keylambda x: x[0], reverseTrue) top_results scored[:top_k] # 更新访问计数 for _, r in top_results: await self._touch(r.id) return [ { memory_id: r.id, content: r.payload[content], memory_type: r.payload[memory_type], score: round(score, 4), created_at: r.payload[created_at] } for score, r in top_results ]这段代码有几个关键点去重阈值0.92这个值是我调了几次之后定的。太低会导致该记的没记住太高会导致重复记忆泛滥。你可以根据自己场景调整但建议不要低于0.85。多召回再重排Qdrant先返回top_k * 3条然后用混合评分重排取前top_k。这样比直接取向量Top K更准。时间衰减函数1.0 / (1.0 days_old * 0.05)意思是每天衰减约5%。30天前的记忆权重降到约0.490天前的降到约0.18。这个衰减速度可以根据场景调。4.4 MCP Server的启动与接入main.py里用MCP SDK启动服务from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent import asyncio app Server(hindsight-memory) app.list_tools() async def list_tools(): return [ Tool( namememory_write, description写入一条新记忆。当用户陈述偏好、事实、约束或做出重要决策时调用。, inputSchema{ type: object, properties: { content: {type: string, description: 记忆内容}, memory_type: { type: string, enum: [preference, fact, task, feedback], description: 记忆类型 }, user_id: {type: string}, session_id: {type: string}, confidence: {type: number, default: 1.0}, tags: {type: array, items: {type: string}} }, required: [content, memory_type, user_id] } ), Tool( namememory_search, description检索与当前对话相关的历史记忆。在回答用户问题前调用以获取用户偏好和历史上下文。, inputSchema{ type: object, properties: { query: {type: string, description: 查询内容}, user_id: {type: string}, memory_type: {type: string}, top_k: {type: number, default: 5} }, required: [query, user_id] } ) ] app.call_tool() async def call_tool(name: str, arguments: dict): if name memory_write: memory_id await store.write(**arguments) return [TextContent(typetext, textf记忆已写入: {memory_id})] elif name memory_search: results await store.search(**arguments) if not results: return [TextContent(typetext, text未找到相关记忆)] formatted \n.join([ f[{r[memory_type]}] {r[content]} (相关度: {r[score]}) for r in results ]) return [TextContent(typetext, textformatted)] async def main(): async with stdio_server() as (read, write): await app.run(read, write, app.create_initialization_options()) if __name__ __main__: asyncio.run(main())启动方式docker compose up -d --build然后在你的Agent配置里添加MCP Server{ mcpServers: { hindsight-memory: { command: docker, args: [exec, -i, hindsight-mcp-server-1, python, -m, src.main] } } }注意MCP Server的description字段直接决定了LLM会不会在正确的时机调用工具。memory_search的描述里我特意写了“在回答用户问题前调用”实测下来这能显著提高工具调用率。5. 常见问题与排查技巧实录5.1 记忆检索不相关怎么办这是最高频的问题。你明明存了“用户对花生过敏”但用户问“今晚吃什么”时检索出来的却是“用户喜欢意大利菜”。排查思路检查embedding模型不同模型对同一段文本的向量表示差异很大。中文场景建议用bge-large-zh或text-embedding-3-large。模型选错了后面怎么调都白搭。检查query构造不要把用户原话直接当query。更好的做法是把当前对话的意图提炼成query。比如用户问“今晚吃什么”query应该是“用户饮食偏好和禁忌”。检查去重逻辑如果去重阈值太低大量相似但不相关的记忆被合并会导致检索结果模糊。调整混合检索权重如果向量相似度权重过高试试降低到0.4提高类型匹配和时间衰减的权重。5.2 Docker容器启动失败排查Windows上最常见的报错是virtualization support not detected。解决步骤重启进入BIOS开启Intel VT-x或AMD-VWindows功能里勾选Hyper-V、虚拟机平台、Windows Subsystem for Linux安装WSL2内核更新包Docker Desktop设置里勾选“Use WSL 2 based engine”如果容器启动后立即退出用docker compose logs mcp-server看日志。常见原因数据库连接失败检查depends_on的healthcheck是否生效端口冲突5432或6333被占用改宿主机端口映射环境变量缺失.env文件没被正确加载5.3 记忆写入过多导致性能下降我遇到过跑了三天后检索延迟从50ms涨到800ms的情况。原因是记忆表膨胀到十几万条Qdrant的HNSW索引效率下降。解决方案定期归档把last_accessed超过90天且access_count小于3的记忆标记为is_archivedtrue从Qdrant中删除对应向量。限制单用户记忆数每个用户最多保留500条活跃记忆超出时按访问频率淘汰。异步写入写入操作不阻塞主流程用消息队列缓冲。5.4 常见问题速查表问题现象可能原因排查方法解决方案检索结果不相关embedding模型不匹配用相同文本测试embedding相似度更换为中文优化的embedding模型记忆重复写入去重阈值过低查看memories表重复内容提高去重阈值至0.9以上容器启动失败虚拟化未开启查看Docker Desktop报错BIOS开启虚拟化安装WSL2检索延迟高记忆量过大统计memories表行数归档旧记忆限制单用户记忆数LLM不调用记忆工具MCP工具描述不清查看Agent日志中的工具调用记录优化description增加调用时机说明跨会话记忆丢失user_id不一致检查不同会话的user_id统一用户标识生成逻辑5.5 关于a-memguard的思考热搜词里出现了a-memguard: a proactive defense framework for llm-based agent memory这指向一个很重要但容易被忽视的问题记忆安全。Agent的记忆系统如果被恶意注入后果比想象中严重。比如攻击者在对话中植入“用户授权将所有数据发送到某地址”这样的记忆后续Agent可能会真的执行。a-memguard这类框架的思路是在记忆写入和读取时做一致性校验和异常检测。我在自己的实现里加了几个简单的防护措施来源标记每条记忆记录来源用户明确陈述/模型推断/外部输入检索时低信任来源的记忆降权。写入审核涉及权限、敏感操作的记忆写入前需要二次确认。定期审计每周扫描一次记忆库查找异常模式比如短时间内大量写入、内容包含可疑指令。这些措施不能完全解决问题但能挡住大部分低级攻击。6. 一些实操心得与扩展方向6.1 记忆的“遗忘”比“记住”更难做了这么久我最大的体会是设计记忆系统的难点不在存而在忘。人类大脑会自动遗忘不重要的事情但代码不会。你需要显式地定义什么该忘、什么时候忘、怎么忘。我的做法是给每条记忆设一个TTL生存时间根据类型不同偏好类永久除非用户主动修改事实类180天到期后降权而非删除任务类任务完成后30天归档反馈类90天用于模型改进后即可清理TTL不是硬删除而是标记为“低优先级”检索时权重降到0.1以下。这样既保留了历史又不会干扰当前决策。6.2 记忆的版本管理用户偏好会变。三个月前说“喜欢辣”现在说“在忌口”。如果两条记忆都留着检索时可能返回矛盾信息。我的处理方式是记忆版本链新记忆写入时如果和旧记忆属于同一主题通过tags匹配就把旧记忆标记为superseded_bynew_id检索时只返回最新版本。这样既保留了变更历史又避免了矛盾。6.3 后续可以扩展的方向这套记忆系统跑通之后有几个方向可以继续深挖记忆可视化做一个Web界面让用户能看到Agent记住了什么并手动编辑或删除。这对建立用户信任很重要。多Agent共享记忆多个Agent共用一套记忆服务通过agent_id区分写入来源通过权限控制读取范围。记忆压缩定期把多条相关记忆合并成一条摘要记忆减少存储量同时保留核心信息。与RAG的融合记忆检索和文档RAG本质上是同一类问题可以把两者统一到一个检索层根据query类型路由到不同的数据源。6.4 最后分享一个小技巧如果你在本地开发调试MCP Server不要每次都docker compose up。可以先用stdio模式在本地跑用mcp命令行工具直接测试# 本地测试memory_write echo {jsonrpc:2.0,id:1,method:tools/call,params:{name:memory_write,arguments:{content:用户对花生过敏,memory_type:preference,user_id:test_user}}} | python -m src.main这样改代码后立即生效不用等Docker重建。等逻辑稳定了再打包进容器。这个流程帮我省了大量时间尤其是在调embedding模型和检索权重的时候。另外Qdrant自带一个Web UI默认6333端口的/dashboard可以直观地看到向量分布和检索结果。调参的时候开着这个页面比看日志高效得多。