上周调试一个 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,可无损缩放)。
六个环节,逐个说人话。
三、契约:生成的工具长什么样
先划定边界——模型生成的代码必须长成什么样。这是所有门禁的检查依据。
// 生成文件的默认导出契约 —— 以 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 } };
},
};
四条不能商量的规则:
name同时就是文件名,必须与调用时传的参数一致——这是静态门能零成本校验的前提- 必须是自包含 ESM:只依赖
node:内置模块或项目里已安装的依赖,不允许运行时联网安装 schema必须是type: 'object'的 JSON Schema——否则模型侧的工具 schema 转换会炸- 运行时不做严格参数校验,
run自己防御非法输入——把校验塞进 schema 只会让模型多猜,多错
第 4 条容易被质疑"为什么不校验得更严"。我的判断是:schema 是给模型决策用的提示,不是给运行时用的护栏。模型看到 pattern 会避开非法值,但攻击性输入、工具被误用的情况依然存在——所以真正的兜底必须写在 run 里。
四、GATE 01 · 静态门:不花一分执行成本,先把明显的挡掉
// 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 上限——生成的源码是要进上下文、要在磁盘上长期存在的,无界增长迟早出事
- 重名检查——这是"手写优先"原则的执行点
手写优先不是一句口号,注册表用两行代码就实现了它:
// registry.mjs
const builtin = new Map(); // 手写工具
const generated = new Map(); // 运行时生成工具
export function all() {
// 先铺生成的,再铺手写的;Map.set 同 key 后写覆盖先写
// => 同名时手写工具永远胜出,生成工具无法覆盖内置实现
return new Map([...generated, ...builtin]);
}
比"遍历时判断优先级"这种写法干净得多,也更容易在代码评审里被看懂。
五、GATE 02 · 人工门:整个设计里我最不敢删的一环
// 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 · 运行门:让代码自己证明自己
前面两门都是文本检查。这一门是真的把代码跑起来。
// 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 的工具能通过门禁,但它的正确性从未被验证过,这一点应该让使用者知道。
④ 失败返回字符串,而不是抛异常
// 错误回喂模型,让它自己纠:
return { ok: false, error: `错误:${message}` };
抛异常会打断整个 Agent 循环;返回字符串则把失败变成模型下一轮的输入。这是 Evaluator-Optimizer 模式里 "Evaluator" 的实际形态——它给的不是分数,是可执行的修改意见。
七、原子落盘:为什么这套设计不需要回滚逻辑
// 三道门全过之后
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 的解析结果,原样回喂会瞬间撑爆上下文。截断比报错好——报错会让整条链路崩掉。
八、"下一轮生效":一个反直觉但必须做的限制
// 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,还有模块缓存。队列是为了正确,不是为了快。
② 启动扫描要能容错
for (const file of await readdir(GENERATED_DIR)) {
try {
register(await load(file));
} catch (err) {
console.warn(`跳过损坏的生成工具 ${file}:${err.message}`);
}
}
一个坏文件不能让整个 Agent 起不来。
③ 错误信息是给模型看的 UI
"错误:…" 不是日志,是下一轮模型的输入。写它的时候想想模型读到之后能不能自我修正。"失败"没用,"name 必须匹配 ^[a-z][a-z0-9_]*$,收到 Foo-Bar" 才有用。
④ 给模型的约束要能被验证 每条硬约束都应该能回答"怎么知道它有没有被违反":
- name 合法?→
NAME_RE.test() - 不超大小?→
Buffer.byteLength() - 契约正确?→
validateModule()+ 真实调用 - 没被越权覆盖?→ registry 合并顺序
验证方式缺失的约束,等于没有约束。
⑤ 上下文是有限资源 冒烟结果截 400 字符、错误信息说人话、schema 里能不传的参数就别传——每一处都是在给模型的注意力预算省钱。
十一、局限与下一步
诚实地列一下现在还做不到的:
- 无沙箱——最大的短板。真要上生产,必须靠运行环境隔离补上
- 无自动评测——冒烟测试只验证"能跑通",不验证"跑得对"。缺一份 ≥20 条的评测集覆盖边界与对抗输入
- 无成本护栏——还没有 token 预算和单次任务成本上限,
maxSteps只是最粗的一道闸 - 可观测性薄弱——缺 trace_id 贯穿和关键指标(成功率 / P95 / 工具失败率)
下一步优先级:
- 加 trace_id 与门禁各段耗时埋点,先能定位问题
- 补评测集,重点是"生成的工具本身有 bug"的场景
- 加成本上限,与
maxSteps联动 - 探索把运行门放进子进程,配合
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 签名——涉及框架绑定的部分请以你所用框架的官方文档为准。