跳转到正文
莫尔索随笔
返回

Agent 应用:代码执行重构 MCP 工作流,节省 90% 上下文开销

预计 14 分钟

第一时间捕获有价值的信号

MCP 已成为 Agent 应用连接外部工具的标准,但普遍采用的“直接工具调用”方法存在严重效率问题,即工具定义和中间结果会大量消耗宝贵的上下文窗口。本文认为 Agent 应用应该转向“代码执行”模式:不直接调用工具,而是让 LLM 生成代码来与 MCP 服务器进行 API 交互。这种方式能更充分地利用 LLM 在编码方面的强大训练优势,极大降低 token 消耗,并实现更复杂、高效、私密和可持久化的工作流。本文核心思想是 code as meta toolCloudflare Anthropic 最近也分别写了篇博客来介绍,而 Manus 最开始就用的这个思路,我最早是从这篇论文Executable Code Actions Elicit Better LLM Agents了解到的(去年五月初),只是当时模型写代码的能力还没有现在这么强。

MCP 的现状:标准协议与效率瓶颈

MCP,即模型上下文协议,是一个开放标准,旨在解决 Agent 应用与外部系统连接的难题。在传统模式下,将智能体连接到各种工具和数据源,需要为每一种组合进行定制化的集成,这导致了严重的碎片化和重复劳动,阻碍了真正互联系统的规模化发展。MCP 提供了一个通用协议,开发者只需在他们的智能体中实现一次 MCP,就能解锁整个由集成组成的生态系统。自 2024 年 11 月推出以来,MCP 得到了迅速普及,社区已经构建了数千个 MCP 服务器,主流编程语言的 SDK 也已可用,使其成为行业内连接智能体与工具和数据的既定标准。

但随着智能体连接的工具数量从几个增长到数百甚至数千个,一个严重的效率瓶颈开始显现。这种瓶颈主要来自当前 MCP 的主流使用方式,即“直接工具调用”,它过度消耗 token,从而增加智能体的成本和延迟。

效率瓶颈

问题 1 :工具定义本身会使上下文窗口过载

大多数 MCP 客户端会在任务开始前,将所有可用工具的定义(包括描述、参数、返回值等)全部加载到模型的上下文中。例如,一个 Google Drive 的工具定义可能是 gdrive.getDocument,描述为从谷歌云端硬盘检索文档,参数包括 documentId 和 fields。一个 Salesforce 的工具定义可能是 salesforce.updateRecord,描述为更新 Salesforce 中的记录,参数包括 objectType、recordId 和 data。当一个智能体连接到数千个此类工具时,模型在读取用户请求之前,就必须处理包含海量工具定义的数十万 token,这极大地增加了响应时间和成本。

问题 2:工具调用的中间结果会消耗额外的 token

在标准流程中,模型发起的每个工具调用及其返回结果,都必须再次通过模型本身进行处理。以一个常见的任务为例:“从 Google Drive 下载我的会议记录,并将其附加到 Salesforce 的一个销售线索上。”模型会首先调用 gdrive.getDocument,获取到完整的会议记录文本。这个庞大的文本,比如一个两小时会议的 50,000 token 记录,会先被加载到模型上下文中。接着,模型为了执行下一步,需要调用 salesforce.updateRecord,并且在其 data 参数中,将这 50,000 token 的完整记录文本再一次原封不动地写入上下文。这意味着完整的转录稿在工作流中流经了模型两次,造成了巨大的浪费。如果文档过大,甚至可能直接超出上下文窗口的限制,导致工作流中断。

更深层次的问题在于,这种“工具调用”机制对 LLM 而言是“非自然”的。在底层,LLM 是通过特殊的 token(例如 <|tool_call|><|end_tool_call|>)来触发工具调用的。这些特殊 token 是 LLM 在其广泛的训练数据(如互联网文本和代码)中从未见过的。它们是模型开发者通过合成的、人为构建的训练数据强行教会模型的。因此,当面对大量或复杂的工具时,LLM 很难准确地选择和使用它们。这就好比让莎士比亚上了一个月中文课,然后要求他用中文写一部戏剧,这显然无法发挥他的最佳水平。

新范式:为何转向代码执行

面对直接工具调用带来的效率和自然度问题,一种更高效、更符合 LLM 本质的新范式应运而生:代码执行。这个见解的核心是,我们一直以来都用错了 MCP。我们不应该强迫 LLM 使用它们不擅长的、受限的“工具调用”语法,而应该利用它们真正擅长的能力——编写代码。

LLM 在编写代码方面表现出色,这得益于它们在训练阶段接触了来自数百万个开源项目的、海量的真实世界代码,例如 TypeScript 或 Python。相比之下,它们见过的“工具调用”示例则局限于模型开发者提供的少量人工数据集。因此,与其将 MCP 工具作为“工具”直接暴露给 LLM,不如将这些工具转换为一个标准的编程 API,比如一个 TypeScript API,然后要求 LLM 编写代码来调用这个 API。

