定时运行的AI Agent失去人工监督后,需从运行频率、文件变更核查、执行日志可追溯三方面加护栏,防止意外失控。
在你面前运行的 agent 不需要护栏,因为你自己就是护栏。你能看到计划、能看到 diff,错误时会及时阻止。同一个 agent 放到 cron 里跑,那些判断力就全消失了。它醒来、做点什么、提交——你第一次去看的时候,已经是几个小时之后了,或者是第二天早上,又或者是已经出了问题的时候。
一旦如此,三个问题就成了承重墙。它可以跑多少次才需要人来看?它是否只动了应该动的文件?它实际做了什么——以你能核查的形式,而不是听它自己说?
这些答案都不需要框架。以下是每个问题的实现方式,使用普通 Node.js,不依赖任何包。
最明显的护栏是消费上限,对于按量计费的 API key 来说这确实是对的。但很多定时运行的 agent 是按固定订阅收费的——比如 Claude Code 的 Max 方案——根本没有每次调用的美元数字可以计量。成本在那里不是失控风险。真正的风险是 agent 唤醒的频率远超你预期,可能是 cron 条目写错了,或者某次运行崩溃导致重试,或者你根本忘了这个定时任务的存在。
所以限制你能真正计数的东西:每月运行次数。
关键细节在于计数来源。一个你自己递增并存储的计数器会以你察觉不到的方式出错,因为任何在写回计数器之前就死掉的运行,都会悄无声息地为自己买到一次免费运行。应该用你已有的时间戳来推导计数:
export function cyclesInMonth(ledger, yyyyMm = new Date().toISOString().slice(0, 7)) {
return ledger.cycles.filter((c) => String(c.startedAt).slice(0, 7) === yyyyMm).length;
}
export function assertCycleBudget(ledger, maxPerMonth, yyyyMm) {
const used = cyclesInMonth(ledger, yyyyMm);
if (used >= maxPerMonth) {
const err = new Error(`Cycle budget exhausted: ${used}/${maxPerMonth} used this month.`);
err.code = "CYCLE_BUDGET_EXCEEDED";
throw err;
}
return { used, max: maxPerMonth, remaining: maxPerMonth - used };
}
ISO 时间戳使得月份比较变成了字符串切片,这就是为什么要以这种方式存储它们的原因。在运行最开头调用它,在 agent 做任何事情之前——超出的预算应该在产生副作用之前就停止工作,而不是工作到一半再停。
还有一件事值得做:当护栏触发时,打印一个单词,agent 自己的指令告诉它遇到这个单词就停止。我的打印 HALT,调度提示里也明确说了 HALT 表示立即停止并结束这一轮。这样限制就被执行了两次——一次在代码里,一次在指令里——两者互不依赖。
这就是普通情况下的上限,而普通情况占了绝大多数。有三种特殊情况会突破这个上限:运行在写入记录之前崩溃为自己买到了免费一次;月边界在错误时区读取导致一年有两天不受计量的运行;两个运行同时启动都读到了低于上限的计数。最后一个我是在刻意竞态之后才发现的——它大约五分之四的时间成立,这就是为什么它能通过测试套件。
有文件写权限的 agent 可能写错文件。通常是意外。偶尔是因为它读到了不该信任的某处指令——一个 README、一个 issue 评论、一个它抓取的页面——然后按文字说的做了。
你可以在写入时尝试预防,权限系统是值得拥有的。但在另一端有一个成本低得多的检查可用:在任何东西被提交之前,将工作树与 agent 被允许操作的路径前缀列表做对比。
import { execFileSync } from "node:child_process";
const normalize = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "");
export function parsePorcelain(out) {
return out
.split("\n")
.filter((line) => line.length > 3)
.map((line) => {
const path = line.slice(3).trim();
const arrow = path.indexOf(" -> "); // renames read "old -> new"
return arrow === -1 ? path : path.slice(arrow + 4);
})
.map((p) => normalize(p.replace(/^"|"$/g, "")))
.filter(Boolean);
}
export function checkScope(allowedPrefixes, cwd = process.cwd()) {
const prefixes = allowedPrefixes.map(normalize);
const changed = parsePorcelain(execFileSync("git", ["status", "--porcelain=v1"], { cwd, encoding: "utf8" }));
const violations = changed.filter((p) => !prefixes.some((prefix) => p.startsWith(prefix)));
return { ok: violations.length === 0, changed, violations };
}
这就是整个护栏。checkScope(["products/", "site/content/"]) 返回那些不应该出现在 diff 中的路径,你来决定这是警告还是硬性停止。
有一个细节会咬你,我被咬过——我就是这样把这个 bug 一起发货的。git status --porcelain=v1 发出的是 XY 路径格式,X 和 Y 是单个状态字符,它们任意一个都可能是空格。暂存的新增是 A file。未暂存的修改是 M file,前面有空格。如果你在切片状态列之前 trim 这一行,空格就没了,你就多切了一个字符:
raw: " M state/ledger.json"
trimmed: "M state/ledger.json"
sliced: "tate/ledger.json" ← 错了,而且看起来几乎是对的
几乎对就是那种危险的错。state/ledger.json 在允许列表里;tate/ledger.json 不在,所以护栏对 agent 明确允许写入的文件报了违规。而且它只在未暂存的更改上误报——也就是说,正是预提交范围检查存在的目的。先切片,再 trim。如果你自己写一个,用 " M path" 这行喂测试。
同时处理重命名(R old -> new,其中目的地才是改变的部分)和 git 用双引号包裹含特殊字符路径的习惯。
「agent 说它更新了文档」不是审计跟踪。它是被审计对象在事后根据记忆写的总结。
审计跟踪是在每个动作发生的时刻写入的记录。只需要一个仅可追加的 JSON 文件:一个 cycles 数组,一个 events 数组,每个条目都有 ISO 时间戳和类型。
{
"cycles": [
{ "id": 2, "startedAt": "2026-08-07T21:02:11.884Z", "endedAt": "2026-08-07T21:29:40.512Z", "summary": "…" }
],
"events": [
{ "at": "2026-08-07T21:04:02.117Z", "type": "decision", "text": "Fix the scope guard before writing anything new" }
]
}
两个属性使得它比日志文件更有价值。它是运行上限计数的同一份数据,所以护栏和历史记录不可能不一致。而且因为它是仓库里的一个小文本文件,在每个周期结束时提交它,使得 git log 变成了无人值守 agent 实际做了什么以及为什么这么做的历史——每次提交都承载着变更和产生这些变更的推理过程,并排呈现。
在事件发生时记录,而不是在末尾做总结运行。运行到一半崩溃的 run 应该仍然留下它在死之前做了什么——那是你最想回头看的 run。
三个护栏希望被实现为 agent 在指令开头和结尾调用的两个命令:
// start
const ledger = loadLedger(LEDGER_PATH);
try {
assertCycleBudget(ledger, MAX_CYCLES_PER_MONTH);
} catch (err) {
if (err.code === "CYCLE_BUDGET_EXCEEDED") { console.log("HALT —", err.message); process.exit(0); }
throw err;
}
startCycle(ledger);
saveLedger(LEDGER_PATH, ledger);
// finish
const scope = checkScope(ALLOWED_PREFIXES, ROOT);
if (!scope.ok) notifyOwner(`Out-of-scope changes: ${scope.violations.join(", ")}`);
finishCycle(ledger, summary);
saveLedger(LEDGER_PATH, ledger);
commitAll(`cycle ${ledger.cycles.length}: ${summary}`);
注意 finish 是警告而非抛出。在提交时发现的范围违规是信息,而通常有用的响应通常是仍然提交但大声标记——变更已经存在于磁盘上,拒绝记录它并不能撤销它,只会让它更难看到。
开头要抛出,结尾要警告。
它们不是沙箱。范围检查是在写入之后运行的,所以它捕获的是已发生的事而非阻止它——如果越范围写入是真正有破坏性的而非仅仅错误的,就把它和真正的文件系统权限配合使用。
运行上限不是消费上限。如果你在按量计费的 API key 上,成本是你担心的东西,那数 token 而不是数运行;那是另一个不同形状的问题。
三个护栏都注意不到从未发生的运行。它们每一个都由运行触发——上限计数运行,范围检查检查运行写入的东西,账本记录运行——所以当调度器不触发的那天,三个都安静,日志看起来就是一个安静的正常日。捕获那个靠的是对调度器的算术运算,而不是护栏能观察到的任何东西——这是最可能一周都无人注意到的失败模式。
这些都不能让 agent 的工作变好。它让工作有界限、可归因、可审查,这是让它无人值守运行的前提——而不是阅读它所做工作的替代品。
三个文件,大约 150 行,无依赖。如果你喜欢写这种东西,值得花一个下午。如果你想跳过这个下午——包括那个 porcelain bug——下面的打包版本包含这三个加上一个 PreToolUse 写入护栏(在写入落地前拒绝意外写入)、一个追踪器告诉你这个钩子是否被调用、以及一个测试套件来验证每个函数。最后那部分不是凑数的:失败模式是沉默的护栏通过任何审查但在每次运行中都失败。
最初发表于 fewparts.co.uk。
我写关于无人值守运行 agent 的内容,并出售这个代码的打包版本——Agent Guardrails Kit,£22.00。先说在前面因为你点一下就能查出来。