1. 从“hindsight”说起:为什么我们需要给 Agent 装一个“后视镜”
第一次看到“hindsight”这个词,我脑子里蹦出来的不是词典释义,而是开车时那个永远在提醒你“刚才发生了什么”的后视镜。把它放到 Agent 和 LLM 的语境里,这个命名其实相当精准——大模型在单轮对话里表现得像个天才,可一旦对话轮次拉长,它就开始“失忆”,前面聊过的关键约束、用户偏好、已经排除的错误方案,统统像没发生过一样。hindsight 要解决的,就是这个“聊着聊着就忘了自己是谁、在干什么”的老毛病。
我接触过不少做 Agent 落地的团队,大家最初的思路都很朴素:把历史对话一股脑塞进 context window 不就完了?实测下来,这条路在几十轮之后就会撞墙。一方面是 token 成本线性上涨,另一方面更致命——上下文越长,模型对中间部分的注意力越涣散,也就是常说的“lost in the middle”。你辛辛苦苦塞进去的早期关键信息,反而被淹没在一堆寒暄和试错记录里。hindsight 这类 agent memory 方案的价值,就在于它不再把记忆当成“一段越堆越长的文本”,而是当成一个可以被检索、被更新、被遗忘的结构化存储层。
这篇文章我想聊的,就是围绕 hindsight 这个项目,把 agent memory 的整套设计思路、核心机制、落地实操和踩坑经验掰开揉碎讲一遍。涉及到的技术栈包括 LLM、MCP 协议、Docker 部署,以及 working memory 的存储模型。适合谁看?如果你正在做 Agent 应用、被上下文长度和记忆一致性问题折磨过,或者单纯想搞明白“agent 存储 working memory”到底是怎么回事,那这篇应该能给你一些能直接抄作业的东西。我会尽量说人话,把那些看起来玄乎的概念落到具体的表结构、参数和命令上。
2. hindsight 的整体设计思路:记忆不是日志,是工作台
2.1 为什么“全量历史”是个陷阱
先把这个反直觉的结论摆出来:把完整对话历史当作记忆,是 Agent 设计里最常见也最昂贵的错误。我见过一个客服 Agent 项目,上线初期效果惊艳,跑到第三周开始出现“答非所问”,排查半天发现是 context 里塞了太多历史工单,模型把三个月前一个用户的特殊要求当成了通用规则。这就是全量历史的副作用——它不区分“当前任务相关”和“历史噪音”,一视同仁地喂给模型。
hindsight 的设计哲学恰恰相反。它把记忆分成几个层次来管理,最核心的一层叫working memory(工作记忆),类比人的话,就是你此刻脑子里正在处理的那几件事。你不需要记住昨天午饭吃了什么才能完成今天的工作,Agent 也一样。working memory 只保留当前任务链路上真正活跃的信息:当前目标、已确认的约束、待办步骤、最近几轮的关键结论。剩下的东西,要么压缩成摘要归档,要么干脆让它随时间衰减掉。
这个思路带来的直接好处是 token 消耗可控。我实测过一个多轮任务型 Agent,用全量历史方案跑到第 40 轮时单次请求已经逼近 12k token,换成 hindsight 的 working memory 机制后稳定在 3k 上下,而且任务完成率反而提升了——因为模型不再被无关信息干扰。
2.2 三层记忆结构:working、episodic、semantic
hindsight 把记忆拆成三层,这个分层不是拍脑袋定的,而是对应了不同的检索频率和生命周期:
| 记忆层 | 存什么 | 生命周期 | 检索方式 | 典型 token 占用 |
|---|---|---|---|---|
| Working Memory | 当前任务目标、活跃约束、待办 | 任务级,任务结束即清理 | 全量注入 | 500-2000 |
| Episodic Memory | 历史交互片段、事件记录 | 中期,按时间衰减 | 向量检索 top-k | 按需 200-800 |
| Semantic Memory | 提炼后的知识、用户偏好、规则 | 长期,稳定沉淀 | 关键词+向量混合 | 按需 100-500 |
working memory 是每次请求都必须带上的,因为它定义了“现在在干什么”。episodic 是“过去发生过什么”,通过向量检索按相关性捞取。semantic 则是“我们总结出来的规律”,比如“这个用户偏好简洁回复”“这个系统的订单号格式是 XXX”。
我特别想强调 working memory 的“易失性”设计。很多团队舍不得清理,觉得删了可惜,结果 working memory 越滚越大,最后退化成全量历史。hindsight 的做法是给每条 working memory 记录打上 TTL 和优先级,任务切换时低优先级条目自动淘汰。这个机制后面讲实操时会给出具体的表结构。
2.3 为什么选 MCP 作为接入层
热词里反复出现 MCP,这里得说清楚它是什么。MCP(Model Context Protocol)本质上是一个软件协议,不是硬件协议——经常有人把它和硬件接口协议搞混。它定义的是 LLM 应用和外部工具/数据源之间怎么通信的标准。你可以把它理解成“AI 世界的 USB-C 接口”:以前每个工具都要写一套适配代码,现在只要工具实现了 MCP server,任何支持 MCP 的客户端都能直接调用。
hindsight 把记忆的读写能力封装成 MCP server,这个选择很聪明。好处有三个:第一,Agent 框架(不管是自研的还是用现成的)只要支持 MCP,就能零成本接入记忆能力;第二,记忆的存储后端可以随时替换,对上层透明;第三,读写记忆变成了标准的 tool call,模型自己就能决定“什么时候该记、什么时候该查”,不需要在 prompt 里硬编码逻辑。
提示:MCP 和传统的 function calling 不是替代关系。function calling 是模型输出一个结构化调用意图,MCP 是把这个调用意图标准化地路由到具体服务。两者配合使用,前者负责“决定调用”,后者负责“怎么调”。
3. 核心机制拆解:working memory 到底怎么存、怎么取
3.1 记忆条目的数据结构设计
working memory 听起来抽象,落到代码里其实就是一张表。hindsight 的核心表结构我简化后大致是这样:
CREATE TABLE working_memory ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id VARCHAR(64) NOT NULL, mem_key VARCHAR(128) NOT NULL, mem_value TEXT NOT NULL, mem_type VARCHAR(32) DEFAULT 'fact', priority TINYINT DEFAULT 5, ttl_seconds INT DEFAULT 3600, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_session (session_id), INDEX idx_session_key (session_id, mem_key) );几个字段值得展开说。mem_key是记忆的“槽位名”,比如current_goal、user_preference、confirmed_constraint。用 key 而不是纯文本堆叠,是为了支持覆盖更新——同一个槽位的新值直接替换旧值,而不是追加。这一条就砍掉了大量冗余。priority决定淘汰顺序,ttl_seconds决定过期时间。mem_type区分是事实、约束还是待办,检索时可以按类型过滤。
我踩过的一个坑是:早期没设计mem_key,所有记忆都是自由文本,结果同一个“用户想要退款”被记了七八遍,措辞还各不相同,模型看了反而困惑。加上 key 之后,写入前先查同 key 是否存在,存在就更新,working memory 立刻清爽了。
3.2 写入时机:让模型自己决定记什么
记忆写入最忌讳的是“每轮都全量记录”。hindsight 的做法是暴露一个memory_write的 MCP tool,由模型在对话过程中自主判断何时调用。tool 的 schema 大致是:
{ "name": "memory_write", "description": "将当前对话中值得长期保留的信息写入工作记忆", "parameters": { "type": "object", "properties": { "mem_key": {"type": "string", "description": "记忆槽位名,如 current_goal"}, "mem_value": {"type": "string", "description": "记忆内容"}, "mem_type": {"type": "string", "enum": ["fact", "constraint", "todo", "preference"]}, "priority": {"type": "integer", "minimum": 1, "maximum": 10} }, "required": ["mem_key", "mem_value"] } }关键在于 description 的措辞。我试过好几版,最后发现要明确告诉模型“只记录跨轮次仍然有用的信息”,否则它会把“用户说了你好”这种废话也记下来。另外 priority 的语义要写清楚:10 是“丢了任务就失败”,1 是“可有可无”。模型对数字的敏感度其实不错,给了明确标尺后,它打分的合理性明显提升。
3.3 读取策略:全量注入 + 相关性检索
读取分两条路。working memory 因为是任务级的、量小,直接全量拼进 system prompt 就行。我通常把它格式化成一段结构化文本:
[当前工作记忆] 目标: 帮用户完成订单退款流程 约束: 订单号必须以 ORD- 开头; 退款金额不超过 500 待办: 1. 确认订单状态 2. 校验退款资格 偏好: 用户希望回复简洁,不要客套话episodic 和 semantic 则走检索。hindsight 用的是向量检索,把历史片段和知识条目都 embed 成向量存起来,查询时用当前对话的语义去捞 top-k。这里有个细节:检索 query 不是用用户原话,而是用 working memory 里的 current_goal 加上最近一轮用户输入拼接而成。为什么?因为用户原话可能很简短(“那这个呢?”),单独拿去检索召回质量很差,加上目标上下文后相关性明显提升。
3.4 遗忘机制:TTL 与优先级淘汰
记忆系统不做遗忘,迟早被自己撑爆。hindsight 的遗忘有两套机制并行。第一套是 TTL,每条记忆写入时带过期时间,后台有个定时任务扫描过期条目清理。第二套是容量淘汰,当某个 session 的 working memory 条目超过阈值(我一般设 50 条),按 priority 升序、updated_at 升序淘汰,直到降到阈值以下。
注意:TTL 不要设太短。我一开始给所有记忆设了 600 秒,结果一个长任务跑到一半,早期确认的关键约束过期了,Agent 直接开始胡来。后来改成按类型区分:constraint 类 TTL 设 7200 秒甚至不过期,todo 类设 1800 秒,fact 类设 3600 秒,稳定多了。
4. Docker 环境搭建与 hindsight 部署实操
4.1 环境准备:Docker Desktop 安装与常见启动失败
hindsight 官方推荐用 Docker 部署,因为要同时跑记忆服务、向量库和 MCP server,手工装依赖容易出乱子。Windows 用户先装 Docker Desktop,这一步看着简单,但坑不少。
最常见的报错是virtualization support not detected,Docker Desktop 起不来。这个基本是 BIOS 里虚拟化没开。进 BIOS 找 Intel VT-x 或 AMD-V,打开就行。如果是 Windows 11,还要确认“虚拟机平台”和“适用于 Linux 的 Windows 子系统”这两个功能在“启用或关闭 Windows 功能”里勾上了。我见过有人折腾一下午,最后发现是 Hyper-V 和 WSL2 冲突,关掉 Hyper-V 重启就好了。
装完之后验证:
docker --version docker compose version docker run hello-world三条都通过,环境就算齐了。docker compose现在是 v2 语法,命令是docker compose而不是老的docker-compose,别搞混。
4.2 用 docker compose 拉起完整服务栈
hindsight 的 compose 文件我整理了一个精简版,包含记忆服务、Postgres(带 pgvector)和 MCP server 三个容器:
version: "3.8" services: hindsight-db: image: pgvector/pgvector:pg16 environment: POSTGRES_USER: hindsight POSTGRES_PASSWORD: hindsight_pass POSTGRES_DB: hindsight ports: - "5432:5432" volumes: - hindsight_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U hindsight"] interval: 10s retries: 5 hindsight-core: image: hindsight/core:latest depends_on: hindsight-db: condition: service_healthy environment: DB_URL: postgresql://hindsight:hindsight_pass@hindsight-db:5432/hindsight EMBED_MODEL: text-embedding-3-small WORKING_MEMORY_MAX_ITEMS: "50" ports: - "8080:8080" hindsight-mcp: image: hindsight/mcp-server:latest depends_on: - hindsight-core environment: CORE_ENDPOINT: http://hindsight-core:8080 ports: - "8090:8090" volumes: hindsight_data:启动命令就一句:
docker compose up -ddepends_on配合condition: service_healthy这个写法很关键。早期我没加 healthcheck,core 服务在 db 还没就绪时就启动,直接连接失败退出。加上健康检查后,compose 会等 db 真正 ready 再拉 core,省去手动重启的麻烦。
4.3 数据库初始化与向量索引
容器起来后,进 db 容器建表:
docker exec -it hindsight-db psql -U hindsight -d hindsight然后执行建表语句。除了前面那张 working_memory 表,还要建 episodic 和 semantic 表,以及向量索引:
CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE episodic_memory ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id VARCHAR(64), content TEXT, embedding vector(1536), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_episodic_vec ON episodic_memory USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);vector(1536)这个维度要和 embedding 模型对齐,text-embedding-3-small 就是 1536 维。ivfflat 索引的lists参数,经验值是数据量的平方根,10 万条以内设 100 够用。设太大反而拖慢查询。
4.4 接入 Agent:MCP 客户端配置
MCP server 跑在 8090 端口,Agent 侧配置大致是这样(以常见的 MCP 客户端配置格式为例):
{ "mcpServers": { "hindsight": { "url": "http://localhost:8090/sse", "tools": ["memory_write", "memory_read", "memory_forget"] } } }配好之后,Agent 就能通过 tool call 读写记忆了。我建议先手动测一遍三个 tool,确认连通性,再接到正式流程里。测试方法是用 curl 直接打 MCP server 的接口,或者用 MCP 官方的 inspector 工具。
提示:如果 Agent 报
codex无法找到mcp之类的错,八成是 url 写成了容器内地址。宿主机上的 Agent 要访问localhost:8090,容器内的服务才用hindsight-mcp:8090。这个网络命名空间的区别坑过不少人。
5. 实操过程:从零跑通一个带记忆的多轮任务
5.1 场景设定与初始状态
我拿一个“帮用户处理订单退款”的任务来演示。这个场景的好处是约束多、步骤长,特别能体现记忆的价值。初始时 working memory 是空的,Agent 只有 system prompt 里的通用指令。
第一轮用户说:“我有个订单想退款,订单号 ORD-20240512-8891。” Agent 判断这是关键信息,调用memory_write:
{ "mem_key": "order_id", "mem_value": "ORD-20240512-8891", "mem_type": "fact", "priority": 9 }同时把current_goal设为“处理订单退款”。这两条写入后,后续每一轮请求都会带上它们。
5.2 多轮交互中的记忆读写实录
第二轮用户问:“这个能退多少?” Agent 先读 working memory,拿到 order_id,然后调用订单查询工具,返回金额 380 元。它把结果写入:
{ "mem_key": "refund_amount", "mem_value": "380", "mem_type": "fact", "priority": 7 }第三轮用户改主意:“算了,先别退,帮我看看能不能换成别的商品。” 这时候 Agent 需要更新current_goal。因为 mem_key 相同,写入操作变成覆盖:
{ "mem_key": "current_goal", "mem_value": "将订单 ORD-20240512-8891 的商品更换为其他商品", "mem_type": "constraint", "priority": 10 }注意这里我把 mem_type 从 fact 改成了 constraint,因为“换货”成了新的硬约束。这个类型切换会影响 TTL 和淘汰优先级,是 hindsight 设计里比较细腻的一点。
到第十轮左右,用户突然问:“我刚才说的订单号是多少来着?” 如果没记忆系统,Agent 要么翻历史要么瞎猜。有了 working memory,它直接读order_id槽位,秒回。这就是 hindsight 最直观的价值。
5.3 记忆检索的召回效果调优
episodic 检索的召回质量,我调过好几轮。最初用纯向量检索,top-3,发现经常捞回不相关的历史。后来改成混合检索:先用关键词过滤 session_id 和时间窗口,再在候选集里做向量排序。召回率明显提升。
具体参数上,top_k我一般设 3 到 5。设太大,噪音多;设太小,可能漏掉关键信息。相似度阈值设 0.75,低于这个分数的直接丢弃。这个阈值不是拍脑袋,是拿一批标注数据跑出来的——低于 0.75 的召回结果,人工判断相关的比例不到 30%。
还有一个技巧:给 episodic 条目加时间衰减权重。最终得分 = 向量相似度 × 时间衰减因子。衰减因子用exp(-λ * 天数),λ 取 0.05 左右。这样近期的记忆天然占优,符合大多数任务场景的直觉。
5.4 完整流程的验证与观测
跑通之后,我建议加一层观测。hindsight core 暴露了/metrics接口,可以看几个关键指标:working memory 平均条目数、检索命中率、写入频率。我搭了个简单的 Grafana 面板盯着这几个数。
有一次发现写入频率异常高,平均每轮 4 次写入,排查发现是模型把每句用户输入都当“偏好”记下来了。回去改 tool description,强调“只记录跨轮次有价值的信息”,写入频率降到每轮 0.8 次,working memory 也稳定了。这个观测环节很多人省掉,但它是发现记忆系统退化的唯一手段。
6. 常见问题与排查技巧实录
6.1 记忆污染:Agent 把错误信息记进去了
这是最头疼的问题。模型偶尔会误解用户意图,然后把错误结论写进 working memory,后续所有轮次都被带偏。我遇到过一次,用户说“我不想要红色的”,模型记成了“用户想要红色”,后面推荐全错。
排查思路是给写入加一道校验。hindsight 支持在写入前做一次轻量的 LLM 自检:把待写入的 mem_value 和最近一轮对话一起丢给模型,问“这条记忆是否准确反映了用户意图”。这个自检会增加一次调用成本,但能挡掉大部分污染。另一个办法是给关键槽位(如 constraint 类)加人工确认,不过会牺牲自动化程度,看场景取舍。
6.2 检索不到:明明记了却查不出来
常见原因有三个。第一,embedding 模型不一致——写入用 A 模型,查询用 B 模型,向量空间对不上,相似度全是乱的。这个必须保证写入和查询用同一个 embedding 模型。第二,session_id 隔离没做好,查询时带了错误的 session 过滤条件。第三,相似度阈值设太高,把本来相关的条目过滤掉了。排查时先把阈值降到 0.5 看能不能召回,能召回就是阈值问题,不能就是前两个原因。
6.3 容器网络不通与依赖冲突
Docker 环境下docker网络不通是高频问题。容器之间通信用 service name,宿主机访问容器用映射端口。如果 core 连不上 db,先docker exec进 core 容器 ping 一下 db 的 service name。ping 不通基本是没在同一个 compose network 里。
依赖冲突方面,pgvector 的版本要和 Postgres 版本匹配。我用 pg16 配 pgvector 0.5.x 时遇到过索引创建失败,换成 0.7.x 就好了。这类问题看容器日志最快,docker compose logs hindsight-db直接定位。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查动作 | 解决方式 |
|---|---|---|---|
| Agent 答非所问 | working memory 被污染 | 导出当前 session 记忆检查 | 加写入自检,清理错误条目 |
| 检索召回为空 | embedding 模型不一致 | 对比写入/查询模型配置 | 统一 embedding 模型 |
| core 启动即退出 | db 未就绪 | 看 core 容器日志 | 加 healthcheck + depends_on |
| 记忆越滚越大 | TTL 未生效 | 查后台清理任务日志 | 检查定时任务与 TTL 配置 |
| MCP tool 调用失败 | url 地址错误 | 确认宿主机/容器网络 | 宿主机用 localhost,容器用 service name |
| 向量索引慢 | lists 参数过大 | 看查询耗时 | 按数据量平方根调整 lists |
6.5 几条压箱底的经验
第一,working memory 的 key 命名要有规范。我统一用 snake_case,前缀区分类型,比如goal_、constraint_、fact_。这样检索和调试时一眼能看出条目性质。
第二,别迷信自动记忆,关键节点手动兜底。任务开始和结束时,我会显式调用一次 memory_write,把目标和结论固化下来,不依赖模型自主判断。这两头的记忆质量,直接决定整个任务链路的稳定性。
第三,定期做记忆审计。每周抽几个 session,把 working memory 导出来人工看一遍,你会发现模型记东西的偏好和你想的不一样。这个习惯帮我改进了好几版 tool description。
第四,episodic 和 semantic 的边界别搞混。episodic 是“发生了什么”,semantic 是“学到了什么”。前者可以多存,后者要精炼。我见过把原始对话直接塞进 semantic 表的,那等于没分层,检索质量一塌糊涂。
7. 记忆系统的扩展方向与个人体会
hindsight 这套东西跑顺之后,能扩展的地方其实不少。我最近在试的一个方向是跨 session 的 semantic memory 共享。同一个用户在不同 session 里的偏好,如果能沉淀到 semantic 层并跨 session 复用,Agent 的“熟悉感”会强很多。实现上就是 semantic 表的 session_id 允许为空,检索时优先查全局条目。
另一个方向是记忆的主动遗忘与压缩。working memory 里那些长期没被访问的条目,与其等 TTL 过期,不如主动压缩成一句摘要挪到 episodic 层。这样既保留了信息,又腾出了 working memory 的空间。我写了个简单的后台任务做这件事,效果还不错。
最后分享一个我自己的体会:agent memory 这东西,难的不是存,是判断什么值得存。技术方案再花哨,如果写入的内容是垃圾,检索出来的也是垃圾。我花在调 tool description 和写入策略上的时间,远比调存储和检索多。所以如果你刚开始做,别急着上复杂的向量库和分层架构,先把“记什么、什么时候记、什么时候忘”这三个问题想清楚,比什么都重要。hindsight 给了一套不错的默认答案,但具体到你的场景,还是得自己磨。