AI 辅助开发

使用护栏钩子在 AI 代理造成破坏前将其阻止

AI 代理会运行真实的工具,偶尔也会运行错误的工具。

本文由 AI 模型从英文原文翻译而来,措辞可能与原文有出入。 阅读英文原文

AI 编码智能体不只是建议文本。它会运行工具:执行 shell 命令、写入文件、提交,有时还会部署。在大多数情况下,它都能胜任。但它偶尔会尝试一些你绝不会批准的操作,而当你读到记录时,命令早已运行完毕。一条“小心”的提示并不能阻止这种情况,因为模型可以忽略它。能阻止它的是钩子(hook):一段位于智能体工具路径上的代码,它会检查每个调用,并能在其运行前拒绝该调用。本文将介绍这些钩子如何工作、应将它们置于何处,以及何时仅靠提示就已足够。

钩子所针对的失败场景

值得部署的智能体是那些你允许其接触真实系统的智能体。这也正是它们的危险所在。给模型一个 shell,它偶尔会使用一个在理论上没问题但在特定情境下却是灾难性的命令:

  • 针对错误目标运行的破坏性命令。在后来发现是符号链接的路径上执行 rm -rf,或在并非一次性的数据库上执行 DROP
  • 提交了密钥。智能体暂存了所有文件,而所有文件中包括了你本想保留在本地的 .env 文件。
  • 本应等待的部署。在模型看来,变更已经完成,于是它直接将其推送到了生产环境。
  • 过早地宣布“完成”。智能体宣布任务完成,而此时测试仍未通过,或检查清单还有一半未完成。

这些问题都不是由糟糕的模型引起的。它们源于一个有能力的模型在信息不完整的情况下采取行动,其速度之快,让人类没有机会在决策和行动之间进行干预。事后审查为时已晚。命令已经执行。

为什么提示不是控制措施

显而易见的修复方法是将规则写入系统提示中。“切勿运行破坏性命令。切勿提交机密。切勿未经批准进行部署。”这有帮助,你也应该这样做,但这并非一种控制措施。它只是一个有较高成功率的建议。

提示塑造了模型下一步行为的概率分布。大多数时候,该分布倾向于安全的路径。但它仍然是一个分布,在数千次工具调用中,尾部事件总会发生。模型会误读上下文,三千个 token 之前的指令权重降低,不安全的命令终究还是会输出。你无法审计一个概率。你无法证明提示在下一次调用中仍然有效。

钩子(hook)则在本质上有所不同。它不是给模型的建议,而是位于模型决策与实际效果之间的代码。模型可以随心所欲地想要运行该命令。但如果钩子返回拒绝,该命令就不会运行。提示是引导。钩子是强制执行。两者做的是不同的工作,当失误的代价很高时,后者是唯一你可以真正依赖的。

钩子(hook)存在于何处

智能体(Agent)运行时会暴露一小组切入点,你可以在这些点上将自己的代码附加到工具执行路径中。这些切入点的名称在不同框架中有所不同,但其形式是一致的。其中两点最为重要。

第一个在工具运行前触发。运行时会将工具名称及其参数传递给你的钩子,然后你的钩子会返回一个决策:允许、修改后允许,或拒绝并附带理由。这里的拒绝意味着工具永远不会执行,并且智能体会收到你的理由作为反馈。

第二个在智能体尝试结束其回合时触发。运行时会询问你的钩子是否允许停止。如果工作实际上尚未完成,你的钩子可以阻止停止操作,并返回一个继续执行的指令。

一个工具前置钩子(pre-tool hook)大致如下:

def pre_tool_use(tool_name, tool_input):
    if tool_name == "bash":
        cmd = tool_input["command"]
        if is_destructive(cmd):
            return deny(
                reason="This command can delete data. Use the "
                       "approved migration script, which takes a backup first."
            )
        if is_direct_deploy(cmd):
            return deny(
                reason="Direct deploys are blocked. Run ./scripts/deploy.sh, "
                       "which enforces the review gate."
            )
    return allow()

重要的部分不是模式匹配,而是返回值。钩子不会只发出警告然后让开。当它拒绝时,运行时会丢弃该工具调用。智能体的下一个观察结果是理由字符串,而不是命令输出,因为命令从未运行过。

三个不可或缺的钩子

并非每条规则都值得设置一个钩子。这三个钩子涵盖了危害最大且误报最少的失败场景。

针对破坏性和特权命令的工具前置守卫。 根据命令的形式进行匹配,而非其背后的意图。例如,递归强制删除、数据库的 drop 和 truncate 操作、对受保护分支的强制推送、对部署工具的直接调用。拒绝只是工作的一半。返回的原因应当指明安全的替代方案,这样智能体才有路可走,而不是重试同样的操作。

针对未完成工作的停止钩子。 在让智能体结束其回合之前,检查任务的客观状态。清单是否已完全勾选?测试套件在上次运行时是否通过?如果未通过,则阻止其停止。

def on_stop(session):
    if session.tests_last_status == "fail":
        return block(reason="Tests are failing. Fix them before finishing.")
    if session.checklist_has_open_items():
        return block(reason="Checklist still has open items. Complete them.")
    return allow_stop()

