Skip to content

在 Yolo 中实现 Programmatic Tool Calling:基于 QuickJS WASM 沙盒的 AI 代码执行

Harry
Lang
原文 中文

TL;DR: 当 AI Agent 需要查询实时数据并执行条件化或重复性动作时,将中间结果反复返回给模型会增加延迟并消耗上下文。在我的个人 AI 原生生产力应用 Yolo 中,我实现了一种本地形式的编程式工具调用 (Programmatic Tool Calling, PTC):模型编写一段小型 JavaScript 程序,Yolo 在 QuickJS WebAssembly 沙盒中执行它。该程序只能编排显式暴露的应用工具,而宿主应用继续强制执行 Schema 校验、权限确认、执行限制和撤销策略。

1. 核心瓶颈:模型中介的编排

一种常见的 AI Agent 架构使用的是迭代式的“模型-工具”循环:

用户请求


   LLM ──► 调用工具 ──► 宿主应用
    ▲                       │
    └────── 工具结果 ◄──────┘

这通常被描述为 ReAct 风格的循环,不过 ReAct 有更具体的学术含义:它交替生成模型的推理轨迹、动作以及来自外部环境的观察。参见 ReAct: Synergizing Reasoning and Acting in Language Models

这种范式在处理简单动作时表现良好:

“创建一个名为‘提交报告’的周五任务。”

模型可以生成一次工具调用,应用执行它,模型返回最终结果。

但当后续动作依赖于实时数据时,情况就变得更有趣了:

“找出我的‘Work’分类中所有过期的任务,并将它们重新安排到下周一。”

直接的工具调用工作流通常如下所示:

  1. 模型调用 list_tasks
  2. Yolo 返回匹配的任务列表。
  3. 模型检查这些结果并生成所需的 update_task 调用。
  4. Yolo 执行这些更新。
  5. 模型总结最终结果。

现代工具调用 API 可以在单次模型响应中返回多个独立的工具调用,因此更新 15 个任务并不一定需要 15 轮模型交互。独立的调用可以一次性发出并并行执行。

核心瓶颈在于顺序依赖深度 (sequential dependency depth)

由于模型在收到中间查询结果之前无法构造存在数据依赖的调用,因此每个依赖步骤都会强制触发另一轮模型交互:

因此,模型交互轮次是随顺序决策点的数量呈线性增长的,而不是随数据记录的条数增长。

额外开销的来源

实战经验侧记: 我在为 Hermes Agent 这类自主 Agent 设置定期日志分析时就遇到过完全相同的问题。每当 Agent 直接将未经清洗的原始日志拉入上下文时,它就会在噪声数据中白白烧掉海量的 Token 预算。解决方案非常简单:先用本地脚本清理和过滤日志,然后只将精简后的摘要发回给模型。编程式工具调用正是将这一模式提取出来,并内置为了开箱即用的自动化运行时能力。

2. 编程式工具调用 (Programmatic Tool Calling)

Yolo 没有要求模型在多个推理阶段中分别挑选每个操作,而是暴露了一个元工具:

execute_program

模型编写一段简单的 JavaScript 程序,该程序可以:

生成的程序成为了一个可执行的控制计划。

这种方法借鉴了 Anthropic 所阐述的 Programmatic Tool Calling 概念:模型编写代码并在代码执行环境中调用工具,而中间结果始终保留在模型上下文之外。

Cloudflare 描述了一种非常相似的架构,称为 Code Mode:模型接收一个代码执行工具,并编写一段程序来组合类型化的工具、处理其结果,并仅返回响应所需的信息。

更广泛的思想也出现在学术研究中:CodeAct 将可执行代码视为 Agent 的动作空间,而 PAL (Program-Aided Language Models) 则将确定性计算委托给解释器,而不是要求语言模型亲自执行每一步推理。

Yolo 将这一范式改编为了一个使用 JavaScript 和 QuickJS 的本地、模型无关运行时,允许配置不同的 LLM 来复用同一个编排运行时,同时安全策略继续由宿主应用强制执行。

3. 具体示例

假设用户提出以下请求:

“找出所有过期的 Work 任务并将它们移动到下周一。”

Yolo 要求模型生成一个程序体。执行引擎会将该程序体包裹在一个异步函数中,因此 awaitreturn 在生成的代码中都是合法语句。

由模型生成的简化程序体如下所示:

const targetDate = "2026-07-27";

const tasks = await list_tasks({
  scope: "overdue",
  category: "Work",
});

