← 回總覽

MCP 响应 Token 膨胀:从度量、精简到代理裁剪的完整治理方案

📅 2026-06-26 09:00 前端早读课 人工智能 23 分鐘 27999 字 評分: 85
MCP 协议 AI Agent LLM Token 成本 上下文工程
📌 一句话摘要 本文系统介绍 MCP 响应 Token 膨胀问题的度量、精简与代理裁剪三层治理方案,附带可复现的测量脚本和轻量代理实现。 📝 详细摘要 文章针对 MCP 调用返回数十万 Token 撑爆上下文窗口的痛点,从度量入手逐步拆解三层防线:首先通过测量脚本(measure.py)量化每个 MCP 服务器的 tools/list 菜单税和工具响应 Token 开销,发现 GitHub 官方 MCP 的 schema 税高达实际工具响应的 3 倍;然后提出三种缩减方法——挂载精简(卸载不需要的服务器或隐藏多余工具)、源头限制(利用原生分页/格式参数)、以及构建 Token 预算代理。代

Title: 【第 3724 期】MCP 响应 Token 膨胀:从度量、精简到代理裁剪的完整治理方案 | BestBlogs.dev

URL Source: https://www.bestblogs.dev/article/944d1c5a?amp%3Butm_medium=feed&%3Butm_campaign=resources&%3Bentry=rss_article_item

Published Time: 2026-06-26 09:00:00

Markdown Content: Prithwish 2026-06-26 09:00 日本

!Image 1

一次 MCP 调用吐出 27 万 Token,撑爆上下文窗口。本文从度量入手,逐步拆解菜单税精简、原生限流与预算代理裁剪三层防线。

前言

一次 MCP 调用吐出 27 万 Token,撑爆上下文窗口。本文从度量入手,逐步拆解菜单税精简、原生限流与预算代理裁剪三层防线。

今日前端早读课文章由 @Prithwish Nath 分享,@飘飘编译。

译文从这开始~~

> 如何衡量每个工具的 MCP Token 开销、削减 "菜单税",并为任何 MCP 服务器设置硬性预算 —— 额外延迟不到 2%。

#### 简短版

一次 MCP 调用就能返回数十万个 Token,导致直接被你的调用框架拒之门外。本文将介绍如何衡量 Token 开销、在原生支持限流的场景下使用内置 Token 限制器,以及 —— 在不支持的场景下 —— 在任何 MCP 服务器前面放一个轻量级 Token 预算代理,让你的智能体不再被自己的工具撑死。

前阵子我在 Claude Code 里接入了 Bright Data 的 MCP,让它抓取一个文档页面,然后眼睁睁看着它壮烈翻车:

> Error: MCP tool "scrape_as_markdown" response (278649 tokens) exceeds maximum allowed tokens (25000). Please use pagination, filtering, or limit parameters to reduce the response size.

LLM 本身还没来得及读一行内容、形成一个想法、做任何我真正要求的工作,278k 个 Token 就已经消耗殆尽了。这已经超过大多数 LLM 的上下文窗口容量!Claude Code 实际上会拒绝所有超过 25k Token 的内容(可通过MAX_MCP_OUTPUT_TOKENS配置),但有些客户端根本没有设置明确的上限 —— 它们任由上下文被填满,直到输出质量开始恶化(这其实更糟)。

所以让我们真正来解决这个问题。我会展示如何统计每个 MCP 服务器究竟消耗了多少 Token,然后介绍一些零成本的快速修复方案,最后,给出一个与客户端无关的小型代理,你可以把它放在任何 MCP 服务器前面,在 LLM 看到响应之前先把 MCP 的负载压缩下来。

#### 为什么 MCP 的响应如此庞大?

MCP 响应之所以庞大,是因为服务端的设计逻辑是追求保真度而非节省开销 —— 它们会返回完整的页面、文件或 JSON 数据块,因为它们无从得知你真正需要的是哪一小部分。还有一项隐性成本,即tools/list的 "菜单税":每一轮对话都会把所有已挂载工具的 schema 注入上下文。

> 💡 MCP 服务器本质上就是面向 LLM 的 API,但响应仍然必须塞进一个有硬性容量限制的缓冲区(即上下文窗口)。我们的 Token 预算代理就像一个阻抗匹配器,横亘在这两个世界之间 —— 将调用转发到上游服务器,再将响应裁剪后才交给模型。

这个问题在三个维度上层层叠加:

* 1、有效负载本身。单次返回结果就可能极其庞大,与领域无关。Bright Data MCP 的scrape_as_markdown抓取一个长 wiki 页面或 API 文档,轻松达到 10 万至 15 万 + 个 Token;GitHub MCP 的get_file_contents/get_pull_request_diff会返回整个文件或完整 diff;Playwright MCP 的browser_snapshot则会倾泻出整棵无障碍访问树。 2、结果的数量。枚举集合的调用会将每条记录的开销无上限地累乘 —— 例如 GitHub 的search_codelist_端点,每页返回 100 条完整水合的条目。

* 3、tools/list的开销。大多数人不知道,使用 MCP 时,每个已连接服务器的每个工具 schema 都会在任何工具运行之前注入到上下文中 —— 这笔 "菜单税" 因服务器而异,差距悬殊。

