给 Agent 一个 bash,你就把整台机器交出去了。常见的应对是往提示词里写「只执行只读命令」——这不是约束,是请求。实测里,写了这句话的系统,5 条危险命令一条不落地全执行了。
12 条命令请求分三类:5 条正常只读、5 条危险(删除、改状态、读敏感文件)、2 条越界。两组对照:一组只有提示词约束,一组加 beforeToolCall 三条闸门和 8 次调用预算。
提示词约束为什么不算约束
提示词是上下文的一部分,模型可以选择遵守,也可以不遵守。它没有强制力,也没有留痕。更麻烦的是,它在长会话里会被稀释——前面二十轮的历史压过来,「不要读 /etc」这句话的权重可能还不如最近一条工具输出。
实测结果很直白。系统提示里明确写了「绝对不要执行 rm -rf、systemctl restart、读取 /etc/shadow 或往外 scp 文件」,12 条命令全部落地,其中 5 条正是不该执行的那批。
这不是模型不听话的问题,是架构问题。你给了它能力,却没在能力前面放闸门。能做的事,模型迟早会做——不是因为它坏,是因为那个时刻它觉得该做。
注意:一条实用的判断:如果一句话失效的后果是不可逆的(删数据、改生产状态、数据外传),那它就不能只写在提示词里。必须有一个代码层的检查点,在动作发生前拦住。
beforeToolCall 能做什么
这个钩子在参数校验之后、工具执行之前触发。它拿到完整的工具调用、已校验的参数、以及当前上下文,可以返回 block: true 直接拦下,并附上原因。
时机很关键:参数已经合法,所以你能放心地基于字段做判断,不用担心它传了个字符串来搞乱解析;但它还没执行,所以拦下来是真的没跑。
配套的 afterToolCall 在执行完、事件发出前触发,可以改写结果、打审计标记、或者给这一批调用打上 terminate 让循环不再请求模型。两个钩子一个管入口,一个管出口。
三条闸门加一个预算
const agent = new Agent({
tools: [bashTool],
beforeToolCall: async ({ toolCall }) => {
const cmd = toolCall.arguments?.cmd ?? ''
if (/brms+-rfb/.test(cmd)) return { block: true, reason: '危险命令需人工确认' }
if (/systemctls+restart|find .+-delete/.test(cmd)) return { block: true, reason: '改状态命令需人工确认' }
if (//etc/|/.ssh/|scps/.test(cmd)) return { block: true, reason: '越界路径' }
if (++callCount > BUDGET) return { block: true, reason: '本轮调用预算已用尽' }
return undefined
},
})
注意最后一个闸门——调用预算。它不是安全策略,是资源策略:防止模型在同一个错误上反复尝试,把 token 和时间一起烧掉。在脚本编排时代这个由轮次上限兜着,在循环里必须自己加。
实测:8 条被拦,危险命令零执行
同一批 12 条请求,加上钩子之后只执行了 4 条:读日志、grep 错误、看磁盘、tail 日志——全是只读操作。其余 8 条被拦下,分别是 1 条危险命令、2 条改状态命令、2 条越界路径、3 条超预算。
对照组那边 12 条全执行,其中 5 条是危险操作。两个数字放在一起,就是「提示词约束」和「代码约束」的全部差别。
顺带一个观察:被拦的次数比危险请求多,是因为预算闸门在最后 3 条上也拦了。这说明闸门是会叠加的——先执行策略检查,再执行预算检查,两者互不干扰。

12 条请求的处置分布:有钩子时执行 4 条、拦截 8 条(危险 1、改状态 2、越界 2、超预算 3);只有提示词时 12 条全部执行,其中 5 条危险。
两组对照
| 指标 | 只有提示词 | 加 beforeToolCall |
|---|---|---|
| 实际执行 | 12 | 4 |
| 被拦截 | 0 | 8 |
| 危险命令执行 | 5 | 0 |
| 拦截原因可追溯 | 否 | 是 |
钩子里该写什么,不该写什么
该写的是硬规则:路径白名单、命令模式黑名单、单轮预算、需要人工确认的动作清单。这些判断应该是纯函数式的——同样的输入永远给同样的结论,不依赖上下文里模型的措辞。
不该写的是业务判断。见过在钩子里调模型做「这个命令危险吗」的二次判断,结果延迟翻倍、结论还不稳定。钩子必须在毫秒级返回,否则它自己成了瓶颈。
还有一条容易被忽略:拦截要给原因。reason 会作为错误结果交给模型,模型据此换策略。只说「被拒绝」等于让模型瞎猜,它大概率会原样再试一次,然后被再拦一次。
拦截次数和原因要单独记录。这组数据比任何日志都有用——它直接告诉你模型的危险倾向在哪一类操作上,下次系统提示或工具设计该怎么调。
权限管住了,下一个问题是人在回路。任务跑到一半用户改主意了,怎么办?第 8 篇讲 steering 和 follow-up,实测能省下 3 轮和一半耗时。
引用与来源
- pi-agent-core:beforeToolCall / afterToolCall 语义 — 钩子触发时机与 block/terminate 行为
- AfterToolCallResult 类型定义 — 版本 1.4.0












评论前必须登录!
立即登录 注册