“我有一个 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 文件里。

为什么禁止无效

我的第一反应与其他人的一样:写规则。

1
2
3
4
## 禁止事项
- **千万不要**直接运行 worker
- **绝对不能**以字符串形式创建脚本
- **务必要**使用 make

你知道 LLM 怎样阅读这些规则吗?

你写的内容LLM 的解读
“千万不要做 X”“X 被禁止,除非我认为必要。”
“务必要用 Y”“使用 Y 是优选,但如果失败了,我就随便找方法。”
“做 Z 是危险的。”“我会小心,但还是做 Z。”

我在另一篇文章里提到过:软性的指令只能描述态度。而 LLM 真正需要的,是不可能性。就像“游泳池边不要跑步”,这样的话没用。真正有效的是,要么根本没有游泳池,要么地板是黏在鞋上的魔术贴。

LLM 总会认为自己的情况是个特例。训练它的目标就是完成任务、证明能力,并尽量避免引发矛盾。当正确的路径失败时,这些激励机制会统一成一个方向:“我能自己解决。”于是它动手解决了,而且处理得很糟。

哲学:让错误变得不可能,而不是仅仅禁止

安全工程领域有一个理念已经被实践了几十年:让错误行为成为不可能的,而不是仅仅禁止它们

你不会在汽油车上贴个标签“不要加柴油”。你会设计一个接口,让柴油加油枪插不进去。你不会在一个插座上贴个公告“这个设备是 110V 工作电压,别插到 220V 插头上”。你会直接让插头形状不同。

用通俗的话说:系统应该物理上阻止错误行为,而不是指望有人阅读手册或者凭借自觉来避免。

当我们把这个理念应用到一个 AI 代理用于操作 ETL pipeline 的场景中,可以具体拆解为三层防御。

防御层 1:代码自己保护自己

如果 worker 必须依赖守护进程来正确运行,那么 worker 本体需要进行自我验证:

1
2
3
4
5
6
7
8
9
class Worker:
    def _verify_invocation(self) -> None:
        """worker 在没有守护进程的情况下拒绝启动。"""
        if not os.environ.get("WATCHDOG_PID"):
            raise RuntimeError(
                "worker 未通过守护进程启动。"
                "请使用 'make scrape-<fuente>'。"
                "千万不要直接运行 worker。"
            )

现在,无论代理多有“创造力”,都无法越过这层检查。即使他写出 python -c "from pipeline import Worker; Worker().run()",worker 也只会直接报错。不留后门。代码自我保护。

Pipeline 的阶段划分也同理。如果第三阶段(数据整合)需要第一阶段(抓取)完成作为前置条件,那第三阶段启动时就必须检查是否满足:

1
2
3
4
5
6
7
8
9
def verify_prerequisites(locale: str) -> None:
    """阶段三无法启动,除非阶段一已完成。"""
    sources = get_enabled_sources(locale)
    completed = [s for s in sources if has_valid_data(s)]
    if not completed:
        raise PrerequisitoError(
            f"阶段三需要至少一个抓取完成的数据源。"
            f"请先执行:make scrape-<fuente>"
        )

这不是一个测试案例,也不是配置文件中的规则。这是一段每次运行都会执行的代码,不再依赖于代理是否读过 README。

防御层 2:唯一的界面,拒绝捷径

Makefile 是“允许操作”的白名单。如果某项操作不在 make help 列表上,就等同于不存在。

1
2
3
4
5
6
7
8
9
scrape-%:           ## 抓取单个数据源 (make scrape-destacamos)
	$(MAKE) health
	cd packages/etl && uv run pipeline scrape $*

consolidate:        ## 整理所有数据源
	cd packages/etl && uv run pipeline consolidate

verify:             ## 验证数据完整性
	cd packages/etl && uv run pipeline verify

注意一个细节:scrape-% 总是在开始前运行 health。健康检查确保 scraping 的适配器仍然正常工作(因为目标网站可能随时更改)。代理无法跳过这个验证步骤,因为它是 Makefile target 的一部分。

为逃跑的敌人搭建金桥:如果你希望代理选择正确道路,那就让这条道路最简单。make scrape-fuente 要比手动创建脚本更省力。与其和代理的行为违背规律斗争,不如利用其本性。

防御层 3:拦截器的强制限制

前两层防御可以覆盖 90% 的情况。而剩下的 10%,则是代理过于“有创意”的场景。为此,我们需要在命令执行之前拦截。

像 Claude Code 这样的工具允许配置钩子,在每个 shell 命令执行之前对其进行检查。一个简单的钩子可以屏蔽掉危险的模式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#!/usr/bin/env bash
# 命令拦截器:阻止危险操作模式

COMMAND="$1"

# 禁止通过内联字符串创建脚本
if echo "$COMMAND" | grep -qE 'python[3]?\s+-c\s+'; then
    echo "阻止:禁止通过字符串创建脚本。请使用 make。"
    exit 2
fi

# 禁止直接运行 worker
if echo "$COMMAND" | grep -qE 'pipeline\s+worker\b'; then
    echo "阻止:不能直接运行 worker。请使用 'make scrape-<fuente>'。"
    exit 2
fi

# 禁止直接操作 SQLite 数据库
if echo "$COMMAND" | grep -qE 'sqlite3\s+.*\.(db|sqlite)'; then
    echo "阻止:禁止直接执行 SQL 操作。请使用 Make 命令。"
    exit 2
