ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

从上下文窗口到MCP:Agent记忆与工具系统设计实践

从上下文窗口到MCP:Agent记忆与工具系统设计实践

做 Agent 做得越久,我越觉得一句话说得透:上下文窗口是工作台,不是仓库。前阵子我调一个多步骤调研 Agent,跑到约莫第 20 轮对话时,模型开始把前面已经确认过的结论重新质疑一遍,还一本正经地给出与先前矛盾的建议。我一度以为是提示词写坏了,排查半天没结果;最后只是把历史记录裁掉一截,症状立刻消失。那一刻我才真正意识到,很多 Agent 的"失忆"根本不是模型笨,而是我们把记忆放错了位置。

后来我把这套东西从头重做了一遍,从上下文窗口管理、分层记忆,到工具调用,再到最近社区里反复被讨论的 MCP(Model Context Protocol,模型上下文协议),才算走通了从"玩具 Agent"到"能干活 Agent"的完整链路。这篇文章不聊花哨的 Demo,只讲我踩过的坑,以及我现在会怎么设计一个 Agent 的记忆与工具系统。

如果你正在搭自己的 Agent,或者你的 Agent 跑到后面总是"颠三倒四"、工具一多就乱套,这篇文章应该能帮你省掉不少试错时间。内容从上下文窗口的局限讲起,逐步落到记忆分层的工程实现、工具调用的基本盘,最后用 MCP 把工具接入标准化,全程都是实践视角。

1. 上下文窗口的真相:工作台不是仓库

1.1 一次会话的失控:从清爽到混乱的 20 轮

我那个调研 Agent 一开始表现很好。前 10 轮,它回答问题干净利落,引用资料也对得上;到第 15 轮左右,开始出现两个毛病:一是重复提问,刚才明确给过的信息它又问一遍;二是立场漂移,上午还认定 A 方案更优,下午它开始倾向 B,而且完全"不记得"自己先前的推理链路。

我把完整的对话日志拉出来看,才发现问题不是出在某一条消息上,而是整个上下文已经变成了一个大杂烩。早期的重要结论埋在中间位置,后面新塞进去的检索片段、中间结果、工具返回值把它越推越深。模型每次回复都要在几千甚至上万 token 里"翻找"相关信息,找不找得到全看运气。

这不是个别模型的缺陷。我后来在不同模型上试过相同模式,表现大同小异。核心原因有两个:一是位置偏差,模型对长上下文开头和结尾的内容更敏感,中间段容易被忽略;二是注意力稀释,token 越多,每个 token 在自注意力计算中分到的"关注度"越有限,早期信息容易被后面的内容淹没。这不是"记忆力不好",而是架构层面的天然倾向。

1.2 症状识别:你的 Agent 是不是也这样"假失忆"

我把实践中常见的症状整理成一个对照表,方便你快速自查:

症状典型表现根因
复读机反复询问已回答的信息早期信息被挤出有效注意力范围
立场漂移前后结论矛盾,且无感知摘要或窗口截断丢了关键推理链
上下文污染被无关的中间步骤带偏工具返回内容混入主对话流
越跑越贵单轮延迟和费用持续上涨全量历史无差别重发
指令稀释系统提示词效果越来越弱长上下文挤压了核心指令的权重

对照这个表,如果你的 Agent 中了三条以上,问题大概率出在"记忆架构"而不是模型选型上。我当时就是中了前两条,才决心把"记忆"当成一个正经模块来设计,而不是继续往窗口里塞东西。

1.3 成本的硬约束:全量历史重发是笔糊涂账

很多人忽略了一个事实:多数 Agent 每一轮都要把完整历史重新发给模型。假设每轮对话平均新增 2k token,第 n 轮时单次输入大约 2n k token,整个会话累计发送量是:

∑ 2k × i ,i 从 1 到 n,约等于 k × n(n+1)

按一个粗略的计价模型(输入约 2 元/百万 token)估算:

