1. 从“hindsight”说起:为什么我们需要给Agent装上“后视镜”
第一次看到“hindsight”这个词,我脑子里蹦出来的不是词典释义,而是自己踩过的一个坑。去年做一套基于LLM的客服工单自动分类系统,模型在测试集上准确率能到92%,上线第一周就翻车了——同一个用户上午问“订单为什么还没发货”,下午问“我的包裹到哪了”,系统当成两个完全无关的请求处理,回复口径不一致,用户直接投诉。问题出在哪?模型没有“记忆”,它每次都在用一张白纸做判断,前一轮对话里已经确认过的订单号、用户情绪、历史处理记录,全被丢掉了。
这就是hindsight要解决的核心问题。它不是某个具体的开源库,而是一类设计思路的统称:让Agent在决策时能够“回头看”,把过去发生过的交互、工具调用结果、环境状态变化,以结构化的方式重新注入到当前推理上下文中。你可以把它理解成给Agent装了一面后视镜——车往前开,但驾驶员始终知道后面发生了什么。
热搜词里反复出现的agent memory、working memory、MCP、Docker,其实都在指向同一个技术栈的不同层次。agent memory是目标,working memory是机制,MCP是接口协议,Docker是部署底座。我打算按这个逻辑,把hindsight从概念到落地拆一遍。适合谁看?如果你正在做LLM应用、Agent编排、或者单纯想搞清楚“为什么我的Agent聊三句就失忆”,这篇应该能帮你省下不少试错时间。
2. hindsight的核心设计思路:不是“记住”,而是“想起来”
2.1 为什么传统上下文窗口方案不够用
很多人第一反应是:记忆嘛,把历史对话全塞进prompt不就行了?我试过,结论是——能撑一阵,但撑不久。假设一轮对话平均200 token,20轮就是4000 token,加上系统提示、工具定义、当前query,轻松突破8K。这带来三个连锁问题:成本线性上涨、推理延迟增加、以及最致命的——模型对长上下文的“中间遗忘”现象。你把关键信息放在第3轮,到第15轮时模型可能已经把它淹没了。
hindsight的思路不是无限扩展上下文,而是做选择性回溯。它维护一个独立于对话窗口的存储层,每次推理前根据当前query去检索“相关的过去”,只把命中片段注入prompt。这就像你不需要记住整本字典,只需要在需要时翻到那一页。
2.2 working memory与长期记忆的分层
热搜里有个词叫“agent 存储 working memory”,这个区分很关键。我把hindsight的记忆分成三层:
- 瞬时层:当前对话轮次的原始消息,生命周期就是这一次请求。
- 工作记忆层:最近N轮的结构化摘要,包含已确认的事实、未完成的动作、用户偏好。这一层是hindsight的核心,它不存原文,存的是“提炼后的状态”。
- 长期层:跨会话的知识,比如用户历史工单、产品文档、过往成功解决方案。这一层通常用向量库或关系库承载。
为什么工作记忆层要用“摘要”而不是原文?因为原文噪声太大。用户说“我昨天买的那个东西怎么还没到,就是那个蓝色的”,原文里“蓝色的”是冗余的,摘要应该提取成{order_id: XXX, status: pending, user_sentiment: impatient}。这个提炼过程本身可以用LLM做,也可以用规则+小模型,取决于你对延迟的容忍度。
2.3 MCP在其中的角色:为什么不是普通API
MCP(Model Context Protocol)这个词在热搜里出现频率极高,很多人搞不清它和普通REST API的区别。我的理解是:MCP是面向模型消费的协议,而REST是面向程序消费的。区别体现在三个地方:
第一,MCP的返回结构天然适合LLM解析,它通常包含content、type、metadata,模型可以直接理解“这是一段文本”“这是一个资源引用”。第二,MCP支持双向能力声明,Agent可以问“你能提供什么”,服务端返回能力列表,这解决了工具动态发现问题。第三,MCP有标准的错误语义,模型能区分“工具不存在”和“工具执行失败”,从而决定是换工具还是重试。
在hindsight架构里,记忆存储层如果暴露成MCP Server,Agent就不需要硬编码“去查Redis”或“去查Postgres”,它只需要调用一个recall工具,传query,拿回相关记忆。换存储后端时,Agent代码不用动。这就是协议层的价值。
2.4 Docker为什么是绕不开的部署底座
热搜里docker安装、docker compose、docker desktop出现次数多到不正常,说明大量开发者卡在环境这一步。hindsight这类系统通常涉及多个组件:Agent运行时、向量库、关系库、MCP Server、可能还有Redis做缓存。裸机部署的依赖冲突能让人崩溃——Python版本、CUDA版本、系统库版本,随便一个不匹配就是半天。
Docker Compose的价值在于把“环境”变成代码。我习惯把每个组件写进docker-compose.yml,网络用自定义bridge,数据卷挂载到宿主机。这样换机器时docker compose up -d就完事,不用重新踩一遍依赖坑。后面我会给一份可直接抄的compose配置。
3. 核心细节拆解:记忆的写入、检索与注入
3.1 写入策略:什么时候该记,什么时候该忘
这是hindsight落地时最容易做错的地方。我见过团队把所有对话原文一股脑塞进向量库,结果检索时返回一堆无关片段,反而干扰模型。写入策略要回答三个问题:
触发时机:不是每轮都写。我的做法是设置事件钩子——当检测到“事实确认”(用户提供了订单号)、“状态变更”(工具调用成功/失败)、“情绪转折”(用户从平静变愤怒)时,才触发写入。普通寒暄不写。
写入内容:存结构化摘要,不存原文。摘要模板可以这样设计:
{ "session_id": "sess_xxx", "turn": 5, "facts": {"order_id": "12345", "issue_type": "logistics"}, "actions": [{"tool": "query_logistics", "result": "in_transit"}], "sentiment": "neutral", "ttl": 86400 }过期策略:工作记忆不是永久的。物流查询结果24小时后基本失效,用户偏好可以存30天。给每条记忆打TTL标签,检索时过滤掉过期项。这比“全量保留+事后清理”要干净得多。
注意:写入摘要的LLM调用会增加延迟。如果对响应时间敏感,可以用小模型(如7B级别)做摘要,或者用规则模板填充。我实测下来,规则模板在结构化场景(订单、工单)里够用,开放域对话才需要LLM摘要。
3.2 检索机制:token的三个点——key、query、value
热搜里有个很有意思的表述:“llm的token三个点key我是谁、query我在找什么、value我能提供什么”。这其实是把注意力机制的概念迁移到了记忆检索上。在hindsight里,检索可以类比成一次注意力计算:
- Key:每条记忆的索引标签,比如
{type: "order_fact", user_id: "u123"}。 - Query:当前请求的意图向量,比如“用户问物流”。
- Value:记忆的实际内容,即摘要JSON。
检索时不是简单做向量相似度,而是混合检索:先用元数据过滤(user_id必须匹配),再用向量相似度排序,最后用时间衰减加权。时间衰减公式我常用:
score = similarity * exp(-λ * age_hours)λ取0.01时,24小时前的记忆权重降到约0.79,72小时降到0.49。这样既保留了历史相关性,又不会让陈旧信息压过新鲜信息。
3.3 注入方式:怎么把记忆塞进prompt而不引起混乱
检索到记忆后,注入prompt的方式直接影响模型表现。我试过三种:
| 注入方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 直接拼在system prompt后 | 实现简单 | 模型可能忽略 | 记忆量少时 |
| 作为独立消息角色 | 模型关注度高 | 占用对话轮次 | 关键记忆 |
| 结构化XML标签包裹 | 边界清晰 | 需要模型支持 | 多段记忆 |
我目前稳定用的是第三种,格式如下:
<memory context="working"> <item type="order_fact" confidence="high"> 用户订单12345,物流状态in_transit,最后更新2小时前 </item> <item type="user_preference" confidence="medium"> 用户偏好短信通知,不接受电话 </item> </memory>confidence字段很重要,它告诉模型这条记忆的可信度。如果记忆来自用户明确陈述,标high;如果来自模型推断,标medium;如果来自第三方工具且未验证,标low。模型在冲突时会优先采信高置信度记忆。
3.4 与MCP工具的协同:记忆也是工具
在MCP框架下,记忆检索本身可以注册成一个工具,比如recall_memory(query, filters)。这样做的好处是Agent可以自主决定“我现在需不需要回忆”。有些简单请求(“今天天气怎么样”)根本不需要查记忆,Agent直接调天气工具就行。只有涉及“我之前说的那个”“上次那个订单”时,才触发recall。
这比“每轮强制注入记忆”要高效。我实测下来,强制注入会让简单请求的token消耗增加40%以上,而按需召回只增加8%左右。
4. 实操落地:从零搭一套带hindsight的Agent记忆系统
4.1 环境准备:Docker Compose一把梭
先把底座搭起来。以下compose文件是我在多个项目里复用过的精简版,包含Agent运行时、向量库(Qdrant)、关系库(Postgres)、缓存(Redis)和MCP Server。
version: "3.9" services: agent-runtime: build: ./agent ports: - "8000:8000" environment: - MEMORY_BACKEND=mcp - MCP_SERVER_URL=http://mcp-server:9000 depends_on: - mcp-server - qdrant - postgres - redis networks: - hindsight-net mcp-server: build: ./mcp-server ports: - "9000:9000" environment: - QDRANT_URL=http://qdrant:6333 - POSTGRES_DSN=postgresql://user:pass@postgres:5432/memory - REDIS_URL=redis://redis:6379/0 depends_on: - qdrant - postgres - redis networks: - hindsight-net qdrant: image: qdrant/qdrant:latest ports: - "6333:6333" volumes: - qdrant_data:/qdrant/storage networks: - hindsight-net postgres: image: postgres:16 environment: - POSTGRES_USER=user - POSTGRES_PASSWORD=pass - POSTGRES_DB=memory volumes: - pg_data:/var/lib/postgresql/data networks: - hindsight-net redis: image: redis:7-alpine volumes: - redis_data:/data networks: - hindsight-net volumes: qdrant_data: pg_data: redis_data: networks: hindsight-net: driver: bridge几个关键点解释一下。网络用自定义bridge而不是默认的,因为默认网络里容器名解析有时不稳定,自定义网络下mcp-server可以直接当hostname用。数据卷全部挂出来,容器删了数据还在。Qdrant选它是因为单机部署简单,API也干净,适合中小规模记忆存储。
注意:Windows下装Docker Desktop如果报“virtualization support not detected”,先去BIOS开VT-x/AMD-V,然后在Windows功能里确认“虚拟机平台”和“适用于Linux的Windows子系统”都勾上。这两个缺一个都起不来。
4.2 MCP Server的实现:暴露recall和remember两个工具
MCP Server的核心是能力声明和工具实现。以下是一个最小可用的Python实现骨架,基于mcp库:
from mcp.server import Server, NotificationOptions from mcp.server.models import InitializationOptions import mcp.server.stdio import mcp.types as types from qdrant_client import QdrantClient import json, time app = Server("hindsight-memory") qdrant = QdrantClient(url="http://qdrant:6333") @app.list_tools() async def list_tools(): return [ types.Tool( name="recall", description="检索与当前query相关的历史记忆", inputSchema={ "type": "object", "properties": { "query": {"type": "string"}, "user_id": {"type": "string"}, "top_k": {"type": "integer", "default": 5} }, "required": ["query", "user_id"] } ), types.Tool( name="remember", description="写入一条结构化记忆", inputSchema={ "type": "object", "properties": { "user_id": {"type": "string"}, "content": {"type": "string"}, "memory_type": {"type": "string"}, "ttl_hours": {"type": "integer", "default": 24} }, "required": ["user_id", "content", "memory_type"] } ) ] @app.call_tool() async def call_tool(name: str, arguments: dict): if name == "recall": results = qdrant.search( collection_name="memories", query_vector=embed(arguments["query"]), query_filter={ "must": [{"key": "user_id", "match": {"value": arguments["user_id"]}}] }, limit=arguments.get("top_k", 5) ) now = time.time() filtered = [] for r in results: age_h = (now - r.payload["created_at"]) / 3600 if age_h > r.payload.get("ttl_hours", 24): continue score = r.score * (0.99 ** age_h) filtered.append({"content": r.payload["content"], "score": score}) filtered.sort(key=lambda x: x["score"], reverse=True) return [types.TextContent(type="text", text=json.dumps(filtered, ensure_ascii=False))] elif name == "remember": qdrant.upsert( collection_name="memories", points=[{ "id": str(uuid.uuid4()), "vector": embed(arguments["content"]), "payload": { "user_id": arguments["user_id"], "content": arguments["content"], "memory_type": arguments["memory_type"], "created_at": time.time(), "ttl_hours": arguments.get("ttl_hours", 24) } }] ) return [types.TextContent(type="text", text="ok")]这段代码里,embed函数需要你接一个embedding模型,可以用本地的小模型,也可以调API。检索时的过滤条件必须带user_id,否则会串数据——这是生产环境的大忌。
4.3 Agent侧的集成:让模型自己决定何时回忆
Agent侧不需要硬编码记忆逻辑,只需要把MCP工具注册进去,然后在system prompt里加一段引导:
你拥有记忆能力。当用户提到“之前”“上次”“那个”等指代词时, 先调用recall工具检索相关记忆,再基于检索结果回答。 当用户提供新的事实(订单号、偏好、地址)时,调用remember工具写入。 不要假设你记得任何事,一切以recall结果为准。这段引导语的关键是最后一句——“不要假设你记得”。我踩过的坑是:模型有时会“幻觉记忆”,明明recall没返回结果,它却编造一个“上次你说过”。加上这句后,幻觉率明显下降。
4.4 参数计算:TTL和top_k怎么定
TTL不是拍脑袋定的,要按记忆类型分:
| 记忆类型 | 建议TTL | 理由 |
|---|---|---|
| 会话状态 | 2小时 | 超过2小时用户大概率换了话题 |
| 订单事实 | 72小时 | 物流周期通常3天内 |
| 用户偏好 | 720小时 | 偏好相对稳定 |
| 工具调用结果 | 1小时 | 状态可能随时变化 |
top_k的定法:先看你的prompt预算。假设你给记忆留了800 token,每条记忆摘要平均80 token,那top_k上限是10。但实际我建议取3-5,因为超过5条后,后面的记忆相关性下降很快,反而增加噪声。可以用一个简单规则:如果第5条的记忆score低于第1条的30%,就截断。
5. 常见问题与排查技巧实录
5.1 记忆检索返回不相关结果
这是最高频的问题。排查顺序:
- 检查embedding模型是否匹配。写入和检索必须用同一个模型,换模型后旧数据要重新embedding。
- 检查过滤条件。
user_id是否传对?有没有漏掉memory_type过滤导致跨类型污染? - 检查时间衰减参数。λ太大时,旧记忆被压得太狠,可能把真正相关的历史挤掉。先把λ设为0测试,确认是衰减问题还是检索问题。
- 检查query本身。用户说“那个东西”,query向量本身就没有信息量。这种情况需要在Agent侧做query改写,把“那个东西”结合上下文改写成“订单12345的物流状态”。
5.2 Docker网络不通导致MCP Server连不上
症状是Agent报connection refused。排查:
# 进入agent容器 docker exec -it agent-runtime sh # 测试连通性 curl http://mcp-server:9000/health如果curl不通,检查两点:一是两个容器是否在同一个network下(docker network inspect hindsight-net);二是MCP Server是否监听在0.0.0.0而不是127.0.0.1。很多框架默认监听localhost,容器间访问必须改成0.0.0.0。
5.3 记忆写入后检索不到
常见原因是向量库的collection没建索引,或者维度不匹配。Qdrant建collection时要指定向量维度,必须和embedding模型输出维度一致。比如用text-embedding-3-small是1536维,建collection时写1536,写384就全错。
另一个坑是异步写入没等待完成。upsert是异步的,写完立刻查可能查不到。加一个wait=True参数,或者写入后sleep 100ms再查。
5.4 模型忽略记忆内容
如果recall返回了正确记忆,但模型回答时没用上,检查注入位置。我试过把记忆放在system prompt最前面,模型经常忽略;放在system prompt末尾(紧挨着用户消息),关注度明显提高。另外,用XML标签包裹比纯文本拼接效果好,因为模型对结构化边界更敏感。
5.5 常见问题速查表
| 现象 | 可能原因 | 快速验证 | 解决 |
|---|---|---|---|
| 检索结果无关 | embedding不匹配 | 用相同文本写入再检索 | 统一embedding模型 |
| 连接超时 | 网络隔离 | docker network inspect | 加入同一自定义网络 |
| 写入丢失 | 异步未等待 | 写入后立即查 | 加wait=True |
| 模型忽略记忆 | 注入位置靠前 | 移到prompt末尾 | 用XML标签包裹 |
| 记忆串用户 | 过滤条件缺失 | 检查query_filter | 强制带user_id |
| 响应变慢 | top_k过大 | 打印检索耗时 | 降到3-5条 |
实操心得:记忆系统的调试一定要打日志。每次recall把query、返回条数、top score、注入后的prompt长度都记下来。出问题时翻日志,比盲猜快十倍。
6. 记忆系统的扩展方向与个人体会
hindsight这套思路跑通之后,能扩展的地方很多。比如把记忆按“事实型”和“程序型”分开存——事实型用向量库,程序型(“用户习惯先问价格再问功能”)用规则引擎。再比如引入记忆冲突检测,当新记忆和旧记忆矛盾时,触发人工确认或按时间戳取新。
我个人的体会是,记忆系统的难点从来不在“存”,而在“取”和“用”。存什么、什么时候取、取多少、怎么让模型用上,这四个问题每个都需要根据业务场景调。没有一劳永逸的参数,只有持续观察和迭代。我现在的习惯是每周抽一批线上case,人工看recall结果和最终回答,标记出“该召回没召回”和“召回了没用上”的比例,然后针对性调检索阈值或prompt。这个笨办法比任何自动调参都管用。
最后分享一个小技巧:给记忆加一个source字段,标记这条记忆是“用户明说”“工具返回”还是“模型推断”。当模型推断的记忆和用户明说的冲突时,永远以用户明说为准。这个优先级规则写进system prompt,能避免很多“模型自作聪明”的翻车。