首先,当工具以 TypeScript API 的形式呈现时,智能体能够处理更多、更复杂的工具。其次,这种方法在需要串联多个调用的复杂任务中大放异彩。在传统模式下,上一个工具的输出必须完整地输入给 LLM,LLM 再将其复制到下一个工具的输入中,浪费了大量时间和 token。而在代码执行模式下,LLM 可以编写一段代码,让数据在执行环境中直接传递,模型本身完全不需要接触这些中间数据,只需在最后读取它需要的最终结果即可。

让我们回到前面那个 Google Drive 和 Salesforce 的例子。在代码执行模式下,LLM 不会再进行两次工具调用,而是会生成类似这样的代码:

// 从 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 }
});

在这段代码中,transcript 变量(即那 50,000 token 的会议记录)只存在于代码执行环境中。它从 gdrive.getDocument 调用中获取,并被直接传递给 salesforce.updateRecord。这个数据流完全绕过了模型,根本没有消耗任何上下文窗口。模型唯一需要知道的可能只是最后的一个确认信息,比如 console.log('成功更新了 1 条记录')

那么,既然我们已经有了现成的 API,为什么还要费力地使用 MCP 呢?为什么不直接让 LLM 编写代码来调用 Google Drive 或 Salesforce 的原始 API?答案在于 MCP 提供的一个关键价值:统一性。MCP 为 Agent 应用提供了一种统一的方式来连接和了解一个 API。一个 Agent 应用可以使用一个它之前闻所未闻的 MCP 服务器,这在传统 API 中是很难做到的,因为每个传统 API 的连接、授权和文档格式都各不相同。MCP 在协议层面就统一了连接、授权(带外处理)和文档,这使得它成为 Agent 应用动态发现和使用工具的完美基础,即使这些工具最终是以代码 API 的形式被调用的。

代码执行的实现与核心优势

将 MCP 工具转换为代码 API 的实现方式有多种。一种方法是将所有连接的 MCP 服务器生成一个文件树。例如,./servers/ 目录下有 google-drivesalesforce 两个子目录,每个子目录下都有对应的 getDocument.tsupdateRecord.ts 文件。这些文件内部封装了对 MCP 工具的实际调用。另一种更动态的方式是,通过 SDK(如 Cloudflare 的 Agents SDK 中的 codemode )在连接时获取 MCP 服务器的 schema,并自动将其转换为一个完整的 TypeScript API,包含基于 schema 生成的文档注释。

servers
├── google-drive
   ├── getDocument.ts
   ├── ... (other tools)
   └── index.ts
├── salesforce
   ├── updateRecord.ts
   ├── ... (other tools)
   └── index.ts
└── ... (other servers)

无论采用哪种实现,这种转变都带来了几项超越传统工具调用的核心优势:

渐进式披露

在传统模式下,所有工具定义都必须预先加载。而在代码模式下,模型可以按需读取工具定义。如果使用文件树,模型可以通过探索文件系统(例如列出 ./servers/ 目录)来查找可用的服务器,然后只读取它当前任务所需的特定工具文件。如果使用动态 API,也可以提供一个 search_tools 工具,让智能体搜索“salesforce”相关的工具,并只加载它需要的定义。这种按需加载的方式极大地节省了上下文。在 Anthropic 的一个测试案例中,上下文使用量从 150,000 token 锐减到 2,000 token,节省了 98.7% 的时间和成本。

高效的上下文结果处理

当处理大型数据集时,智能体可以在代码中对结果进行过滤和转换,然后再将其返回给模型。比如获取一个包含 10,000 行数据的电子表格。在传统模式下,这 10,000 行数据将全部涌入模型上下文。而在代码执行模式下,智能体可以编写如下代码:const allRows = await gdrive.getSheet(...),然后执行 const pendingOrders = allRows.filter(row => row["Status"] === 'pending'),最后只通过 console.log(pendingOrders.slice(0, 5)) 向模型展示前 5 行待处理的订单。模型看到的只是 5 行数据,而不是 10,000 行。这种模式同样适用于数据聚合、跨多个数据源的连接或提取特定字段,完全不会使上下文窗口膨胀。

更强大的控制流

代码执行允许使用熟悉的编程结构,如循环、条件和错误处理,而不是依赖模型通过一系列单独的工具调用来拼凑逻辑。例如,如果需要等待一个 Slack 通知。传统方式是让模型反复调用 getChannelHistorysleep 命令,在模型和工具之间低效循环。而代码模式下,智能体可以直接编写一个 while 循环:while (!found) { ... if (!found) await new Promise(r => setTimeout(r, 5000)); }。这个循环在执行环境中高效运行,模型只在最后收到“部署通知已收到”的结果。这还减少了“首 token 时间”的延迟,因为逻辑判断(如 if 语句)由代码环境快速执行,而不是等待模型慢速评估。