轮数累计发送 token输入成本(约)
20 轮约 420k不到 1 元
50 轮约 2.55M约 5 元
200 轮约 40.2M约 80 元

这只是单条会话。如果你的 Agent 跑在线上,每天上千条会话,这个成本是指数级膨胀的。更别说 200 轮时单次输入已经 400k token,延迟可能从 1 秒涨到十几秒,用户的耐心早就耗光了。所以"把历史全塞回去"这条路,从工程和体验两个角度看都走不通。

2. 记忆分层工程化:短时、场景与长期记忆

2.1 对话缓冲区:只保留真正"正在处理"的内容

我开始重建时,第一件事是给"上下文"设定边界。现在我的标准做法是:上下文窗口里只放三类东西——系统提示词、当前任务的工作笔记、最近几轮对话。系统提示词负责身份和行为约束;工作笔记是一个由 Agent 自己维护的临时便签,记录"当前目标、已完成步骤、关键结论";最近几轮对话保持对话的连贯感。

窗口大小我一般按 token 预算控制,而不是按轮数。比如预算 8k token,系统提示词固定占 1k,工作笔记最多占 2k,剩下 5k 给最近对话。超出预算时,优先丢弃最旧的普通对话,但工作笔记永远保留,因为那才是"当前任务的核心状态"。

这一步改造之后,调研 Agent 的稳定性立刻回来了。原因很简单:模型的注意力不再需要去垃圾堆里翻线索,关键信息被固定在窗口前部,而且格式统一。这也符合位置偏差的规律——把最重要的东西放在开头,比放在中间可靠得多。

2.2 摘要记忆:让模型帮模型"写日记"

窗口只解决"当下",解决不了"跨会话"和"超长任务"。于是第二层我加了摘要记忆。做法不复杂:每当对话轮次或 token 数超过阈值,就调用一次模型,把历史浓缩成结构化摘要,比如"用户目标、已确认事实、未解决问题、当前风险"。

摘要记忆有个常见坑:摘要本身会丢失细节。用户可能在第 5 轮说过一个对后续很重要的约束条件,而第 6 轮做摘要时模型觉得它"不重要"就没写进去。为了缓解这个问题,我会让摘要过程始终保留一个"待确认清单"节,并且定期把新事实与旧摘要做比对,发现遗漏就补进去。等于让摘要具备"追加"能力,而不是每次从头概括。

2.3 长期记忆:向量库、键值存储与"记忆胶囊"

跨天、跨会话的长期记忆,我采用了混合存储:向量库管语义检索,键值存储管结构化事实,再加上一层"记忆胶囊"机制。

向量库的应用场景是"语义相似"的召回。比如用户之前问过"怎么优化推理延迟",今天又问"响应太慢怎么办",这两句话字面不同但语义相近,靠关键词匹配不到,需要向量检索。这里的关键是 chunk 切分和元数据设计。我通常按语义段落切,每段控制在 200~500 字,并打上时间戳、来源会话、话题标签。检索时,除了向量相似度,还要叠加一个时间衰减因子,让近期的记忆权重更高——否则旧记忆会频繁抢占新记忆。

键值存储负责精确记忆,比如"用户的订单号""偏好设置""之前确认过的参数"。这类信息不适合向量化,直接用键值形式存,按用户 ID 或会话 ID 关联。

"记忆胶囊"是我自己摸索的一个小机制:一条记忆不是简单的一段文本,而是一个结构体,包含内容、类型(事实/偏好/约束/经验)、置信度、访问次数、最后访问时间。Agent 每次写出新记忆时按这个结构提交,读取时也按这个结构解析。这样长期记忆就变成了可查询、可淘汰、可打分的对象,而不是一堆散乱的文本。

2.4 混合策略:摘要兜底 + 检索择优 + 窗口保鲜活

单独用任何一种记忆都不够,我现在的主力方案是三段式:

  1. 对话缓冲区保证"当下"连贯,预算 5~8k token;
  2. 滚动摘要保证"任务中"不丢主线,每 8~12 轮更新一次,预算 1~2k token;
  3. 长期检索保证"跨会话"可回忆,按用户意图实时查询向量库和键值库,只把得分最高的 3~5 条注入上下文。

