如果你最近在搞 LLM 应用,八成也被 MCP 这个东西惊艳过,又被它烦过。MCP 把外部工具接入模型的姿势确实优雅,代码查询、文档检索、数据库操作、定时任务,只要挂一个 server,模型就能直接调用外部能力。但用着用着就发现一个尴尬的问题:Prompt 还没开始干活,光是一堆工具定义就把上下文窗口撑掉一大截。最夸张的时候,接入的 server 一多、工具一多,模型还没正经回答几句话,上下文已经没了一半。
这篇文章就来聊聊我是怎么解决这个问题的。核心思路是把“全量工具定义常驻上下文”改成“按需加载 + 瘦身描述”,再加一层本地网关统一管控。整个过程并不复杂,但踩了不少坑,我把完整方案、关键代码、配置参数和避坑经验都整理在下面,希望能帮你省下时间和 token。
1. MCP 为什么会成为“上下文吞金兽”
1.1 工具注册机制决定了它天生就费 token
MCP 的工作方式,简单说就是:服务端启动后会向客户端暴露一份工具清单,清单里包含工具名称、描述、输入输出 schema。模型在对话时能看到这份清单,才知道自己“有哪些工具可以用、每个工具需要传什么参数”。
问题就出在“能看到”这三个字上。主流实现里,客户端拿到这份清单后,会把工具的元数据直接拼进 system prompt,并且是所有的工具全量注入。哪怕某次对话根本用不上数据库查询,只要这个 server 挂了数据库工具,那套 SQL 相关的 schema 和枚举值定义就会一直占着上下文。
我之前在模拟项目 X 里做技术验证时,先是接了一个代码检索 server,又接了一个内部文档 server,再加一个数据库查询 server,三个服务加起来有一百多个工具。模型每次对话开始时,光解析工具清单就要花掉好几秒钟的“思维预算”,很多简单问题还没处理,上下文就已经被塞得满满当当。
1.2 上下文账单到底花在哪里
要解决问题,得先知道钱花在哪。MCP 上下文的开销大头主要有四块:
- 工具名称和描述。每个工具都会有一段自然语言描述,本意是让模型理解工具用途,但很多服务为了实现“智能路由”,把描述写得比段落还长。一百个工具,每个 200 token 的描述,就是两万 token。
- 参数 schema。输入参数的类型定义、枚举值、必填项说明。这玩意儿最占地方,尤其是带 enum 的参数,一个字段可能就列出几十个可选项。
- 服务端信息。server 本身的说明、版本信息、授权信息、返回格式约定,这些也会被注入 prompt,日积月累也不是小数目。
- 工具结果的残留。每次工具调用后的返回结果会被附加到上下文里,如果返回结果体积大,几轮对话后历史就极速膨胀。
从数据上看,我做过一次统计:在只挂一个中等规模 server 的情况下,工具定义就占了上下文总预算的 15% 左右。挂三四个 server,这个比例能到 40%–50%。要是工具数量多、描述写得随意,吃掉一大半上下文真的不是夸张。
1.3 为什么这个问题容易被忽视
一开始大家往往会觉得“上下文窗口那么大,浪费一点无所谓”。但实际上,上下文预算不只是“够不够用”的问题,它直接影响模型的理解质量和响应速度。上下文越长,注意力计算负担越重,模型更容易忽略藏在长文本里的关键信息。很多时候模型“忘了”某个工具存在,不是它能力不行,而是那堆工具定义已经把真正有用的信息给淹没了。
所以“省上下文”不只是为了省钱,更是为了保证模型在最需要的时候能注意到关键工具。这也是后来我下定决心做整改的根本原因。
2. 哪些场景最容易触发“上下文爆仓”
2.1 多 server 同时挂载
这是最典型的场景。一个成熟的 Agent 应用,往往要同时接代码库、知识库、数据库、告警系统、工单系统好几个 MCP server。每个 server 都有自己的工具集,模型并不知道这次对话会用到哪些,客户端也无从判断,只能一股脑全部塞进去。
我在某次内部讨论时见过一个更极端的例子:接入十几个 server,工具总数超过四百个,手一抖把上下文窗口选了个中等档位,结果光是工具清单就占掉 60% 的预算,后面每轮对话都显得臃肿又迟钝。
2.2 工具数量多但实际命中少
即便只有一个 server,工具数量也可能非常多。比如一个“系统运维助手”server,光是日志查询、进程操作、磁盘检查、服务启停这些方向就能拆出几十个工具。但在一次具体的排障对话里,模型大概率只需要用到其中两三个。
现实就是:绝大多数工具常年躺在 prompt 里“吃空饷”,只有在极少数场景才被调用。这种长尾分布让上下文利用率极低。
2.3 长上下文叠加多轮对话
还有一类场景是被时间“熬”出来的。单轮对话工具清单占 20% 不算什么,但多轮对话后,工具返回结果和历史消息一起累积,每轮都新增几万 token,上下文很快就到了临界点。尤其在做长任务拆解时,前面几步的工具调用结果还没清理,后面几步又要加载新工具的开销。
2.4 小上下文窗口的应用不敢放开用
不是所有应用都用得上超大上下文窗口。很多端侧应用、在线推理服务的上下文窗口只有 32K 甚至更小。在这种受限环境下,MCP 工具定义占比畸高的问题就被放大了,有时候你甚至想不起来自己接了多少个 server,token 就快没了。
我见过不少团队因为这个问题直接把 MCP 砍掉了,退回手写工具调用逻辑。这其实有点可惜,因为 MCP 的协议价值和生态优势是实打实的,问题只是实现方式不够精细。
3. 解决思路:三条路线怎么选
3.1 方向一:工具裁剪,只保留核心集
最直觉的做法是砍掉不常用的工具。在 server 配置里只暴露少量核心工具,让工具清单保持精简。
这个方案的好处是简单、直接、见效快。坏处也很明显:灵活性差。业务需求一变,要接新工具就得改配置重新部署;某个长尾场景突然要用到平时不常用的工具,模型就会一脸茫然地告诉你“没有这个能力”,实际上只是你没给它看到。
3.2 方向二:压缩 schema,把描述写短
另一个思路是对工具的元数据做瘦身。默认生成的 JSON Schema 往往包含大量冗余字段,比如每个字段都带详细描述、所有枚举值都展开写。把这些精简掉,上下文占比能降不少。
但只做压缩不够,因为只要工具是全量注入的,数量一多,基数还是很大。压缩能省 30%,省不掉那 50% 的固定开销。
3.3 方向三:按需加载,让模型先看到“目录”而不是“全书”
最终我选择的是“动态按需加载”方案。核心想法很简单:不要在初始 prompt 里把几百个工具的完整 schema 全部塞给模型,只给模型一个“目录”,也就是每个工具的名称和一句话摘要。模型看到目录后,判断出需要用哪个工具,再通过一个专门的动作去“展开”那个工具的完整 schema。
类比一下:以前相当于把一本几百页的工具手册直接扔给模型,让它从头到尾读一遍再干活;现在只给它看目录和简介,它想看某一章的时候,再单独翻到那一页。这对上下文预算的节省是数量级的。
3.4 方案对比与最终选择
| 方案 | 节省效果 | 灵活性 | 实现成本 | 风险 |
|---|---|---|---|---|
| 工具裁剪 | 中高 | 低 | 低 | 长尾场景能力缺失 |
| 描述压缩 | 中 | 中 | 中 | 工具理解不充分,误用 |
| 按需加载 | 高 | 高 | 高 | 多一次交互,需处理懒加载延迟 |
我在模拟项目 X 里最终采用了按需加载为主、压缩 schema 为辅的组合方案。按需加载解决“数量多”的膨胀问题,schema 瘦身解决“单个工具太重”的问题,两条路同时走,效果最明显。
4. 落地实现:按需加载的完整改造过程
4.1 整体架构:加一层轻量网关
要支持按需加载,不能直接拿现成的 MCP 客户端去连 server,需要在中间加一层代理,也就是本地网关。这个网关做的事情很简单:启动时从各个 MCP server 拉取完整工具清单,然后自己只保留“工具名 + 一句话摘要”的索引;当模型决定使用某个工具时,网关再去对应的 server 拉取完整的工具 schema,并临时注入到当前会话里。
架构变成这样:
- 模型应用 ←→ 本地网关 ←→ 多个 MCP server
- 网关维护一个工具索引,索引里只有轻量的摘要信息
- 模型需要某个工具时,通过一个固定入口展开它
这层网关在有客户端封装的情况下做起来成本不高。我们内部实现时,直接基于标准协议封装了一个共享库,对外暴露接口基本保持兼容,改动集中在系统内部的调用链路上。
4.2 关键实现:目录式工具暴露
启动阶段,网关把每个 MCP server 返回的工具清单处理成如下结构:
{ "tools": [ { "name": "query_user_order", "summary": "查询用户订单列表,支持状态筛选", "source": "order_server" }, { "name": "get_user_profile", "summary": "获取用户基本资料", "source": "user_server" } ] }这里 summary 是整个方案的关键。它要足够短,最好控制在一句话以内,但又要足够准确,让模型能判断“这个工具是否满足当前需求”。我在写摘要时有一条经验:把“这个工具用于”之类的废话去掉,直接说功能和关键限制,比如“查询订单,返回金额和状态”“修改配置,需管理员权限”。
模型侧的提示词里只出现这一份轻量目录。当模型判断某个工具可用时,它会调用一个专门的加载动作,字段类似这样:
{ "load_tool": "query_user_order" }网关收到这个请求后,再去真实的 server 拉取完整 schema,把工具的完整描述注入当前这一轮的系统提示词里,随后模型就可以像以前一样直接调用该工具。
4.3 二次瘦身:完整 schema 的精简模板
按需加载解决“数量”问题,但每次展开工具时,schema 本身的体积还是值得优化。大部分自动生成的 schema 有很多值得压缩的地方。
第一,去掉多余的 description。工具的描述只要保留能帮助模型理解用途的最短长度,那种“本工具用于处理用户提交的订单信息查询请求,支持按照订单状态字段进行过滤,状态字段的取值包括未支付、已支付、已取消等”可以直接压成“按订单状态筛查询订单列表”。
第二,枚举值可以按需保留。有的工具参数枚举值几十个,但实际常用的就几个。如果枚举值是稳定的,可以只列高频值,加一句“其他取值请联系管理员”之类的话。当然这个要看具体场景,如果模型需要枚举值的完整语义来做判断,就不能省。
第三,去掉服务端元数据。很多 server 返回的 schema 里带了服务版本、认证信息、调用说明,这些对模型理解工具没有帮助,网关在转发给模型之前可以提前清洗掉。
经过这套处理,一个原来需要 800 token 的工具定义,能压到 200 token 左右。配合按需加载,整体上下文开销降幅非常可观。
4.4 会话级工具缓存与生命周期管理
按需加载如果每次都要重新拉取 schema,响应延迟会很高。我后来加了一个会话级缓存:同一个会话里,某个工具的完整 schema 一旦加载过,后续调用直接复用,不用再次展开。
这个缓存的过期策略值得注意。我的做法是:工具 schema 在单个会话内长期有效;跨会话可以共享一份最近使用的 LRU 缓存,但每条记录有过期时间,避免服务端工具更新后模型还在用旧 schema。
在并发场景下,多个会话同时请求加载同一个工具,网关要做并发合并,只向真实 server 发一次请求,然后广播给所有等待的会话,避免“惊群效应”打爆服务端。
4.5 关键代码思路示意
这里用一段简化逻辑展示网关的核心流程,方便你对照实现:
class ToolGateway: def __init__(self): self.index = {} # 工具名 -> 摘要 self.schemas = {} # 工具名 -> 完整schema self.session_cache = {} # session_id -> {tool_name: schema} async def start(self, servers): for server in servers: tools = await server.list_tools() for tool in tools: self.index[tool.name] = { "summary": self._short_summary(tool), "source": server.name } async def load_tool(self, session_id, tool_name): if tool_name in self.session_cache.get(session_id, {}): return self.session_cache[session_id][tool_name] server = self.index[tool_name]["source"] full_schema = await self._fetch_full_schema(server, tool_name) slim_schema = self._slim_schema(full_schema) self.session_cache.setdefault(session_id, {})[tool_name] = slim_schema return slim_schema def _slim_schema(self, schema): # 只保留关键字段,去掉冗长描述和多余元数据 return { "name": schema["name"], "description": self._short_summary(schema), "input_schema": self._compress_params(schema["input_schema"]) }实际的实现当然还要处理错误重试、日志追踪、服务端不可用等异常逻辑,但骨架大致就是上面这个样子。这个方案跟我们内部原来直接全量注入的做法相比,模型可见的上下文占用从“所有工具的总和”变成了“整个目录 + 本次用到的少数几个工具的完整定义”,在工具规模大的场景里几乎是一劳永逸。
4.6 动态工具发现与灰度发布
顺带提一点额外好处:按需加载之后,“新增工具”变得非常平滑。以前全量注入模式下,每新增一个工具,全体会话的上下文开销立刻上涨;现在只是索引里多一两条摘要,模型只在用得到的时候才付出完整 schema 的代价。
我们内部现在可以把新工具先以“影子模式”发布,也就是只在索引里出现,但如果模型尝试加载时报错“工具未启用”,不会影响正常功能。等确认无误后再正式放开。这比直接在线上改 MCP server 配置安全得多。
5. 效果验证:上下文占用到底降了多少
5.1 同一套任务下的对比
改造完成后,我拿同一批测试用例跑了一遍对比。测试环境是模拟项目 X 的接入方案,挂三个 MCP server,工具总数一百出头。任务包含代码搜索、数据库查询、文档读取等多轮调用。
| 对比项 | 改造前(全量注入) | 改造后(按需加载 + 瘦身) |
|---|---|---|
| 初始上下文占用 | 约 28000 token | 约 6000 token |
| 一次简单工具调用的上下文峰值 | 约 38000 token | 约 12000 token |
| 多轮任务完成后的总消耗 | 约 86000 token | 约 49000 token |
| 单轮平均响应时间 | 约 4.2s | 约 3.1s |
初始上下文的削减幅度超过四倍,多轮任务的整体消耗也省了接近一半。这里节省的主要就是那些“只被目录标记、却从未被展开”的工具定义。
5.2 对模型行为的影响
节省只是一方面,模型的理解质量也有提升。工具摘要变短后,模型反而更容易记住上下文里有哪些可用能力,误用工具的频次明显下降。之前我会遇到模型把“查询用户订单”和“创建退款单”搞混的情况,精简描述之后,这类混淆少了很多。
这个现象背后的逻辑不难理解:信息过载会让注意力被稀释。把真正有用的工具定义单独展开时,模型是在一个聚焦的上下文片段里“认识”这个工具,理解效果自然更好。
5.3 成本账也要算一下
上下文消耗下降直接对应成本下降。如果按单位 token 计费,长多轮对话的 token 消耗要是能省 40%–50%,那对一个高频使用的内部系统来说,月度成本能差出不少。我见过有些团队因为 MCP 吃 token 的问题把工具调用方案砍了,但如果能用按需加载解决掉这个痛点,留下的生态优势显然更有价值。
6. 踩坑记录与排查经验
6.1 摘要写不好,模型就“找不到”工具
第一个坑就是 summary 写得太泛。最开始我图省事,直接用工具原来的长描述截断成一句,结果信息残缺,模型经常判断不出某个工具到底该不该用,有时候还会把一个工具误当成另一个。
后来我把摘要的写法规范成:动作 + 对象 + 关键限制。比如“查询用户订单列表,支持按支付状态筛选,返回订单金额和状态”,这就比“订单信息查询服务”有效得多。摘要里尽量不要出现“可能”“或许”这种模糊词,模型做判断时更喜欢明确的语义。
6.2 缓存导致的工具信息过期
第二坑是 schema 缓存引发的“幽灵工具”。有个 server 调整了工具参数结构,但网关的会话缓存里还存着旧 schema,导致模型按旧参数构造请求,连续报错。
排查过程比较折腾,一度以为是模型理解不到位,后来才发现是缓存没失效。最终我给缓存加了两层保护:一是设置较短的有效期,二是服务端工具更新时主动通知网关清掉对应记录。目前跑下来稳定很多。
6.3 并发加载造成服务端压力
改造刚上线时,多个会话同一时刻请求加载同一个工具,网关直接把请求打向了后端的 server,结果服务端短时间内收到一堆重复请求。虽不致命,但浪费资源。
解决方式就是前面说的并发合并。可以把网关收到加载请求后的处理做成带锁的单例逻辑:第一个请求真正去拉 schema,其余请求在门口等同一批结果。这个处理对整体稳定性帮助很大。
6.4 与旧版客户端的兼容性
还有一类问题出在对接层。部分旧版 MCP 客户端并不理解“目录 + 按需加载”这种交互方式,它们总期望启动后立刻拿到完整工具清单,否则就认为服务不可用。
我的处理是在网关里做能力协商:识别到老客户端时,自动降级为全量注入模式,同时打印告警日志。新客户端则走按需加载。降级方案虽然牺牲了部分收益,但保证了老系统不炸,改造可以平滑切换。
6.5 上下文预算的兜底策略
最后再补一个兜底策略:即使做了按需加载,长对话中工具返回结果和历史消息还是会累积。我建议在网关或应用侧做上下文预算监控,当累计 token 接近阈值时,自动把最老的工具返回结果压缩成一句摘要,或者直接截断丢弃。
这在实现上不复杂,但能有效防止“慢性的上下文膨胀”慢慢侵蚀窗口。我的经验是:做上下文治理要有预算概念,就像花钱一样,先给各个模块划比例,再动态调整。
6.6 常见问题速查
| 问题 | 可能原因 | 解决办法 |
|---|---|---|
| 模型说找不到工具 | 摘要太模糊,或索引未更新 | 优化摘要写法,检查索引刷新逻辑 |
| 工具参数报错 | schema 缓存过期 | 清理会话缓存,增加更新通知 |
| 下拉工具后响应变慢 | 缺少并发合并,重复拉取 | 网关加锁做请求合并 |
| 老客户端连不上 | 不支持按需加载协议 | 实现能力协商,优雅降级 |
| 长对话中途上下文飙升 | 工具返回结果累积 | 加预算监控,压缩历史结果 |
最后分享几个实用的调优建议
如果你也准备动手改造,我建议按这个顺序来:先量化自己当前的上下文占用,别凭感觉判断“吃得多还是少”;再决定是否引入网关层,按需加载虽然香,但如果只有一两个 server、工具数量也不多,全量注入的维护成本反而更低;如果确实要做,先把摘要规范定好,这是收益最大的部分。
有一个小技巧值得单独说:别忘了对工具返回结果做压缩。很多情况下上下文膨胀的根源不是工具定义,而是工具返回了大段 JSON。我通常在网关口对返回结果做字段裁剪,只保留模型真正需要判断的字段,这个操作省下的 token 甚至比工具定义瘦身还多。
整体做下来,最大的感受是:MCP 并不是天生就“吃上下文”,而是传统的接入方式太粗糙。加一层按需加载的抽象,既保住了协议带来的生态便利,又在资源开销上恢复了掌控感。如果你也被这个问题困扰,希望这篇分享能帮你少走几步弯路。