1. 从“hindsight”说起为什么我们需要给Agent装上“后视镜”第一次看到“hindsight”这个词我脑子里蹦出来的不是词典释义而是自己踩过的一个坑。去年我搭了一个基于LLM的客服Agent上线头一周表现还行第二周开始用户投诉“它怎么又忘了昨天说过的话”。排查半天发现问题不在模型本身而在于Agent的记忆机制——它只有一层薄薄的上下文窗口对话一长早期信息就被挤出去了。用户昨天反馈的订单号、前天确认的地址全丢了。这就是“hindsight”要解决的核心问题。它不是某个具体的开源库名字而是一类设计思路的代称让LLM Agent具备回溯性记忆能力能够像人一样“回头看”把过去发生过的交互、决策、环境状态沉淀下来在需要的时候重新调取。你可以把它理解成给Agent装了一面后视镜开车的时候不用一直扭头但需要变道时扫一眼就知道后面有什么。围绕这个思路当前社区里最热的几个关键词是agent memory、LLM、MCP和Docker。这四个词基本勾勒出了一条完整的落地路径LLM是大脑agent memory是记忆系统MCP是连接外部工具和数据的协议层Docker则是把这一整套东西打包跑起来的基础设施。我接下来要聊的就是怎么把这四块拼成一个能用的东西以及我在拼的过程中踩过的那些坑。这篇文章适合谁看如果你正在做LLM应用开发尤其是涉及多轮对话、任务型Agent、知识库问答这类需要“记住东西”的场景那下面的内容应该能帮你省下不少试错时间。如果你只是刚听说MCP想了解一下我也会用生活化的类比把概念讲清楚不要求你先懂Docker或者分布式系统。2. 整体设计思路记忆不是一个大桶而是三层抽屉2.1 为什么“把所有对话塞进上下文”是死路一条很多人做Agent记忆的第一反应是把历史对话全部拼到prompt里不就行了我一开始也这么干结果很快撞墙。假设一轮对话平均200个token用户聊了50轮就是10000个token再加上系统提示词、工具描述、当前查询轻轻松松突破模型上下文上限。就算用上128K上下文的模型成本也会飙升——每次请求都带着一万多token的历史token费用是按输入量算的长期跑下来账单很难看。更关键的是长上下文不等于好记忆。学术界有个说法叫“lost in the middle”意思是模型对上下文中间部分的信息召回率明显低于开头和结尾。你把50轮对话塞进去模型真正能有效利用的可能只有最近几轮和最早那几句系统提示。中间那些关键信息比如用户第三轮提到的偏好、第七轮确认的约束条件反而被淹没了。所以正确的思路不是“塞更多”而是“分层存、按需取”。我把它类比成办公桌的三个抽屉工作记忆是桌面放当前正在处理的几份文件短期记忆是第一个抽屉放最近几天可能还要翻的资料长期记忆是档案柜放已经归档但将来可能查证的历史记录。hindsight的核心设计就是这三层抽屉的协同。2.2 三层记忆的职责划分与数据流具体来说我是这样划分的工作记忆Working Memory对应当前会话的上下文窗口只保留最近N轮对话和当前任务相关的状态。N的取值我一般设成6到10轮具体看任务复杂度。这部分直接拼进prompt不涉及外部存储。短期记忆Short-term Memory用向量数据库存最近7到30天的交互摘要。每轮对话结束后用一个轻量LLM把对话压缩成一段结构化摘要包含用户意图、关键实体、决策结果。检索时按语义相似度召回Top-K条。长期记忆Long-term Memory存经过验证的、可复用的知识。比如用户明确说“我以后都用顺丰快递”这条就应该从短期记忆晋升到长期记忆并且在后续所有会话中生效。长期记忆的写入需要更严格的触发条件避免噪音污染。数据流是这样的用户发来一条消息Agent先从工作记忆里找上下文如果不够去短期记忆检索相关摘要再不够查长期记忆。三层都没命中才走通用知识回答。这个“逐层下探”的策略既控制了每次请求的token量又保证了关键信息不丢。2.3 为什么选MCP做工具层而不是自己写函数调用记忆系统不是孤立的它需要和外部工具交互——比如查数据库、调日历、读文件。传统做法是在代码里写一堆函数然后用OpenAI的function calling或者类似机制暴露给模型。这种方式能用但有两个问题一是每接一个新工具就要改代码、重新部署二是工具的描述和参数格式散落在各处维护起来很乱。MCPModel Context Protocol解决的正是这个问题。你可以把它理解成Agent世界的USB接口标准不管你是查天气的工具、读数据库的工具还是发邮件的工具只要按MCP协议封装成一个ServerAgent就能通过统一的Client去调用。我实测下来用MCP接一个新工具的时间从原来的半天缩短到半小时以内而且工具描述集中管理改起来不容易漏。至于Docker它的角色是“打包盒”。MCP Server、向量数据库、Agent主程序这些东西依赖的Python版本、系统库、环境变量各不相同直接在宿主机上装容易打架。用Docker Compose把每个组件跑在独立容器里网络互通但环境隔离换台机器一条命令就能复现。下面我会详细讲怎么搭。3. 核心细节解析记忆写入、检索与晋升的实操要点3.1 记忆写入什么时候写、写什么、谁来写记忆写入是整套系统里最容易出问题的地方。我见过太多项目要么写得太频繁导致存储爆炸要么写得太稀疏导致关键信息丢失。我的经验是不要每轮对话都写而是按事件触发。触发条件我设了三个任务完成时用户说“好了”“谢谢”“没问题”这类收尾信号或者Agent完成了一个明确的子任务比如成功创建了一个工单这时候把整个任务过程压缩成一条记忆。关键信息出现时用户提到偏好、约束、身份信息、重要日期这些用规则或轻量分类器识别出来单独写一条高优先级记忆。会话结束时如果检测到用户长时间不回复比如超过10分钟把当前会话的摘要写入短期记忆。写什么内容我用的模板是这样的{ session_id: abc-123, timestamp: 2025-01-15T10:30:00Z, summary: 用户咨询订单退款流程确认订单号SF123456要求退款到原支付账户预计3个工作日到账。, entities: { order_id: SF123456, intent: refund, constraint: 原支付账户 }, importance: 0.8, ttl_days: 30 }这里importance是我自己加的一个字段用来控制记忆的晋升和淘汰。0.8以上会进入长期记忆候选池0.3以下会在7天后自动清理。ttl_days是过期时间避免短期记忆无限膨胀。谁来写我试过两种方案一种是用主LLM顺便生成摘要另一种是单独跑一个轻量模型比如7B级别的专门做摘要。实测下来单独跑轻量模型更划算。主LLM的调用成本高而且让它一边回答用户一边写摘要容易分心导致回答质量下降。轻量模型虽然摘要质量稍差但胜在便宜、快而且可以异步跑不阻塞用户响应。注意摘要生成一定要做实体抽取不能只写一段自然语言。后面检索的时候实体匹配比语义匹配更精准。比如用户问“我那个退款到哪了”语义检索可能召回一堆退款相关的记忆但加上order_id过滤就能精确定位。3.2 记忆检索语义、实体、时间的三路召回检索环节我踩过最大的坑是只做语义检索。向量相似度确实能召回语义相近的内容但它对精确匹配不敏感。用户问“SF123456这个订单”语义检索可能返回一堆“订单”“退款”相关的记忆但真正包含这个订单号的那条可能排在第五位。如果只取Top-3就漏了。我的解决方案是三路召回然后融合排序语义路用embedding模型把查询向量化在向量库里做ANN检索取Top-20。实体路从查询里抽取实体订单号、日期、人名等在结构化字段里做精确匹配取Top-20。时间路如果查询里有时间词“昨天”“上周”“刚才”按时间范围过滤取最近的相关记忆。三路结果合并后用一个简单的加权公式打分final_score 0.5 * semantic_score 0.3 * entity_match 0.2 * recency_score权重可以根据业务调。客服场景下实体匹配更重要可以调到0.4闲聊场景下语义权重可以到0.7。这个公式不复杂但比单一检索稳得多。还有一个细节检索回来的记忆要重新排序和截断。我一般取Top-5拼进prompt每条记忆截断到200字以内。太多会挤占上下文太长会引入噪音。如果Top-5的总token超过1500就再砍到Top-3。3.3 记忆晋升从短期到长期的“转正”机制短期记忆和长期记忆之间需要一道闸门不能什么都往长期存。我的晋升规则是这样的显式偏好用户明确说“以后都……”“我一直……”“记住……”直接晋升。高频重复同一个实体或意图在7天内出现3次以上自动晋升。人工确认在管理后台提供一个“晋升”按钮运营人员可以手动把某条短期记忆提升为长期记忆。晋升后的长期记忆会写入一个单独的集合检索时优先级高于短期记忆。同时长期记忆的TTL设得更长默认180天但也不是永久——每90天会做一次“复审”如果某条长期记忆在90天内没有被任何会话命中就降级回短期记忆。这个机制避免了长期记忆库变成垃圾场。实操心得晋升阈值不要设得太低。我一开始把“出现2次”就晋升结果长期记忆里塞了一堆“用户问了天气”这种无意义内容。后来改成3次并且加了“必须包含实体”的条件噪音少了很多。4. 实操过程用Docker Compose把整套系统跑起来4.1 环境准备与Docker安装的坑先说Docker安装。Windows用户最容易遇到的问题是Virtualization support not detectedDocker Desktop启动失败。这不是Docker的锅是BIOS里虚拟化没开。重启进BIOS找到Intel VT-x或AMD-V设为Enabled保存重启就行。如果开了还报错检查一下Hyper-V和WSL2有没有冲突——Windows上Docker Desktop依赖WSL2而Hyper-V和某些虚拟机软件会抢虚拟化资源。Linux用户相对省心但要注意Docker网络不通的问题。我遇到过容器之间ping不通的情况排查发现是防火墙规则把Docker的bridge网络拦了。解决办法是确认iptables里没有DROP掉docker0接口的流量或者直接用--network host模式跑开发环境可以生产环境不建议。安装命令我贴一下Ubuntu系的# 卸载旧版本 sudo apt-get remove docker docker-engine docker.io containerd runc # 安装依赖 sudo apt-get update sudo apt-get install ca-certificates curl gnupg # 添加官方GPG key sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod ar /etc/apt/keyrings/docker.gpg # 添加仓库 echo deb [arch$(dpkg --print-architecture) signed-by/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release echo $VERSION_CODENAME) stable | sudo tee /etc/apt/sources.list.d/docker.list /dev/null # 安装 sudo apt-get update sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin装完后跑docker run hello-world验证。如果拉镜像慢配置一下国内镜像加速器这个网上教程很多不展开。4.2 Docker Compose编排四个容器的分工我的docker-compose.yml里定义了四个服务version: 3.8 services: agent-core: build: ./agent ports: - 8000:8000 environment: - LLM_API_KEY${LLM_API_KEY} - VECTOR_DB_URLhttp://vector-db:6333 - MCP_SERVER_URLhttp://mcp-server:9000 depends_on: - vector-db - mcp-server networks: - agent-net vector-db: image: qdrant/qdrant:latest ports: - 6333:6333 volumes: - ./data/qdrant:/qdrant/storage networks: - agent-net mcp-server: build: ./mcp ports: - 9000:9000 environment: - DB_CONNECTIONpostgresql://user:passpostgres:5432/agent depends_on: - postgres networks: - agent-net postgres: image: postgres:16 environment: - POSTGRES_USERuser - POSTGRES_PASSWORDpass - POSTGRES_DBagent volumes: - ./data/postgres:/var/lib/postgresql/data networks: - agent-net networks: agent-net: driver: bridge四个容器的分工agent-core主程序跑LLM调用、记忆管理逻辑、MCP Client。vector-dbQdrant存短期和长期记忆的向量。mcp-server封装了数据库查询、日历、文件读写等工具按MCP协议暴露。postgres存结构化数据比如会话元信息、实体表、晋升记录。为什么用Qdrant而不是Chroma或MilvusQdrant的过滤功能强支持在向量检索的同时做payload过滤这对我的“实体路召回”很关键。Chroma轻量但过滤能力弱Milvus重但运维复杂。Qdrant在中间适合中小规模。4.3 MCP Server的封装与工具注册MCP Server的核心是把工具按协议注册。我用Python的mcp库一个工具的定义大概长这样from mcp.server import Server from mcp.types import Tool, TextContent server Server(agent-tools) server.list_tools() async def list_tools(): return [ Tool( namequery_order, description根据订单号查询订单状态和退款进度, inputSchema{ type: object, properties: { order_id: {type: string, description: 订单号格式如SF123456} }, required: [order_id] } ), Tool( namesave_preference, description保存用户偏好到长期记忆, inputSchema{ type: object, properties: { key: {type: string}, value: {type: string} }, required: [key, value] } ) ] server.call_tool() async def call_tool(name: str, arguments: dict): if name query_order: result await db.query_order(arguments[order_id]) return [TextContent(typetext, textjson.dumps(result))] elif name save_preference: await memory.save_long_term(arguments[key], arguments[value]) return [TextContent(typetext, text偏好已保存)]这里的关键是工具描述要写清楚。模型是根据description来决定调不调、怎么调的。我一开始把description写得很简略结果模型经常传错参数。后来改成“根据订单号查询订单状态和退款进度订单号格式如SF123456”调用准确率明显提升。注意MCP Server的端口不要和宿主机上已有服务冲突。我习惯用9000段但如果你本地跑了其他服务先netstat -tuln查一下。4.4 记忆检索的代码实现与参数调优检索部分我用Qdrant的Python client核心逻辑from qdrant_client import QdrantClient from qdrant_client.models import Filter, FieldCondition, MatchValue client QdrantClient(urlhttp://vector-db:6333) async def retrieve_memory(query: str, entities: dict, top_k: int 5): # 语义路 query_vector embed(query) semantic_results client.search( collection_nameshort_term, query_vectorquery_vector, limit20 ) # 实体路 entity_results [] for key, value in entities.items(): results client.scroll( collection_nameshort_term, scroll_filterFilter( must[FieldCondition(keyfentities.{key}, matchMatchValue(valuevalue))] ), limit20 ) entity_results.extend(results[0]) # 融合排序 scored {} for r in semantic_results: scored[r.id] scored.get(r.id, 0) 0.5 * r.score for r in entity_results: scored[r.id] scored.get(r.id, 0) 0.3 for r in semantic_results entity_results: recency 1.0 / (1 days_since(r.payload[timestamp])) scored[r.id] scored.get(r.id, 0) 0.2 * recency # 取Top-K sorted_ids sorted(scored, keyscored.get, reverseTrue)[:top_k] return [get_payload(id) for id in sorted_ids]参数调优方面我试过几组配置最后稳定在参数取值说明语义路Top-2020太少会漏太多噪音大实体路Top-2020同上最终Top-K5拼进prompt的记忆条数单条截断200字超过就截断语义权重0.5客服场景可降到0.4实体权重0.3客服场景可升到0.4时间权重0.2闲聊场景可升到0.3这套参数在我的测试集上召回率大概85%精确率70%左右。不算完美但够用。如果业务对精确率要求更高可以加一个rerank模型做二次排序但会增加延迟。5. 常见问题与排查技巧实录5.1 记忆检索召回不准的三种典型情况情况一查询太短语义向量质量差。用户只发“那个呢”embedding出来是个很泛的向量检索结果乱七八糟。解决办法是做查询改写用轻量LLM把短查询扩展成完整句子再向量化。比如“那个呢”改写成“用户之前提到的订单退款进度查询”。情况二实体抽取漏了。用户说“我上周买的那个东西”没有明确订单号实体路召回为空。这时候要靠时间路兜底同时语义路要能理解“上周买的”对应的时间范围。我在实体抽取模块加了一个相对时间解析器把“上周”“前天”转成具体日期范围。情况三记忆写入时摘要质量差。轻量模型有时候会把关键信息漏掉。我的对策是双写轻量模型写一版摘要同时把原始对话的embedding也存一份。检索时如果摘要没命中还能靠原始对话的向量兜底。代价是存储翻倍但召回率提升明显。5.2 Docker环境下的网络与存储问题速查问题现象可能原因排查命令解决方案容器间ping不通网络未加入同一bridgedocker network inspect agent-net确认所有服务在同一个networks下向量库连接超时端口未暴露或防火墙拦截docker exec agent-core curl vector-db:6333检查ports映射和iptables规则数据丢失volume未挂载或路径错误docker volume ls确认volumes配置指向宿主机目录启动顺序导致依赖失败depends_on不保证就绪docker compose logs agent-core加healthcheck或重试逻辑镜像拉取慢默认源在国外-配置镜像加速器实操心得depends_on只保证容器启动顺序不保证服务就绪。我一开始没注意agent-core启动时vector-db还没准备好直接报连接拒绝。后来在agent-core里加了重试逻辑连不上就等5秒重试最多10次。这个问题在分布式系统里很常见别指望编排工具帮你解决所有时序问题。5.3 LLM调用失败的排查思路热词里有个llm request failed: provider rejected the request schema or tool payload这个我遇到过好几次。原因通常是工具调用的参数格式和schema不匹配。比如schema里定义order_id是string模型传了个数字或者required字段没传。排查步骤打开debug日志把发给LLM的完整payload打出来。对照工具的inputSchema逐字段检查类型和必填项。如果模型经常传错在工具description里加示例。比如“order_id: 字符串例如SF123456”。加一层参数校验模型传错了就返回错误信息让它重试而不是直接抛异常。还有一个坑是token超限。记忆检索召回太多条拼进prompt后超过模型上限provider直接拒绝。我的做法是在拼prompt前算一下token数超了就砍记忆条数。用tiktoken算别用字符数估算误差太大。5.4 记忆系统的性能优化经验记忆检索是每次请求都要跑的延迟直接影响用户体验。我做过一轮优化把P99延迟从800ms降到200ms以内主要做了三件事向量索引参数调优Qdrant的HNSW参数m和ef_construct影响检索速度和召回率。我把m从16降到12ef_construct从100降到64召回率只掉了2%但检索速度提升40%。缓存热点记忆最近1小时内被命中过的记忆缓存在内存里下次直接返回不走向量库。命中率大概30%但省下的延迟很可观。异步写入记忆写入不阻塞用户响应丢到消息队列里慢慢处理。用户感知不到写入延迟。注意异步写入要处理好失败重试。我有一次消息队列挂了记忆丢了一批用户第二天发现Agent“失忆”了。后来加了持久化队列和死信队列确保写入最终一致。6. 关于hindsight这套思路的延伸思考写到这里我想聊一个稍微远一点但我觉得很重要的点记忆系统的“遗忘”机制。很多人做Agent记忆只想着怎么存、怎么取忽略了怎么忘。但人脑的遗忘是有意义的——它帮我们过滤噪音、抽象规律。Agent记忆如果只增不减迟早会变成一团浆糊。我在长期记忆的复审机制里加了一个“抽象”步骤如果多条短期记忆指向同一个模式比如用户每周五都会问周末配送安排就自动生成一条更高层的长期记忆“用户关注周末配送”然后把原始的多条短期记忆标记为可清理。这样长期记忆库不会膨胀而且抽象出来的规律比原始记录更有用。这个思路和热词里提到的a-memguard有点类似——那个框架强调的是主动防御我理解成在记忆写入前做一层过滤和验证防止恶意或错误信息污染记忆库。我的做法是在写入前加一个轻量分类器判断这条记忆是否“值得存”。准确率大概90%误杀率5%左右但省下的存储和检索噪音很值。最后分享一个我踩过的坑不要用同一个向量库同时存短期和长期记忆。我一开始图省事用一个collection加type字段区分结果检索时过滤条件写错长期记忆被当成短期记忆召回导致Agent把三个月前的偏好当成当前偏好。后来拆成两个collection物理隔离再没出过这个问题。存储成本增加不多但逻辑清晰太多。这套系统我跑了大概半年迭代了十几个版本现在稳定支撑日均几千次会话。核心代码不复杂难的是那些边界情况的处理——什么时候写、写什么、怎么取、什么时候忘。这些没有标准答案只能根据业务场景慢慢调。希望上面这些经验能帮你少走点弯路。