注入顺序也很重要。我的经验是:系统提示词 → 检索到的长期记忆 → 当前任务摘要 → 最近对话 → 工具结果。检索记忆放在前部容易被模型"当作背景约束"采纳,放在后部则更像"临时参考",权重会低很多。这个顺序问题我调了很久,最后是用一组对照实验才定的,差异比想象中大得多。

3. 工具调用:让 Agent 从"会说"到"会做"

3.1 函数调用的基本盘:一个循环解决的事

记忆解决"记得什么",工具解决"能做什么"。早期的 Agent 只能生成文本,现在的模型普遍支持函数调用(Function Calling),也就是在正常回复之外,模型可以输出一个结构化的"我要调用某个工具"的请求,由运行环境真正执行,再把结果回填给模型。

整个过程是一个循环:

messages = [system_prompt] while True: response = chat(messages, tools=tool_schemas) # 模型要求调用工具 if response.tool_calls: for call in response.tool_calls: result = execute(call.name, call.arguments) messages.append({ "role": "tool", "tool_call_id": call.id, "content": result }) continue # 否则就是最终回答 answer = response.content break

工具的定义格式一般是 JSON Schema,关键是写清楚参数。举个我常用的订单查询工具:

{ "name": "query_order", "description": "按订单号查询订单详情,返回订单状态、金额和物流信息", "parameters": { "type": "object", "properties": { "order_id": { "type": "string", "description": "订单号,格式为 8 位数字" }, "include_detail": { "type": "boolean", "description": "是否返回商品明细,默认 false" } }, "required": ["order_id"] } }

这个 JSON 的小细节往往决定成败。description 不是写给用户看的,是写给模型看的。写得含糊,模型就不知道什么场景该调这个工具;参数说明写得细,模型的调用准确率会明显上升。

3.2 工具一多就乱:选错、漏参、结果爆炸

工具少的时候什么都好说,一旦超过十个,问题就来了。

第一个问题是工具选择漂移。模型可能拿订单工具去查库存,或者在多个相似工具之间反复横跳。我的对策是给工具做分组命名,并在 description 里写清楚"什么时候用、什么时候千万别用"。比如:"本工具仅用于已下单用户的订单查询;如果要查询商品库存,请调用另一个工具 inventory_query,切勿混用。"

第二个问题是漏参数和幻觉参数。模型可能漏填必填字段,或者编一个不存在的订单号。我的做法是两层防御:外层在工具执行前做参数校验,内层在工具返回错误信息时,把错误原因结构化地回传,引导模型修正调用,而不是直接中断。

第三个问题最容易被新手忽略:工具返回值太长。一次数据库查询可能返回几百行 JSON,全量塞回上下文,既浪费 token,又污染后续决策。我现在会给工具返回值加长度上限,默认只返回摘要或前 N 条;需要完整数据时,再提供一个"分页获取"工具让模型按需拉取。这一步做完,我的 Agent 单轮 token 消耗平均降了 40% 左右。

4. MCP:把工具访问变成标准插座

4.1 为什么需要 MCP:每个工具一套私有协议的噩梦

函数调用解决了"模型能触发工具",却留下一个工程问题:每个工具都要写一套独立的接入代码。文件系统一套、数据库一套、外部服务又一套。每接一个新工具,就要为它写认证、写参数转换、写错误处理;每换一个模型供应商,工具调用的格式可能又不一样,全得重来一遍。

更麻烦的是安全边界。早期我图省事,直接给 Agent 挂了一个"能执行任意 Python 代码"的工具,结果模型有一次为了完成任务,自己 pip 安装了一个依赖包,虽然没出大事,但把环境搞得一团糟。这种"全权限工具"完全不可控。

