ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从上下文窗口到MCP:Agent记忆与工具调用的设计实战

从上下文窗口到MCP:Agent记忆与工具调用的设计实战

最近跟几个做 Agent 的朋友聊了一圈,发现大家几乎都在同一个地方卡住:上下文窗口。很多人刚开始都觉得,8k、32k、甚至 128k 的上下文已经很宽裕了,可一旦真正跑长期对话、接上三五个工具、还要记住用户偏好,窗口里的东西马上就会失控。问题不是“不够长”,而是“塞不下了、分不清了、忘了该保留什么了”。今天这篇文章,我想好好聊聊我在这段时间里对 Agent 记忆与工具的思考,从最朴素的上下文窗口一路讲到 MCP,把里面涉及的分类、设计思路和实操踩坑都捋清楚。

1. 上下文窗口不是记忆,是工作台

1.1 工作台越小,越考验整理能力

我一开始也犯过这个错误,以为上下文窗口就是 Agent 的记忆,对话长了就往里塞。后来发现,上下文窗口更像一个物理工作台:你能在这个台面上铺开东西,但同时摊在上面的东西越多,真正有用的东西反而越难找。

假设工作台上能摊 128 张纸,看起来很多。但系统提示词要占十张,对话历史占二十张,两个工具返回的数据各占二十张,检索回来的文档又占二十张。真正留给 Agent 做决策的空间,可能就剩下十来张。这些纸堆在一起,模型处理时会一视同仁地去“翻阅”,很难保证它永远把注意力放在最关键的那几张上。

更麻烦的是,工具返回结果往往是结构化的 JSON。有一次我的 Agent 调用一个搜索工具,返回了几百行 JSON,里面九成都是无关字段,最后模型不仅没找到答案,还被里面某个奇怪的错误码带偏了思路。那一刻我才反应过来:上下文窗口是稀缺资源,如何组织、筛选、压缩里面的内容,才是真正的工程问题。

1.2 注意力稀释是隐性杀手

这里有一个常见误解:既然上下文窗口支持 128k,那我就把全部历史、全部文档都丢进去,反正模型能处理。实际上,很多主流模型使用的是类似检索式注意力机制,一个 token 是否被重视,取决于 Attention 得分。当相关信息和大量无关内容混在一起时,相关信息的注意力权重会被稀释,模型表现反而明显下降。

打个比方,你在一间满是杂音的房间里找一个人说话,如果房间里只有你俩,声音很清楚。如果旁边多了二十个人同时说话,就算那个人嗓门再大,你也容易听漏关键内容。上下文窗口越大,这个问题越突出,不是线性的“越大越好”。

我自己的实测是:同样的任务,上下文占用 30% 时,正确率明显高于占用 90% 时。尤其是在涉及多步工具调用、需要记忆早期用户偏好的场景里,塞满的历史会让 Agent “忘掉”最初的指令。

1.3 一条典型的失败链路

举个实战中的例子。我做一个客服 Agent,用户在第一轮说:“我叫小李,喜欢简洁的回答风格。”

对话进行到第 40 轮,中途用户问了十几次商品库存,Agent 也调用了几十次库存工具。此时模型在回答末尾的问题时,已经完全不记得“简洁风格”这个偏好了,开始输出一大段泛泛的总结。原因就是:早期会话被挤到很靠前的位置,工具调用记录占据了大量注意力。

这不是模型“笨”,而是我设计上的错。我根本没给 Agent 一个机制去记住这个偏好,而是指望它一直盯着一堆历史文本,这是不现实的。也正因为这个失败,我开始把“记忆”跟“上下文窗口”分开处理,才有了后面一系列设计。

2. Agent 记忆的三种形态:短期、长期与结构化

2.1 短期记忆:一个会滑动的窗口

短期记忆本质上就是对话历史本身。它最简单、最容易实现,但也最容易失控。常见做法是维护一个滑动窗口,只保留最近 N 轮对话,再加系统提示词。打开思路的第一步是,把不重要的中间过程从窗口里移出去。