!Image 2

官方 GitHub MCP 在这方面尤为离谱 —— 它的 schema 税几乎是实际工具响应大小的 3 倍。

#### 大型 MCP 响应究竟要付出了什么代价?

代价是双重的:一是每次 API 调用的真金白银,二是上下文窗口的宝贵容量 —— 而这一切发生在 LLM 还没来得及看到任何数据之前。

为了让大家有直观感受,以下是我用 Bright Data MCP 的scrape_as_markdown工具抓取不同页面的结果,已从 Token 数换算为美元成本(按 Sonnet 定价计算):

!Image 3

注意颜色的区分。蓝色(博客文章)是唯一低于 Claude Code 25k 上限的条柱。其余所有内容 —— 哪怕是 "小小的" 商品页面抓取 —— 都已经超出预算了。

#### 如何衡量 MCP 的 Token 开销

衡量方法是编写一个测试工具,对每个服务器调用tools/list以及一个具有代表性的工具,然后用 tiktoken(cl100k_base)统计 Token 数。对每个服务器分别运行,就能看到每轮对话的 schema 税和每次调用的负载大小 —— 这是驱动总 MCP 开销的两个独立吞金口。 measure.py # 衡量 1) tools/list 的 schema 税 # 以及 2) 每个服务器一次代表性调用的 Token 数 import asyncio, json, os import tiktoken from mcp import ClientSession, StdioServerParameters, types from mcp.client.stdio import stdio_client ENC = tiktoken.get_encoding("cl100k_base")  # 用于预算估算已经足够接近 def ntokens(text: str) -> int:     return len(ENC.encode(text)) def all_text(res: types.CallToolResult) -> str:     """统计每个文本块——包括 TextContent 和 EmbeddedResource(GitHub 文件读取)。"""     parts = []     for c in res.content:         if isinstance(c, types.TextContent):             parts.append(c.text or "")         elif isinstance(c, types.EmbeddedResource):             t = getattr(c.resource, "text", None)             if t:                 parts.append(t)     return "".join(parts) AMAZON_SERP = "https://www.amazon.com/s?k=wireless+earbuds" SERVERS = [     # 在此添加你自己的服务器     ("Bright Data rapid", StdioServerParameters(         command="npx", args=["-y", "@brightdata/mcp"],         env={os.environ, "API_TOKEN": "<your-bright-data-token>"},     )),     ("Bright Data PRO", StdioServerParameters(         command="npx", args=["-y", "@brightdata/mcp"],         env={os.environ, "API_TOKEN": "<your-bright-data-token>", "PRO_MODE": "true"},     )),     ("Playwright", StdioServerParameters(         command="npx", args=["-y", "@playwright/mcp@latest"],     )),     ("GitHub official", StdioServerParameters(         command="docker",         args=["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN",               "ghcr.io/github/github-mcp-server"],         env={**os.environ, "GITHUB_PERSONAL_ACCESS_TOKEN": "<your-github-token>"},     )), ] PROBES = {     "Bright Data rapid": ("scrape_as_markdown", {"url": AMAZON_SERP}),     "Bright Data PRO":   ("scrape_as_markdown", {"url": AMAZON_SERP}),     "GitHub official":   ("get_file_contents", {         "owner": "modelcontextprotocol", "repo": "python-sdk", "path": "README.md",     }), } async def measure(label: str, server: StdioServerParameters):     async with stdio_client(server) as (read, write):         async with ClientSession(read, write) as session:             await session.initialize()             # 1) 仅 tools/list 的开销有多大?             tools = await session.list_tools()             schema_tokens = ntokens(json.dumps([t.model_dump() for t in tools.tools]))             print(f"tools/list ({label}): {len(tools.tools)} tools, {schema_tokens:,} tokens")             # 2) 一次实际调用的开销有多大?             if label == "Playwright":                 await session.call_tool(                     "browser_navigate",                     arguments={"url": "https://en.wikipedia.org/wiki/World_War_II"},                 )                 tool, args = "browser_snapshot", {}             else:                 tool, args = PROBES[label]             res = await session.call_tool(tool, arguments=args)             body = all_text(res)             tag = "错误信息" if res.isError else "响应"             print(f"{tool} ({label}): {ntokens(body):,} tokens({tag})")             print() async def main():     for label, server in SERVERS:         await measure(label, server) asyncio.run(main()) 如果你想跟着做,Bright Data MCP 需要你先注册,然后从控制面板获取API_TOKEN并设为环境变量。用npx -y @brightdata/mcp启动服务器。暂时不要设置PRO_MODE=true。那会启用基于远程 Scraping Browser 的浏览器工具,其快照的体积跟 Playwright 一样臃肿。

GitHub 需要你从 Settings → Tokens 获取GITHUB_PERSONAL_ACCESS_TOKEN,还需要 Docker。Playwright 则不需要额外配置。