MCP 就是冲着这两个痛点来的。它是一个开放协议,把"工具提供方"和"工具消费方"解耦:工具提供方实现一个标准接口,任何支持 MCP 的 Agent 都能直接使用,不需要知道对方内部是怎么实现的。你可以把它理解成工具世界的 USB-C 接口——以前每个设备都要专门的充电线,现在一条线通吃。

4.2 MCP 的核心零件:Host、Client、Server 与三类原语

MCP 的体系里常见四个角色:

角色作用类比
Host用户直接面对的 Agent 应用你的电脑系统
ClientHost 与 Server 之间的连接器,每个 Server 一个连接USB 控制器
Server提供工具、数据等能力的程序外接设备
Transport连接方式,常见 stdio 和 HTTP线缆

Server 向外界提供三类原语:Tools(可执行动作,比如查订单、发邮件)、Resources(可读取数据,比如某个配置文件、某份文档)、Prompts(可复用的提示词模板)。对多数 Agent 场景,最常用的是 Tools。

运行时走的是 JSON-RPC 风格的消息:客户端发 initialize 握手,然后发 tools/list 获取工具清单,再发 tools/call 执行具体工具。协议本身不绑定语言,Python、TypeScript、Java 都有对应的 SDK 实现。

4.3 一个 MCP Server 从零到通的落地片段

写一个最简 MCP Server 的骨架大概长这样(示意代码,具体 SDK 用法以官方文档为准):

# 示意:MCP Server 最小骨架 from mcp import server, types # 伪代码风格,示意结构 app = server.Server("order-server") @app.tool( name="query_order", description="按订单号查询详情", schema={ "type": "object", "properties": { "order_id": {"type": "string"} }, "required": ["order_id"] } ) def query_order(order_id: str) -> str: # 这里是真实业务逻辑 row = database.fetch_one(order_id) return row.to_json() # stdio 方式启动,Agent 子进程直接和它通信 app.run(transport="stdio")

对应的客户端接入也很简单:通过 MCP Client 连接后,调用list_tools()拿工具清单,代转换格式后交给模型做函数调用;模型决定调用某个工具时,再通过call_tool()转发。

这套结构带来的好处是:工具与 Agent 完全解耦。我后来把常用的工具全部封装成独立 Server,新开一个项目时,只需要在配置里声明要连哪些 Server,代码一行不用改。团队协作也轻松了,负责数据库的人只维护数据库 Server,负责邮件的人只维护邮件 Server,互不干扰。

5. 记忆与工具协同:一个可落地的 Agent 参考骨架

5.1 骨架总览:四个模块,一个循环

把记忆和工具串起来之后,我现在的 Agent 骨架长这样:

  • 编排层(Orchestrator):控制主循环,决定这一步是调用模型、调用工具,还是先做记忆检索。
  • 记忆管理器(Memory Manager):对外提供"写入记忆""检索记忆""更新摘要"三个接口,内部接向量库、键值库和摘要模块。
  • 上下文构造器(Context Builder):按固定顺序把系统提示词、检索记忆、摘要、最近对话、工具结果组装成最终输入。
  • 工具路由器(Tool Router):连接各 MCP Server,负责工具清单聚合、参数校验、超时控制和结果预处理。

每一轮的处理流程是:先看当前任务是否需要新信息 → 需要就从记忆管理器检索 → 上下文构造器组装 → 模型生成 → 如果要调工具,路由到对应 MCP Server → 工具结果经过清洗和截断,回填上下文 → 继续下一轮。

5.2 上下文预算怎么分:我的默认配比

上下文预算分配是整个骨架里最容易忽略又最关键的一环。我的默认配比供你参考(总量按 16k 模型设计):

组件预算
系统提示词1.5k token
检索到的长期记忆1k token(约 3~5 条)
当前任务摘要1.5k token
最近对话8k token
工具结果与工作笔记3k token
预留余量1k token

这个配比不是死的。工具密集型任务,我会把"工具结果"的预算调高;长对话型任务,则把"最近对话"调高。关键是心里有这个预算意识,而不是让内容无节制地堆积。

5.3 最小可用实现:先跑通,再优化