我通常会在会话开始前写一个“记忆归档逻辑”:每轮对话结束,先判断这轮是否值得保留。如果只是寒暄或重复内容,直接丢掉。如果涉及用户指令、问题解决结果,就保留一轮。然后窗口大小控制在一个比较小的范围,比如最近 8~12 轮。

这里有一个关键细节:滑动窗口从末尾往前删,会导致最早的关键信息(比如用户偏好)丢失。所以要在窗口之外做一个持久层,把那些重要信息抽出来存在结构化字段里,后面再讲。

2.2 长期记忆:向量库只是一个起点

长期记忆是我折腾得最多的一块。主流的做法是把对话记录、文档片段做切分,转成向量,存入向量数据库,等需要时做相似度检索。这个方案本身没毛病,但如果只知道“embedding 然后检索”,很容易踩坑。

我踩过的第一个坑是:直接存整个对话文本,检索时发现返回的都是“长得很像”但根本不重要的东西。比如用户说“我昨天退了个货”,向量上跟“退货政策”高度相似,但用户当前的问题其实是“换货流程”。单靠向量相似度,很难区分“语义相关”和“任务相关”。

后来我的做法是:先把原始对话做切分和摘要,把切分出来的每个片段提炼成一个带有意图标签的记忆单元。例如:

  • user_preference:用户说自己喜欢简单回答
  • transaction_info:用户退过某件商品,原因是不合身
  • task_progress:用户正在咨询换货流程,已经提交了申请单

每个记忆单元包含内容摘要、原始片段、标签、时间戳、会话 ID。做检索时,先按意图标签过滤,再做向量相似度检索,最后还可以用重排序模型把真正用于当前任务的内容提到前面。

2.3 结构化记忆:最有价值但常被忽略

如果说长期记忆是“碎片”,那结构化记忆就是“骨架”。我强烈建议每个 Agent 项目都维护一张用户属性表或实体关系表,里面存用户偏好、正在进行的任务状态、已经确认的关键事实。

举个例子:

字段值
用户IDuser_10086
回答风格偏好简洁
当前任务状态换货申请已提交,等待审核
当前商品运动鞋
历史问题退货政策、库存查询

当 Agent 需要回答“我的换货申请怎么样了”时,不必从长对话里去捞,直接查结构化记忆就能拿到当前状态。这比任何向量检索都靠谱,因为它是确定性数据,不是概率相似度。

我通常会建立三类表:用户偏好表、会话状态表、实体关系表。会话状态表在每次工具调用或关键事件之后更新,实体关系表则记录用户提到的商品、订单、地址之类。这套做法在工程上很直观,但很多 Agent 项目一开始会忽略,结果就是在上下文里堆了一堆历史,却连“用户叫什么”都记不准。

3. 记忆工程化:存储、检索与更新什么时候做

3.1 事件流与摘要双轨制

刚做完三种记忆形态,我脑子里又冒出一个问题:怎么组织写入和读取?总不能每次对话都把所有记忆重新算一遍。

我的解决方案是双轨制:原始事件流 + 定期摘要。

事件流就是一条日志表,记录每次用户输入、Agent 动作、工具返回的关键字段。它是完整的真相来源(source of truth)。摘要层则是定期对事件流做聚合和压缩,生成带有时间段的摘要,比如“第 1~20 轮:用户申请退货;第 21~40 轮:用户改换换货流程”。

检索时,我先拿摘要做粗筛,定位相关时间段,再回到事件流里找细节。这样既避免了把整个事件流都塞进上下文,又不会丢失关键细节。这个思路很像做日志系统时的冷热数据分离,热数据实时处理,冷数据归档。

3.2 检索策略:时间衰减与多路召回

检索不能只靠一个向量查询。我现在使用的策略是:

  1. 从结构化记忆里先拿“当前状态”相关字段,比如用户当前任务、偏好。
  2. 从事件流里按时间范围过滤,提取最近 N 天或本会话内的相关事件。
  3. 再做一次向量检索,但会加上时间衰减权重。太老的记忆即使语义相关,权重也要降一点。
  4. 最后用一个小型重排模型,或者直接让 Agent 从结果里挑相关性最高的几条。