数据隐私保护

在代码执行模式下,中间结果默认保留在执行环境中。模型只能看到你明确记录或返回的内容。这意味着你不想与模型共享的数据(例如 PII (Personally Identifiable Information, 个人身份信息))可以在工作流中流转,而无需进入模型的上下文。更进一步,MCP 客户端(即执行代码的“Harness”)可以自动对敏感数据进行标记化。例如,智能体编写代码将客户联系信息从表格导入 Salesforce。当数据流过时,客户端可以自动将真实的电子邮件和电话号码替换为 [EMAIL_1][PHONE_1] 这样的占位符。如果模型需要查看日志,它看到的只是这些占位符。而当这些数据被传递给下一个 MCP 工具(如 Salesforce)时,客户端会在数据离开环境前将其“去标记化”,恢复为真实数据。这样,真实的 PII 就实现了从 Google Sheets 到 Salesforce 的端到端流动,但从未被模型本身看到或处理。

状态持久化与技能(Skills)

代码执行与文件系统的访问相结合,允许智能体跨操作维护状态。智能体可以将中间结果写入文件,例如 await fs.writeFile('./workspace/leads.csv', csvData),以便在后续执行中读取 fs.readFile 来恢复工作。更重要的是,智能体可以将其自己开发的工作代码保存为可重用的函数,例如 saveSheetAsCsv。随着时间的推移,智能体可以建立一个由这些保存的脚本和资源组成的“技能”工具箱,不断进化它所需的高级能力,使其工作更有效率。

安全与实现:沙盒环境的挑战与方案

尽管代码执行带来了巨大的好处,但它也引入了一个必须解决的复杂问题:安全。运行由 Agent 应用生成的任意代码具有固有风险,这要求一个安全的执行环境,具备适当的沙盒(Sandbox)机制、资源限制和监控。这种基础设施需求增加了操作开销和安全方面的考量,这也是传统直接工具调用所避免的。

那么,在哪里运行这些代码呢?使用容器(Containers)?这太重了,启动缓慢且成本高昂。Cloudflare 提出了一种解决方案:Isolates。具体来说,是 Cloudflare Workers 平台基于 V8 的 Isolates。Isolates 远比容器轻量。一个 Isolate 可以在几毫秒内启动,仅占用几兆字节的内存。它们的速度快到我们可以为智能体生成的每一小段代码都创建一个全新的 Isolate,用完即扔,完全无需复用或预热。这种按需创建的开销几乎可以忽略不计。

为了实现这一点,Cloudflare 平台增加了一个新的 API,称为 Workers Loader API (Workers 加载器 API)。这个 API 允许一个 Worker(即 Agent 应用)按需加载另一个包含任意代码的 Worker(即沙盒,默认是完全隔离的,它无法访问互联网,全局的 fetchconnect 函数都会被禁止)。 code-execution-with-mcp-2

那么,这个被隔离的代码如何访问它需要的 MCP 服务器呢?答案是使用“绑定”(Bindings 功能)。在 Workers 中,环境 env 对象不仅可以包含字符串,还可以包含“绑定”,即实时的 JavaScript 对象。在代码模式下,沙盒环境会被注入代表其连接的 MCP 服务器的“绑定”对象。因此,智能体代码可以通过调用这些 JavaScript 接口来明确地访问被允许的 MCP 服务器,而无法进行任何通用的网络访问。

个人思考

“代码优先”的智能体流程控制

停止将 LLM 视为工具调用者,而是将其视为代码编写者。将所有外部工具(如 MCP)抽象为清晰的、类型化的代码 API(如 TypeScript)。让 LLM 编写代码来执行任务,而不是通过受限的工具调用语法。这能充分利用 LLM 的训练优势并提高效率。

“执行环境”数据处理思维

模型上下文是宝贵的,应不惜一切代价避免被中间数据污染。设计一个流程,让原始数据(如大型文件、数据库查询)在代码执行环境中被处理、过滤或聚合。只将最终的、最小化的结果或摘要返回给 LLM,供其决策。

基于绑定的安全沙盒

运行 AI 生成的代码必须安全。相比基于网络的过滤,基于能力的显式授予(explicit capability-granting)更安全。使用默认隔离(如 V8 Isolates)的环境,不授予通用网络访问权限,而是通过绑定将特定工具(如 MCP API)作为对象注入执行环境。


欢迎订阅合集AI 冷思考:个人在 AI 狂欢下的冷思考,聚焦 AI 工程化、Agent Infra 产品、效率型 AI 工具和人机协作话题,寻找可持续的产业价值,其他合集:

  • AI 博客精选:精心编译海外技术厂商发布的高质量 AI 领域博客文章
  • AI 播客捕手:专门面向文字内容爱好者分享 AI 领域优质播客。

如需加入 AI 交流群,后台回复交流,请务必备注(城市+行业+岗位+一句话介绍),否则不予通过。