如果你现在手上还没有成熟代码,我的建议是先用最简版本跑通全链路,再逐层加东西。最简版本只需要三样东西:一个支持函数调用的模型接口、三个工具(比如搜索、读文件、写文件)、一个简单的滚动窗口记忆。先确认"记忆 + 工具"的循环能稳定运行,然后按文章前面的顺序逐步加摘要、加向量库、接 MCP。

伪代码级别的流程大概是:

while task_not_finished: memory = memory_manager.retrieve(task, top_k=5) context = context_builder.build(system, memory, summary, history) response = model.generate(context, tools=all_tools) if response.requires_tool: result = tool_router.call(response.tool_call) result = tool_router.clean(result) # 截断、格式化 history.append(result) continue answer = response.text memory_manager.write_episode(task, answer) # 沉淀记忆 break

这个骨架看起来简单,但每一行背后都对应前面提到的一个工程决策:记忆怎么写入、检索结果怎么过滤、工具结果怎么清洗。能把这几件事都理顺,Agent 的可用性会有质的提升。

6. 实战复盘:踩过的坑与我现在会规避的事

6.1 记忆污染比没有记忆更可怕

我最早给 Agent 加记忆时,踩的最大的坑是"乱检索"。有次用户问一个技术问题,向量库把一篇不太相关但语义沾边的历史文章召回来了,模型把它当成权威依据,一本正经地给出了错误答案。那次之后我加了三个约束:检索必须有最低相似度阈值,低于阈值宁可返回空;检索结果要按时间衰减排序,避免陈旧记忆霸榜;注入上下文时明确标注"以下是可能相关的历史记忆,仅供参考,不要作为唯一依据"。从此误召回对输出的干扰大幅下降。

6.2 工具结果的"二次污染"

工具调用本身也会污染上下文。早期我把工具完整输出直接塞回对话,结果模型被一堆日志、调试信息带偏,开始一本正经地分析无关字段。后来我养成了一个习惯:工具返回必须先经过"清洁层"——提取关键字段、截断超长内容、必要时让模型对结果做一次预总结,再进入主上下文。这等于在工具和模型之间加了一道"编辑审核",效果立竿见影。

6.3 MCP Server 的假死与超时

MCP 用上手之后,新的麻烦是稳定性。stdio 方式的 Server 是子进程,偶尔会悄悄挂掉;HTTP 方式的 Server 则可能超时。如果客户端没做超时和重连机制,Agent 会卡死在那里,用户看到的就是"转圈圈"。我现在所有的工具调用都强制设置超时(默认 10 秒),超时后返回结构化错误给模型,让模型决定是重试还是换方案;同时对 stdio 子进程做心跳检测,检测到异常自动拉起新进程。这套机制上线后,工具类故障导致的失败率降了一个数量级。

6.4 我现在的几个日常习惯

复盘这大半年,有几个习惯是我现在坚持的,也一并分享给你。

每轮关键的上下文组装,我都会留一份日志,记录"当前窗口里到底放了什么、检索了哪些记忆、调了哪些工具"。这个小动作在排查"模型为什么答歪"时价值极大,没有日志,你只能猜。

新改动上线前,我会准备一组固定的测试场景,比如"长对话后是否保持立场一致""工具调用失败后是否能优雅降级""新会话能否回忆起昨天的关键信息"。每次改完跑一遍,比临时抱佛脚靠谱得多。

最后是版本固定。MCP 协议还在快速迭代,SDK 版本、Server 版本我都会锁死,避免"昨天还能用,今天突然报错"的玄学问题。

做 Agent 这件事,很多人一开始都盯着模型有多强,实际跑起来才发现,真正决定上限的是记忆怎么管、工具怎么接。从上下文窗口到 MCP,这条路径我走了一年多,弯路没少走,但每个坑都值得踩一遍——踩过之后,你才会明白"Agent 的智能不在模型里,而在系统设计里"这句话到底是什么意思。

RELATED READING

延伸阅读

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