运行测试: pip install mcp tiktoken python measure.py 以下是我对所有四个 MCP 服务器运行后的输出: tools/list (Bright Data rapid): 5 tools, 1,161 tokens scrape_as_markdown (Bright Data rapid): 278,649 tokens in response   # Amazon搜索结果页 tools/list (Bright Data PRO):   74 tools, 13,851 tokens scrape_as_markdown (Bright Data PRO): 278,649 tokens in response   # 相同,只是增加了工具开销 tools/list (Playwright):        23 tools, 4,986 tokens browser_snapshot (Playwright):  293,164 tokens in response            # 维基百科二战条目 # 这个简直离谱,schema开销是实际工具调用结果的约3倍。 tools/list (GitHub official):   43 tools, 56,333 tokens get_file_contents (GitHub official): 19,406 tokens in response      # python-sdk/README.md 这个tools/list的开销是真正让我毛骨悚然的 —— 这笔 schema 或 "菜单" 税是你每一轮对话、每一次会话、永远都要支付的。而 GitHub MCP 的菜单税竟然几乎是实际工具调用响应的 3 倍!

#### 三种缩减 MCP 响应体积的方法

首先,要认识到 Token 的消耗来自两个不同的出口。每一轮对话你都要付出双重代价:

* 1、tools/list的 schema"菜单" 税(所有工具的描述信息,在任何工具执行前就已注入上下文);

* 2、每次工具调用返回的响应负载。

你可以通过四种手段来削减这些开销:(1)挂载更少的工具以削减tools/list的 schema 税,(2)使用服务器原生的限制参数,(3)部署 Token 预算代理来压缩响应负载,(4)将超大响应负载转存到磁盘。下面的方法 1 和方法 2 针对的是 schema 开销;方法 3 针对的是响应负载,并附带磁盘转存策略。

##### 方法一:别加载你用不到的工具

要削减每轮对话中向 LLM"报菜名" 的开销,唯一万无一失的办法就是缩短菜单 —— 卸载不需要的服务器、在客户端配置中隐藏单个工具,或者在服务端启用精简工具模式。

(a) 压根别挂载那个服务器。如果某个任务完全不涉及 GitHub,就让 GitHub MCP 保持禁用状态 —— 也就是说,根本不要出现在你的客户端配置里。一个未连接的服务器不会往tools/list里塞任何东西,自然也不会产生 Token 开销。

!Image 4

所有 MCP 客户端都允许你在界面中进行此操作,直接修改 JSON 配置文件同样可以。

(b) 在你确实需要挂载的服务器上,隐藏多余的工具。大多数客户端允许你在已启用的服务器内逐个挑选工具(Cursor 在界面中就能操作,Claude Code 有权限拒绝规则如mcp__github__*,其他客户端则有allowedTools/excludeTools)。通过这种细粒度方式被拒绝的工具会从上下文中彻底消失;它们不只是在调用时被拦截那么简单。

!Image 5

Cursor 中对 MCP 服务器工具的细粒度控制。变灰的工具表示已被隐藏。

(c) 让服务器自行精简。这种情况比较少见,但有些 MCP 服务器默认只暴露一组精选工具,需要时才让你手动启用更多。例如,Bright Data 默认只有 5 个工具 / 1,161 个 Token,只有在设置PRO_MODE=true时才会展示完整目录(74 个工具 / 13,851 个 Token)。GitHub 的--toolsets/--tools以及 Playwright 的--caps选项同理。

简而言之,把 "菜单" 精简到你的智能体在当前场景下真正需要的程度。

##### 方法二:在源头就请求更小的响应

先检查服务器是否已经支持返回更小的响应负载 —— 在源头做限制远胜于先下载 30 万个 Token 再把大部分扔掉。

这种 "少要一些" 可以有不同的形式。有些 MCP 服务器允许你分页(max_resultsper_page)、请求更少的字段(fields=[...]),或调整格式和详细程度(format: "compact")。

!Image 6

这个服务器能原生返回更小的响应吗?三个主流 MCP 服务器的对比。此处 "磁盘转存" 仅计入原生方法。

问题在于,大多数 MCP 服务器并没有这类设置,而且按调用传参依赖于模型每次都正确设置参数。服务器作者对开放式的字段选择也心存顾虑,因为模型会凭空编造字段名,所以他们更倾向于强制使用一个合理的默认值。

当原生限制缺失或不可靠时,你需要一个 Token 预算代理,为每个响应强制设置硬性上限,无论模型请求了什么。

##### 方法三:构建 Token 预算代理

Token 预算代理是你自己编写的一个轻量 MCP 服务器,它介于客户端和真实服务器之间,将所有调用转发到上游,并在模型看到响应之前,通过清理、JSON 投影或磁盘转存等手段,将每个响应压缩到一个硬性 Token 预算(例如 8,000 个 Token)以内。

智能体与这个代理对话,代理与实际的 MCP 服务器对话。双方都察觉不到中间多了一层。 ┌──────────┐   stdio    ┌────────────────┐   stdio    ┌───────────────────┐ │  客户端   │ ─────────► │  budget-proxy  │ ─────────► │ Bright Data MCP   │ │ (Claude) │ ◄───────── │  (你的代码)     │ ◄───────── │ (@brightdata/mcp) │ └──────────┘  裁剪后    └────────────────┘   原始数据  └───────────────────┘               ≤ 预算                       30万Token 下面我来一步步讲解。我们用 FastMCP 来写服务端,用官方 Python MCP 客户端 SDK 来对接上游。