时间衰减这一点非常重要。用户的偏好和状态是变化的,三个月前的“喜欢便宜耐用”很可能已经不适用了。我在检索结果里会加上时间标签,让模型知道这条记忆是什么时候的,是否应该被当前事实覆盖。

3.3 更新时机:别在对话中间频繁写入

还有一种常见的坑:每轮结束就把整段对话存进向量库。结果库里的记忆碎片爆炸式增长,召回时重复、矛盾的内容一堆。

更合理的做法是异步批量更新。也就是在一段对话结束,或者达到一定轮次之后,再对这一段做摘要、提取偏好、更新结构化字段。这样既减少了写入次数,也方便做去重和冲突解决。

比如用户一开始说“我喜欢简洁回答”,过了三轮说“其实详细一点也行”,这时候应该以后者为准。异步批处理时,我会通过规则或简单判断把最新一次偏好覆盖旧值,避免同时存在两条矛盾偏好。

4. MCP:统一工具与记忆的接入层

4.1 为什么需要 MCP 这层“插口”

讲完记忆,再讲工具。在做 Agent 的时候,我经常要接各种服务:查订单、搜库存、算价格、读文档。一开始每个服务都有一个自定义接口,要么是 HTTP 回调,要么是函数调用。做得越多越痛苦:每次接一个新工具,都得重新写一套参数映射、错误处理、超时逻辑。而且工具返回的数据是随意的,有的返回 JSON,有的返回纯文本,有的直接甩一个大文件路径。

MCP(模型上下文协议)本质上是给 Agent 和外部世界之间定了一套标准插口,你可以把它理解成 Agent 的 USB-C 接口。H2 4.1 没有 USB-C 之前,你要给电脑接显示器要带几种线,接硬盘要带另一种,接网线还要再带一个。有了 USB-C,大多数设备都能共用。MCP 做的事情类似:如果你有一个工具或数据源,也照这个协议实现,Agent 就能直接用,不用为每个工具单独开发一套集成逻辑。

4.2 MCP 提供的三类核心能力

MCP 的模型里,最重要的几个概念是资源(Resource)、工具(Tool)和提示词(Prompt)。资源是一些上下文数据,比如用户档案、某个项目的文档;工具是可执行的函数,比如发送订单、计算价格;提示词是一些可复用的指令模板,用来规范 Agent 在特定场景下的行为。

我更喜欢从工程角度来理解:MCP 其实定义了一个标准 JSON-RPC 通道,client 和 server 之间按固定格式交互。你可以在同一个 server 里既暴露工具,又暴露资源,这样记忆和工具就在同一个接入层里了。

更重要的是,MCP 还考虑了安全控制。某个工具能访问哪些资源、哪些操作需要人工审批,都可以在协议层面做约束,而不是散落在各个 Agent 的业务逻辑里。当 Agent 接入的第三方服务越来越多时,这个统一管控的作用会越来越重要,不然整个 Agent 就是一个毫无边界的“全仓库钥匙”。

4.3 一个完整的记忆服务 MCP 设计

我把我自己的记忆模块做成了一个 MCP server,对外暴露三个主要入口:

  • search_memory:按查询条件检索记忆片段
  • save_memory:保存新的记忆
  • get_user_profile:获取当前用户的结构化档案

下面是一个简化的 TypeScript 示意(包名和 SDK 结构不必照搬,领会思路即可):

import { Server } from '@model-context/protocol/server'; const server = new Server({ name: 'memory-server', version: '0.1.0' }); interface MemoryItem { id: string; userId: string; content: string; tags: string[]; timestamp: string; } const db = new MemoryStore(); server.registerTool('search_memory', async ({ query, userId }) => { const results = await db.search({ userId, query }); return { results: results.slice(0, 5) }; }); server.registerTool('save_memory', async ({ userId, content, tags }) => { const item: MemoryItem = { id: generateUUID(), userId, content, tags, timestamp: new Date().toISOString(), }; await db.store(item); return { id: item.id }; }); server.registerTool('get_user_profile', async ({ userId }) => { return db.getProfile(userId); });

在这个设计里,Agent 主程序根本不需要关心记忆存在哪个数据库、是向量检索还是 SQL 查询,它只需要知道“我调 search_memory 能拿到历史片段,调 get_user_profile 能拿到用户偏好”。存储细节完全被封装在 MCP server 内部。