if (!tasks.ok) {
  return {
    ok: false,
    error: tasks.error,
  };
}

let applied = 0;
let queued = 0;
const failures = [];

for (const task of tasks.data) {
  const update = await update_task({
    task_id: task.id,
    due_date: targetDate,
  });

  if (!update.ok) {
    failures.push({
      taskId: task.id,
      error: update.error,
    });
  } else if (update.queued) {
    queued += 1;
  } else {
    applied += 1;
  }
}

log(
  `Matched ${tasks.data.length} tasks: ` +
    `${applied} applied, ${queued} queued, ` +
    `${failures.length} failed.`,
);

return {
  ok: failures.length === 0,
  matched: tasks.data.length,
  applied,
  queued,
  failed: failures.length,
  failures,
  targetDate,
};

宿主执行引擎通过将其包装在自执行的异步函数中来评估生成的程序体:

(async () => {
  // 生成的程序体
})();

从模型的角度来看,工具接口就是普通的异步 JavaScript:

const tasks = await list_tasks({ scope: "overdue" });

典型的执行路径包含两个模型推理阶段:

  1. 生成程序。
  2. 总结程序结果。

重要的区别在于:这并不是把 15 次数据库写入变成了一次数据库写入。上面的示例依然会为每个任务执行一次 update_task

相反,Yolo 将循环和中间决策从重复的模型推理中移出,转由在本地执行的结构化程序来承载。

bulk_update_tasks 这样专门构建的批量操作可以进一步减少领域工具的调用次数。编程式工具调用最适合用于:工作流需要灵活过滤、条件分支、组合或错误处理,而这些无法通过单一固定的批量 API 清晰表达的场景。

4. 架构设计

Yolo 是一款基于 Tauri v2、React 和 TypeScript 构建的桌面应用程序。

在模型之下,编程式运行时划分为四个主要层次:

┌───────────────────────────────────────────────────────────┐
│                         AI 模型                           │
│                 生成 JavaScript 程序                      │
└──────────────────────────┬────────────────────────────────┘


┌───────────────────────────────────────────────────────────┐
│                       程序校验层                          │
│          大小限制、语法检查、许可入口点                      │
└──────────────────────────┬────────────────────────────────┘


┌───────────────────────────────────────────────────────────┐
│                QuickJS WebAssembly 沙盒                   │
│                                                           │
│  循环 · 条件分支 · 临时状态 · JSON 数据处理                 │
│                                                           │
│  暴露的能力:                                              │
│  list_tasks · update_task · log                           │
└──────────────────────────┬────────────────────────────────┘
                           │ 显式宿主函数桥接

┌───────────────────────────────────────────────────────────┐
│                 Yolo 标准工具注册表                       │
│                                                           │
│  Schema 校验 · 权限检查 · 人工确认 · 撤销机制 (Undo)       │
└──────────────────────────┬────────────────────────────────┘


┌───────────────────────────────────────────────────────────┐
│                 SQLite / Tauri / 应用状态                 │
└───────────────────────────────────────────────────────────┘

A. 能力隔离 (Capability Containment)

如果直接在 Yolo 的主前端环境中通过 eval 执行模型生成的 JavaScript,该代码将能够访问同一 JavaScript Realm 中已存在的任何对象。

根据应用的不同,这可能包括:

虽然 Tauri 会对其 IPC 命令施加自身的权限和运行时权威检查,Webview 并不仅仅因为 JavaScript 在其内部运行就自动获得无限制的原生访问权。

然而,在主应用 Realm 中执行生成的代码会无谓地暴露大得多的攻击面。

因此,Yolo 将程序放在编译为 WebAssembly 的独立 QuickJS 实例中运行。

WebAssembly 模块天然无法隐式访问其宿主环境。嵌入者通过控制导入的函数和对象来严格决定哪些能力可用。

QuickJS 程序不会自动获得对以下内容的访问权限:

它仅接收 Yolo 显式安装的宿主函数。

这就是能力隔离:程序保留了循环、变量、数组和条件分支等正常语言特性,但其外部权威被限制在一个极小的允许列表中。

B. 桥接异步宿主工具

生成的程序使用标准的 async/await,但底层桥接需要显式处理。

普通的 quickjs-emscripten 宿主回调无法直接返回原生 JavaScript Promise 或普通的 JavaScript 对象并期望 QuickJS 自动接收它。