以下示例通过 stdio 在本地启动 MCP 服务器;如果你的上游是远程 HTTP MCP 服务器,把stdio_client换成streamablehttp_client即可。

这个代理并不是什么能神奇压缩任意响应的压缩器。它是一个具备格式感知路由能力的熔断器。它的职责是确保客户端永远不会被超大负载噎住,具体做法是根据负载的格式将响应导向破坏性最小的处理路径 —— 对 JSON 做投影,对文本去除噪声,对仍然过大的部分转存到磁盘。

当负载超出你设定的 Token 预算(我设的是 8k)时,模型仍然会收到一段预览加上一个文件句柄,可以按需使用 grep 或读取剩余内容 —— 如今几乎所有 LLM 都足够聪明,完全能处理这种情况。

我们将分阶段构建这个路由器。先从每个文本负载都要经过的路径开始 —— 两个处理步骤就已经覆盖了大部分工作。 第一步:清除已知垃圾 + 超出预算时转存到磁盘

首先,我们去除明显的噪声 —— 追踪 URL、base64 内联图片等。然后,如果结果仍然超出预算,就将完整负载写入磁盘,返回一段预览和文件路径。

compact.py 完整代码见:https://gist.github.com/sixthextinction/7ba52d1cc9f8f7b0b688de129c26a2c9

spill.py 完整代码见:https://gist.github.com/sixthextinction/b5e322f7ca9b97d7b0a6c1dc8a272baf # compact.py + spill.py # 清除噪声,超出预算则转存到磁盘 import re import tiktoken import pathlib ENC = tiktoken.get_encoding("cl100k_base") def ntokens(s: str) -> int:     return len(ENC.encode(s)) # 第一步:清除明显的垃圾。在电商抓取场景中,markdown链接括号里 # 那些带超长追踪参数的URL往往是最大的收益点。 _DATA_URI = re.compile(r"!\[[^\]]*\]\(data:image/[^)]+\)") _LONG_URL = re.compile(r"\(https?://[^)]{200,}\)") def strip_noise(text: str) -> str:     text = _DATA_URI.sub("[image removed]", text)     text = _LONG_URL.sub("(url removed)", text)     text = re.sub(r"\n{3,}", "\n\n", text)     return text.strip() # 第二步:如果仍然超出预算,将全文转存到磁盘;预览填满内联预算。 def prose_spill_or_pass(text, tool, arguments, budget, spill_dir):     cleaned = strip_noise(text)     if ntokens(cleaned) <= budget:         return cleaned, "pass_through", None     path = write_spill(spill_dir, tool, arguments, cleaned)     header = f"[budget-proxy] Full result ({ntokens(text):,} tokens) saved to:\n  {path}\n\nPreview:\n\n"     footer = "\n\nUse grep or a file-reading tool to pull specific sections on demand."     preview_budget = budget - ntokens(header) - ntokens(footer)     preview = truncate_to_budget(cleaned, preview_budget)[0]     body = header + preview + footer  # 总计 ≤ 预算     return body, "spill", path 以下是这个方案在一批真实 URL 上的实际效果,预算设为 8k:

!Image 7

绿色柱子 = 是的,在 8k 内联预算以内。绿色虚线是预算目标。红色虚线是 Claude Code 的 25k MCP 上限。

所以这两个步骤本质上是分工协作。strip_noise删除的是真正的垃圾,而磁盘转存则在不丢失一个字节的前提下卸载了大部分体积。当负载被转存时,内联响应会充分利用预算 —— 由头部信息加上尽可能多的预览内容组成,而不是只给一个几行的片段 —— 而完整负载则安静地躺在磁盘上,等待 grep 或更深入的读取来调用它。

这个策略还有一个额外的好处:重复调用同一请求几乎是零成本 —— 因为 spill.py 以{tool, arguments}的哈希值作为文件名。

> 💡 成熟的服务器也开始内置这一模式了。Playwright MCP 就有完全相同的原生选项:--output-mode file将快照、控制台消息和网络日志写入磁盘(存放在--output-dir中,通过--output-max-size淘汰旧文件),而不是将它们全部灌入上下文。

在继续之前,先把上面的代码整合进一个小型路由器。 budget.py # budget.py(后面还会继续扩展!) from dataclasses import dataclass import pathlib from lib.compact import strip_noise from lib.spill import prose_spill_inline from lib.tokens import ntokens @dataclass class BudgetResult:     text: str     strategy: str  # pass_through | spill def budget_text(text, tool, arguments, budget, spill_dir):     if not text.strip():         return BudgetResult(text="", strategy="pass_through")     cleaned = strip_noise(text)     if ntokens(cleaned) <= budget:         return BudgetResult(text=cleaned, strategy="pass_through")     _path, body, _, _ = prose_spill_inline(         tool, arguments, text, spill_dir, budget     )     return BudgetResult(text=body, strategy="spill") def budget_from_extracted(extracted_text, tool, arguments, budget, spill_dir, *, preview_tokens=1500):     return budget_text(extracted_text, tool, arguments, budget, spill_dir) 第二步:搭建代理服务器

