我给 Agent 造了一个"元工具":让它自己写工具,再用三道门锁住风险

上周调试一个 Agent 时,它对着"解析简历"的任务卡住了------工具箱里翻遍了,没有能吃 Markdown 的工具。

常规做法:我停下来,手动写一个解析器,保存,重启,会话继续。

我这次没这么做。我给了它一个工具:meta_tool。剩下的事情,它自己走完了。


一、先回答一个老问题:这真的需要 Agent 吗?

在展开设计之前,先诚实地回答一个容易被跳过的问题------这个需求值得引入 Agent 吗?

值得。但不是我最初以为的那个理由。

我最初想的是"让 Agent 更自主"。写完第一版我发现真正的价值是另一个:把一次性的、依赖开发者响应速度的卡点,变成一次性的、沉淀下来的资产。

区别在这:

手写工具 meta_tool
谁来决定要什么 人(读到日志后) 模型(当场判断)
阻塞时长 人回到电脑前的时间 单轮对话内
产物寿命 用完即弃 落盘到 generated/,永久可复用
第二次遇到同类需求 再手写一遍 直接调用

关键在于:这是一个 Evaluator-Optimizer 模式的极小切片 ------模型发现缺口 → 生成 → 门禁校验(评估)→ 不通过则带着错误信息自纠 → 通过则落盘。它不是"让 Agent 更聪明",而是给 Agent 装了一个"自己长出新器官"的能力。

如果你要解决的只是"把固定几步跑通",那不需要这套东西,写个 DAG 编排脚本更稳、更便宜、可测试。这套方案的复杂度是明码标价的------值得付的前提是:工具需求的长尾足够长,长到人工响应的成本超过这套机制的复杂度。


二、整体流程

模型发现能力缺口后,调用 meta_tool,此后一切由系统接管:

flowchart TD A[模型扫描 registry.all 发现能力缺口] --> B[meta_tool: name / code / testArgs] B --> C[进入内部串行队列] C --> D[GATE 01 静态门] D -->|name 非法 / 超 100KB / 与内置重名| F[返回错误字符串] D -->|通过| E[GATE 02 人工门] E -->|用户拒绝| F E -->|确认| G[GATE 03 运行门] G -->|语法错误 / 契约不符 / 冒烟失败| F G -->|通过| H[rename 原子落盘] H --> I[registerGenerated 注入注册表] I --> J[下一轮 bindTools 后可见] F -->|错误回喂模型| A J --> K[下次启动 loadGenerated 自动装载] K --> A style D fill:#eef2f7 style E fill:#fdf4e7 style G fill:#eaf0fe style F fill:#fdeee8 style H fill:#e6f4f1

完整流程图见文末配图(矢量 SVG,可无损缩放)。

六个环节,逐个说人话。


三、契约:生成的工具长什么样

先划定边界------模型生成的代码必须长成什么样。这是所有门禁的检查依据。

javascript 复制代码
// 生成文件的默认导出契约 ------ 以 parse_markdown_resume 为例
export default {
  name: 'parse_markdown_resume',
  description: '把 Markdown 格式的简历解析为结构化字段。用户提到简历、CV、岗位投递时使用。',
  schema: {
    type: 'object',
    properties: {
      markdown: {
        type: 'string',
        description: '完整的简历 Markdown 原文。',
      },
    },
    required: ['markdown'],
  },
  async run(args) {
    const lines = String(args.markdown ?? '').split('\n');
    return { personalInfo: { name: lines[0] ?? '', sections: lines.length } };
  },
};

四条不能商量的规则:

  1. name 同时就是文件名,必须与调用时传的参数一致------这是静态门能零成本校验的前提
  2. 必须是自包含 ESM :只依赖 node: 内置模块或项目里已安装的依赖,不允许运行时联网安装
  3. schema 必须是 type: 'object' 的 JSON Schema------否则模型侧的工具 schema 转换会炸
  4. 运行时不做严格参数校验,run 自己防御非法输入------把校验塞进 schema 只会让模型多猜,多错

