最开始做这个系统时,我其实有一个很直接的目标:

让 LLM 能理解当前场景,然后根据用户指令完成操作。

用户说一句话,Agent 查数据,找到目标,再控制前端。

但后来场景规模继续上升后,我慢慢意识到,问题不在于 Agent 会不会调用工具,而在于它根本不应该看到那么多数据。

在我负责的数字孪生系统里,一个典型场景里经常会有上万个 entity。

每个 entity 又会带着大量属性:状态、指标、业务字段、前端状态、实时数据。

更容易被低估的是,很多时候甚至不需要属性,光是 ID 本身就已经足够给上下文带来压力。

例如在某些场景里,可能同时存在上万个 building。

仅仅是把这些 building ID 完整传给 LLM,用于后续筛选或控制,token 数量就已经非常夸张。

更不用说每个 building 后面还会跟着几千 token 的属性数据。

做到后面其实会越来越明显:

任何试图把完整场景数据直接塞进 LLM 上下文,再让模型做决策的方案,在规模上都很难持续。

这也是后来我开始尝试 MCP-as-Code 的原因。


最开始的 Tool 模式其实很直接

重构之前,我使用的是一种比较直接的 MCP Tool 调用方式。

系统会向模型提供两类 Tool。

一类负责查询场景数据。

另一类负责控制 3D 场景,比如上色、隐藏、变换、定位。

LLM 在这里更像一个指挥器。

它根据用户指令选择 Tool,拿到返回结果,再继续调用下一个 Tool。

例如用户说:

把所有建筑染红。

最直接的执行过程是:

  1. 调用 Tool 查询所有 building ID。
  2. 调用 Tool 对这些 ID 执行上色。

这个模式的优点非常明显。

它快。

它稳定。

参数类型、schema、权限这些问题,大多可以在真正执行前就被代码验证拦下来。

对于 UI 操作、状态修改、单实体查询这类任务,这个模型其实非常合适。

问题是,它默认了一个前提:

Tool 返回的数据不会太大。

在真实场景里,这个前提很快就不成立了。


真正的问题不是查不到数据,而是数据不该进上下文

一开始我关注的是:

API 能不能查到足够完整的数据?

后来发现这个问题问错了。

在这种规模下,更重要的问题是:

查到的数据应该放在哪里?

如果 Tool 把几千、几万条 entity 直接返回给 LLM,系统很快会进入一种很奇怪的状态。

模型并不是在认真分析任务。

它是在努力维持上下文。

大量 token 被用来承载原始数据,而不是用于推理、决策或控制。

这时 LLM 真正需要的其实不是“所有数据”。

它需要的是处理数据的方法。

也就是:

给它一套逻辑,让它知道如何过滤、聚合、统计、提取结果。

而不是让它吞下所有 entity,再在上下文里硬做筛选。

这也是 MCP-as-Code 对我最有吸引力的地方。

它把模型的角色从 Tool selector 推向了 logic writer。

模型不再只是选择一个 API。

它开始写处理逻辑。


Anthropic 的 MCP-as-Code 看起来正好解决这个问题

后来我读到了 Anthropic 的文章《Code Execution with MCP》。

它提出的思路很简单,但非常击中我当时的问题。

不要把所有 MCP Tool 都以 JSON Schema 的形式直接塞给模型。

把 MCP Server 看成一组可以被代码调用的 API。

然后让模型生成 Python 或 TypeScript,在受控的 sandbox 中执行。

模型最后只需要看到执行结果的摘要。

对于大数据集,模型不需要看到一万行原始数据。

它只需要看到前几行样本、统计结果,或者最终过滤出来的少量目标。

这个思路几乎正好对应我当时遇到的上下文问题。

所以我一开始对这次重构的预期非常明确:

大规模过滤、聚合、统计都放到后台执行。

LLM 只看结果。

上下文压力降下来。

模型从“工具选择器”变成“逻辑编写者”。

从设计上看,这是一条很自然的路。


我实际落地的是 Java + Python Sandbox

考虑到原有系统已经是 Java 核心服务,我没有推翻整个架构。

最后加的是一层 Python Sandbox。

Java 层继续负责:

  • session 管理
  • 鉴权
  • API 调用
  • 场景数据预过滤
  • 前端通信
  • 结果解析

例如在真正处理前,Java 会先向前端确认当前场景中存在的 ID,把不在场景里的数据过滤掉。

Python Sandbox 负责执行 LLM 生成的代码。

它通过 HTTP 和 Java 通信。

代码可以调用 MCP API,也可以通过 WebSocket 控制前端。

执行后返回 stdout、result、ui_events 和 errors。

从执行模型上看,LLM 不再是直接调用 Tool。

它进入了另一种循环:

写代码 → 执行 → 读结果 → 再写代码

这也是后面问题开始出现的地方。

因为我一开始想象的是,一段脚本可以完成多步逻辑。