接下来是封装层 —— 也就是一个可运行的 MCP 服务器,用于包装上游服务器。它在启动时通过 FastMCP 的 lifespan 机制建立一个上游连接,对外暴露一个call工具用于按名称转发到任意上游工具,并让每个响应都经过我们上面的预算路由器(budget.py)处理:

budget_proxy.py 完整代码见:https://gist.github.com/sixthextinction/9a9b3b9a51bfebce401133d164a71f5f # budget_proxy.py — 带单一 call 封装的Token预算MCP代理 import os import pathlib from collections.abc import AsyncIterator from contextlib import asynccontextmanager from mcp import ClientSession, StdioServerParameters, types from mcp.client.stdio import stdio_client from mcp.server.fastmcp import Context, FastMCP from lib.budget import budget_from_extracted # 内联上限:低于此值直接透传;否则转存 + 预览填满剩余预算。 TOKEN_BUDGET = int(os.getenv("MCP_TOKEN_BUDGET", "8000")) PREVIEW_TOKENS = int(os.getenv("MCP_PREVIEW_TOKENS", "1500"))  # JSON转存时仅提供简短预览 SPILL_DIR = pathlib.Path(os.getenv("MCP_SPILL_DIR", "./mcp_spill")) UPSTREAM = StdioServerParameters(     command="npx",     args=["-y", "@brightdata/mcp"],     env={"API_TOKEN": os.environ["API_TOKEN"]}, ) def text_from_result(res: types.CallToolResult) -> str:     return "".join(c.text or "" for c in res.content if isinstance(c, types.TextContent)) @asynccontextmanager async def upstream_lifespan(_server: FastMCP) -> AsyncIterator[ClientSession]:     async with stdio_client(UPSTREAM) as (read, write):         async with ClientSession(read, write) as session:             await session.initialize()             yield session mcp = FastMCP("bright-data-budgeted", lifespan=upstream_lifespan) @mcp.tool() async def call(tool: str, arguments: dict, ctx: Context) -> str:     """代理任意上游Bright Data工具,通过清理 + 转存或透传进行预算控制。"""     session: ClientSession = ctx.request_context.lifespan_context     res = await session.call_tool(tool, arguments=arguments)     text = text_from_result(res)     result = budget_from_extracted(         text, tool, arguments, TOKEN_BUDGET, SPILL_DIR,         preview_tokens=PREVIEW_TOKENS,     )     return result.text if __name__ == "__main__":     mcp.run() 然后让你的客户端指向这个代理,而非实际的 MCP: {   "mcpServers": {     "Bright Data (budgeted)": {       "command": "python",       "args": ["budget_proxy.py"],       "env": {         "API_TOKEN": "<your-token>",         "MCP_TOKEN_BUDGET": "8000"       }     }   } } 智能体始终只看到一个工具 ——call—— 并在其中传入真正的上游工具名和参数:call(tool="scrape_as_markdown", arguments={"url": "..."})

在这背后,代理获取完整的 278k Token 响应,清除噪声,如果仍然超出预算就转存到磁盘,然后返回一段填满预算的预览,以及磁盘上完整数据的文件路径。

我第一次用这个代理跑那个 Amazon URL 时,内联响应从 278,649 个 Token 降到了约 7,700 个,而完整的 12.7 万 Token(清理后)抓取结果安静地躺在磁盘上。当任务只需要顶部的商品列表时,那段预览本身就够用了;当需要找到页面中间某个具体商品时,Claude 只需对保存的文件做一次 grep,分段读取即可。

这种方案的权衡在于:模型必须足够聪明,能通过call工具进行路由而非直接调用 MCP 的工具,并且 —— 当结果被转存时 —— 能主动发起 grep 或部分读取的后续操作。对前沿模型来说,这两点都不是问题。

##### MCP 代理增加了多少延迟?

根据同一份亚马逊搜索结果页面的抓取数据,上游请求耗时约 8.6 秒 —— 这是他们 Web Unlocker 基础设施加载页面的时间,与我们的代理服务器无关。清理 + 转存步骤(对 278k Token 的负载写入磁盘 + 生成预览)增加了约 175 毫秒 —— 大约是请求总时间的 2%。

对于较小的页面,整个处理过程始终在 10-12 毫秒左右,基本可以忽略不计。

!Image 8

在此负载上,预算处理步骤仅占请求时间的约 2%。

当然,在此之上你还要付出一次额外的 stdio 跳转和 JSON 帧封装的开销,但相对于 8 秒的网络请求来说,这完全是噪声级别的。代理中真正昂贵的部分并不是代理本身 —— 依然是那个你无论如何都要发起的上游调用。

#### 如何根据负载形态压缩 MCP 响应

上面的策略能够在不丢失数据的前提下让任何简单文本负载都控制在预算以内,但仍有几个坑可能会绊倒我们:

* 1、一些 MCP(如 GitHub)把文本藏在EmbeddedResource而非TextContent中。

* 2、并非所有文本都能安全地按行截断。记住:JSON 从技术上讲也是 "文本"。压缩成一行的 JSON 响应没有任何可切割的边界,盲目裁剪只会把它变成垃圾。

