
1. 从“hindsight”说起为什么我们需要给Agent装上“后视镜”第一次看到“hindsight”这个词我脑子里蹦出来的不是词典释义而是开车时看后视镜的那个动作。后视镜这东西平时不起眼但变道、倒车、超车的时候没有它你心里就是没底。把这个概念放到Agent和LLM身上其实特别贴切——现在的大模型能力已经很强了推理、写代码、调工具都不在话下但它有一个致命短板记不住事。你跟它聊了半小时把项目背景、技术选型、踩过的坑都交代清楚了结果新开一个会话它又变成一张白纸。这不是模型笨是架构决定的。LLM本身是无状态的每次推理都是一次独立的函数调用上下文窗口再大也有上限而且token是要花钱的。所以“agent memory”这个概念这两年才会这么火大家都在想办法给Agent装一个持久化的记忆系统。“hindsight”这个项目标题我理解它要解决的核心问题就是让Agent能够回看过去发生过什么并基于这些历史信息做出更好的决策。这跟人类做事是一个道理——有经验的人和新手的区别往往不在于智商而在于前者脑子里存着大量“上次这么干翻车了”的记忆。Agent要真正好用光靠一个强大的LLM不够还得有一套靠谱的记忆机制。这篇文章我会围绕hindsight这个主题把Agent记忆系统的设计思路、核心实现、实操部署、常见坑点全部拆开讲一遍。涉及到的技术栈包括LLM、MCP协议、Docker容器化部署以及记忆存储的几种典型方案。不管你是刚接触Agent开发的新手还是已经在做相关项目的从业者应该都能从中找到可以直接抄作业的东西。2. Agent记忆系统的整体设计与思路拆解2.1 为什么“无状态”是Agent最大的天花板先把这个事情说透。现在市面上大部分Agent框架本质上都是“一次性”的。用户发一条消息系统把历史对话拼成prompt扔给LLM拿到回复结束。下一轮再来一遍。这个模式在短对话里没问题但一旦任务变复杂立刻就崩。我举个实际场景。你让Agent帮你重构一个模块它需要先读代码、理解依赖关系、制定重构计划、逐步执行、跑测试、修bug。这个过程可能跨越几十轮交互。如果每一轮Agent都“失忆”它就没法保持对整体目标的理解很容易做着做着就跑偏了。更麻烦的是当你第二天想接着昨天的进度继续做对不起它完全不记得昨天干了什么。这就是为什么“agent 存储 working memory”这个概念会被反复提及。Working memory不是简单的聊天记录堆砌而是Agent在执行任务过程中形成的、结构化的、可检索的中间状态。它包括当前任务目标是什么、已经完成了哪些步骤、遇到了什么问题、做了哪些决策以及为什么这么决策。2.2 hindsight的核心思路把“记忆”从LLM里剥离出来传统做法是把所有东西都塞进context window靠LLM自己“记住”。这个方案的问题很明显贵、慢、有上限、不可靠。hindsight的思路我认为是把记忆做成一个独立的、外部的系统LLM只负责推理和决策记忆的存储、检索、更新交给专门的模块来做。这个设计思路跟计算机体系结构里的内存分层是一个道理。CPU有寄存器、L1/L2/L3缓存、主存、硬盘每一层的容量、速度、成本都不一样。Agent的记忆系统也应该分层短期记忆当前对话的上下文存在context window里容量小但访问快工作记忆当前任务的执行状态存在结构化存储里比如Redis或SQLite长期记忆跨会话的知识积累存在向量数据库或关系数据库里容量大但检索需要时间hindsight要做的就是在这几层之间建立高效的读写通道。什么时候把信息从短期记忆提升到工作记忆什么时候把工作记忆归档到长期记忆什么时候从长期记忆里检索相关内容注入到当前上下文——这些策略的设计直接决定了Agent的“智商”表现。2.3 MCP协议在记忆系统里扮演什么角色热词里反复出现MCP这里得专门说一下。MCP全称是Model Context Protocol是一个开放协议用来标准化LLM和外部工具、数据源之间的交互方式。你可以把它理解成Agent世界的USB接口——不管你是数据库、文件系统、API还是记忆存储只要实现了MCP协议LLM就能用统一的方式去调用。在hindsight的架构里MCP的价值在于解耦。记忆存储的具体实现可以是Redis、PostgreSQL、SQLite甚至是一个远程服务但只要它暴露了符合MCP协议的接口Agent这边就不需要关心底层用的是什么。这跟微服务架构里服务之间通过标准协议通信是一个思路。我实测下来的感受是MCP最大的好处不是技术上的而是生态上的。以前每接一个新工具就要写一套适配代码现在只要工具支持MCP直接就能用。对于记忆系统来说这意味着你可以随时替换存储后端而不用改Agent的核心逻辑。2.4 Docker化部署让记忆系统跑起来不那么折腾再好的架构部署不起来都是白搭。hindsight涉及到的组件不少LLM服务、记忆存储、MCP网关、Agent运行时。如果每个都手动装光是环境依赖就能把人搞疯。Docker在这里的作用就是把这些组件打包成标准化的容器一条docker compose up就能全部拉起来。我见过太多项目死在“在我机器上能跑”这个环节。Docker解决的就是这个问题——开发环境、测试环境、生产环境用同一套镜像行为一致。对于hindsight这种多组件系统来说Docker Compose几乎是标配。3. 核心细节解析与实操要点3.1 记忆的三种类型与对应的存储选型在动手之前得先把记忆分类搞清楚。不同类型的记忆存储方式和检索策略完全不同。记忆类型存储内容推荐存储检索方式生命周期短期记忆当前对话上下文内存/Context Window直接拼接单次会话工作记忆任务状态、中间结果Redis/SQLite键值查询任务周期长期记忆知识、经验、偏好向量数据库/PostgreSQL语义检索永久短期记忆最简单就是对话历史直接拼进prompt就行。但要注意控制长度超过模型上下文窗口就得做截断或摘要。工作记忆是hindsight的核心。它记录的是Agent在执行任务过程中的状态。比如一个代码重构任务工作记忆里应该存当前重构到哪个文件了、已经改了哪些函数、测试通过率是多少、遇到了什么报错。这些信息用键值对存最合适Redis的读写性能足够支撑高频更新。长期记忆解决的是跨会话的知识积累。比如用户之前说过“我们项目用TypeScript不用JavaScript”这个偏好应该被长期记住。长期记忆的检索通常用向量相似度搜索把历史信息embedding后存进向量库需要的时候用当前query去检索最相关的几条。3.2 记忆写入策略什么时候该记什么时候不该记这是最容易踩坑的地方。很多新手做记忆系统恨不得把Agent说的每句话都存下来结果就是存储爆炸、检索噪音大、成本飙升。我的经验是记忆写入要遵循“三问原则”这条信息未来还会用到吗如果是一次性的中间结果用完就扔没必要存这条信息不存会怎样如果丢了会导致任务失败或重复劳动那就必须存这条信息存了会不会干扰后续检索如果会产生大量噪音宁可不存具体到实现上我通常会在Agent的推理循环里加一个“记忆决策”步骤。每次产生新的信息后让LLM自己判断这条信息的重要性等级然后根据等级决定存储位置和保留时间。# 记忆写入决策的伪代码示例 def decide_memory_write(info, context): importance llm_judge_importance(info, context) if importance 8: # 高重要性写入长期记忆 long_term_memory.store(info, embeddingembed(info)) working_memory.store(info) elif importance 5: # 中等重要性写入工作记忆 working_memory.store(info) else: # 低重要性只保留在短期上下文 pass这个判断逻辑本身也可以用LLM来做prompt大概是“以下信息是在执行XX任务时产生的请判断它对后续任务执行的重要性1-10分。考虑因素是否包含关键决策、是否包含用户偏好、是否是难以重新获取的信息。”3.3 记忆检索怎么在正确的时间找到正确的记忆存进去容易取出来难。记忆检索的核心挑战是在Agent需要某个信息的时候准确地把它找出来而且不能找太多。找太少Agent缺信息决策质量下降。找太多context被无关信息占满LLM注意力被分散反而更容易出错。这个平衡点需要反复调。我常用的检索策略是混合检索精确匹配对于结构化的工作记忆直接用key查比如task:current_step语义检索对于长期记忆用向量相似度搜索取top-k条时间衰减越近的记忆权重越高老记忆需要更高的相似度才能被召回重要性加权存储时标记的重要性分数参与排序def retrieve_memories(query, top_k5): # 精确匹配工作记忆 working working_memory.get_relevant(query) # 语义检索长期记忆 query_embedding embed(query) long_term long_term_memory.search( query_embedding, top_ktop_k, time_decay0.95, # 每天衰减5% importance_weight0.3 ) # 合并去重按综合分数排序 combined merge_and_rank(working, long_term) return combined[:top_k]注意检索出来的记忆不要直接全部塞进prompt最好先做一次摘要或压缩。我试过把10条记忆原封不动拼进去结果LLM反而被干扰了。后来改成让LLM先对检索结果做一次“相关性过滤”只保留真正有用的效果明显好很多。3.4 MCP接口设计让记忆系统对Agent透明MCP协议的核心是定义了一套标准的工具调用格式。对于记忆系统来说需要暴露的MCP工具大概有这几个memory_store写入一条记忆memory_retrieve根据query检索记忆memory_update更新已有记忆memory_forget删除记忆GDPR合规需要memory_summarize对一段记忆做摘要压缩每个工具的定义包括名称、描述、参数schema。MCP的好处是Agent不需要知道底层是Redis还是PostgreSQL只需要按照schema调用就行。{ name: memory_retrieve, description: 根据查询检索相关记忆, parameters: { type: object, properties: { query: { type: string, description: 检索查询语句 }, memory_type: { type: string, enum: [working, long_term, all], default: all }, top_k: { type: integer, default: 5 } }, required: [query] } }这个schema定义好之后任何支持MCP的Agent框架都能直接调用。我试过在几个不同的Agent框架之间切换只要MCP接口不变记忆系统完全不用改。4. 实操过程与核心环节实现4.1 环境准备Docker Compose一键拉起全套服务先把基础设施搭起来。hindsight的部署我推荐用Docker Compose把所有组件编排在一起。下面是完整的compose文件version: 3.8 services: # 记忆存储Redis用于工作记忆 redis: image: redis:7-alpine ports: - 6379:6379 volumes: - redis_data:/data command: redis-server --appendonly yes restart: unless-stopped # 长期记忆PostgreSQL pgvector postgres: image: pgvector/pgvector:pg16 ports: - 5432:5432 environment: POSTGRES_DB: hindsight POSTGRES_USER: hindsight POSTGRES_PASSWORD: hindsight_dev_2024 volumes: - pg_data:/var/lib/postgresql/data restart: unless-stopped # MCP网关连接Agent和记忆存储 mcp-gateway: build: ./mcp-gateway ports: - 8080:8080 environment: REDIS_URL: redis://redis:6379 POSTGRES_URL: postgresql://hindsight:hindsight_dev_2024postgres:5432/hindsight depends_on: - redis - postgres restart: unless-stopped # Agent运行时 agent-runtime: build: ./agent-runtime ports: - 3000:3000 environment: MCP_GATEWAY_URL: http://mcp-gateway:8080 LLM_API_KEY: ${LLM_API_KEY} LLM_BASE_URL: ${LLM_BASE_URL} depends_on: - mcp-gateway restart: unless-stopped volumes: redis_data: pg_data:这个编排文件里每个服务的职责很清晰。Redis负责工作记忆的高速读写PostgreSQL加pgvector负责长期记忆的向量存储和检索MCP网关做协议转换Agent运行时是业务逻辑。启动命令就一行docker compose up -d提示Windows环境下如果Docker Desktop启动报“virtualization support not detected”需要先在BIOS里开启虚拟化支持。这个坑我踩过折腾了半天才发现是主板设置的问题。4.2 数据库初始化建表和索引PostgreSQL启动后需要初始化表结构。pgvector扩展要先启用CREATE EXTENSION IF NOT EXISTS vector; -- 长期记忆表 CREATE TABLE long_term_memories ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), content TEXT NOT NULL, embedding vector(1536), -- 根据embedding模型调整维度 importance FLOAT DEFAULT 0.5, memory_type VARCHAR(50) DEFAULT general, metadata JSONB DEFAULT {}, created_at TIMESTAMP DEFAULT NOW(), accessed_at TIMESTAMP DEFAULT NOW(), access_count INTEGER DEFAULT 0 ); -- 向量索引用IVFFlat加速检索 CREATE INDEX idx_memories_embedding ON long_term_memories USING ivfflat (embedding vector_cosine_ops) WITH (lists 100); -- 时间索引用于时间衰减查询 CREATE INDEX idx_memories_created ON long_term_memories (created_at DESC); -- 工作记忆表如果不用Redis的话 CREATE TABLE working_memories ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id VARCHAR(255) NOT NULL, task_id VARCHAR(255), key VARCHAR(255) NOT NULL, value JSONB NOT NULL, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(session_id, task_id, key) ); CREATE INDEX idx_working_session ON working_memories (session_id, task_id);这里有个细节值得说embedding的维度必须和你的embedding模型一致。OpenAI的text-embedding-3-small是1536维如果换其他模型这个数字要改。维度不匹配会直接报错而且错误信息不一定直观。4.3 记忆写入的完整实现下面是一个完整的记忆写入流程包含重要性判断、embedding生成、存储import json import redis import psycopg2 from openai import OpenAI class HindsightMemory: def __init__(self, redis_url, pg_url, llm_client): self.redis redis.from_url(redis_url) self.pg psycopg2.connect(pg_url) self.llm llm_client def judge_importance(self, content, context): 用LLM判断记忆重要性 prompt f判断以下信息对后续任务的重要性返回1-10的整数。 上下文{context} 信息{content} 考虑因素 - 是否包含关键决策或用户偏好 - 是否是难以重新获取的信息 - 是否会影响后续任务的执行 只返回数字不要解释。 response self.llm.chat.completions.create( modelgpt-4o-mini, messages[{role: user, content: prompt}], temperature0 ) return int(response.choices[0].message.content.strip()) def get_embedding(self, text): 生成文本embedding response self.llm.embeddings.create( modeltext-embedding-3-small, inputtext ) return response.data[0].embedding def store(self, content, session_id, task_idNone, context): 存储记忆的主入口 importance self.judge_importance(content, context) # 工作记忆存Redis if task_id: key fworking:{session_id}:{task_id} self.redis.hset(key, mapping{ content: content, importance: importance, updated_at: str(datetime.now()) }) self.redis.expire(key, 86400 * 7) # 7天过期 # 长期记忆重要性7才存 if importance 7: embedding self.get_embedding(content) with self.pg.cursor() as cur: cur.execute( INSERT INTO long_term_memories (content, embedding, importance, metadata) VALUES (%s, %s, %s, %s) , ( content, embedding, importance / 10.0, json.dumps({session_id: session_id, task_id: task_id}) )) self.pg.commit() return importance这段代码里重要性判断用了一个便宜的模型gpt-4o-mini因为这一步调用频率很高用贵模型成本扛不住。embedding生成也是单独调用的因为不是所有记忆都需要embedding。4.4 记忆检索的完整实现检索比写入更复杂因为要平衡召回率和精确率def retrieve(self, query, session_id, task_idNone, top_k5): 混合检索工作记忆 长期记忆 results [] # 1. 精确获取当前任务的工作记忆 if task_id: key fworking:{session_id}:{task_id} working_data self.redis.hgetall(key) if working_data: results.append({ source: working, content: working_data.get(bcontent, b).decode(), score: 1.0 }) # 2. 语义检索长期记忆 query_embedding self.get_embedding(query) with self.pg.cursor() as cur: cur.execute( SELECT content, importance, 1 - (embedding %s::vector) as similarity, EXTRACT(EPOCH FROM (NOW() - created_at)) / 86400 as days_ago FROM long_term_memories WHERE 1 - (embedding %s::vector) 0.7 ORDER BY (1 - (embedding %s::vector)) * 0.6 importance * 0.3 (1.0 / (1 EXTRACT(EPOCH FROM (NOW() - created_at)) / 86400)) * 0.1 DESC LIMIT %s , (query_embedding, query_embedding, query_embedding, top_k)) for row in cur.fetchall(): content, importance, similarity, days_ago row results.append({ source: long_term, content: content, score: similarity * 0.6 importance * 0.3 (1/(1days_ago)) * 0.1 }) # 3. 按综合分数排序返回 results.sort(keylambda x: x[score], reverseTrue) return results[:top_k]这个检索逻辑里综合分数由三部分组成语义相似度占60%重要性占30%时间新鲜度占10%。这三个权重是可以调的具体数值取决于你的应用场景。如果是知识问答类相似度权重可以更高如果是任务执行类工作记忆的权重应该更大。4.5 与Agent主循环的集成记忆系统最终要嵌入到Agent的推理循环里。典型的集成方式是在每轮推理前检索记忆推理后写入记忆class HindsightAgent: def __init__(self, memory, llm): self.memory memory self.llm llm def run(self, user_input, session_id, task_id): # 1. 检索相关记忆 memories self.memory.retrieve( queryuser_input, session_idsession_id, task_idtask_id, top_k5 ) # 2. 构建带记忆的prompt memory_context \n.join([ f[{m[source]}] {m[content]} for m in memories ]) prompt f你是一个有记忆的Agent。以下是相关历史信息 {memory_context} 用户输入{user_input} 请基于历史信息和当前输入做出回应。 # 3. 调用LLM response self.llm.chat(prompt) # 4. 写入新记忆 self.memory.store( contentf用户: {user_input}\nAgent: {response}, session_idsession_id, task_idtask_id, contextmemory_context ) return response这个循环看起来简单但实际跑起来有很多细节要调。比如记忆注入的格式、检索数量、写入时机都会影响最终效果。5. 常见问题与排查技巧实录5.1 记忆检索不准确怎么办这是最常见的问题。Agent明明存了相关信息但检索的时候就是找不出来。排查思路按优先级来第一检查embedding质量。如果embedding模型本身对中文支持不好检索效果会很差。我试过用某些英文为主的embedding模型处理中文内容相似度分数普遍偏低。换成支持多语言的模型后效果立竿见影。第二检查相似度阈值。上面代码里设了0.7的阈值这个值不是固定的。如果你的embedding模型输出的相似度普遍偏低阈值要相应下调。建议先跑一批测试数据看看正样本和负样本的相似度分布再定阈值。第三检查记忆内容是否被截断。有些embedding模型有token上限超长文本会被截断导致语义丢失。如果记忆内容很长建议先做摘要再embedding。第四考虑混合检索。纯向量检索对精确匹配不友好。比如用户问“上次说的那个API key”向量检索可能找不出包含具体key的那条记忆。这时候需要结合关键词检索。5.2 Docker容器网络不通的排查多容器部署最容易遇到网络问题。Agent运行时连不上MCP网关或者MCP网关连不上Redis都是常见故障。排查步骤确认容器都在同一网络。Docker Compose默认会创建一个网络所有服务都在里面。用docker network ls和docker network inspect查看。用容器名而不是localhost。容器之间通信要用服务名比如redis:6379而不是localhost:6379。这个坑我踩过无数次。检查端口映射。容器内部端口和宿主机端口是两回事。如果Agent在容器里连的是mcp-gateway:8080如果在宿主机上跑连的是localhost:8080。看日志。docker compose logs -f mcp-gateway能直接看到连接错误的具体信息。# 进入容器内部测试网络连通性 docker compose exec agent-runtime sh # 在容器内执行 curl http://mcp-gateway:8080/health redis-cli -h redis ping5.3 记忆存储膨胀的处理跑一段时间后长期记忆表会越来越大检索变慢存储成本上升。这时候需要做记忆压缩和清理。我的做法是定期跑一个“记忆整理”任务把访问次数为0且超过30天的记忆标记为“冷记忆”把相似度高于0.95的记忆合并内容重复把多条相关的短记忆合并成一条摘要记忆删除重要性低于3且超过90天的记忆-- 找出可以合并的相似记忆 SELECT a.id, b.id, 1 - (a.embedding b.embedding) as similarity FROM long_term_memories a JOIN long_term_memories b ON a.id b.id WHERE 1 - (a.embedding b.embedding) 0.95 AND a.created_at NOW() - INTERVAL 7 days;提示记忆清理一定要做软删除先标记再延迟删除。我有一次直接硬删结果发现删掉了一条关键的用户偏好记忆导致Agent行为异常排查了半天才找到原因。5.4 LLM调用失败与重试策略记忆系统里LLM调用很频繁重要性判断、embedding生成、摘要压缩任何一次失败都可能影响主流程。必须做好容错。常见错误和处理方式错误类型表现处理策略速率限制429错误指数退避重试最多3次超时请求超过30秒降级到备用模型或跳过Schema错误provider rejected request检查参数格式记录原始请求认证失败401错误检查API key不重试直接告警import time from tenacity import retry, stop_after_attempt, wait_exponential retry( stopstop_after_attempt(3), waitwait_exponential(multiplier1, min2, max30) ) def call_llm_with_retry(client, **kwargs): try: return client.chat.completions.create(**kwargs) except Exception as e: if 429 in str(e): raise # 触发重试 elif 401 in str(e): raise ValueError(认证失败请检查API key) # 不重试 else: raise5.5 记忆一致性问题当多个Agent实例同时操作同一份记忆时可能出现一致性问题。比如两个实例同时更新同一条工作记忆后写的覆盖先写的。解决方案有几个层次乐观锁更新时检查版本号版本不匹配就重试Redis事务用WATCH/MULTI/EXEC保证原子性分布式锁对关键记忆加锁但要注意死锁我一般用乐观锁就够了因为记忆更新的冲突概率其实不高。真正高频冲突的场景说明架构设计有问题应该考虑分片。6. 记忆系统的扩展方向与个人经验6.1 从“记住”到“遗忘”记忆的生命周期管理做了这么久记忆系统我最大的体会是遗忘比记住更重要。一个什么都记的系统跟什么都不记的系统一样难用。人类大脑的高明之处在于它能自动过滤掉无关信息只保留真正重要的。hindsight后续可以扩展的方向是引入更精细的遗忘曲线。不是简单的按时间删除而是根据记忆的访问频率、重要性、时效性动态调整保留策略。经常被访问的记忆权重上升长期不用的记忆逐渐淡化。这个机制可以用一个简单的分数衰减函数来实现def memory_strength(memory): 计算记忆强度决定是否保留 base memory.importance recency 1.0 / (1 days_since(memory.accessed_at) / 30) frequency min(memory.access_count / 10, 1.0) return base * 0.5 recency * 0.3 frequency * 0.2强度低于阈值的记忆进入“待清理”队列定期批量处理。6.2 多Agent共享记忆的架构设想单个Agent的记忆系统跑通之后下一步自然是多Agent协作。多个Agent共享一份长期记忆各自维护自己的工作记忆。这时候需要考虑的问题就更多了记忆的权限控制、冲突解决、版本管理。我目前的想法是用一个中心化的记忆服务每个Agent通过MCP接口读写。写入时带上Agent ID和任务ID检索时根据权限过滤。冲突用CRDT无冲突复制数据类型来解决保证最终一致性。这个方向还在探索中等有成熟方案了再单独写一篇。6.3 一些零散但实用的经验最后分享几个实操中总结的小技巧都是踩坑换来的embedding缓存很有必要。同样的文本反复embedding是浪费。我在Redis里加了一层embedding缓存key是文本的hashvalue是向量。命中率挺高的尤其是系统提示词这类固定文本。记忆注入的位置有讲究。放在system prompt里和放在user message里效果不一样。我实测下来关键记忆放在system prompt里权重更高但太多会稀释注意力。一般重要的放system补充性的放user message。定期做记忆质量评估。我会每周抽一批记忆人工看看检索出来的内容是否相关。如果发现大量不相关的记忆被召回说明embedding模型或者检索策略需要调整。这个评估不用很复杂抽20条看看就行。别忘了给记忆加时间戳。很多bug都是因为分不清“什么时候说的”。用户上周说用方案A这周改主意用方案B如果记忆里没有时间信息Agent就懵了。时间戳是记忆的元数据里最重要的字段没有之一。测试环境用独立的数据库。别问我怎么知道的。开发的时候把测试数据写进生产库清理起来极其痛苦。Docker Compose里给测试环境单独配一套volume成本很低收益很大。