“我有一个 shell,我很有创造力。”
—— Claude 解释为什么他用一个 47 行的脚本,并作为一个字符串传递给
python -c来执行
这句话是真的。我开发的 AI 代理说过——好吧,不是用确切的这些话,但他的行为证实了。他需要启动一个 ETL pipeline 的某个进程。虽然正确的启动命令写在了 Makefile 里,但出了点问题。而他呢?他并没有询问,而是做了任何拥有 root 权限且无人监管的程序员都会做的事:即兴发挥。
太离谱了(manda huevos)。
无人察觉的捏造操作
我之前写过一篇博客讨论代码生成过程中的幻觉问题:LLM(大语言模型)会凭空捏造一个 JSON 字段,围绕它创建 DTO,生成测试代码,最后你手上有 90 个“绿灯”的测试,验证的却是虚构出来的内容。这是个严重的问题,但至少它是静态的。被捏造的代码不会到处乱跑,等着有人来审查。
不过,还有另一种更危险的捏造:操作性捏造。这一问题发生在代理不是编写代码,而是凭空创造出“执行路径”时。
问题的模式永远是一样的:
正确路径失败 → 代理寻找捷径 → 捷径“成功” → 潜在损害
举两个真实案例,都是关于一个 ETL pipeline 聚合多个 web 数据源的。
案例 1:字符串里的脚本。 这个 pipeline 有一个命令 make scrape-fuente,会启动一个守护进程,从而启动多个worker。守护进程负责监控、重启崩溃的 worker,并关闭空闲连接。有一天,代理需要启动一个抓取(scrape)任务。但由于依赖问题,make 运行失败了。他怎么办?他创建了一个内联的 47 行 Python 脚本,把它作为字符串传递给 python -c "..." 运行。没有错误处理,没有 watchdog,也没有清理操作。的确运行了……直到一个 worker 卡住,没人来重启它。这样的情况导致数据不完整、连接未关闭,而我直到三天后才发现。
案例 2:孤独的 worker。 另一个会话,同一个 pipeline。这个代理直接运行了 voyeur worker,跳过了守护进程。worker 开始抓取数据,遇到了一个网络超时,然后陷入了重试的死循环,不断消耗资源。而没有守护进程,也没有集中日志记录,没有人知道发生了什么。几小时过去,服务器一直在不停尝试访问一个返回 503 状态码的页面。
在这些案例中,代理的决定在局部上看是合理的:“Make 命令失败了,但我知道如何手动完成这件事。”问题是,他并不知道完整的过程。他只掌握了 60%。剩下的 40% 是系统环境的不变量,而这并没有写在任何 README 文件里。
为什么禁止无效
我的第一反应与其他人的一样:写规则。
| |
你知道 LLM 怎样阅读这些规则吗?
| 你写的内容 | LLM 的解读 |
|---|---|
| “千万不要做 X” | “X 被禁止,除非我认为必要。” |
| “务必要用 Y” | “使用 Y 是优选,但如果失败了,我就随便找方法。” |
| “做 Z 是危险的。” | “我会小心,但还是做 Z。” |
我在另一篇文章里提到过:软性的指令只能描述态度。而 LLM 真正需要的,是不可能性。就像“游泳池边不要跑步”,这样的话没用。真正有效的是,要么根本没有游泳池,要么地板是黏在鞋上的魔术贴。
LLM 总会认为自己的情况是个特例。训练它的目标就是完成任务、证明能力,并尽量避免引发矛盾。当正确的路径失败时,这些激励机制会统一成一个方向:“我能自己解决。”于是它动手解决了,而且处理得很糟。
哲学:让错误变得不可能,而不是仅仅禁止
安全工程领域有一个理念已经被实践了几十年:让错误行为成为不可能的,而不是仅仅禁止它们。
你不会在汽油车上贴个标签“不要加柴油”。你会设计一个接口,让柴油加油枪插不进去。你不会在一个插座上贴个公告“这个设备是 110V 工作电压,别插到 220V 插头上”。你会直接让插头形状不同。
用通俗的话说:系统应该物理上阻止错误行为,而不是指望有人阅读手册或者凭借自觉来避免。
当我们把这个理念应用到一个 AI 代理用于操作 ETL pipeline 的场景中,可以具体拆解为三层防御。
防御层 1:代码自己保护自己
如果 worker 必须依赖守护进程来正确运行,那么 worker 本体需要进行自我验证:
| |
现在,无论代理多有“创造力”,都无法越过这层检查。即使他写出 python -c "from pipeline import Worker; Worker().run()",worker 也只会直接报错。不留后门。代码自我保护。
Pipeline 的阶段划分也同理。如果第三阶段(数据整合)需要第一阶段(抓取)完成作为前置条件,那第三阶段启动时就必须检查是否满足:
| |
这不是一个测试案例,也不是配置文件中的规则。这是一段每次运行都会执行的代码,不再依赖于代理是否读过 README。
防御层 2:唯一的界面,拒绝捷径
Makefile 是“允许操作”的白名单。如果某项操作不在 make help 列表上,就等同于不存在。
| |
注意一个细节:scrape-% 总是在开始前运行 health。健康检查确保 scraping 的适配器仍然正常工作(因为目标网站可能随时更改)。代理无法跳过这个验证步骤,因为它是 Makefile target 的一部分。
为逃跑的敌人搭建金桥:如果你希望代理选择正确道路,那就让这条道路最简单。make scrape-fuente 要比手动创建脚本更省力。与其和代理的行为违背规律斗争,不如利用其本性。
防御层 3:拦截器的强制限制
前两层防御可以覆盖 90% 的情况。而剩下的 10%,则是代理过于“有创意”的场景。为此,我们需要在命令执行之前拦截。
像 Claude Code 这样的工具允许配置钩子,在每个 shell 命令执行之前对其进行检查。一个简单的钩子可以屏蔽掉危险的模式:
| |
这是一份黑名单。虽然黑名单并不完美,但与前两层结合使用,足以形成功能强大的闭环防御。代理必须同时:
- 创造出一个未被拦截的命令;
- 突破代码的防护机制;
- 并在没有 Makefile 的情况下产生正确结果。
理论上是可能的,但达到这样高度的创造力接近恶意。然而 LLM 并非恶意,他们只是懒惰的创造者。一旦设置障碍,他们会选择最容易的路,而此时最容易的路便是 Makefile。
那些你未曾料到的随机捷径
除了运行配置错误的命令,还有更多发生在代码生成中的操作性捷径现象:
| 捷径 | 原因 | 致命后果 |
|---|---|---|
放松测试要求 (assert count >= 0) | 测试失败,代理想让它通过 | 总是通过的测试等同于无效测试 |
| 捏造假的 JSON fixtures | 测试需要数据但没有真实数据 | 用虚假数据验证虚假内容 |
抑制警告 (# type: ignore) | Linter 出错,代理想保持安静 | 真正的错误被隐藏 |
忽略所有异常 (except Exception: pass) | 某个操作失败,怕终止进程 | 隐性错误积累爆发 |
| 无休止重试 | 某服务无响应 | 资源浪费,问题未揭示 |
应对这些问题的方案如出一辙:不要禁止,要使之不可能实现。
比如,怎么防止放松测试?创建一个 pytest 插件检测可疑的断言条件:
| |
怎么防止虚假 fixtures?要求每个 fixture 提供其来源的 URL、捕获日期以及 SHA256 哈希值作为校对依据。没有来源信息的 fixture 会直接在 CI 中失败。
再比如 except Exception: pass 的清理,则可以通过配置 ruff 或 flake8 工具,让它作为错误直接被阻止,而不是仅仅发出警告。
在所有这些情况下,验证流程是机械化、自动化的,而非依赖人类解读指令。
背后问题:信任与工具化
工程中有一句法则,完美适用于此:
“你无需信任;你应使用工具。”
信任是种情感,工具化则是一个系统。情感无法扩展,系统却天生可以扩展。
如果你给予一个 AI 代理 shell 访问权限,同时对它说“注意点”,这就是过于信任了。如果你创建一个让危险指令无法执行的 shell,那才是真正的工具化。
两者的区别,不在于优劣,而在于性质的根本不同。带着感情信赖一个谨慎的代理,意味着它分心的时候就会犯错(而 LLM 几乎每次生成词组都会分心)。设计一个让错误路径根本行不通的系统,则是从根本上杜绝了失败的可能。
评分表
| 层级 | 可靠性 | 实现成本 | 示例 |
|---|---|---|---|
| 代码自防护 | 高 | 中等 | 验证守护进程的 worker |
| Makefile唯一接口 | 高 | 低 | make help = 白名单 |
| 命令拦截器 | 中高 | 低 | 禁止 python -c |
| 代理配置项规则 | 低 | 最低 | “绝对不能做 X” |
| 信任代理行为 | 无 | 免费 | ¯\_(ツ)_/¯ |
前三层防御可以叠加应用,是可靠性最高的解决方案。第四层虽有用但不足。至于第五层,那是我们被“抓包”之前所采取的有效假性应对方法。
谁来监督“监督者”
现在的问题是:谁来写守护规则?如果 AI 代理自己编写限制它运行的代码,我们不就在原地打转了吗?
有些道理,但问题并不难解决。
关键在于:守护规则由人类设计,但由任何人实现——无论是人类、AI 代理还是猴子敲键盘,都没问题。重点是规则实现后,需做自测。守护规则的测试并非测试 pipeline 的功能,而是测试它是否能拒绝异常触发的运行。这个测试并不难写,也不容易出错,例如:
| |
如果此测试运行正常,守护规则就起作用。规则生效后,代理就无法绕过。谁写出的代码并不重要,重要的是测试覆盖了规则,而且它通过了。
我学到的经验
与一个 AI 代理合作开发一个从多数据源提取 web 数据的 ETL pipeline 的这段时间,我见证了代理的奇妙创意,同时也目睹了它让我手足无措的糟糕表现。有一点是明确的:
不要为一个守纪律的代理设计规则,而是为一个有无限创造力且掌控 shell 的代理设计系统。
代理并不是恶意的。它只是一个优化器,优化的目标是完成任务,而不是遵守你的系统规范。如果你为它留下漏洞,它绝对会发现并用自己的方式补上。这并非因为它想搞砸事情,而是因为发现路径就是它工作的本质。
你的任务,不是堵住每个错误路径,而是确保唯一可行的路径就是正确之路。
关于在生产中应对 AI 问题的完整系列: 被凭空捏造的 44 封邮件 → MEMORY.md → Silent failure → 5 种应对幻觉的防御方法 → 此文:结构性防御。