* 3、保存到磁盘需要智能体主动跟进。预览内容是负载的开头部分,所以如果答案在文件中间,仍然需要 grep 或范围读取。这是设计使然,但前提是模型确实有文件系统或 grep 工具可以调用 —— 并且不会把预览当成全部内容。如果忽略这一点,你就会继承截断带来的幻觉风险。

本质上,代理需要一棵按负载形态进行路由的决策树。以下就是这个路由器。

##### 故障一:代理看不到的文本

我们的text_from_result只读取TextContent块。对于所有内容都以单个文本块返回的 MCP 来说,这没问题。但举个例子,GitHub MCP 的get_file_contents工具会把响应拆成两个块:一个简短的TextContent状态行,和一个存放在EmbeddedResource中的实际文件。

以获取python-sdk/README.md为例:

* 旧版text_from_result(仅 TextContent)——TextContent 状态行("successfully downloaded text file (SHA: …)")。这是我们收到的全部内容 —— 区区 31 个 Token。

* 文件本身(完全被遗漏)——EmbeddedResource(markdown)。19,376 个 Token。

* 完整响应 —— 两个块合计。19,406 个 Token。

所以本该压缩 19k Token 文件的代理,反而兴高采烈地透传了 31 个 Token—— 而模型压根没看到实际的文件。这绝对不是我们想要的。

修复方法是提取每一个包含文本的块,而不只是TextContent

extract.py 完整代码见:https://gist.github.com/sixthextinction/7c412d2b8901a71f23a6395236748756 # extract.py — 读取 TextContent 和 EmbeddedResource.text from mcp import types def all_text_from_result(res: types.CallToolResult) -> tuple[str, list[str]]:     parts, kinds = [], []     for block in res.content:         kinds.append(type(block).__name__)         if isinstance(block, types.TextContent):             parts.append(block.text or "")         elif isinstance(block, types.EmbeddedResource):             resource = block.resource             text = getattr(resource, "text", None)             if text:                       # 文本资源(文件、diff)                 parts.append(text)             elif getattr(resource, "blob", None):   # 二进制 — 不做内联                 parts.append(                     f"[budget-proxy] Binary resource at {getattr(resource, 'uri', '?')} "                     f"({len(resource.blob)} bytes base64 — not inlined)."                 )     return "".join(parts), kinds 接下来,处理返回 JSON 响应的 MCP。

##### 故障二:粗暴截断 JSON 只会产生垃圾

GitHub 的 list/search 类工具返回的是压缩在单个TextContent块中的紧凑 JSON。在这里按行截断将是灾难性的:一个 25k Token 的 JSON 对象挤在一行里,根本没有行边界可以切割,所以截断器要么保留整个对象,要么(设了硬性字符上限的话)从对象中间一刀切下去,交给模型一段无效的 JSON。

那怎么办?很简单 —— 根本不要截断 JSON。在序列化之前先投影出你需要的字段。JMESPath 是一种小巧的 JSON 查询语言,正好能帮我们做这件事。

GitHub - jmespath/jmespath.py: JMESPath is a query language for JSON:https://github.com/jmespath/jmespath.py

JMESPath 是一种 JSON 查询语言。通过在 GitHub 上创建账户来参与 jmespath/jmespath.py 的开发。

你编写一个表达式来选择字段、切片数组、重命名键,它就会返回一个更小的 JSON 对象。可以把它想象成针对 JSON 数据的 SQL SELECT——items[:5].{title: title, url: html_url}只保留五条列表记录中你关心的列,其余全部丢弃。完美契合我们的需求。

在代理中使用 jmespath,我们把它组织成一个注册表 —— 一个以上游工具名为键的字典,每个值是针对该工具 JSON 结构定制的 jmespath 表达式。当响应被解析为 JSON 时,我们在JMESPATH_REGISTRY中查找对应的工具:

* 如果匹配,运行jmespath.search(expr, data)并重新序列化结果。

* 没有匹配?降级到通用结构压缩。

* 根本不是 JSON?交给文本 / 清理路径处理。

你只需为每个确定会返回重型响应的工具编写一次表达式,代理就会在每次调用时自动应用 —— 模型永远不需要记住要请求更少的字段。

shrink_json.py 完整代码见:https://gist.github.com/sixthextinction/8d313cb753e02deb15f9fc3408f0ad28 # 对结构化工具做投影而非截断 import json, jmespath PREVIEW_ITEMS = 5 JMESPATH_REGISTRY = {     "search_code": (         "{total_count: total_count, incomplete_results: incomplete_results, "         f"items: items[:{PREVIEW_ITEMS}].{{name: name, path: path, sha: sha, "         "repository: repository.full_name, html_url: html_url}}"     ),     "list_pull_requests": (         f"[:{PREVIEW_ITEMS}].{{number: number, title: title, state: state, "         "user: user.login, html_url: html_url, created_at: created_at}}"     ),     # … search_repositories, search_issues, get_commit, get_gist … } def shrink_json_text(text: str, tool: str) -> tuple[str | None, str]:     """返回 (shrunk_json, method),method 为 jmespath|generic|none。"""     try:         data = json.loads(text)     except json.JSONDecodeError:         return None, "none"                      # 不是JSON — 交给文本路径处理     expr = JMESPATH_REGISTRY.get(tool)     if expr:         shrunk = jmespath.search(expr, data)         if shrunk is not None:             return json.dumps(shrunk, indent=2), "jmespath"     return json.dumps(_generic_shrink(data), indent=2), "generic"  # 截断数组、裁剪字符串 在 8k 预算下,对 GitHub 几个 "大户" 工具实测,使用 jmespath 和不使用的有效 Token 数对比:

