做了大半年 Agent 落地,我几乎每天都会被同一个问题卡住:上下文窗口。最初我把精力都放在怎么把窗口用满、怎么塞更多历史,后来才发现,真正让 Agent 变聪明的并不是窗口本身,而是记忆和工具这两件事。等我把外置记忆和工具调用都梳理清楚,又发现它们最终都指向 MCP 这套统一协议。这篇文章不念叨概念,按我在真实项目里的踩坑顺序,聊聊为什么上下文窗口永远不够用、记忆怎么拆层、工具怎么设计,以及 MCP 在这个链路里到底扮演什么角色。
1. 为什么上下文窗口越改越大,Agent 却越来越笨
1.1 窗口是“输入上限”,不是“有效记忆上限”
先明确一个容易混淆的点:上下文窗口只代表模型一次能接收的 token 上限,不代表它能把窗口里的每一行都同等对待。早期我们总以为只要把窗口从几千扩到几万,Agent 就会像人一样“记得更多”,事实恰恰相反。
我在一次测试里把项目资料放到对话中部,把用户问题放到最后,模型竟然完整忽略了中间那段看似关键的资料。这不是模型坏的,而是注意力天然偏向开头和结尾,越长的上下文,中间内容被“稀释”得越严重。这种效应在业界有个很形象的说法:迷失在中间。窗口 10 万 token 的时候,你实际有效利用的可能只有一半,另一半是在给模型制造噪音。
更要命的是一笔经济账。每轮请求都要把之前所有对话重新发送一遍,输入 token 数随对话轮数线性增长。长会话跑到五十轮,单次请求的成本和耗时已经很可观,但回答质量并没有同步提升,甚至因为到处是冗余信息而开始跑偏。所以“扩大窗口”只能算被动扩容,它不是解决 Agent 记忆问题的手段。
1.2 一股脑塞历史的代价:成本、噪音、指令漂移
有朋友看完我上面的话,会说“那我把窗口初始化时就把系统提示词压短,把所有历史按时间顺序丢进去,总行吧?”听起来合理,踩过的坑都知道不行。
第一是成本。客服机器人场景里,用户反复问同样的售后规则,历史记录里同一段产品说明被粘贴了七八遍。每次请求都带着这七八遍重复文本,既浪费 token,又让模型把注意力放到“这句话怎么在各种上下文里长得不太一样”上。
第二是噪音。历史里往往夹杂着用户当时的情绪、管理员误操作、测试消息、甚至用户贴错的日志。这些内容一旦进了上下文,模型分不清主次,常常被带偏。我遇到过 Agent 因为把历史里几句闲聊当成工具调用的辅助信息,结果连续两次输出了错误的查询参数。
第三是指令漂移。早期在提示词里写死的规则,随着用户消息不断插入,会被模型逐渐“遗忘”。尤其当一条用户消息写得很长时,模型更倾向于服从用户消息里的表述,而不是最开始的系统约束。这种情况在长上下文里比想象中更常见,几乎是在提示词里堆规则的大忌。
1.3 一次对比实验让我彻底放弃“无限加窗口”的路线
为了实际评估,我做过一个对照实验:同样的问答测试集,分别用“全部历史入上下文”和“只保留摘要加最近 10 轮”两种方式跑。
结果让我挺意外。全部历史的方式在 20 轮内准确率尚可,但到 50 轮之后明显下滑,模型开始混淆两个相似的历史问题;而只保留摘要和最近对话的方式,虽然前期准确率略低,但全程曲线平稳,最终反超了第一种。更关键的是,后者的单轮 token 消耗下降了约 60%。
从那之后,我的观点就变了:上下文窗口应该只装“当前任务需要的东西”,历史信息和外部知识应该放到 Agent 的外部记忆里,按需取用。这个思路直接决定了后面整套架构的走向。
2. Agent 的三种记忆,别混在一个筐里
2.1 工作记忆、情景记忆、语义记忆的分工
在具体工程设计里,我习惯把 Agent 记忆拆成三层,借用认知科学里的概念但并不严格照搬:
- 工作记忆对应当前上下文窗口里的内容,处理完一轮就消失,主要用来做推理。
- 情景记忆对应完整的会话历史,保留事情发生的先后顺序,适合按时间和语境回溯。
- 语义记忆对应从历史中提炼出来的事实和知识,比如“用户偏好 Python”“项目的部署地址是内网某域”这类脱离原始对话也能独立使用的信息。
三者的存储方式完全不同。情景记忆适合 JSON 或文档型存储,按 session 维度组织;语义记忆需要经过抽取、清洗后写入结构化表或向量库;工作记忆则只是运行时拼装出来的一段 prompt。很多项目一开始就把所有历史一股脑写入向量库,结果检索出来的全是对话碎片,缺少事实归纳,这在后面会花很大精力还债。
2.2 检索不是装个向量库就完事
语义记忆的标配是向量检索,但只用向量库会漏掉两类重要信息:时间和来源。举个例子,用户上个月说“我喜欢在晚上运行定时任务”,这个信息本身是语义记忆;但如果你没有记录时间戳,下个月用户说“改成白天跑”时,新记忆和旧记忆冲突,你根本不知道应该信哪条,因为没有新鲜度可判断。
我现在的做法是给每条记忆都附带元数据,包括创建时间、更新时间、来源消息 ID、可信度评分。检索时除了向量相似度,还会做时间衰减和来源过滤。查询脚本里还可以同时考虑结构化属性的精确匹配,比如用户 ID、项目环境、对话主题。混合检索的效果通常比纯向量检索更稳。
下面是一条典型语义记忆记录的示例:
{ "memory_id": "mem_20240701_001", "type": "semantic", "content": "用户偏好 Python 3.11,依赖管理用 uv", "metadata": { "user_id": "u_10086", "created_at": "2024-07-01T10:24:00Z", "updated_at": "2024-07-02T09:12:00Z", "source": "session_7788", "confidence": 0.92, "topics": ["language", "tooling"] } }“先过滤、再相似、后排序”是我常用的检索流程:先用结构化字段把无关记忆排除,再做向量相似度召回,最后按时间衰减和置信度重排。这样既保证了精度,又不会让无关用户的记忆串进上下文。
2.3 给记忆加“保鲜期”和“可信度”
记忆不是越多越好,很多 Agent 项目死在“什么都记”。用户偶尔说一句“这个功能有点笨”,你就把它当成长期偏好写进语义库,之后系统在每次决策时都可能被这条噪音干扰。
我的经验是,写入语义记忆前先做一道门槛判断:这条信息是否稳定、是否会影响后续行为、是否已经存在等价表述。如果只是闲聊情绪,不写入;如果是明确的偏好或事实,写入前还要做一次去重合并,避免同一条信息在库里膨胀成十几个变体。
同时每条记忆都要有可信度。可信度来自统计来源,比如被用户明确纠正过一次的事实,可信度下调;被用户多次复述的事实,可信度上调。检索排序时,可信度是排在相似度之后的权重项。这套机制不复杂,但能让记忆库长期保持干净。
3. 工具调用:从提示词里“猜”到结构化调用的转变
3.1 为什么提示词里让模型自己拼参数不靠谱
刚开始做 Agent 时,我用的方式是告诉模型“如果需要查天气,就生成一个包含 city 和 date 字段的 JSON,然后调用 weather API”。看起来简单,实际跑起来全是问题。
模型不是代码执行器,它会猜测字段名,把date写成datetime,把city写成city_name;也会在 API 需要数字 ID 时,把用户输入里的中文名称原样塞进去。更麻烦的是它偶尔会编造成功响应,明明 API 调用失败了,模型反而编出一个像模像样的结果。这个问题在系统提示词里写得越隐晦,翻车概率越高。
结构化工具体系解决的就是这个“最后一公里”的问题。模型不需要自己拼 HTTP 请求,它只需要按照工具 schema 输出参数,由运行时负责校验、执行、返回真实结果。
3.2 工具清单:名字、描述、参数缺一不可
一个工具被模型选中的概率,很大程度取决于描述写得好不好。所谓的“好”,不是给人类看的运维文档,而是给模型看的功能说明。描述里要写清楚:这个工具什么时候用、什么时候不用、参数怎么填、边界条件是什么。
我常用的工具定义格式是这样:
{ "name": "get_customer_order", "description": "根据订单号查询客户订单的当前状态、金额和物流信息。仅在用户提供订单号且需要查询订单详情时使用。", "parameters": { "type": "object", "properties": { "order_id": { "type": "string", "description": "客户订单号,例如 ORD20240001" } }, "required": ["order_id"] } }参数描述要具体到格式示例,因为模型非常吃“看得懂的样例”。我还习惯把可能的值枚举在描述里。比如状态字段,直接写明“取值范围为 pending、shipped、completed、cancelled”,模型基本不会乱填。没有这些细节,模型会自由发挥,你只能靠出错日志去猜哪里出了错。
工具数量也要克制。我见过项目把几十个工具一次性全给模型,结果模型频繁选错工具。后来我把工具按场景分组,在一个场景里只暴露 8 到 15 个真正相关的工具,效果立刻改善。工具不是越多越好,模型需要的是“在正确时间看到正确工具”。
3.3 工具执行层的可靠性建设
模型把参数选对只是第一步,真正决定 Agent 是否可靠的是工具执行层。我在这里吃过亏的系统性工程问题有三个:
- 超时与重试。外部 API 慢起来没底线,我给工具执行统一加超时控制,超时后的错误也会作为结果反馈给模型,让它尝试替代方案。
- 结果截断与摘要。工具返回大段 JSON 时,如果原样塞回上下文,一次调用就吃掉上万 token。正确的做法是把结果做字段裁剪,再针对模型需要的信息生成一段摘要。
- 幂等与防重。模型可能因为网络抖动连续调用同一个扣费型工具,这就要求底层接口具备幂等控制,或者在运行时做同参数去重。
工具执行层的另一个重点是反馈闭环。调用失败时,错误信息要原样返回给模型,而不是在运行时悄悄吞掉。模型看到明确的报错,才能修正自己的下一步动作。这个设计看似简单,却是很多 Agent“卡在坏循环里出不来”的根源。
4. MCP:记忆、工具、数据源共用一套“插座”
4.1 MCP 到底解决什么问题
当我们同时维护记忆服务、向量库、公司内部 API、第三方工具时,最头疼的其实不是单一工具的开发,而是几十种接口协议各自为政:一个工具用 REST,另一个用 WebSocket,还有一个要老旧的 XML 接口。Agent 每接一个新数据源,都要写一套适配器,维护成本直线上升。
MCP(Model Context Protocol)是一套开放的通信协议规范,它的目标就是把“模型如何访问数据和工具”这件事标准化。在 MCP 的框架里,Agent 是 Host,模型运行环境里的客户端负责发起请求,外部的数据源和工具则通过 MCP Server 提供能力。一个 MCP Server 可以被多个 Agent 复用,一个 Agent 也可以同时连接多个 Server。从上下文窗口到处是 prompt 的混沌状态,走到“统一协议 + 标准化接入”,这一步非常关键。
4.2 三种原语:Tools、Resources、Prompts
MCP 里最核心的抽象是三类原语:
- Tools:可执行的动作,比如“创建工单”“查询订单状态”,模型按 schema 传参,由 Server 执行并返回结果。它跟前面讲的结构化工具体系是天然对齐的。
- Resources:可读取的数据,比如“用户画像”“知识库中的某篇文章”,可以由模型主动读取,也可以由客户端注入上下文。记忆数据完全可以包装成 Resource。
- Prompts:可复用的提示词模板,比如“工单总结模板”“周报生成模板”。它们把常用任务的结构固定下来,减少每次都在 prompt 里现编的随机性。
这套设计的好处是,同一个 MCP Server 既可以暴露业务数据,也可以暴露业务操作。Agent 侧不再关心数据源是 MySQL 还是 S3,它只跟协议打交道。
4.3 一个最小 MCP 交互长什么样
MCP 基于 JSON-RPC 2.0 风格的请求响应。我不打算贴完整协议,只说最常用的两个动作。
客户端发现工具列表时,会发一个tools/list请求,Server 返回工具描述列表;需要调用工具时,客户端发送tools/call,带上工具名和参数。一条典型的调用请求大致长这样:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_customer_order", "arguments": { "order_id": "ORD20240001" } } }Server 返回的结果同样遵循统一结构,里面包含执行结果或错误信息。模型通过客户端拿到结果后,再决定继续执行工具还是生成最终回答。整个过程里,模型本身不感知协议细节,它只看到“我有一个工具可以用、它给出了结果”。这套抽象让 Agent 的扩展成本大幅降低。
4.4 安全边界才是 MCP 落地的主战场
MCP 把工具和数据接入门槛降低了,也意味着攻击面变大了。一个 MCP Server 如果有文件读写能力,那它就能读服务器上的文件;如果它直连数据库,那条连接就是潜在的数据泄漏通道。所以接入 MCP Server 时,我坚持四条纪律:
- 使用最小权限身份连接数据库或云 API,不把最高权限令牌交给 Agent。
- 对资源和工具做路径白名单,限制读写范围。
- 记录所有
tools/call和resources/read审计日志,出现异常时能定位。 - 优先使用本地进程内的 MCP Server,远程连接时走安全通道并做认证。
MCP 本身只是个协议,它不会替你做安全决策。真正决定系统安不安全的,是你给每个 Server 赋了多大的权限。
5. 我的落地架构:上下文窗口只装当前任务
5.1 一次带回忆、带工具调用的完整流程
把记忆、工具、MCP 拼起来之后,我的 Agent 调用流程变成了一条明确的流水线:
- 用户输入到达,先做意图识别,判断是普通问答、检索记忆还是调用工具。
- 从记忆系统召回相关信息,包括最近对话摘要、语义记忆 TopK、结构化用户画像。
- 把这些信息连同系统约束、上一轮工具结果一起,组装成当次上下文。
- 模型只围绕当前上下文推理,如果决定调用工具,输出结构化调用请求。
- 运行时通过 MCP 统一通道执行工具,拿到结果,必要时截断或摘要。
- 工具结果回到上下文,模型继续推理,直至生成最终回答。
- 流程结束后异步执行记忆写入,把本轮的稳定事实更新进语义库。
这里面最容易忽略的是第 7 步。很多项目做完一轮对话就把结果丢了,下一次用户重新说起同一件事,Agent 就像失忆一样。记忆写入必须落在任务完成后,而不是模型回答结束时——因为模型可能在最后一步才得到关键信息。
5.2 记忆管理器的设计:能让我直接“抄作业”的版本
我提供一个简化但可直接扩展的记忆管理器伪代码,方便理解核心逻辑:
class MemoryManager: def __init__(self, semantic_store, vector_index, profile_store): self.semantic_store = semantic_store self.vector_index = vector_index self.profile_store = profile_store async def recall(self, user_id, query, top_k=5): # 1. 结构化画像优先 profile = await self.profile_store.get(user_id) # 2. 向量召回语义记忆 candidates = await self.vector_index.search(query, top_k) # 3. 按时间衰减和可信度重排 ranked = self._rerank(candidates, profile) return {"profile": profile, "memories": ranked} async def remember(self, event): if not self._should_store(event): return merged = await self._deduplicate(event) await self.semantic_store.upsert(merged) await self.profile_store.merge(merged)重点在_should_store和_deduplicate:前者过滤低价值消息,后者合并重复表达。这两步做不做,决定了你的记忆库是越用越聪明,还是越用越乱。很多项目一开始图省事,所有消息都写入,结果库越滚越杂,检索出来的全是噪音。
5.3 效果怎么衡量:指标比感觉可靠
做记忆和工具改造,最怕没有量化验证。我自己固定看几个指标:
| 指标名 | 计算方式 | 反映问题 |
|---|---|---|
| 任务完成率 | 用户任务被成功完成的占比 | Agent 整体有效性 |
| 工具调用成功率 | 工具成功执行次数 / 调用次数 | 工具 schema 与执行层质量 |
| 有效记忆召回率 | 人工标注的“应召回记忆”中真正被召回的比例 | 记忆系统质量 |
| 单任务 token 消耗 | 完成任务平均消耗 token 数 | 上下文压缩效率 |
| 无意义工具调用率 | 未经用户确认就触发的高风险工具次数 | 决策边界是否合理 |
这些指标不要求一次全上,但至少要在迭代前先建立起几个。否则你改一版记忆策略,只会觉得“好像聪明了”,无法判断是真进步还是偶然。
6. 这些坑我踩过,写出来希望你绕开
第一个坑,是把“向量库”当成记忆系统本身。向量库只解决“相似检索”,它不会帮你归纳、合并、去重。真正的记忆系统是“抽取层 + 存储层 + 检索层 + 更新策略”的组合,向量索引只是存储层里的一个组件。项目初期低估抽取层的复杂度,后期几乎都要回头补课。
第二个坑,是给模型的工具太多但描述太差。我曾把二十多个工具全量暴露给模型,模型竟然选了名字看起来像但功能完全不相关的工具。原因就是我在描述里用了太多抽象词,比如“处理用户需求”,模型理解不了边界。后来我把描述改成类似“当用户明确要求取消订单时使用,且必须提前确认退款金额”,选择准确率立刻上来。
第三个坑,是工具返回结果不裁剪就往上下文里塞。MCP 接入一个数据库查询工具后,返回值一次性把几十行记录全带回来,一次工具调用就占掉大半窗口,模型后续几乎无法展开推理。解决办法是给 MCP Server 的输出做一层“结果摘要中间件”,只保留模型需要的字段和结论。
第四个坑,是忽视记忆写入的时效性。我有一版方案把记忆写入做成同步操作,结果用户对话高峰期时,Agent 响应变慢,因为每条消息都先去重、再嵌入、再写入。后来改成异步队列,把写记忆放到后台,响应速度恢复,记忆质量也没有明显下降。异步写记忆时要注意时序:同一个信息可能先后有两个版本入队,合并在写入侧再处理一次,避免旧值覆盖新值。
最后分享一个我很常用的测试技巧:每种记忆策略或工具配置上线前,我会固定用一个包含 30 个真实用户问题的回归集,跑 5 轮取平均,对比任务完成率和 token 消耗。如果任务完成率上升且 token 下降,说明这次改动方向对了;如果只降 token 不降准确率,说明还能再压;如果 token 涨了但准确率没涨,那基本就是方向错了。这个朴素的办法,帮我避开了很多“自我感觉良好”的无效优化。