尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

事后复盘才是Agent记忆的正确打开方式:hindsight记忆服务实战

事后复盘才是Agent记忆的正确打开方式:hindsight记忆服务实战 1. 为什么“事后复盘”才是 Agent 记忆的正确打开方式做过 LLM Agent 项目的人大概都有过这种体验会话一长模型就开始“失忆”前面聊过的关键约束、用户偏好、已经确认过的方案到后面全被冲淡甚至覆盖。你辛辛苦苦搭了一套 RAG把文档塞进向量库结果发现检索出来的东西跟当前任务八竿子打不着。问题的根子不在模型不够强而在于我们把“记忆”这件事想得太简单了。hindsight这个项目标题本身就点出了核心——事后之明。它不是让 Agent 在对话过程中实时记住一切而是让 Agent 在任务完成之后回过头去审视整段交互把真正值得留存的东西提炼出来写进长期记忆。这个思路跟人类学习很像你在开会时不会逐字记录但会后复盘时会把关键结论、待办事项、踩过的坑整理成笔记。Agent 也一样working memory负责当前任务的即时上下文而 hindsight 负责把 working memory 里的经验沉淀成可复用的长期知识。这套东西解决的核心问题是Agent 的记忆不该是流水账而应该是经过提炼的结构化经验。它适合谁适合那些正在做多轮对话 Agent、任务型 Agent、或者需要跨会话保持一致性的 LLM 应用的开发者。如果你只是做个单轮问答那确实用不上但只要你的 Agent 需要“记住用户上次说过什么”“记住这个项目里已经确认过的技术选型”hindsight 这套思路就值得你花时间研究。我接下来会从整体设计、核心机制、实操落地、问题排查几个维度把这套东西拆开讲清楚。涉及到的技术栈包括 LLM、MCP 协议、Docker 部署以及 Agent 记忆的存储与检索策略。内容会比较长但都是实打实的经验你可以直接抄作业。2. 整体设计思路把记忆拆成三层来管2.1 为什么不能只靠一个向量库打天下很多人做 Agent 记忆的第一反应就是上向量数据库把对话历史 embedding 一下存进去需要的时候检索 top-k。这个方案在 demo 阶段能跑通但一到生产环境就露馅。原因有三个第一对话历史里噪音太多。用户说“嗯”“好的”“我再想想”这些内容 embedding 之后也会占据向量空间检索时经常把真正有用的信息挤掉。第二向量检索缺乏结构。你检索出来的是一段文本但 Agent 需要知道的是“这个信息属于哪个类别、可信度多高、什么时候写入的、有没有过期”。第三跨会话一致性没法保证。用户上周说“我用 Python”这周说“我换 Java 了”两个 embedding 都在库里检索时可能同时返回Agent 就懵了。hindsight 的思路是把记忆分层。working memory是当前会话的短期上下文通常就是对话窗口里的消息列表容量有限随会话结束而丢弃。episodic memory是任务级别的记忆记录“这个任务里发生了什么”比如用户的目标、已经完成的步骤、遇到的错误。semantic memory是长期知识记录“用户是谁、偏好什么、这个项目的技术栈是什么”跨会话持久化。这三层不是简单的堆叠而是有明确的写入和读取路径。working memory 在对话过程中实时更新任务结束时触发 hindsight 流程把 working memory 和 episodic memory 里的内容提炼成 semantic memory。下次会话开始时semantic memory 被加载进 working memory 作为初始上下文。2.2 hindsight 流程的核心让 LLM 做“记忆编辑”hindsight 最关键的一步是在任务结束后调用 LLM 对整段交互做一次“记忆编辑”。这个编辑过程不是简单的摘要而是带着明确的问题去审视这段交互里有哪些信息是跨会话仍然有效的有哪些信息是用户明确表达过的偏好或约束有哪些信息是已经确认过的事实而不是猜测有哪些信息是临时性的任务结束就可以丢弃我实测下来这个编辑过程用LLM as judge的模式效果最好。你给模型一个结构化的 prompt让它输出 JSON 格式的记忆条目每个条目包含key、value、confidence、source、timestamp几个字段。key是记忆的索引比如user.preferred_languagevalue是具体内容比如Pythonconfidence是置信度0 到 1 之间source标记这条记忆来自哪次会话timestamp记录写入时间。这里有个关键设计记忆的 key 要设计成可覆盖的。也就是说如果用户后来改了偏好新的记忆条目会覆盖旧的而不是并存。这解决了前面说的跨会话一致性问题。实现上可以用一个简单的规则写入时如果 key 已存在就比较 timestamp新的覆盖旧的。2.3 为什么选 MCP 作为记忆服务的接口MCP 协议在这套架构里扮演的是“记忆服务的标准接口”角色。你可能会问为什么不直接写个 REST API原因在于 MCP 的设计初衷就是让 LLM 能够以统一的方式调用外部工具和数据源。用 MCP 暴露记忆服务意味着任何支持 MCP 的 Agent 框架都能直接接入不需要为每个框架单独写适配层。具体来说hindsight 的记忆服务通过 MCP 暴露几个核心工具memory_write用于写入记忆memory_query用于检索记忆memory_forget用于删除记忆。Agent 在任务结束时调用memory_write在任务开始时调用memory_query。这个接口设计足够简单但覆盖了记忆管理的完整生命周期。MCP 的另一个好处是传输层可替换。你可以用 stdio 做本地进程间通信也可以用 WebSocket 做远程调用。对于 Docker 部署的场景WebSocket 更合适因为容器之间需要网络通信。热词里提到的wss://api.xiaozhi.me/mcp/?token...就是一个典型的远程 MCP 端点通过 token 做鉴权。2.4 Docker 化部署的考量把记忆服务 Docker 化核心目的是环境隔离和可移植性。记忆服务依赖向量数据库、嵌入模型、LLM API 调用这些依赖的版本和配置很容易冲突。用 Docker Compose 把服务、数据库、缓存编排在一起换台机器docker compose up就能跑起来省去了大量环境配置时间。我建议的容器划分是这样的一个容器跑 MCP 服务本身一个容器跑向量数据库比如 Qdrant 或 Milvus一个容器跑 Redis 做缓存和会话状态。如果嵌入模型是本地部署的再加一个容器跑模型推理。这样拆的好处是每个组件可以独立扩缩容向量数据库挂了不影响 MCP 服务的其他功能。3. 核心细节解析记忆的写入、检索与遗忘3.1 记忆写入从对话流里提炼结构化条目写入是 hindsight 流程里最复杂的一步。你不能把整段对话直接丢给 LLM 让它“总结一下”那样出来的东西太泛。我的做法是分两步走第一步任务边界识别。在触发 hindsight 之前先判断当前任务是否真的结束了。判断依据可以是用户明确说“好了”“就这样”也可以是 Agent 完成了某个预定义的目标。如果任务没结束就触发写入会把中间状态当成最终结论存进去后面检索出来就是错的。第二步结构化提炼。给 LLM 的 prompt 大概长这样你是一个记忆编辑助手。请审视以下对话历史提取出跨会话仍然有效的记忆条目。 输出 JSON 数组每个条目包含 - key: 记忆的唯一标识用点号分隔的命名空间如 user.preference.language - value: 记忆的具体内容 - confidence: 0-1 之间的置信度 - category: 记忆类别可选值为 preference, fact, constraint, skill 只提取用户明确表达或确认过的信息不要提取你的猜测。 对话历史 {conversation_history}这个 prompt 的关键在于约束输出格式和明确提取标准。我试过不加约束让模型自由发挥结果出来的东西格式五花八门后面解析起来很痛苦。加上 JSON schema 约束之后解析成功率从 60% 提升到 95% 以上。还有一个细节置信度的赋值。用户直接说“我用 Python”和用户说“我可能用 Python 吧”置信度应该不一样。我在 prompt 里加了一条规则用户明确陈述的给 0.9 以上用户犹豫或推测的给 0.5 到 0.7从上下文推断的给 0.3 到 0.5。这样检索时可以根据置信度做过滤低置信度的记忆只在没有高置信度记忆时才使用。3.2 记忆检索不只是向量相似度检索这块很多人只做向量相似度就完事了但实际用下来问题很多。我的方案是混合检索向量相似度 关键词匹配 时间衰减。向量相似度负责语义层面的匹配比如用户问“我用什么语言写代码”能检索到user.preference.language这条记忆。关键词匹配负责精确匹配比如用户提到“Python”能直接命中 value 里包含 Python 的记忆。时间衰减负责给旧记忆降权比如半年前写入的记忆权重乘以一个衰减因子。具体实现上我给每条记忆算一个综合得分score 0.6 * vector_similarity 0.3 * keyword_match 0.1 * time_decay权重是我根据实际效果调的你可以根据自己的场景调整。time_decay用指数衰减半衰期设 30 天左右。也就是说30 天前的记忆权重减半60 天前的再减半。检索时还有一个类别过滤的步骤。如果当前任务是写代码就优先检索skill和preference类别的记忆如果是问答就优先检索fact类别的记忆。这个过滤能显著减少无关记忆的干扰。3.3 记忆遗忘主动删除比被动过期更重要遗忘这件事很多人只做 TTL 过期但实际场景里主动遗忘更重要。比如用户说“忘掉我之前说的那个偏好”你就得能精确删除对应的记忆条目。或者用户改了偏好旧的那条应该被标记为失效而不是等它自然过期。我的做法是给每条记忆加一个status字段可选值为active、superseded、deleted。写入新记忆时如果 key 已存在把旧条目的 status 改成superseded新条目 status 为active。检索时只返回active状态的记忆。这样既保留了历史记录又不会让旧记忆干扰当前决策。主动删除通过memory_forget工具实现接受一个 key 或一组 key把对应条目的 status 改成deleted。这个操作要谨慎我建议加一个确认机制Agent 调用memory_forget时先返回“即将删除以下记忆请确认”用户确认后再真正执行。3.4 记忆的存储选型向量库 关系库的组合存储这块我试过纯向量库、纯关系库、以及两者组合。纯向量库的问题是结构化查询能力弱你想查“所有 confidence 大于 0.8 的 preference 类记忆”向量库做起来很别扭。纯关系库的问题是语义检索能力弱只能做关键词匹配。最终我选的是组合方案向量库存 embedding 和元数据关系库存完整的记忆条目和状态。写入时两边都写检索时先从关系库按条件过滤出候选集再用向量库做语义排序。这样兼顾了结构化查询和语义检索。具体来说关系库用 PostgreSQL表结构大概是CREATE TABLE memories ( id UUID PRIMARY KEY, key VARCHAR(255) UNIQUE NOT NULL, value TEXT NOT NULL, confidence FLOAT NOT NULL, category VARCHAR(50) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT active, source VARCHAR(255), created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() );向量库用 Qdrant每个 point 的 payload 里存memory_id和key方便关联。检索时先用 PostgreSQL 查出符合条件的memory_id列表再用 Qdrant 做向量检索最后按综合得分排序。4. 实操落地从零搭一套 hindsight 记忆服务4.1 环境准备与 Docker Compose 编排先把基础环境搭起来。你需要装 Docker DesktopWindows 用户注意开启虚拟化支持否则 Docker Desktop 起不来。热词里提到的virtualization support not detected就是这个问题去 BIOS 里把 VT-x 或 AMD-V 打开就行。Docker Compose 文件我建议这样写version: 3.8 services: mcp-server: build: ./mcp-server ports: - 8080:8080 environment: - DATABASE_URLpostgresql://user:passpostgres:5432/memories - QDRANT_URLhttp://qdrant:6333 - LLM_API_KEY${LLM_API_KEY} depends_on: - postgres - qdrant - redis postgres: image: postgres:16 environment: - POSTGRES_USERuser - POSTGRES_PASSWORDpass - POSTGRES_DBmemories volumes: - pgdata:/var/lib/postgresql/data qdrant: image: qdrant/qdrant:latest ports: - 6333:6333 volumes: - qdrantdata:/qdrant/storage redis: image: redis:7-alpine ports: - 6379:6379 volumes: pgdata: qdrantdata:这个编排里mcp-server是核心服务postgres存结构化记忆qdrant存向量redis做缓存和会话状态。depends_on保证启动顺序但注意它只保证容器启动顺序不保证服务就绪。实际生产里建议加 healthcheck 和重试逻辑。启动命令就一句docker compose up -d第一次启动会拉镜像时间取决于网络。如果拉取慢可以配置国内镜像源。启动后用docker compose ps检查各容器状态确保都是running。4.2 MCP 服务的核心代码实现MCP 服务用 Python 写基于mcp官方 SDK。核心是三个工具的实现from mcp.server import Server from mcp.types import Tool, TextContent import json app Server(hindsight-memory) app.list_tools() async def list_tools(): return [ Tool( namememory_write, description写入一条记忆, inputSchema{ type: object, properties: { key: {type: string}, value: {type: string}, confidence: {type: number}, category: {type: string} }, required: [key, value, confidence, category] } ), Tool( namememory_query, description检索记忆, inputSchema{ type: object, properties: { query: {type: string}, category: {type: string}, min_confidence: {type: number} }, required: [query] } ), Tool( namememory_forget, description删除记忆, inputSchema{ type: object, properties: { key: {type: string} }, required: [key] } ) ]memory_write的实现逻辑是先查 PostgreSQL 里有没有同 key 的 active 记忆有就把它改成 superseded然后插入新记忆同时把 embedding 写入 Qdrant。memory_query的实现逻辑是先用 PostgreSQL 按 category 和 min_confidence 过滤拿到候选 memory_id 列表再用 Qdrant 做向量检索最后合并排序返回。这里有个坑embedding 的生成要跟检索时用同一个模型。我一开始写入用 OpenAI 的 embedding检索用本地模型结果相似度完全对不上。后来统一用同一个模型问题就解决了。如果你要换模型得把所有历史记忆重新 embedding 一遍。4.3 与 Agent 框架的对接MCP 服务跑起来之后Agent 框架通过 MCP 客户端连接。以常见的 Python Agent 框架为例配置大概是from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client server_params StdioServerParameters( commandpython, args[-m, hindsight_memory.server], env{DATABASE_URL: ..., QDRANT_URL: ...} ) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools await session.list_tools() # 把 tools 注册到 Agent 的工具列表里如果 MCP 服务是远程部署的用 WebSocket 传输from mcp.client.websocket import websocket_client async with websocket_client(wss://your-host/mcp?tokenxxx) as (read, write): async with ClientSession(read, write) as session: await session.initialize() # 后续操作同上对接的关键是在 Agent 的生命周期里埋好钩子。任务开始时调用memory_query加载相关记忆注入到 system prompt 里任务结束时调用memory_write触发 hindsight 流程。这两个钩子埋好了记忆的读写就自动运转了。4.4 参数调优置信度阈值与检索数量置信度阈值和检索数量这两个参数直接决定记忆服务的实际效果。我踩过的坑是阈值设太高很多有用的记忆被过滤掉设太低噪音太多干扰模型判断。经过多轮测试我总结了一套参考值场景置信度阈值检索数量说明用户偏好类0.75偏好要准宁缺毋滥事实类0.68事实可以多召回一些约束类0.83约束必须准确不能有歧义技能类0.510技能可以宽松一些检索数量不是越多越好。我试过一次返回 20 条记忆结果 system prompt 被撑爆模型反而抓不住重点。5 到 10 条是比较合适的范围具体看你的上下文窗口大小。还有一个动态调整的技巧如果当前会话已经进行了很多轮working memory 里内容很多就减少检索数量如果是新会话刚开始就多检索一些。这个逻辑可以用一个简单的规则实现retrieval_count max(3, 10 - len(working_memory) // 5)。5. 常见问题与排查技巧实录5.1 记忆写入失败或格式解析错误这是最常见的问题。表现是 Agent 调用memory_write后报错或者写入的记忆条目格式不对。排查思路分三步第一步检查 LLM 输出。把 LLM 返回的原始文本打出来看是不是 JSON 格式。如果模型返回的是 markdown 代码块包裹的 JSON需要先剥离代码块标记再解析。我一般会在 prompt 里明确说“直接输出 JSON不要用代码块包裹”但模型有时候不听话所以解析时要加容错。第二步检查字段完整性。JSON 解析成功后检查key、value、confidence、category四个字段是否都有。缺字段的话要么给默认值要么拒绝写入。我建议拒绝写入并记录日志因为缺字段的记忆后面检索时会有问题。第三步检查数据库约束。key字段有唯一约束如果并发写入同一个 key会触发唯一性冲突。解决办法是用INSERT ... ON CONFLICT语句做 upsert或者加分布式锁。提示写入失败时不要静默丢弃一定要记录日志。我吃过亏记忆写入失败没发现后面检索时发现少了很多关键信息排查了半天才发现是写入环节的问题。5.2 检索结果不相关或遗漏关键记忆检索不相关通常是 embedding 模型的问题。检查一下写入和检索用的 embedding 模型是不是同一个维度是不是一致。如果模型没问题那就是检索策略的问题。试试调整向量相似度和关键词匹配的权重或者加一个 rerank 步骤。检索遗漏关键记忆可能是置信度阈值设太高或者 category 过滤太严格。先把阈值调低看看能不能召回。如果能召回但排序靠后那就是排序算法的问题调整权重或者加时间衰减。还有一个隐蔽的问题记忆的 key 命名不一致。比如写入时用user.language检索时用user.preferred_language虽然语义相近但精确匹配就命不中。解决办法是维护一个 key 的命名规范文档写入和检索都遵循同一套规范。5.3 Docker 网络不通与容器间通信失败Docker Compose 里容器之间通过服务名通信比如mcp-server里访问postgres:5432。如果网络不通先检查是不是在同一个 network 里。Docker Compose 默认会创建一个 network所有服务都在里面一般不会有问题。如果确实不通检查这几点容器的ports映射是不是只映射了宿主机端口容器间通信不需要ports只需要expose或者同 network 即可防火墙是不是拦了容器间流量DNS 解析是不是有问题可以进容器ping postgres试试。Windows 上还有一个常见问题Docker Desktop 的 WSL2 后端有时候网络会抽风。重启 Docker Desktop 或者wsl --shutdown再启动通常能解决。5.4 记忆膨胀与性能下降用久了之后记忆库会越来越大检索变慢存储成本上升。解决办法有三个一是定期归档。把超过一定时间比如 90 天且 confidence 低于 0.5 的记忆标记为 archived检索时不返回但保留在库里备查。二是合并相似记忆。定期跑一个任务找出语义相似的记忆条目合并成一条。比如user.language和user.preferred_language可以合并。三是分库分表。如果记忆量真的很大按用户 ID 或项目 ID 分库每个库独立检索。这个方案复杂度高一般到千万级记忆量才需要考虑。问题现象可能原因排查方法解决方案写入报错LLM 输出格式不对打印原始输出加 JSON 解析容错检索不相关embedding 模型不一致检查模型配置统一 embedding 模型检索遗漏阈值过高调低阈值测试按场景调整阈值容器不通网络配置问题进容器 ping 测试检查 network 配置性能下降记忆量过大查看库大小归档 合并 分库5.5 实操心得三个容易被忽略的细节第一个细节记忆的写入时机。不要等到会话完全结束才写入那样如果会话异常中断记忆就丢了。我的做法是每隔几轮对话做一次增量写入把已经确认的信息先存下来会话结束时再做一次全量 hindsight。这样即使中断也不会丢太多。第二个细节记忆的可解释性。每条记忆都要能追溯到来源也就是source字段要记清楚是哪次会话、哪轮对话产生的。这样当用户质疑“你为什么记得这个”时你能给出依据。我一开始没记 source后来排查问题特别痛苦加上之后好多了。第三个细节记忆的版本管理。用户偏好会变记忆也要跟着变。我用superseded状态做版本管理旧版本不删除但标记失效。这样既能追溯历史又不会让旧记忆干扰当前决策。如果你做的是合规要求高的场景这个设计还能满足审计需求。6. 记忆服务的扩展方向与个人体会这套 hindsight 记忆服务跑通之后我陆续加了一些扩展。一个是记忆的可视化面板用简单的 Web 页面展示当前用户的记忆条目支持按 category 过滤和手动编辑。这个面板在调试时特别有用能直观看到 Agent 到底记住了什么。另一个是记忆的导入导出支持把记忆导出成 JSON 文件换环境时直接导入省去重新积累的时间。还有一个方向是多 Agent 共享记忆。多个 Agent 协作时共享同一套记忆服务每个 Agent 写入的记忆带上自己的标识检索时可以选择只读自己的还是读全部。这个在复杂工作流里很有用比如一个 Agent 负责需求分析一个负责编码编码 Agent 可以直接读取分析 Agent 写入的项目约束记忆。我个人在实际操作中的体会是记忆服务的核心不是技术复杂度而是对“什么值得记”的判断。技术方案再花哨如果记了一堆没用的东西反而拖累 Agent 的表现。hindsight 这个思路的价值就在于它强迫你去做这个判断——任务结束后回过头看到底什么才是真正值得留存的。这个判断做对了记忆服务就成功了一大半。最后分享一个小技巧定期人工审查记忆库。我每个月会花半小时看看记忆库里都存了什么把明显错误或过时的条目清理掉。这个习惯帮我发现了好几个 prompt 设计上的问题比如某类信息总是被错误提取调整 prompt 后就解决了。机器再智能也还是需要人来把关。
返回列表