但真实交互系统不是这样跑的。


后来脚本开始被不断打断

我最初的设想是:

让 LLM 生成一段 Python 脚本,把原来多个 Tool 调用合并起来。

查询、过滤、聚合、控制前端,尽量在一次执行里完成。

这样理论上既能减少上下文压力,也能减少多轮调用。

但真实运行后,这个假设很快被打破。

一个典型过程最后变成了这样:

  1. LLM 先生成一段 Python,用来查询或过滤 building ID。
  2. Java / Python 执行完成,把结果返回给 LLM。
  3. LLM 读取结果,重新判断下一步。
  4. LLM 再生成第二段 Python,用来通过 WebSocket 控制前端。
  5. 前端执行操作。
  6. 前端返回执行结果。
  7. LLM 再决定是否继续补救。

也就是说,脚本往往只执行了一小步,就必须停下来等下一轮反馈。

这不是因为脚本写得不够好。

而是因为交互式系统本身会不断产生新的状态。

前端是否真的执行成功。

哪些对象失败了。

当前场景里是否还有这些 ID。

权限、分页、异步结果有没有变化。

这些都需要下一轮反馈。

所以原本想象中的“一段脚本完成多步逻辑”,最后退化成了多轮串行往返。

这件事对延迟的影响非常明显。


简单操作反而变慢了

最典型的例子还是:

把所有建筑染红。

在原来的 MCP Tool 模式下,这类操作平均大约 2 秒。

到了 MCP-as-Code 模式,经常会变成 10 秒以上。

不是因为 Python 本身慢。

真正的开销来自整个执行链:

  • LLM 生成代码
  • Java 转发请求
  • Python Sandbox 执行
  • Java 解析 stdout / result / errors
  • LLM 读取结果并判断下一步
  • LLM 生成下一段代码
  • 前端执行动作
  • 再返回结果

每一步单独看都合理。

连在一起后,简单操作的延迟就被放大了。

这也是这次重构最早暴露出来的问题之一。

MCP-as-Code 确实降低了部分上下文压力。

但它也把一些原本很短的交互动作,放进了一条更长的执行链里。


失败开始出现在更晚的位置

另一个明显变化是失败发生的位置。

在原有 MCP Tool 模式里,很多错误发生在执行前。

Java 端有 JSON Schema Validator。

参数类型不对,字段缺失,权限不够,很多问题都可以在真正执行前被拒绝。

这类失败通常比较便宜。

Agent 收到的是一个结构化错误。

它可以改参数,或者换一个 Tool。

但到了 Python Sandbox 后,很多错误会变成运行时错误。

代码已经生成了。

Sandbox 已经启动了。

脚本已经开始跑了。

然后才发现字段不存在、类型不对、API 返回结构不符合预期,或者某个依赖不可用。

这时返回给 LLM 的往往是一整段 stack trace。

接下来 Agent 要做的事情变成:

读错误。

理解错误。

重写代码。

再次执行。

如果下一次又遇到新的运行时问题,就继续循环。

这并不说明 MCP-as-Code 不好。

它只是让我第一次非常清楚地看到:

MCP-as-Code 不只是把数据处理从上下文移到了 sandbox。

它也改变了失败发生的位置。


这次重构真正暴露的问题

这次尝试解决了一个真实问题。

LLM 不应该吞下完整场景数据。

对于大规模过滤、聚合、统计这类任务,把数据留在代码环境里,让模型只看摘要,确实更合理。

但它也暴露了另一个问题。

当一个任务不是离线分析,而是高频、交互式、强约束的系统操作时,Code execution 会带来新的成本。

原来一次 Tool 调用能完成的事情,可能会变成:

生成代码。

执行代码。

读结果。

修代码。

再次执行。

再控制前端。

再读取结果。

这时上下文压力是下降了。

但延迟、重试和运行时失败成本开始上升。

这是我一开始低估的地方。

我原本以为 MCP-as-Code 只是把数据处理从上下文搬到了 sandbox。

后来发现,它同时也改变了整个执行路径。

更准确地说:

它改变了失败发生的位置。

在原来的 Tool 模式里,很多错误可以在执行前被拦住。

到了 Code 模式里,很多错误会等到程序真正跑起来之后才暴露。

这个问题本身值得单独拆开讲。


最后

这次 MCP-as-Code 重构最大的价值,不是它直接解决了所有问题。

而是它让我看清楚了一个边界。

LLM 不应该吞下整个场景。

但也不是所有事情都应该交给代码执行。

对于大规模数据处理,Code 是更自然的环境。

但对于实时控制和强约束操作,Tool 仍然有很强的优势。

真正的问题开始变成:

为什么这两种方式会产生完全不同的失败成本?

以及为什么在一个多轮 Agent 系统里,这种差异会被不断放大?

下一篇我会先定义这个问题:

Tool 和 Code 不只是表达能力不同。

它们也会在不同时间失败。


References