!Image 9

上游原始 Token 数 vs JMESPath 投影后 Token 数(对数刻度,8k 预算)

看看list_pull_requests:盲目压缩返回了 0 个有效 Token—— 它把那个单行压缩 JSON 从中间切断,产生了无法解析的 JSON。而 jmespath 策略则完美运作 —— 既正确压缩又保持有效 —— 把 25,378 个 Token 变成了 473 个,模型仍然能正常解析。

对于没有注册投影表达式的工具,你可以使用通用的结构压缩(将数组截断到 N 个元素、裁剪过长的字符串值)—— 虽然不如 jmespath 精准,但输出仍然是有效的 JSON。

##### 完整整合

像这样扩展前面的budget.py。代理于是变成了一棵小型决策树,根据负载的实际类型进行路由:

新版 budget.py 完整代码见:https://gist.github.com/sixthextinction/c40cfe9552466612868ffe22e5d5e7c8 # 新版 budget.py # 替换 budget_text(),但保留第一步中的 BudgetResult + budget_from_extracted() def budget_text(text, tool, arguments, budget, spill_dir, , preview_tokens=1500):     if not text.strip():         return BudgetResult(text="", strategy="pass_through")     # 1) JSON → 投影(jmespath)或结构压缩;仍然过大则转存。     shrunk, method = shrink_json_text(text, tool)     if method != "none" and shrunk is not None:         if ntokens(shrunk) <= budget:             return BudgetResult(text=shrunk, strategy=f"json_{method}")         path, preview, _ = spill_response(             tool, arguments, text, spill_dir, preview_tokens=preview_tokens, suffix=".json"         )         body = f"[budget-proxy] Full JSON saved to:\n  {path}\n\nPreview:\n\n{preview}"         return BudgetResult(text=body, strategy="spill")     # 2) 文本 — 与第一步相同的路径(不变)。     cleaned = strip_noise(text)     if ntokens(cleaned) <= budget:         return BudgetResult(text=cleaned, strategy="pass_through")     _path, body, _, _ = prose_spill_inline(tool, arguments, text, spill_dir, budget)     return BudgetResult(text=body, strategy="spill") def budget_from_extracted(extracted_text, tool, arguments, budget, spill_dir, , preview_tokens=1500):     return budget_text(extracted_text, tool, arguments, budget, spill_dir, preview_tokens=preview_tokens) 这是一种 "最小破坏优先" 的策略。结构化数据经过投影处理 —— 输出最小、精确且仍然是有效 JSON—— 而文本则经过噪声清理,如果仍然过大就保存到磁盘。永远不会出现按行截断后直接丢弃的情况。

更新第二步中budget_proxy.pycall工具,使用extract.py和扩展后的路由器: # 在 budget_proxy.py 中 # 替换第二步的 call 工具 from lib.extract import all_text_from_result @mcp.tool() async def call(tool: str, arguments: dict, ctx: Context) -> str:     """代理任意上游MCP工具,采用分层Token预算控制。"""     session: ClientSession = ctx.request_context.lifespan_context     res = await session.call_tool(tool, arguments=arguments)     text, _ = all_text_from_result(res)        # 故障一修复 — 合并 EmbeddedResource.text     result = budget_from_extracted(         text, tool, arguments, TOKEN_BUDGET, SPILL_DIR,         preview_tokens=PREVIEW_TOKENS,     )     return result.text 同一个代理,同样 8k 预算,实时对接每个上游 —— 每个服务器自然会命中不同的分支,而智能体看到的负载从不超过约 8k:

!Image 10

只有一个call工具,四种策略,根据负载形态而非你碰巧挂载了哪个服务器来选择。

#### 常见问题

##### Claude 中 "MCP tool response exceeds maximum allowed tokens" 是什么意思,如何解决?

这意味着某个 MCP 工具单次返回的 Token 数超过了你的客户端允许的上限 —— 例如,Claude Code 默认会拒绝超过 25,000 个 Token 的工具返回结果。按照操作难度从低到高排列,修复方法如下:(1)暴露更少的工具以削减tools/list开销,(2)使用原生限制参数如max_resultsper_page,(3)在服务器前面放一个 Token 预算代理,将每个响应裁剪到硬性上限(我们使用 8,000 个 Token),(4)将超大负载转存到磁盘,返回一个文件句柄。

##### Claude Code、Claude Desktop 和 Cursor 的 MCP 输出 Token 限制是多少?

MCP 没有统一的标准 —— 每个客户端自行设定限制。Claude Code 拒绝超过 25,000 个 Token 的工具返回结果(可通过MAX_MCP_OUTPUT_TOKENS配置)。Claude Desktop 大约有 150,000 字符的上限(根据连接器文档)。Cursor 和 VS Code Copilot 没有明确公布具体数值,而是直接截断或降级处理。有些客户端根本没有设置明确的上限,只是任由上下文被填满,直到输出质量下降。