一种受到支持的做法是:

  1. 在 QuickJS 上下文中创建一个 Promise。
  2. 启动异步宿主操作。
  3. 将结果转换为 QuickJS 值。
  4. Resolve 或 Reject 该 QuickJS Promise。
  5. 执行挂起的 QuickJS Job,以便 Guest 程序能够恢复运行。

该库也提供了基于 Asyncify 的构建版本,适用于整个 WebAssembly 执行必须在异步宿主工作期间挂起的情况。由于 Asyncify 构建版在体积、性能、可重入性和挂起限制上有额外的开销,因此需要谨慎评估选择。

以下是延迟 Promise 桥接的简化实现:

type JsonValue =
  | null
  | boolean
  | number
  | string
  | JsonValue[]
  | { [key: string]: JsonValue };

type ToolExecutor = (
  args: Record<string, JsonValue>,
  options: { signal: AbortSignal },
) => Promise<JsonValue>;

function installJsonTool(
  name: string,
  execute: ToolExecutor,
  signal: AbortSignal,
): void {
  const hostFunctionName = `__host_${name}`;

  const hostFunction = vm.newFunction(
    hostFunctionName,
    (argsHandle) => {
      const args = vm.dump(argsHandle) as Record<string, JsonValue>;
      const deferred = vm.newPromise();

      void execute(args, { signal }).then(
        (result) => {
          try {
            const jsonString = JSON.stringify(result ?? null);
            const resultHandle = vm.newString(jsonString);
            deferred.resolve(resultHandle);
            resultHandle.dispose();
          } catch (err: unknown) {
            const message =
              err instanceof Error ? err.message : String(err);
            const errorHandle = vm.newString(message);
            deferred.reject(errorHandle);
            errorHandle.dispose();
          }
        },
        (error: unknown) => {
          const message =
            error instanceof Error
              ? error.message
              : String(error);

          const errorHandle = vm.newString(message);

          deferred.reject(errorHandle);
          errorHandle.dispose();
        },
      );

      deferred.settled.then(() => {
        vm.runtime.executePendingJobs();
      });

      return deferred.handle;
    },
  );

  vm.setProp(
    vm.global,
    hostFunctionName,
    hostFunction,
  );

  hostFunction.dispose();

  const toolNameLiteral = JSON.stringify(name);
  const hostNameLiteral = JSON.stringify(hostFunctionName);

  const wrapperResult = vm.evalCode(`
    globalThis[${toolNameLiteral}] = async (args) => {
      const json = await globalThis[${hostNameLiteral}](args);
      return JSON.parse(json);
    };
  `);

  vm.unwrapResult(wrapperResult).dispose();
}

从模型的角度来看,这种桥接机制是完全透明的。LLM 只需使用根据 Schema 描述的工具接口撰写符合习惯的异步 JavaScript(例如 await list_tasks(...)),而宿主运行时负责处理句柄生命周期管理、JSON 序列化以及 Job 队列唤醒。

5. 保留应用安全策略

编程式运行时是一种替代性的编排机制,它绝不是特权后门。

每一个暴露给沙盒的函数都会路由经过 Yolo 现有的工具注册表。

Schema 校验

注册表会在执行底层操作之前校验工具参数。

沙盒不能仅因为调用源自生成的代码就绕过工具的输入 Schema。

权限模式

Yolo 支持不同的执行模式:

无论工具是由模型直接调用还是通过 execute_program 调用,应用的权限模式都完全一致。

人工确认 (Human Confirmation)

在 Plan 或 Ask 模式下,修改类工具可以返回:

{
  "ok": true,
  "queued": true
}

随后 Yolo 会渲染预览卡片,而不是静默修改应用数据。

破坏性操作始终需要二次确认。

撤销与版本检查 (Undo and Version Checks)

成功的修改操作可以返回包含足够信息的 UndoOp 以供撤销。

Yolo 还会记录写入后的 updated_at 时间戳。在应用撤销操作之前,系统会验证该记录在此期间未被修改。

这防止了旧的撤销动作覆盖用户最新的手动编辑。

已实现的安全防护措施

为了防止死循环或过度的资源消耗,Yolo 实施了严格的运行时防护措施:

6. 安全模型

WebAssembly 沙盒很有用,但它只是安全设计中的一层。

真正的安全边界由以下多层组成:

  1. QuickJS/Wasm 隔离层。
  2. 注入的宿主函数集合。
  3. 工具参数校验。
  4. 应用权限检查。
  5. 确认策略。
  6. 资源配额。
  7. Tauri Capabilities。
  8. 审计与撤销行为。

