上一篇里,我写了一次 MCP-as-Code 的重构尝试。

它确实缓解了一部分上下文压力,但也暴露了另一个更隐蔽的问题:Code execution 改变了失败发生的位置。

在一个交互式 Agent 系统里,这件事影响很大。

这里先说明一下我说的“交互式 Agent 系统”是什么。

它不是一个只生成文本的聊天机器人。

它会真的操作系统:调用后端 Tool,读取返回结果,再控制前端场景。

每一步执行后,系统都会返回一些反馈,比如查询结果、错误信息、前端回执,或者一部分成功、一部分失败的结果。

后文里我会统一把这些叫做“反馈”。

这也是为什么这篇文章会反复讨论一个问题:下一步动作为什么经常依赖上一步反馈。

这篇文章主要讲这个边界:为什么 Tool 和 Code 不是同一类动作的两种写法,以及为什么有些 Agent 系统天然会变成多轮。


有些 Agent 任务无法稳定地一步完成

我当时系统里很常见的一个任务是:

找出异常实体,并在前端高亮。

乍看起来,这像是一条很简单的流程。

查询数据。

过滤实体。

发送 UI 指令。

结束。

但真实交互系统通常不会这么干净。

更真实的流程往往是:

  1. 先确认需要的字段是否存在。

  2. 检查数据覆盖率是否足够。

  3. 只对有效子集进行过滤。

  4. 处理分页,或者只返回了一部分数据的情况。

  5. 向前端发送高亮指令。

  6. 读取前端是否执行成功的回执。

  7. 如果只有一部分对象执行成功,再重试或降级。

这里真正重要的是,每一步都依赖上一步返回了什么。

字段不存在,下一步查询就会变。

数据不完整,过滤方式就会变。

前端拒绝了一部分操作,补救路径也会变。

所以很多 Agent 任务很难被稳定压缩成一次调用。

多轮并不总是说明 Agent 不够聪明。

有时候是系统本身就需要反馈闭环。

Agent 并不知道系统的完整状态。

它只能看到每一步执行后的反馈。

只要下一步动作依赖这些反馈,runtime 就天然会变成多轮。


Tool 和 Code 的失败发生在不同时间

当我开始从这个角度看系统时,Tool 和 Code 的差异就变得更清楚了。

它们不只是表达能力不同。

更关键的是:系统能在什么时候拒绝一个错误动作。

Tool-based execution 通常有一个有限的动作集合。

每个 Tool 都有 schema。

参数在执行前就已经确定。

很多错误可以提前拦下来:类型不对、字段缺失、枚举值非法、权限不足。

失败发生在真正执行之前。

这让失败相对便宜。

Agent 收到的是结构化错误,通常可以改参数,或者换一个 Tool。

这也是为什么 Tool 仍然非常适合控制类操作。

修改 UI 状态。

选中对象。

隐藏图层。

更新已知字段。

触发一个边界清晰的后端操作。

这些动作需要窄。

窄不是缺点。

窄是它们安全、稳定、可预测的原因。

Code execution 则不一样。

动作不再是从一个小集合里选择。

Agent 会生成一段程序。

这段程序可以包含循环、分支、搜索、聚合、重试和中间状态。

这让 Code 的表达能力强很多。

对于大规模筛选、统计、批处理,或者任何需要遍历数据集的任务,这正是你想要的能力。

但很多错误只有在程序真正跑起来之后才会出现。

字段不存在。

类型和模型预期不同。

API 返回结构有细微差异。

依赖不可用。

前端操作只成功了一部分。

到了这一步,系统已经进入 runtime execution。

Agent 需要读错误、理解错误、重写代码,再执行一次。

这是完全不同的 failure model。


多轮系统会放大小成本

在一次性任务里,一个小失败就是一个小失败。

但在多轮系统里,同样的成本会叠起来。

每多一轮,都可能增加:

  • latency

  • token 消耗

  • context pressure

  • retry 成本

  • 系统状态继续变化的机会

这也是为什么一个单独看起来可以接受的设计,一旦放进交互循环里,就可能变得很贵。

一段生成代码可能只需要几秒。

一次结果总结可能只多几百 token。

一次 retry 看起来也不严重。

但如果一个任务本身需要五六轮反馈,这些小成本会很快叠起来。

这是我第一次 MCP-as-Code 重构时低估的地方。


回到异常实体筛选这个任务

在异常实体筛选这个任务里,这个差异非常明显。

数据侧更需要 Code。

实体可能有几千、几万条。

schema 不一定完全稳定。

某些字段可能缺失。

有些过滤条件需要聚合或派生计算。

把这些都塞进 LLM 上下文不是一个好选择。

Code 才是更自然的执行环境。

文件、DataFrame、循环、过滤和统计都应该在那里完成。

但控制侧仍然更需要 Tool。

一旦目标集合确定,前端操作就需要快速、有边界、可验证。

高亮这些对象。

隐藏这个图层。

移动相机。

应用这个样式。

如果一个 schema-validated Tool 能直接完成这些动作,就没有必要再生成一段脚本。

真正的分界线不是 MCP 还是 Code-as-MCP。

而是两类工作。

一类是数据处理。

另一类是控制。

数据处理更适合 Code。

控制更适合 Tool。

如果强行把两者塞进同一条执行路径,成本就会开始出现。


验证时机成了真正的边界

后来我越来越觉得,validation timing 是一个更有用的视角。

Tool-based execution 更容易在执行前失败。

Code-based execution 更容易在运行时失败。

这个差异会改变整个成本结构。

执行前失败通常比较便宜。

运行时失败通常更贵。

不是因为 runtime failure 一定不好。

而是因为恢复需要再进入一轮循环。

Agent 要检查发生了什么。

系统要保留足够上下文来支持恢复。

下一步动作可能要改变。

在高交互系统里,这个恢复循环才是真正的成本。

这也是为什么 Code 很适合低交互的分析任务。

如果任务主要发生在数据集内部,Code 可以在分析路径里运行、失败、恢复、总结。

用户不需要看到每个中间步骤。

前端也不需要确认每一个操作。

最后输出可以被压缩。

但如果任务和 UI 状态、权限、前端部分执行、用户可见动作紧密绑定,晚失败就会变得非常昂贵。


这也是为什么后面不能只看表达能力

经过那次 MCP-as-Code 重构后,我越来越觉得,单独比较 Tool 和 Code 的表达能力是不够的。

Code 能表达更复杂的逻辑。

但它也把很多错误推迟到了运行时。

Tool 的表达能力更窄。

但它能在执行前拦住更多问题。

如果一个系统本身是多轮的,这个差异会被不断放大。

所以真正需要比较的不是:

哪一种方式更强。

而是:

在当前任务里,失败应该尽量发生在什么时候。

这也是下一篇会继续展开的问题:如果 Tool 和 Code 适合不同类型的任务,runtime 应该什么时候让 Agent 切换路径。


最后

这部分工作里,对我最有用的经验其实很简单:

有些 Agent 系统是多轮的,因为系统本身就是多轮的。

Agent 只能看到部分反馈。

系统会在动作之间继续变化。

前端操作可能只有一部分成功。

后端结果可能不完整。

schema 也可能漂移。

一旦这些情况存在,失败发生的时间就会变得和动作表达能力一样重要。

Tool 和 Code 都有用。

但它们会以不同方式失败。

而在一个高交互 Agent 系统里,它们在哪里失败,往往比它们能表达什么更重要。

下一篇会继续讲这个切换问题。