这个钩子可以阻止过早地宣布“完成”。当客观信号表明任务尚未完成时,智能体无法宣布胜利,因为这个声明本身就是一个被该钩子拦截的工具调用。

提交前的密钥扫描。 拦截提交操作,并检查暂存区的内容。如果其中包含 .env 文件、私钥、credentials.json 文件,或匹配已知密钥模式的行,则拒绝提交。

def pre_tool_use(tool_name, tool_input):
    if is_commit(tool_name, tool_input):
        for path in staged_files():
            if looks_like_secret(path) or contains_key_material(path):
                return deny(
                    reason=f"{path} looks like a secret. Unstage it and add "
                           f"it to .gitignore before committing."
                )
    return allow()

git 历史记录中的密钥一旦被推送,移除成本高昂,且必须被视为已泄露。这个钩子的成本低廉,但它所阻止的失败代价却很高。

保持钩子有用的设计原则

有几条规则可以将有用的护栏与变成噪音的护栏区分开来。

默认阻止。 当钩子无法判断一个操作是否安全时,它应该阻止,而不是允许。阻止一个安全的操作会让你付出一次重试和一条更清晰指令的代价。而允许一个不安全的操作则会让你付出一个事故的代价。这种不对称性是钩子存在的全部原因,所以让模棱两可的情况倒向安全的一边。

让拒绝信息具有可操作性。 一个只返回“denied”而没有其他信息的钩子会让代理去猜测,而它通常会猜测一个与同样错误的命令相近的变体。一个说明原因并指出许可路径的钩子,会将阻止操作转变为重定向。代理读取原因,采用被批准的路径,任务得以继续。原因字符串不是日志行,它是代理的下一个输入。

专注于高成本操作。 你添加的每一条规则都是你需要维护的规则,并且都有可能阻止合法的操作。把这份预算花在错误不可逆或代价高昂的地方:生产数据、部署、凭据,以及任何你无法撤销的事情。不要限制普通的编辑和读取操作。过度阻止会训练操作员不信任护栏,并扼杀代理的吞吐量,这违背了运行代理的初衷。

基于模式匹配,并接受一些误报。 你无法枚举所有危险的命令。基于结构性信号进行匹配:recursive-force 标志、drop 动词、受保护的分支名称、密钥形状的文件名。你偶尔会拦截到一些无害的操作。当另一种选择是漏掉真正的危险时,这是一种正确的权衡,只要拒绝信息能解释得足够清楚以便快速解除即可。

你需要接受的权衡取舍

钩子并非免费,而假装它们免费,最终只会让你得到一个无人信任的护栏。

它们是代码,因此需要维护。一个脆弱的正则表达式,每运行三次就拦截一次合法命令,最终会被恼怒的运维人员禁用,而一个被禁用的护栏什么也保护不了。随着项目的变化,规则会变得过时。每个钩子都是一个有其自身缺陷的小程序,而护栏中的缺陷比没有护栏更糟糕,因为你曾指望它能发挥作用。

误报的实际成本远不止重试那么简单。每一次错误的拦截都会侵蚀对整个系统的信任。一旦越过某个阈值,人们就会开始绕过护栏,在钩子不触发的模式下运行代理,这会让你回到你试图摆脱的不受保护的状态。规则集还会产生复合效应:十个钩子相互作用,一个钩子允许的操作可能会为另一个本应捕获的问题埋下伏笔。

坦率地讲,这就是成本与风险的权衡。成本是维护和偶尔的错误拦截。风险是破坏性命令、泄露的机密、未经审查的部署。在低风险的沙箱环境中,风险很小,钩子就成了额外开销。而面对生产数据、实时部署和真实凭证时,风险占据主导地位,只要钩子能在第一时间触发并阻止本会引发事故的命令,它们就值回了票价。

何时一个提示就已足够

并非每条规则都需要一个钩子,而将每条规则都视为钩子本身就是一种失败。当三件事同时满足时,才应使用钩子:行为成本高昂或不可逆,不安全的情况可以从工具调用及其参数中识别出来,以及你需要规则每一次都生效而不是通常情况下生效。破坏性命令、秘密提交和未经审查的部署都符合这些条件。

当风险较低,当罕见的失误在审查中很容易被发现和纠正,或者当规则关乎风格和判断,而不是代码中可以匹配的明确界限时,简单的提示指令就是合适的工具。“倾向于小提交”是一个提示。“绝不 force-push 到 main”则是一个钩子。如果你无法将检查编写成一个查看工具调用并返回 allow 或 deny 的函数,那么它可能就属于提示的范畴,因为一个必须猜测意图的钩子会因误报而最终被关闭。

这种划分并非提示与钩子之争,而是指导与保证之争。使用提示来引导智能体在其日常处理的广泛而模糊的事务空间中,趋向于采用良好的默认行为。使用钩子来划定少数几条绝不能逾越的硬性界限,并使这些界限在行为实际发生的层面上无法被逾越,而不仅仅是在模型决定尝试的层面上不鼓励这样做。