第 4 条容易被质疑"为什么不校验得更严"。我的判断是:schema 是给模型决策 用的提示,不是给运行时 用的护栏。模型看到 pattern 会避开非法值,但攻击性输入、工具被误用的情况依然存在------所以真正的兜底必须写在 run 里。


四、GATE 01 · 静态门:不花一分执行成本,先把明显的挡掉

javascript 复制代码
// gate1_static.mjs
const NAME_RE = /^[a-z][a-z0-9_]*$/;
const MAX_SOURCE_BYTES = 100 * 1024;

export function staticGate({ name, code }, builtinNames) {
  if (typeof name !== 'string' || !NAME_RE.test(name)) {
    return fail(`name 必须匹配 ${NAME_RE},收到:${JSON.stringify(name)}`);
  }

  const bytes = Buffer.byteLength(code, 'utf8');
  if (bytes > MAX_SOURCE_BYTES) {
    return fail(`源码 ${bytes} 字节,超过上限 ${MAX_SOURCE_BYTES}`);
  }

  if (builtinNames.has(name)) {
    return fail(`与内置工具重名:${name}。手写工具优先,生成工具不可覆盖内置实现`);
  }

  return { ok: true };
}

function fail(message) {
  return { ok: false, error: message };
}

三行校验,三个意义:

  • ^[a-z][a-z0-9_]*$ ------同时挡住了路径穿越(../)、命令注入(;、$())和 Windows 保留名。文件名是安全边界的一部分,不能随便
  • 100 KB 上限------生成的源码是要进上下文、要在磁盘上长期存在的,无界增长迟早出事
  • 重名检查------这是"手写优先"原则的执行点

手写优先不是一句口号,注册表用两行代码就实现了它:

javascript 复制代码
// registry.mjs
const builtin = new Map();     // 手写工具
const generated = new Map();   // 运行时生成工具

export function all() {
  // 先铺生成的,再铺手写的;Map.set 同 key 后写覆盖先写
  // => 同名时手写工具永远胜出,生成工具无法覆盖内置实现
  return new Map([...generated, ...builtin]);
}

比"遍历时判断优先级"这种写法干净得多,也更容易在代码评审里被看懂。


五、GATE 02 · 人工门:整个设计里我最不敢删的一环

javascript 复制代码
// gate2_human.mjs
import { createInterface } from 'node:readline/promises';

export async function humanGate({ name, code, testArgs }, { autoApprove = false } = {}) {
  // 非交互环境默认拒绝:宁可少一个工具,也不静默执行模型生成的代码
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
    return {
      ok: false,
      error: '非交互环境默认拒绝;如确需放行,请显式设置 META_TOOL_AUTO_APPROVE=1',
    };
  }

  if (autoApprove) {
    return { ok: true, approvedBy: 'META_TOOL_AUTO_APPROVE' };
  }

  process.stdout.write(`\n即将创建工具 ${name},完整源码如下:\n\n${code}\n`);
  if (testArgs) {
    process.stdout.write(`冒烟测试参数:${JSON.stringify(testArgs, null, 2)}\n`);
  }

  const rl = createInterface({ input: process.stdin, output: process.stdout });
  try {
    const answer = (await rl.question('确认创建?[y/N] ')).trim().toLowerCase();
    return answer === 'y'
      ? { ok: true, approvedBy: 'human' }
      : { ok: false, error: '用户拒绝:未 import、未测试、未写文件、registry 未变更' };
  } finally {
    rl.close();
  }
}

这一门承担了全部安全责任,所以每个设计细节都不是随意的:

为什么是"展示完整源码"而不是"展示摘要"? 因为审批的前提是人真的能看懂。摘要让人点头签字,却把判断权交了出去------那不叫 human-in-the-loop,叫 rubber stamp。

为什么非交互环境默认拒绝,而不是默认放行? 默认放行意味着:CI 里跑一次测试,就可能执行模型凭空生成的任意代码。默认拒绝的代价只是"自动化脚本用不了这个能力",收益是"代码库不会被无声污染"。这个不对称是我要的。

为什么拒绝时返回"无 import、无测试、无文件、registry 不变"? 因为这是给人看的回执。收到这句话的开发者,才知道自己不需要去清理任何东西。

