MCP上下文爆仓?按需加载与工具瘦身,让Token开销直降一半
如果你最近在搞 LLM 应用八成也被 MCP 这个东西惊艳过又被它烦过。MCP 把外部工具接入模型的姿势确实优雅代码查询、文档检索、数据库操作、定时任务只要挂一个 server模型就能直接调用外部能力。但用着用着就发现一个尴尬的问题Prompt 还没开始干活光是一堆工具定义就把上下文窗口撑掉一大截。最夸张的时候接入的 server 一多、工具一多模型还没正经回答几句话上下文已经没了一半。这篇文章就来聊聊我是怎么解决这个问题的。核心思路是把“全量工具定义常驻上下文”改成“按需加载 瘦身描述”再加一层本地网关统一管控。整个过程并不复杂但踩了不少坑我把完整方案、关键代码、配置参数和避坑经验都整理在下面希望能帮你省下时间和 token。1. MCP 为什么会成为“上下文吞金兽”1.1 工具注册机制决定了它天生就费 tokenMCP 的工作方式简单说就是服务端启动后会向客户端暴露一份工具清单清单里包含工具名称、描述、输入输出 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 工具定义占比畸高的问题就被放大了有时候你甚至想不起来自己接了多少个 servertoken 就快没了。我见过不少团队因为这个问题直接把 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 并不是天生就“吃上下文”而是传统的接入方式太粗糙。加一层按需加载的抽象既保住了协议带来的生态便利又在资源开销上恢复了掌控感。如果你也被这个问题困扰希望这篇分享能帮你少走几步弯路。