其中最核心的原则是:

生成的代码绝不能获得超出用户与应用策略预期赋予的权限。

因此,宿主函数桥接本身也是攻击面的一部分。

例如,安全的 update_task 函数不应该接收任意的 SQL 片段。它应该只接收经过严格校验的窄对象:

{
  task_id: string;
  due_date: string;
}

沙盒限制了代码可以在哪里执行,而工具注册表限制了代码可以做什么。两者缺一不可。

7. 局部失败与幂等性

一段程序可能会在其中某一步失败之前已经执行了若干次修改。

例如:

任务 1 已更新
任务 2 已更新
任务 3 已更新
任务 4 失败
任务 5 尚未尝试

运行时绝不能将其简单报告为成功或失败。它应该返回结构化的执行摘要:

{
  "matched": 5,
  "applied": 3,
  "queued": 0,
  "failed": 1,
  "notAttempted": 1
}

未来强化工作

为了实现可靠的批量工作流,Yolo 还需要考虑:

沙盒可以限制程序,但无法自动将业务操作变得具备事务性。

8. 直接工具调用 vs. 编程式工具调用

设:

维度直接工具调用 (Direct Tool Calling)编程式工具调用 (Programmatic Tool Calling)
模型推理阶段通常随依赖深度 ($D$) 增加,而不一定随数据条数 ($N$) 增加通常固定为 2 个阶段:生成程序与总结结果
领域工具执行次数$O(N)$,除非有现成的批量工具$O(N)$,除非有现成的批量工具
并行操作当调用相互独立时支持当宿主桥接和业务规则允许时支持
中间结果频繁进入模型上下文可以完全留在执行环境中
控制流分散在多次模型响应中通过 JavaScript 显式表达
模型延迟在每个依赖决策阶段都会增加当本地代码处理中间决策时显著降低
工具延迟依然存在依然存在
执行开销工具序列化与模型编排工具序列化、沙盒启动与 VM 执行开销
失败处理模型或宿主协调每个阶段程序可以聚合失败,但生成的逻辑本身可能出错
安全攻击面工具 Schema 与宿主策略工具 Schema 与宿主策略,加上沙盒和代码桥接
最佳适用场景小型、固定、易于审查的单次动作存在数据依赖的循环、过滤、分支及多工具组合

因此,编程式工具调用并不能自动在所有场景下都带来速度提升。

它的核心优势体现在:

9. 何时不应使用

在以下情况下,直接工具调用通常是更好的选择:

例如,实现以下需求的最佳方式:

“将所有选中的任务标记为完成。”

可能只需简单调用:

complete_tasks({
  task_ids: selectedTaskIds,
});

当窄范围、经过充分测试的领域操作已经能够精准表达用户意图时,没有理由再去生成一段程序。

编程式工具调用应当是对良好工具设计的补充,而不是替代。

总结

编程式工具调用并没有把 $N$ 次数据库操作变成一次操作。

它将存在数据依赖的编排逻辑从重复的模型推理中移出,转由在受限环境中执行的结构化程序来承载。

在 Yolo 中,QuickJS 提供了 JavaScript 运行时,WebAssembly 帮助建立了隔离的执行边界,而现有的工具注册表则保留了权限、校验、确认和撤销行为。

其最终成果是一个混合架构:

对于复杂的 Agent 工作流而言,这种清晰的职责分离远比仅仅减少工具调用次数更为重要。

参考文献

  1. Yao et al., “ReAct: Synergizing Reasoning and Acting in Language Models”
  2. Anthropic, “Programmatic Tool Calling”
  3. Anthropic, “Introducing Advanced Tool Use on the Claude Developer Platform”
  4. Anthropic, “Code Execution with MCP: Building More Efficient AI Agents”
  5. Cloudflare, “Code Mode”
  6. Cloudflare, “Create a Durable Code Mode Runtime”
  7. Wang et al., “Executable Code Actions Elicit Better LLM Agents”
  8. Gao et al., “PAL: Program-Aided Language Models”
  9. quickjs-emscripten Documentation
  10. WebAssembly Core Specification
  11. Tauri v2 Capability Reference
  12. Tauri v2 Runtime Authority
Previous
5 分钟释放 23 GB 硬盘空间:Rust 与 Tauri 项目的 Cargo 瘦身指南
Next
Yolo 开发记录 02:从记录一天,到看清一天