这带来一个额外好处:如果你换了一套数据库,或者把记忆存储从单机迁移到云端,Agent 主程序的代码几乎不用动。MCP 这一层把“业务算法”和“基础设施”完全解耦,维护成本大幅下降。

5. 实战排坑:上下文污染、检索失效与工具返回垃圾

5.1 工具返回内容照样会“污染”对话

这是我踩过最深的一个坑。一开始我以为,上下文污染只发生在对话历史里,后来发现工具返回内容也一样。比如某次 Agent 调用一个天气工具,返回的 JSON 里包含了一大串status_code、latitude、longitude、headers之类,模型在下一步逻辑里就可能被这些冗余字段干扰。

我的解决方案是给工具返回做一次“显示层精简”。工具返回给模型之前,先经过一个清理函数,只保留真正需要的字段:

function cleanResult(data: any, keepFields: string[]) { const out: any = {}; for (const key of keepFields) { if (data[key] !== undefined) { out[key] = data[key]; } } return out; }

通常我会保留:status、message、result,其他一律丢弃。复杂度高的返回,我还会加上一层摘要,让模型只看到结论和关键数字,细节放到有需要时再通过另一个 MCP 工具去查。这本质上也是“按需加载”的思路,上下文里的内容越聚焦,模型表现越稳。

5.2 检索命中率太低时,别急着加阈值

我最初遇到记忆检索不准时,第一反应是调低向量相似度阈值,希望多召回一些内容。结果发现,阈值越低,召回内容越多,相关的反而被埋得更深。

后来我意识到问题出在查询语句上。记忆检索用的 query 不是用户当下说的话,而应该是一个“检索意图”的句子。比如用户说“我那件退货怎么还没退款”,直接拿这句话去查记忆,效果一般。如果先提炼成“退款进度、退货订单状态”,再去查,命中率会明显好很多。

所以我在 MCP 的search_memory前面加了一步“查询改写”,用一小段提示词或规则,把用户自然语言转换成检索语句。这一步很便宜,收益却很大。如果你也遇到检索失效,先检查 Query 本身就问题,而不是急着换模型或调参数。

5.3 别把所有历史都存成记忆

还有一个容易被忽视的问题:记忆不是越多越好。跟短期记忆一样,长期记忆也需要“遗忘机制”。

我最初把几十轮对话全部存进向量库,结果就是召回时出现大量高度相似、甚至互相矛盾的记忆。比如用户每轮都问一遍“发货没”,如果不加清理,向量库里会有七条相似的“发货未到”记录,模型检索时可能被重复信息带偏,反而错过真正重要的收件地址记录。

后来我的过滤策略是两条:一是重复内容合并,同一天内同类问题只保留最新一条;二是对事件流做自动摘要,摘要之外的细节在超过一定时间后自动降权。记忆有了“遗忘”,反而更精确。

5.4 调试 Agent 时,最容易被忽视的是日志

最后分享一个习惯性问题:调试工具调用和记忆检索时,很多人只盯最终回复,看不到中间过程。我之前也是,空对空猜 Agent 为什么答错。后来我开始在记忆检索和工具调用前后加上结构化日志,记录每一步的输入与输出。日志多了以后,大多数问题都一目了然,比如:

  • 用户问题 -> 检索 Query -> 召回的几条记忆 -> Agent 最后选了哪条
  • 调用工具 -> 返回内容 -> 清理后喂给模型的内容

这套日志体系,比我后来用的任何重排模型都管用。说起来很简单,但也是很多 Agent 项目做得最欠缺的一环。没有这一步,谈 Agent 优化基本是盲人摸象。

我在实际把记忆和 MCP 跑通之后,最大的体会是:Agent 不该被当成一个全知全能的怪物,它更像一个小管家,工作台上的东西要由你来决定放多少、放什么,工具要按需调用,记忆要有存有取、有更新也有遗忘。上下文窗口只是那张工作台,真正的能力来自工作台背后的那套整理术。如果你也在打磨自己的 Agent,不妨先从这五个层次梳理一遍,会少走很多弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进