META_TOOL_AUTO_APPROVE=1 为什么必须显式设置? 环境变量不会"碰巧"是 1。它必须是有人、在知情的前提下、主动敲进去的。这个开关的存在本身就是一份审计记录。

⚠️ 把 meta_tool 当作 L2 级(不可逆写) 工具来对待。它落盘的文件会在后续所有会话中被加载执行------审批的那一秒,责任就转移出去了。


六、GATE 03 · 运行门:让代码自己证明自己

前面两门都是文本检查。这一门是真的把代码跑起来。

javascript 复制代码
// gate3_runtime.mjs
import { writeFile, rename, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';

const GENERATED_DIR = 'src/tools/generated';

export async function runtimeGate({ name, code, testArgs }, validateModule) {
  // 唯一文件名:绕过 ESM 模块缓存,否则同名重试会命中上次的旧模块
  const rand = Math.random().toString(36).slice(2, 8);
  const tmpPath = join(GENERATED_DIR, `.tmp-${name}-${Date.now()}-${rand}.mjs`);

  await writeFile(tmpPath, code, 'utf8');

  try {
    const mod = await import(pathToFileURL(tmpPath).href);
    const tool = validateModule(mod.default);      // 校验默认导出契约

    let smoke = null;
    if (testArgs) {
      smoke = await tool.run(testArgs);            // 真实调用一次,不是假调用
    }

    const finalPath = join(GENERATED_DIR, `${name}.mjs`);
    await rename(tmpPath, finalPath);              // 同分区原子替换

    return { ok: true, tool, smoke, path: finalPath };
  } catch (err) {
    await rm(tmpPath, { force: true });            // 失败不留痕
    return { ok: false, error: err?.message ?? String(err) };
  }
}

四个关键点:

① .tmp-<name>-<ts>-<rand>.mjs ------ 时间戳加随机数,不是多余的 Node 的 import() 有模块缓存。同一个文件名第二次 import(),拿到的是缓存里的旧模块------冒烟测试会测到上一次的代码。唯一文件名是绕过缓存的最小成本手段。

② 用 pathToFileURL() 而不是拼字符串 Windows 下路径是 C:\...,直接拼进 import() 会因为反斜杠和盘符解析出错。这是个跨平台必踩的坑。

③ testArgs 是"真实调用",不是形式检查 静态门能验证"形状对不对",但验证不了"逻辑对不对"。让模型自己带上一个真实的输入样本,跑一次------这是模型唯一一次能自己证明自己的机会 。没有 testArgs 的工具能通过门禁,但它的正确性从未被验证过,这一点应该让使用者知道。

④ 失败返回字符串,而不是抛异常

javascript 复制代码
// 错误回喂模型,让它自己纠:
return { ok: false, error: `错误:${message}` };

抛异常会打断整个 Agent 循环;返回字符串则把失败变成模型下一轮的输入。这是 Evaluator-Optimizer 模式里 "Evaluator" 的实际形态------它给的不是分数,是可执行的修改意见。


七、原子落盘:为什么这套设计不需要回滚逻辑

javascript 复制代码
// 三道门全过之后
await rename(tmpPath, join(GENERATED_DIR, `${name}.mjs`));
registerGenerated(name);
return `已创建工具 ${name};冒烟测试结果:${preview.slice(0, 400)}`;

很多"生成并保存"的实现会这样做:先删旧的,再写新的。这里如果第二步失败,旧文件已经没了------你得到一个坏掉的工具,而且没有备份可恢复。

改成"写临时文件 → 同分区 rename()"之后:

  • rename() 在同一文件系统内是原子操作:不存在"旧文件已删、新文件未写"的中间态
  • 只有三道门全部通过,才产生唯一的持久化副作用
  • 任何一步失败,rm(temp, { force: true }) 清掉临时文件,旧版本和 registry 条目原封不动

"更新失败"这个最烦的分支,被文件系统的一个特性消解了。 没有备份,没有回滚栈,没有事务日志------因为根本没有中间态需要恢复。

还有个容易忽略的细节:结果预览截断到 400 字符。冒烟结果可能是个几 MB 的解析结果,原样回喂会瞬间撑爆上下文。截断比报错好------报错会让整条链路崩掉。


八、"下一轮生效":一个反直觉但必须做的限制

javascript 复制代码
// meta_loop.mjs ------ 每轮重新绑定
async function runLoop(initialMessages, maxSteps = 10) {
  let messages = initialMessages;

  for (let step = 0; step < maxSteps; step++) {
    // 关键:每轮都按当前注册表重新绑定,而不是复用上一轮的工具列表
    const tools = registry.all();
    const response = await llm.invoke(messages, { tools });

    messages.push(response);

    const calls = response.tool_calls ?? [];
    if (calls.length === 0) {
      return messages;                      // 没有工具调用 = 正常收敛
    }

    for (const call of calls) {
      const tool = tools.find((t) => t.name === call.name);
      messages.push(new ToolMessage(await tool.run(call.args)));
    }
  }

  return messages;
}

注意 for (const call of calls) 里的工具是从本轮开始时的 tools 快照里查的。

这带来一条明确的限制:不允许在同一轮里创建工具并立刻调用它。

原因是同一条 assistant 消息里的多个 tool_call 是并行执行 的,顺序没有保证。模型在同一轮里发 meta_tool + 调用新工具,新工具大概率还没进注册表。

所以规则是:新工具从下一轮模型调用开始可见。 这不是实现偷懒,是并行的必然结果。与其假装它能用,不如把规则写清楚------模型知道"下一轮才能用",就不会在同一轮里空等一次失败的调用。

顺带一提,maxSteps 是硬性上限。没有它,一个陷入"创建失败 → 重试 → 再失败"的模型能把成本烧穿。


九、把安全边界摊开说

这套东西没有沙箱,我不打算假装有。

生成代码和 Agent 同进程运行。它能访问什么,取决于 Agent 本身能访问什么。

明确列出来,比含糊其辞有用:

边界 现状 说明
代码执行隔离 ❌ 无 与 Agent 同进程,无容器、无 seccomp
创建前人工确认 ✅ 有 展示完整源码,等 y/N
非交互环境 ✅ 默认拒绝 需显式 META_TOOL_AUTO_APPROVE=1
写入范围 ✅ 受限 只写 generated/,不碰手写工具
产物可读 ✅ 是 纯文本源码,随时可检查或删除
网络访问 ⚠️ 不受限 取决于生成代码自身和 Agent 进程权限

怎么用才安全:

  • 把它当成"在受控环境里跑的开发者",而不是"安全的自动化"
  • 人工门不要习惯性闭眼按 y------你按下的那个键,等价于 node 执行一段你还没读完的代码
  • CI / 无人值守环境保持默认拒绝
  • 需要更强隔离,让 Agent 本身跑在容器或独立进程里,meta_tool 继承那个边界

最后一条我想强调:真正的安全边界应该由 Agent 的运行环境提供,而不是由工具自己声称。 工具层能做的是"不成为最短的那块板"。


十、几个可复用的经验

① 串行队列不是优化,是正确性 创建请求先进内部串行队列。否则同一条消息里多个创建请求会并发争抢注册表和文件------而且用的是 ESM,还有模块缓存。队列是为了正确,不是为了快。

② 启动扫描要能容错

javascript 复制代码
for (const file of await readdir(GENERATED_DIR)) {
  try {
    register(await load(file));
  } catch (err) {
    console.warn(`跳过损坏的生成工具 ${file}:${err.message}`);
  }
}

一个坏文件不能让整个 Agent 起不来。

③ 错误信息是给模型看的 UI "错误:..." 不是日志,是下一轮模型的输入。写它的时候想想模型读到之后能不能自我修正。"失败"没用,"name 必须匹配 ^a-za-z0-9_*$,收到 Foo-Bar" 才有用。

④ 给模型的约束要能被验证 每条硬约束都应该能回答"怎么知道它有没有被违反":

  • name 合法?→ NAME_RE.test()
  • 不超大小?→ Buffer.byteLength()
  • 契约正确?→ validateModule() + 真实调用
  • 没被越权覆盖?→ registry 合并顺序

验证方式缺失的约束,等于没有约束。

⑤ 上下文是有限资源 冒烟结果截 400 字符、错误信息说人话、schema 里能不传的参数就别传------每一处都是在给模型的注意力预算省钱。


十一、局限与下一步

诚实地列一下现在还做不到的:

  1. 无沙箱------最大的短板。真要上生产,必须靠运行环境隔离补上
  2. 无自动评测------冒烟测试只验证"能跑通",不验证"跑得对"。缺一份 ≥20 条的评测集覆盖边界与对抗输入
  3. 无成本护栏 ------还没有 token 预算和单次任务成本上限,maxSteps 只是最粗的一道闸
  4. 可观测性薄弱------缺 trace_id 贯穿和关键指标(成功率 / P95 / 工具失败率)

下一步优先级:

  1. 加 trace_id 与门禁各段耗时埋点,先能定位问题
  2. 补评测集,重点是"生成的工具本身有 bug"的场景
  3. 加成本上限,与 maxSteps 联动
  4. 探索把运行门放进子进程,配合 node:vm 或容器做轻量隔离

写在最后

给 Agent 一个"自己造工具"的能力,最吸引人的理由是自主。

但真正让它能安全落地的,恰恰是那些不自主的部分:静态门不灵活、人工门很烦人、必须下一轮才能用。

自主性是能力,约束是代价。 这套设计里所有让我觉得"多余"的检查,最后都变成了我敢在生产里按下运行键的理由。

如果这套东西对你有启发,欢迎交流。我也在持续加固它------尤其是那个"没有沙箱"的洞。


文中结论的验证情况

本文的几个关键论断不是"听起来合理",是在 Node 22 上实际跑过验证的:

论断 验证方式 结果
静态门能挡住非法名 / 超大源码 / 重名 4 个用例逐一跑 staticGate 全部按预期拒绝
new Map([...generated, ...builtin]) 实现手写优先 同名注入后读 all() 返回 BUILTIN,生成工具无法覆盖
唯一文件名可绕过 ESM 缓存 同名工具连跑三次,每次内容不同 依次拿到 v1/v2/v3,未命中旧缓存
门禁失败不留痕、旧文件保留 写入临时文件后 rm,再 readdir 目录仅剩 x.mjs,无残留

顺带一个实测发现:验证过程中若用 .pathname 拼路径再 import(),Windows 上会直接抛 ERR_UNSUPPORTED_ESM_URL_SCHEME------这正好印证了文中强调用 pathToFileURL() 的必要性。

声明

文中代码为依据本文所述契约重写的示意实现 ,用于说明设计意图;你仓库中的真实实现可能与此有出入(尤其是 validateModule、registerGenerated 与具体 Agent 框架的绑定细节)。文中未使用任何未经核实的第三方 API 签名------涉及框架绑定的部分请以你所用框架的官方文档为准。

相关推荐
9i编程1 小时前
5. 带 AI 从零搭低代码平台·踩坑实录:测试脚本按界面重写,别拿假数据糊弄我
人工智能·openai·ai编程
一麻袋土豆灬2 小时前
Workbuddy模型名后面那个 0.11x 是什么意思
大数据·程序人生·语言模型·ai编程
熊猫钓鱼>_>2 小时前
Kotlin Multiplatform for OpenHarmony 实战:kotlinx-coroutines 结构化并发适配
开发语言·kotlin·agent·harmonyos·openharmony·适配·华为云码道
前端双越老师2 小时前
程序员 1v1 访谈系列第四期:全栈比 Agent 重要
agent·ai编程·全栈
流烟默2 小时前
A2A第 1 篇:认识 A2A——Agent 之间的 HTTP
agent·a2a
SharpCJ2 小时前
Koog(6)——会话记忆、历史压缩与执行恢复
llm·agent·koog
Web3&Basketball2 小时前
litellm 做 Agent 成本监控:3 个账单维度实战
python·大模型·agent·可观测性·litellm·成本控制·llm运维
羞儿3 小时前
Cordis 内核:五概念、生命周期与事件
llm·agent·harness·cordis 内核
一缕82年的清风3 小时前
生产级多智能体协作避坑:死锁防范与确定性状态机实战
agent