这篇解决一个具体的效率问题:MCP 工具一多,Agent 的 token 成本和延迟就失控。
核心做法是把 MCP 服务器暴露成文件系统上的代码 API,让模型用写代码代替直接工具调用:工具定义按需读取(渐进式披露),中间结果在执行环境里过滤聚合后再回传,循环和条件交给代码控制流。最容易被误用的地方,是低估执行环境本身的成本——沙箱隔离、资源限制与监控是这套方案的前置条件,不是免费午餐。
相比「压缩工具定义、做工具召回」这类普遍认知,这篇文章的增量在于结构性方案:让数据根本不经过模型上下文。对国内从业者,还可以叠加数据安全与 PII 合规的语境来理解令牌化机制的设计动机。
建议你盘点自己 Agent 中 token 消耗最大的三处工具调用,挑一处改成代码执行模式,实测成本与延迟差异。
—— FDEChina编辑部 · 架构师视角
模型上下文协议(Model Context Protocol,MCP)是一个把 AI Agent 连接到外部系统的开放标准。按照传统做法,要把 Agent 接上工具和数据,需要为「某个 Agent + 某个外部系统」的每一种配对单独编写一套定制集成;由此产生的碎片化和重复劳动,使得真正互联的系统难以规模化。MCP 提供了一个通用协议——开发者只需在自己的 Agent 中实现一次 MCP,就能解锁整个集成生态。
自 2024 年 11 月发布 MCP 以来,它的采用速度非常快:社区已经构建了数千个 MCP 服务器,所有主流编程语言都有了对应的 SDK,业界也把 MCP 采纳为连接 Agent 与工具、数据的事实标准。
如今,开发者构建的 Agent 常规化地连接着几十个 MCP 服务器上的成百上千个工具。然而,随着接入工具数量的增长,把所有工具定义预先加载进上下文、并让所有中间结果都穿过上下文窗口的做法,会拖慢 Agent 的运行速度并推高成本。
这篇文章的核心论点一句话就能说完:直接工具调用会为每一条工具定义和每一次调用结果消耗上下文;Agent 想要规模化,更好的方式是写代码来调用工具。下文先拆解直接工具调用模式的两类 token(词元,模型处理文本的基本计费单位)浪费,再给出代码执行的实现方式与各项收益,最后讨论这套方案的取舍。从下面的示例可以看到,这种改造并不需要变动 MCP 协议本身,真正改变的是客户端呈现工具给模型的方式。
工具导致的 token 过度消耗让 Agent 效率下降
随着 MCP 使用规模的扩大,有两种常见模式会增加 Agent 的成本和延迟:
- 工具定义把上下文窗口塞得过满;
- 中间工具结果消耗额外的 token。
1. 工具定义挤爆上下文窗口
大多数 MCP 客户端会把所有工具定义预先、一次性地直接加载进上下文,并使用直接工具调用(direct tool-calling)语法把它们暴露给模型。这些工具定义可能长这个样子:
gdrive.getDocument
Description: Retrieves a document from Google Drive
Parameters:
documentId (required, string): The ID of the document to retrieve
fields (optional, string): Specific fields to return
Returns: Document object with title, body content, metadata, permissions, etc.
salesforce.updateRecord
Description: Updates a record in Salesforce
Parameters:
objectType (required, string): Type of Salesforce object (Lead, Contact, Account, etc.)
recordId (required, string): The ID of the record to update
data (required, object): Fields to update with their new values
Returns: Updated record object with confirmation
工具描述占据的上下文窗口空间越多,响应时间与成本就越高。当 Agent 连接了数千个工具时,它们在开始阅读你的请求之前,就需要先处理掉几十万 token。换句话说,工具接得越多,留给真正任务的空间就越小,而每一轮请求都要重复支付这笔固定开销。
2. 中间工具结果消耗额外 token
大多数 MCP 客户端允许模型直接调用 MCP 工具。举个例子,你可能会对你的 Agent 说:”把我的会议记录从 Google Drive 下载下来,附加到 Salesforce 的这条线索上。”模型会做类似下面这样的调用:
TOOL CALL: gdrive.getDocument(documentId: "abc123")
→ returns "Discussed Q4 goals...n[full transcript text]"
(loaded into model context)
TOOL CALL: salesforce.updateRecord(
objectType: "SalesMeeting",
recordId: "00Q5f000001abcXYZ",
data: { "Notes": "Discussed Q4 goals...n[full transcript text written out]" }
)
(model needs to write entire transcript into context again)
每一个中间结果都必须经过模型。在这个例子里,完整的通话记录全文在模型上下文中流过了两次:一次是取回来时,一次是写出去时。对一场两小时的销售会议来说,这可能意味着额外处理 5 万 token。更大的文档甚至可能直接超出上下文窗口的长度上限,把整个工作流拦腰打断。
而且,面对大文档或复杂的数据结构时,模型需要在两次工具调用之间逐字复制数据,此时它出错的概率也会明显上升。
把问题归纳一下:MCP 客户端把工具定义加载进模型的上下文窗口,并编排一个消息循环,每一次工具调用与其结果,都要在各个操作之间穿过模型本身。模型被迫扮演所有数据的中转站,而它本可以只承担决策的角色。这个消息循环(message loop)结构,正是两类 token 浪费共同的根源。
用 MCP 代码执行提升上下文效率
随着代码执行环境在 Agent 中变得越来越常见,一个解决方案随之浮现:把 MCP 服务器呈现为代码 API。这里的代码执行环境(code execution environment),指的是可以实际运行模型所写代码的运行时,,而不是一整套直接工具调用。Agent 随后可以像写普通程序那样,通过编写代码来与 MCP 服务器交互。这一方案同时解决了前述两个挑战:Agent 可以只加载当前需要的工具,并且可以在执行环境里先把数据处理完毕,再把结果传回模型。
具体的实现方式有多种。其中一种,是从已连接的 MCP 服务器出发,生成一棵列出所有可用工具的文件树。下面是一个用 TypeScript 写的实现:
servers
├── google-drive
│ ├── getDocument.ts
│ ├── ... (other tools)
│ └── index.ts
├── salesforce
│ ├── updateRecord.ts
│ ├── ... (other tools)
│ └── index.ts
└── ... (other servers)
然后,每个工具对应一个文件,内容类似这样:
// ./servers/google-drive/getDocument.ts
import { callMCPTool } from "../../../client.js";
interface GetDocumentInput {
documentId: string;
}
interface GetDocumentResponse {
content: string;
}
/* 从 Google Drive 读取一篇文档 */
export async function getDocument(input: GetDocumentInput): Promise<GetDocumentResponse> {
return callMCPTool<GetDocumentResponse>('google_drive__get_document', input);
}
前面那个「Google Drive 到 Salesforce」的例子,就变成了这样一段代码:
// 从 Google Docs 读取会议记录,并写入 Salesforce 线索
import * as gdrive from './servers/google-drive';
import * as salesforce from './servers/salesforce';
const transcript = (await gdrive.getDocument({ documentId: 'abc123' })).content;
await salesforce.updateRecord({
objectType: 'SalesMeeting',
recordId: '00Q5f000001abcXYZ',
data: { Notes: transcript }
});
Agent 通过浏览文件系统来发现工具:先列出 ./servers/ 目录,看看有哪些可用的服务器(比如 google-drive 和 salesforce),再按需读取具体的工具文件(比如 getDocument.ts 和 updateRecord.ts),以理解每个工具的接口。这样一来,Agent 只加载当前任务真正需要的那些定义。这一改变可以把 token 消耗从 15 万降到 2000——时间和成本节省了 98.7%,省下来的空间与预算可以转投给任务本身。
Cloudflare 也发表过类似的发现,并把这种「在 MCP 之上做代码执行」的方式称为「Code Mode」。核心洞见是相同的:LLM 非常擅长写代码,开发者应当利用这一长处,构建出能以更高效方式与 MCP 服务器交互的 Agent。
MCP 代码执行的收益
MCP 代码执行让 Agent 能够更高效地使用上下文:按需加载工具、在数据抵达模型之前先行过滤、并把复杂逻辑收进单步执行。除此之外,这种做法在安全和状态管理方面也有收益。下面逐一展开。
渐进式披露(progressive disclosure)
模型非常擅长在文件系统中导航。把工具以代码文件的形式呈现在文件系统上,模型就可以按需读取工具定义——需要哪个工具,就打开哪个文件——而不是一开始就把全部定义读进上下文。
另一种做法,是在服务器上增加一个 search_tools 工具,用来检索相关的定义。例如,在上文那个假想的 Salesforce 服务器上工作时,Agent 搜索「salesforce」这个关键词,就只加载当前任务需要的那几个工具。在 search_tools 工具里再加入一个 detail level(详细程度)参数,允许 Agent 选择自己需要的细节层级(比如只要名称、名称加描述,或者带完整 schema 的完整定义),同样有助于 Agent 节约上下文,并高效地找到工具。这种「先搜索、再读取、最后调用」的三段式,与传统的「先全量加载、再祈祷装得下」形成了鲜明对比:上下文里装的是目录索引,还是整套说明书,成本完全是两个量级。
上下文高效的工具结果
在处理大型数据集时,Agent 可以先在代码里对结果进行过滤和转换,然后再把精简后的结果返回。设想需要取回一张 1 万行的电子表格:
// 不用代码执行——所有行都流经上下文
TOOL CALL: gdrive.getSheet(sheetId: 'abc123')
→ returns 10,000 rows in context to filter manually
// 用代码执行——在执行环境里完成过滤
const allRows = await gdrive.getSheet({ sheetId: 'abc123' });
const pendingOrders = allRows.filter(row =>
row["Status"] === 'pending'
);
console.log(`Found ${pendingOrders.length} pending orders`);
console.log(pendingOrders.slice(0, 5)); // 只打印前 5 条供检查
这样,Agent 看到的是 5 行数据,而不是 1 万行。类似的模式还适用于聚合计算、跨多个数据源的 join,或者提取特定字段——无论哪种,都不会把上下文窗口撑大。这个模式的关键在于:过滤逻辑由模型只写一次,之后的数据搬运全部发生在执行环境里,模型只需要面对最终结果。
更强大且上下文高效的控制流
循环、条件分支和错误处理,都可以用熟悉的代码模式来完成,而不必把一个个工具调用串成长链。例如,如果你需要等待一条 Slack 上的部署完成通知,Agent 可以这样写:
let found = false;
while (!found) {
const messages = await slack.getChannelHistory({ channel: 'C123456' });
found = messages.some(m => m.text.includes('deployment complete'));
if (!found) await new Promise(r => setTimeout(r, 5000));
}
console.log('Deployment notification received');
这种写法,比在 Agent 循环里交替执行「MCP 工具调用 + sleep 命令」高效得多:轮询逻辑完整地活在一段代码里,而不是散落在多轮对话中。
此外,把一整棵条件树写出来交给执行环境一次性运行,还能节省「首 token 时间」(time-to-first-token,即从发出请求到收到第一个响应字之间的等待)上的延迟:不需要等模型逐一评估每个 if 语句,Agent 可以让代码执行环境代劳。换句话说,原本要花掉多轮模型往返的等待过程,被压缩成了一段普通代码的运行时间。
保护隐私的操作
当 Agent 使用 MCP 代码执行时,中间结果默认留在执行环境内。这样一来,Agent 只会看到你显式打印或返回的内容;你不希望暴露给模型的数据,可以在工作流中自由流转,却从不进入模型的上下文。
对于更敏感的工作负载,Agent harness(执行框架,即编排模型调用的外围程序)还可以自动把敏感数据令牌化(tokenize)。例如,设想你需要把客户联系方式从一张电子表格批量导入 Salesforce。Agent 会写出这样的代码:
const sheet = await gdrive.getSheet({ sheetId: 'abc123' });
for (const row of sheet.rows) {
await salesforce.updateRecord({
objectType: 'Lead',
recordId: row.salesforceId,
data: {
Email: row.email,
Phone: row.phone,
Name: row.name
}
});
}
console.log(`Updated ${sheet.rows.length} leads`);
MCP 客户端会拦截这些数据,并在它们到达模型之前,对 PII(个人身份信息,personal identifiable information)做令牌化替换:
// 如果 Agent 打印 sheet.rows,它看到的是:
[
{ salesforceId: '00Q...', email: '[EMAIL_1]', phone: '[PHONE_1]', name: '[NAME_1]' },
{ salesforceId: '00Q...', email: '[EMAIL_2]', phone: '[PHONE_2]', name: '[NAME_2]' },
...
]
之后,当这些数据在另一个 MCP 工具调用中被再次使用时,MCP 客户端会通过一张查找表把令牌还原成真实值。于是,真实的邮箱、电话和姓名从 Google Sheets 流向 Salesforce,却从不经过模型。这可以防止 Agent 意外地记录或处理敏感数据。你还可以基于同样的机制定义确定性的安全规则,明确指定数据可以从哪里流向哪里、不能流向哪里。换句话说,令牌化把「模型能不能看到敏感字段」从一个脆弱的提示词约束,升级成了协议层的确定性保证。
状态持久化与技能(skills)
带文件系统访问能力的代码执行,让 Agent 能够跨操作维持状态。Agent 可以把中间结果写入文件,从而实现断点续做和进度跟踪。对需要分阶段推进的长任务——比如先导出、再清洗、后导入——工作区里的文件就是天然的检查点:
const leads = await salesforce.query({
query: 'SELECT Id, Email FROM Lead LIMIT 1000'
});
const csvData = leads.map(l => `${l.Id},${l.Email}`).join('n');
await fs.writeFile('./workspace/leads.csv', csvData);
// 稍后的执行可以从上次停下的地方继续
const saved = await fs.readFile('./workspace/leads.csv', 'utf-8');
Agent 同样可以把自己的代码持久化为可复用的函数。一旦某个 Agent 为某个任务写出了可用的实现,它就可以把这段代码保存下来,供将来复用:
// 位于 ./skills/save-sheet-as-csv.ts
import * as gdrive from './servers/google-drive';
export async function saveSheetAsCsv(sheetId: string) {
const data = await gdrive.getSheet({ sheetId });
const csv = data.map(row => row.join(',')).join('n');
await fs.writeFile(`./workspace/sheet-${sheetId}.csv`, csv);
return `./workspace/sheet-${sheetId}.csv`;
}
// 之后,在任意 Agent 执行中:
import { saveSheetAsCsv } from './skills/save-sheet-as-csv';
const csvPath = await saveSheetAsCsv('abc123');
这与技能(Skills)的概念紧密相关——技能是可复用的指令、脚本和资源文件夹,用来提升模型在专门任务上的表现。给这些保存下来的函数加上一个 SKILL.md 文件,就构成了一个结构化的技能,模型可以随时引用和使用——函数本体负责「怎么跑」,SKILL.md 负责「什么时候用、怎么用」,两者合在一起才算一个完整的技能。随着时间推移,这会让你的 Agent 逐步建立起一个由更高层能力组成的工具箱,演化出它能最有效工作所需的脚手架。
需要注意的是,代码执行也引入了它自己的复杂性。要运行 Agent 生成的代码,就需要一个具备适当沙箱(sandbox,与宿主环境隔离的受控执行区域)隔离、资源限制和监控的安全执行环境。这些基础设施要求会带来直接工具调用所没有的运维开销与安全考量。代码执行的收益——更低的 token 成本、更低的延迟、更强的工具组合能力——应当与这些实现成本放在一起权衡,再决定是否采用。
小结
MCP 为 Agent 连接大量工具与系统提供了基础协议。然而,一旦接入的服务器过多,工具定义和工具结果就会消耗过多的 token,拖累 Agent 的效率。
尽管这里面的许多问题看起来很新颖——上下文管理、工具组合、状态持久化——但它们在软件工程领域早有成熟的解法——分别对应着缓存策略、函数库设计与检查点机制这些老朋友。代码执行把这些久经考验的工程模式搬到了 Agent 身上,让它们可以用熟悉的编程结构与 MCP 服务器更高效地交互。如果你实现了这种方案,我们鼓励你把你的发现分享给 MCP 社区。如果你的 Agent 正被不断膨胀的工具列表困扰,不妨从最常用的一台 MCP 服务器开始改造,先量出真实的 token 节省,再决定推广的范围。
致谢:本文由 Adam Jones 和 Conor Kelly 撰写。感谢 Jeremy Fox、Jerome Swannack、Stuart Ritchie、Molly Vorwerck、Matt Samuels 和 Maggie Vo 对本文草稿的反馈。
延伸阅读:如果你正在搭建自己的 Agent 工具链,建议先看 Agent 主题页梳理多 Agent 协作与编排的整体图景,再到 FDE 实战指南里找可以直接落地的操作步骤;想理解 Agent 写代码的能力如何改变日常交付方式,参见 AI Coding 主题页;而把工具链放回端到端项目的完整位置,见 FDE 项目生命周期指南。
本文由 FDEChina 团队翻译自原文,转载已注明出处;如需引用请以原文为准。