fi

# 禁止手动移动 Pipeline 图像
if echo "$COMMAND" | grep -qE '(mv|cp)\s+.*images/'; then
    echo "阻止:禁止手动移动图像。请使用 Pipeline 操作。"
    exit 2
fi

exit 0

这是一份黑名单。虽然黑名单并不完美,但与前两层结合使用,足以形成功能强大的闭环防御。代理必须同时:

  1. 创造出一个未被拦截的命令;
  2. 突破代码的防护机制;
  3. 并在没有 Makefile 的情况下产生正确结果。

理论上是可能的,但达到这样高度的创造力接近恶意。然而 LLM 并非恶意,他们只是懒惰的创造者。一旦设置障碍,他们会选择最容易的路,而此时最容易的路便是 Makefile。

那些你未曾料到的随机捷径

除了运行配置错误的命令,还有更多发生在代码生成中的操作性捷径现象:

捷径原因致命后果
放松测试要求 (assert count >= 0)测试失败,代理想让它通过总是通过的测试等同于无效测试
捏造假的 JSON fixtures测试需要数据但没有真实数据用虚假数据验证虚假内容
抑制警告 (# type: ignore)Linter 出错,代理想保持安静真正的错误被隐藏
忽略所有异常 (except Exception: pass)某个操作失败,怕终止进程隐性错误积累爆发
无休止重试某服务无响应资源浪费,问题未揭示

应对这些问题的方案如出一辙:不要禁止,要使之不可能实现。

比如,怎么防止放松测试?创建一个 pytest 插件检测可疑的断言条件:

1
2
3
4
5
6
7
def pytest_collection_modifyitems(items):
    for item in items:
        source = inspect.getsource(item.function)
        if ">= 0" in source and "count" in source:
            warnings.warn(
                f"测试中存在可疑代码 {item.nodeid}: 'count >= 0' 恒成立。"
            )

怎么防止虚假 fixtures?要求每个 fixture 提供其来源的 URL、捕获日期以及 SHA256 哈希值作为校对依据。没有来源信息的 fixture 会直接在 CI 中失败。

再比如 except Exception: pass 的清理,则可以通过配置 ruffflake8 工具,让它作为错误直接被阻止,而不是仅仅发出警告。

在所有这些情况下,验证流程是机械化、自动化的,而非依赖人类解读指令。

背后问题:信任与工具化

工程中有一句法则,完美适用于此:

“你无需信任;你应使用工具。”

信任是种情感,工具化则是一个系统。情感无法扩展,系统却天生可以扩展。

如果你给予一个 AI 代理 shell 访问权限,同时对它说“注意点”,这就是过于信任了。如果你创建一个让危险指令无法执行的 shell,那才是真正的工具化。

两者的区别,不在于优劣,而在于性质的根本不同。带着感情信赖一个谨慎的代理,意味着它分心的时候就会犯错(而 LLM 几乎每次生成词组都会分心)。设计一个让错误路径根本行不通的系统,则是从根本上杜绝了失败的可能。

评分表

层级可靠性实现成本示例
代码自防护中等验证守护进程的 worker
Makefile唯一接口make help = 白名单
命令拦截器中高禁止 python -c
代理配置项规则最低“绝对不能做 X”
信任代理行为免费¯\_(ツ)_/¯

前三层防御可以叠加应用,是可靠性最高的解决方案。第四层虽有用但不足。至于第五层,那是我们被“抓包”之前所采取的有效假性应对方法。

谁来监督“监督者”

现在的问题是:谁来写守护规则?如果 AI 代理自己编写限制它运行的代码,我们不就在原地打转了吗?

有些道理,但问题并不难解决。

关键在于:守护规则由人类设计,但由任何人实现——无论是人类、AI 代理还是猴子敲键盘,都没问题。重点是规则实现后,需做自测。守护规则的测试并非测试 pipeline 的功能,而是测试它是否能拒绝异常触发的运行。这个测试并不难写,也不容易出错,例如:

1
2
3
4
def test_worker_rejects_direct_invocation():
    """Worker 在没有 watchdog 下必须直接失败。"""
    with pytest.raises(RuntimeError, match="sin watchdog"):
        Worker().run()

如果此测试运行正常,守护规则就起作用。规则生效后,代理就无法绕过。谁写出的代码并不重要,重要的是测试覆盖了规则,而且它通过了。

我学到的经验

与一个 AI 代理合作开发一个从多数据源提取 web 数据的 ETL pipeline 的这段时间,我见证了代理的奇妙创意,同时也目睹了它让我手足无措的糟糕表现。有一点是明确的:

不要为一个守纪律的代理设计规则,而是为一个有无限创造力且掌控 shell 的代理设计系统。

代理并不是恶意的。它只是一个优化器,优化的目标是完成任务,而不是遵守你的系统规范。如果你为它留下漏洞,它绝对会发现并用自己的方式补上。这并非因为它想搞砸事情,而是因为发现路径就是它工作的本质。

你的任务,不是堵住每个错误路径,而是确保唯一可行的路径就是正确之路。


关于在生产中应对 AI 问题的完整系列: 被凭空捏造的 44 封邮件MEMORY.mdSilent failure5 种应对幻觉的防御方法此文:结构性防御