##### 为什么 MCP 响应如此庞大?

MCP 服务器会返回完整的页面、文件或 JSON 数据块,因为它无从得知你实际需要的是哪一部分。菜单税是问题的另一半。在任何工具运行之前,客户端就会把tools/list中每个已挂载工具的名称、描述和参数 schema 注入上下文,而且无论你是否调用这些工具,每一轮对话都要支付这笔开销。挂载大量服务器,每个有几十个工具,光这一项就可能让单次工具调用的输出相形见绌。

##### 如何减少 MCP 的 Token 消耗?

构建一个小型 Token 预算代理 —— 你自己编写的一个轻量 MCP 服务器,它将每次调用转发到真实服务器,并在响应到达模型之前将其压缩到固定的内联预算以内。它会将每个负载路由到开销最小且正确的策略:对文本执行清理 + 磁盘转存(完整负载保存在磁盘上,预览填满约 8k 的内联预算),对 JSON 使用 JMESPath 字段投影(这样你永远不会给模型一段截断后的无效 JSON)。

##### 为什么不直接提高客户端的 Token 限制?

因为提高限制只是治标不治本。一个 30 万 Token 的工具返回结果确实能塞进 100 万 Token 的上下文窗口,但它会把智能体需要记住的其他所有内容都挤出去,而且每轮的成本会高得多。这个限制实际上是一个有益的设计。况且,也不是每个客户端都暴露了这个设置。

##### 裁剪 MCP 响应会影响回答质量吗?

清除已知的垃圾内容 —— 如追踪 URL、base64 图片和 markdown 噪声 —— 通常是纯收益。磁盘转存在磁盘上保留了完整的保真度 —— 任务质量取决于智能体是否会通过 grep 或部分读取来跟进,而不是悄无声息地丢掉尾部内容。唯一真正的失败场景,是把预览当作了完整负载。

##### MCP 代理会增加多少延迟?

非常少。在一次 278k Token 的 Amazon 搜索结果页抓取中,上游请求耗时约 8.6 秒,而压缩步骤(清理 + 截断)仅增加了约 175 毫秒 —— 大约 2%。对于一个 5.6k Token 的小页面,压缩耗时约 12 毫秒,几乎可以忽略不计。真正昂贵的是你本来就要发起的上游网络调用;代理额外的 stdio 跳转和 Token 计数相比之下只是噪声。

#### 总结:如何解决 MCP 的 Token 膨胀问题

MCP Token 膨胀来自两个出口 ——tools/list的 schema"菜单税" 和超大的工具响应负载。根据哪个出口在困扰你来选择对应的修复方案:

* Schema 税太高? 挂载更少的服务器和工具(方法一)。GitHub 官方 MCP 仅tools/list就消耗 56,333 个 Token—— 几乎是一个典型工具响应的 3 倍。

* 响应太大,但 MCP 服务器支持限制? 优先使用原生分页和字段过滤(方法二),然后对仍然超标的部分加上代理。

* 响应巨大且你可能需要其中任何部分? 部署 Token 预算代理(方法三),转存到磁盘并返回预览 + 文件路径 —— 磁盘上保留完整保真度,内联约 8k。MCP 的原生分页只覆盖列表操作(tools/listresources/list),不覆盖工具调用结果 —— 所以对于超大负载,你需要自己设计 "句柄 + 按需获取" 的机制。

先用 tiktoken 测试工具进行度量,修复菜单税,在源头能限制的地方先限制,其余地方一律强制设置硬性内联预算。

要灵活应变!最常见的错误是一把锤子打所有钉子 —— 当智能体只需要抓取网页时却挂载了每个服务器的所有工具,或者在源头设个max_results=3就够用的情况下偏要把整兆页面拉进上下文。

#### 最后

对前端和全栈工程师而言,这篇文章在于刷新了 AI 时代的 “性能优化” 与架构认知:

* 架构防爆: 告别盲信大模型长上下文的盲区,学会利用轻量级代理(Proxy)在网关层做 “阻抗匹配”,解决上游 API 长响应撑爆下游模型的痛点。

* Token 降本(省钱): 揭示了容易被忽视的 “菜单税”(工具 Schema 引发的 Token 膨胀),指导工程师在前端 UI 层面实现工具的 “按需加载” 与精细化隐藏。

* 设计范式迁移: 沉淀出 “内联摘要 + 溢出落盘” 的全新全栈交互范式,用 “返回本地文件句柄” 代替 “全量数据硬塞”,是开发高效、低延迟、低成本 AI Agent 应用的必备工程基本功。

关于本文

译者:@飘飘

作者:@Prithwish Nath

原文:https://levelup.gitconnected.com/a-single-mcp-call-returned-278-649-tokens-heres-the-proxy-i-built-to-stop-it-dfaa9b5282e0

这期前端早读课

对你有帮助,帮”赞“一下,

期待下一期,帮”在看” 一下。 阅读原文

查看原文 → 發佈: 2026-06-26 09:00:00 收錄: 2026-06-26 16:00:39

🤖 問 AI

針對這篇文章提問,AI 會根據文章內容回答。按 Ctrl+Enter 送出。