MCP Tool Proxy - 我如何在代理层解决上下文爆炸问题
# MCP Tool Proxy - 我如何在代理层解决上下文爆炸问题
我在做一个 multi-agent 系统(这比我想象的要难得多)。其中一个 agent 会调用 MCP 工具,然后问题来了。
# 问题:Tool Call Result 是个黑盒
MCP (Model Context Protocol) 工具的输出大小是完全不固定的。
有时候很正常:
// 调用天气 API
{
"temp": 25,
"condition": "sunny"
}
// 几十个字节,没问题
但有时候...灾难来了:
# Agent 调用 bash 工具列出所有文件
find . -type f
# 返回 5000 行文件名 = ~200KB
# Agent 读取一个日志文件
cat /var/log/app.log
# 返回 10MB 日志 = ~2.5M tokens
# Agent 安装依赖
npm install
# 返回 10,000 行安装日志 = ~500KB
两个致命问题:
- 爆上下文:2.5M tokens 直接超过模型限制(请求失败)
- 污染 context:即使没爆,塞进 10MB 的垃圾数据(后续推理都变慢了)
我一开始不知道怎么办(这种感觉就像你精心准备的晚餐被狗吃了)。
# 天真的方案(我试过,都失败了)
# 方案 1:在 agent 端限制
// Agent 收到结果后截断
async function callTool(tool, args) {
const result = await mcpClient.call(tool, args)
return result.slice(0, 10000) // 只保留前 10K 字符
}
问题:agent 永远不知道是不是完整数据(可能关键信息在后面被截断了)。
# 方案 2:在 MCP server 端限制
// MCP server 返回时截断
server.on("tool:result", (result) => {
if (result.size > THRESHOLD) {
return truncate(result) // 直接截断
}
})
问题:每个 MCP server 都要改(不现实,而且你控制不了第三方的 server)。
# 方案 3:让 agent 自己处理
// Agent 调用前预估大小
if (estimatedSize(args) > THRESHOLD) {
return usePagination(args) // 分页获取
}
问题:预估大小很难(你不知道 find 会返回多少文件)。
# 正确方案:MCP Tool Proxy(代理层拦截)
我意识到需要一个中间层(就像 nginx 之于 web 服务器):
Agent ←→ MCP Tool Proxy ←→ MCP Servers
(拦截 + 截断)
核心思想:
- Proxy 拦截所有 tool call result(在返回给 agent 之前)
- 判断大小(按 tokens 计算,不只是字节)
- 超过阈值 → 返回预览 + 保存完整数据
- Agent 需要时 → 通过 sandbox MCP 获取
让我给你看代码(这花了我一周时间才想明白)。
# Step 1: Proxy 架构(拦截层)
// mcp-proxy/src/truncation.ts
import { tokenize } from "gpt-tokenizer" // 计算 token 数量
interface ProxyConfig {
maxTokens?: number // 默认: 10000 tokens
storageDir?: string // 完整数据保存路径
}
interface TruncatedResult {
content: string // 预览内容
truncated: boolean // 是否被截断
metadata?: {
fullSize: number // 完整大小(tokens)
previewSize: number // 预览大小(tokens)
storageKey: string // 完整数据的 key
}
}
// Proxy 核心拦截逻辑
async function interceptToolResult(
result: string,
config: ProxyConfig = {}
): Promise<TruncatedResult> {
const maxTokens = config.maxTokens ?? 10000
const storage = new ResultStorage(config.storageDir)
// 1. 计算 token 数量(不是字符数!)
const fullTokens = tokenize(result).length
// 2. 快速路径:小输出直接返回
if (fullTokens <= maxTokens) {
return { content: result, truncated: false }
}
// 3. 慢路径:截断 + 保存
const previewContent = truncateToTokens(result, maxTokens)
const storageKey = await storage.save(result) // 保存完整数据
// 4. 返回元数据(agent 用这个来决定是否需要完整数据)
return {
content: previewContent,
truncated: true,
metadata: {
fullSize: fullTokens,
previewSize: maxTokens,
storageKey,
retrievalHint: `输出已截断 (${formatNumber(fullTokens)} tokens → ${formatNumber(maxTokens)} tokens)\n完整数据已保存至: ${storageKey}\n\n使用以下方式获取完整数据:\n1. 通过 bash: cat /mnt/data/${storageKey}\n2. 通过 sandbox 写代码过滤数据`
}
}
}
看到了吗?关键点:
- 按 token 计算(不是字节,因为模型按 token 计费)
- 快速路径(小输出零开销)
- 元数据返回(agent 知道有更多数据可用)
# Step 2: Token 级截断(核心算法)
// mcp-proxy/src/truncation.ts
import { tokenize } from "gpt-tokenizer"
function truncateToTokens(text: string, maxTokens: number): string {
const tokens = tokenize(text)
// 快速路径:不需要截断
if (tokens.length <= maxTokens) {
return text
}
// 截断到 maxTokens(保留前 N 个 tokens)
const truncatedTokens = tokens.slice(0, maxTokens)
// 这里的技巧:tokens → 文本不是简单的 slice
// 我们需要找到最后一个完整的 token 边界
const previewText = detokenize(truncatedTokens)
// 计算实际截断了多少
const removed = tokens.length - maxTokens
const percentage = ((removed / tokens.length) * 100).toFixed(1)
// 构建提示信息
const hint = `\n\n... ${formatNumber(removed)} tokens 被截断 (${percentage}%)\n使用 storageKey 获取完整数据`
return previewText + hint
}
为什么要按 token 截断?
// 字符截断的问题
"你好世界" = 4 字符, 但可能是 6-8 tokens(中文字符编码效率低)
// Token 截断的优势
"Hello world" = 3 tokens, 3 字符(英文效率高)
"你好世界" = 8 tokens, 4 字符(中文效率低)
// 如果用字符截断,可能切到半个 token(解码失败)
# Step 3: Proxy 如何集成到 MCP 调用链
// mcp-proxy/src/index.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
class MCPToolProxy {
private upstreamClient: Client // 真正的 MCP server
private config: ProxyConfig
constructor(upstreamClient: Client, config: ProxyConfig = {}) {
this.upstreamClient = upstreamClient
this.config = config
}
// 拦截 tool call
async callTool(toolName: string, args: any): Promise<any> {
// 1. 调用上游 MCP server
const rawResult = await this.upstreamClient.callTool({
name: toolName,
arguments: args
})
// 2. 提取文本内容
const textContent = this.extractText(rawResult)
// 3. 拦截 + 截断(这就是 magic 发生的地方)
const processed = await interceptToolResult(
textContent,
this.config
)
// 4. 返回处理后的结果给 agent
return {
...rawResult,
content: [{ type: "text", text: processed.content }],
_metadata: { // 隐藏字段,agent 可以访问
truncated: processed.truncated,
fullSize: processed.metadata?.fullSize,
storageKey: processed.metadata?.storageKey
}
}
}
private extractText(result: any): string {
// MCP result 格式: { content: [{ type: "text", text: "..." }] }
return result.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join("\n")
}
}
这就是整个代理层的核心(只有 ~30 行有效代码)。
# Step 4: Agent 如何获取完整数据(Sandbox MCP)
这里有个关键设计决策:我给 agent 提供了一个 sandbox MCP(包含 bash 工具)。
// agent 收到截断后的结果
const toolResult = await mcpProxy.callTool("bash", {
command: "find . -type f"
})
// 结果(被截断了):
// file1.ts
// file2.ts
// ...
// file1999.ts
// file2000.ts
//
// ... 50000 tokens 被截断 (83.3%)
// 使用 storageKey 获取完整数据
//
// _metadata: {
// truncated: true,
// fullSize: 60000,
// storageKey: "tool_result_abc123"
// }
// Agent 决定需要完整数据
const fullData = await sandbox.callTool("bash", {
command: `cat /mnt/data/tool_result_abc123`
})
// 或者 Agent 只需要过滤后的数据
const filtered = await sandbox.callTool("bash", {
command: `cat /mnt/data/tool_result_abc123 | grep '\\.ts$' | wc -l`
})
为什么用 sandbox?
- 隔离性:完整数据不污染 agent 上下文(只在 sandbox 里处理)
- 灵活性:agent 可以写任意 bash 命令过滤数据(
grep、awk、sed) - 可组合:多个 agent 可以同时访问同一个 storageKey(无需重复获取)
# Step 5: 存储层(完整数据去哪了?)
// mcp-proxy/src/storage.ts
import fs from "fs/promises"
import crypto from "crypto"
class ResultStorage {
private dir: string
constructor(dir: string = "/mnt/data/mcp-proxy") {
this.dir = dir
}
async save(content: string): Promise<string> {
// 确保目录存在
await fs.mkdir(this.dir, { recursive: true })
// 生成唯一 ID(用 hash 避免重复存储)
const hash = crypto.createHash("sha256")
.update(content)
.digest("hex")
.slice(0, 12)
const filename = `tool_${Date.now()}_${hash}.txt`
const filepath = `${this.dir}/${filename}`
// 写入完整数据
await fs.writeFile(filepath, content, "utf-8")
// 返回 storage key(agent 用这个来获取)
return filename
}
async get(key: string): Promise<string | null> {
try {
const filepath = `${this.dir}/${key}`
return await fs.readFile(filepath, "utf-8")
} catch {
return null // 文件可能过期被删了
}
}
// 清理过期文件(保留 24 小时)
async cleanup(maxAge: number = 24 * 60 * 60 * 1000) {
const files = await fs.readdir(this.dir)
const now = Date.now()
for (const file of files) {
const filepath = `${this.dir}/${file}`
const stat = await fs.stat(filepath)
const age = now - stat.mtimeMs
if (age > maxAge) {
await fs.unlink(filepath) // 删除过期文件
}
}
}
}
存储策略:
- 用 hash 做去重(相同内容只存一次)
- 短过期时间(24 小时,够 agent 处理完了)
- 简单文件系统(不需要 Redis,本地文件足够了)
# 完整流程(从 agent 视角)
// ============================================
// Step 1: Agent 调用 MCP 工具(通过 proxy)
// ============================================
const result = await mcpProxy.callTool("bash", {
command: "find . -type f"
})
// ============================================
// Step 2: Agent 收到截断后的结果
// ============================================
console.log(result.content)
// file1.ts
// file2.ts
// ...
// file2000.ts
//
// ... 50000 tokens 被截断 (83.3%)
// 使用 storageKey 获取完整数据
//
// _metadata: {
// truncated: true,
// fullSize: 60000,
// storageKey: "tool_1234567890_abc123def456.txt"
// }
// ============================================
// Step 3: Agent 分析预览,决定是否需要完整数据
// ============================================
if (result._metadata.truncated) {
// 看了前 2000 个文件,agent 发现:这是个大项目
// 需要统计所有 .ts 文件数量
const countResult = await sandbox.callTool("bash", {
command: `cat /mnt/data/${result._metadata.storageKey} | grep '\\.ts$' | wc -l`
})
console.log(countResult.content) // 3421(全部 .ts 文件)
// 注意:这个结果很小,不会被截断
}
// ============================================
// Step 4: 如果真的需要完整数据(罕见情况)
// ============================================
if (agentDecidesNeedsFullData) {
const fullResult = await sandbox.callTool("bash", {
command: `cat /mnt/data/${result._metadata.storageKey}`
})
// 但即使这样,完整数据也只在 sandbox 里
// 不会污染主 agent 的上下文
}
# 为什么这个设计有效(直觉)
把它想象成数据库的分页查询:
SELECT * FROM users; -- 返回 100 万行(太慢)
-- 改成这样:
SELECT * FROM users LIMIT 100; -- 返回前 100 行(够用了)
-- 需要更多?用 OFFSET 分批获取
MCP Tool Proxy 做的就是同样的事情:
- LIMIT 10000 tokens(预览)
- 告诉 agent 还有更多(metadata)
- 按需获取完整数据(通过 sandbox)
关键差异:
| 方案 | 问题 |
|---|---|
| 直接返回完整数据 | 爆上下文 + 污染 context |
| 直接截断 | Agent 不知道有更多数据 |
| Proxy + Sandbox | 两全其美 |
# 实际部署(我是怎么集成的)
// agent-system/src/mcp-proxy.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { MCPToolProxy } from "./mcp-proxy/truncation.js"
import { SandboxMCP } from "./sandbox/index.js"
// 1. 创建真正的 MCP client(连接到 server)
const upstreamClient = new Client({
name: "my-agent",
version: "1.0.0"
})
await upstreamClient.connect(transport)
// 2. 用 proxy 包装(拦截所有调用)
const mcpProxy = new MCPToolProxy(upstreamClient, {
maxTokens: 10000, // 截断阈值
storageDir: "/mnt/data/mcp"
})
// 3. 创建 sandbox MCP(agent 用这个获取完整数据)
const sandbox = new SandboxMCP({
mountPath: "/mnt/data"
})
// 4. agent 使用 proxy(而不是直接用 upstreamClient)
const agent = new Agent({
tools: {
mcp: mcpProxy, // 所有 MCP 调用都走 proxy
sandbox: sandbox // sandbox 用于获取完整数据
}
})
// 5. 启动清理任务(每小时清理过期文件)
setInterval(() => {
mcpProxy.cleanup()
}, 60 * 60 * 1000)
# 常见陷阱(我踩过的坑)
# 陷阱 1:按字符而不是 token 截断
// 错误方式
const truncated = text.slice(0, 10000) // 10K 字符
// 问题:
// "你好你好你好..." = 10000 字符 = ~15000 tokens(超过限制了!)
修复:用 gpt-tokenizer 计算真实 token 数。
# 陷阱 2:截断后不告诉 agent
// 错误方式
return { content: truncatedText } // agent 以为这就是全部
// 问题:agent 不知道有更多数据,做出错误决策
修复:总是返回 metadata(truncated, storageKey)。
# 陷阱 3:永久保存文件
// 错误方式
const filename = `tool_${random()}.txt`
await fs.writeFile(filename, data) // 永不删除
// 问题:磁盘被填满(我遇到过 2GB 的 /mnt/data)
修复:定时清理过期文件(24 小时足够了)。
# 陷阱 4:storageKey 暴露给 agent 上下文
// 错误方式
return {
content: `完整数据: /mnt/data/${key}`, // 文件路径进入上下文
metadata: { storageKey: key }
}
// 问题:agent 引用这个路径,路径进入上下文(浪费)
修复:storageKey 只在 _metadata 里(需要时才用)。
# 性能测试(实际数据)
我测试了几种场景(用真实 MCP 工具):
| 工具调用 | 完整输出 | Token 截断 (10K) | 节省 |
|---|---|---|---|
find . -type f (5K files) | 60K tokens | 10K tokens | 83% |
cat large.log (50MB) | 12M tokens | 10K tokens | 99.9% |
npm install | 25K tokens | 10K tokens | 60% |
git log (1K commits) | 15K tokens | 10K tokens | 33% |
关键观察:
- 大多数时候不需要完整数据(预览足够)
- 需要时才获取(按需加载)
- token 节省显著(平均 70%+)
# 总结
MCP Tool Proxy 解决了一个核心问题:如何让 agent 在不爆上下文的情况下访问任意大的工具输出。
设计原则:
- Proxy 拦截(在代理层统一处理,无需改每个 MCP server)
- Token 级截断(按模型计费单位计算,不是字符)
- 元数据返回(agent 知道有更多数据可用)
- Sandbox 获取(完整数据不污染 agent 上下文)
- 短过期时间(24 小时后自动清理)
这个模式让我想到 CDN 的边缘缓存(预览)+ 源站拉取(完整数据)。agent 系统也可以借鉴这些成熟的架构模式。
现在去实现你的 MCP Tool Proxy(你的上下文窗口会感谢你的)。
P.S. 我一开始想把这个逻辑放在 agent 里(而不是 proxy)。但那样每个 agent 都要实现一遍(很蠢)。Proxy 层是唯一正确的地方(所有 agent 自动受益)。
P.P.S. 10K tokens 阈值不是魔法数字。我试过 1K(太小,agent 老要更多)和 100K(太大,还是会爆)。10K 似乎是个好平衡(足够预览,又不会爆)。根据你的模型和用例调整。