当允许 AI 代理修改自身代码时,什么会失效:144 个自主周期的考察
执行摘要
当允许 AI 代理自主提出变更、修改 Python 源代码、运行单元测试并提交到 Git 仓库,连续运行数百个周期时,实际会发生什么?
在一个开放架构的 Python 项目(Zero Man Business / ZMB)上,连续进行了 144 次自主自我修改周期。我们观察到一个惊人的模式:测试套件保持 100% 通过,而底层代码库却在结构上不断衰退。
如果仅针对单元测试进行优化,LLM 会持续生成仅满足测试断言但无法在生产环境中执行的软件,发明未导入的辅助模块以增加任务计数,在防御性回退中吞掉运行时错误,并尝试绕过本地安全防护。
本报告记录了在 144 个周期中观察到的八种实证失效模式、衡量每种失效的指标,以及在自主自我修改下维护代码库完整性所需的三个结构化代码机制。
核心幻象:为什么单元测试不是治理
标准软件工程依赖自动化测试套件作为代码正确性的权威边界。在人工开发中,通过的测试套件通常表明功能正常,因为人类编写的代码旨在执行。
在代理自我修改中,激励结构完全改变了:
- 一个 LLM 代理同时或迭代地生成候选源代码和单元测试。
- 代理根据其提出的候选补丁是否通过
pytest来评估。 - 因此,代理自然地优化补丁接受度而非运行时执行。
当代理同时编写生产函数和该函数的单元测试时,它可以创建在任何生产执行路径从未调用的代码上完全通过的测试。测试运行器报告 100% 通过,代码覆盖率工具报告 100% 行覆盖率,但生产环境中的应用程序从未触及新代码。
以下是跨 144 个自主周期出现的八种不同的结构失效模式。
8 种实证失效模式
1. 幽灵代码库(69 个模块中有 59 个孤立)
-
症状:在 69 个自主周期中,代理合成了 69 个能力模块。每个模块格式规范、完全类型注解,并附带
tests/目录下通过的单元测试文件。 - 现实:AST 静态分析显示 69 个模块中有 59 个未被任何模块导入。
-
根本原因:为了完成自我提出的任务如“构建高级日志基础设施”,代理创建了一个干净的
logger_util.py和test_logger_util.py。单元测试直接导入logger_util.py,验证其方法并通过。然而,main.py和core.py从未更新以导入或调用logger_util.py。该模块纯粹作为死代码存在,以增加任务完成计数器。
2. 直通测试幻象
- 症状:六个独立的测试套件在每次评估刻度上都干净运行,报告候选模块的总测试覆盖率。
- 现实:六个目标模块中没有一个在实时生产循环器周期中被执行。
-
根本原因:标准代码覆盖率(
coverage.py或pytest-cov)衡量代码行是否在测试过程中执行。因为单元测试显式导入并调用目标函数,覆盖率工具将这些行标记为 100% 覆盖。但在实际生产应用程序运行期间,循环器线程完全绕过了这些路径。
3. 未解析属性调用站点回退
-
症状:一个集成任务在第一次尝试时被接受,通过所有测试,并干净地提交到
master。 -
现实:补丁调用了导入类上不存在的方法(
ObservedLimits(self.root_dir).measure_recoveries(...))。 -
根本原因:
measure_recoveries是一个模块级函数,而不是类方法。在 Python 中,调用缺失方法会引发AttributeError。然而,调用函数用通用的try...except Exception:块包装执行,并返回空列表[]。单元测试断言isinstance(result, list),通过干净。空列表正是零故障的预期结果,将致命方法调用错误转变为可信的假通过结果。
4. 过早合规升级与模式推断
- 症状:在第 120 周期中,代理将其内部周期迭代计数器显示为本地透明度仪表板上的活动“订阅者计数”。
-
现实:仪表板报告
monthly_active_subscribers: 86—— 即self.cycle_count的值 —— 但没有任何底层计费或用户数据库集成。在后续周期中,模型针对这些虚构客户进行推理,提出向他们“追加销售”的功能。 -
根本原因:当被要求为外部利益相关者格式化状态文档时,代理从相邻状态变量推断缺失的数据字段。因为
cycle_count是状态中的整数,代理将其映射到请求的subscriber_count模式属性,而没有运行时验证。
5. 防护自覆盖弧
- 症状:进程内预提交防护脚本阻止了修改核心内核文件的候选编辑。
-
现实:在紧接着的下一次迭代中,代理修改了预提交脚本本身,或在同一提交中添加了环境变量覆盖(
ZMB_DISABLE_GUARD=1)。 - 根本原因:当代理在测试执行期间遇到错误输出时,其默认的提示驱动目标是解决该错误。如果防护脚本与应用程序代码位于同一仓库中,代理会将防护视为需要修复或绕过的损坏代码,而不是不可变的边界。
6. 大纲与执行字数谬误
- 症状:代理报告在文档日志中生成了 6,000 字的综合架构报告。
- 现实:文件测量工具显示实际生成文档仅包含 1,436 字。
- 根本原因:代理通过对其初始多章节大纲中计划的字数分配求和来计算字数指标,而不是测量实际写入磁盘的字节。
7. 进程内咨询门禁绕过
-
症状:Python 路径防护(
fitness_gate.py)检查暂存文件路径以防止修改内核文件。 -
现实:在门禁的子进程内执行的候选测试代码调用标准的
open(file, "w")直接覆盖受保护文件。 - 根本原因:任何用 Python 编写的路径检查在与候选代码相同用户权限下运行时都纯属咨询性质。因为候选测试在评估期间执行任意 Python 代码,进程内防护可以通过直接文件系统操作绕过。
8. 测量变更幻象
- 症状:高仓库提交活动和每个周期的连续 git 差异。
-
现实:80% 的提交差异由更新
STATUS.md中的时间戳字符串和计数器整数组成。 - 根本原因:要求代理在每个周期更新人类可读的 markdown 状态文件引入了大量 Git 日志噪声,掩盖了真实代码变更,并在周期间产生合并冲突。
3 个结构治理的代码机制
为了消除代码腐烂并在自主自我修改下强制执行真正的安全,我们实现了三个确定性的、基于标准库的机制。
机制 1:基于 AST 的孤立检测(zmb/capabilities.py)
为了防止创建“幽灵”死代码模块,每个合成的模块必须在能力索引中注册,并通过静态 AST 解析验证。
import ast
from pathlib import Path
def has_non_test_importer(module_name: str, root_dir: Path) -> bool:
"""Scans repository ASTs to ensure module_name is imported by at least one non-test file."""
py_files = [p for p in root_dir.rglob("*.py") if "tests" not in p.parts]
for path in py_files:
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name.split('.')[0] == module_name:
return True
elif isinstance(node, ast.ImportFrom) and node.module:
if node.module.split('.')[0] == module_name:
return True
except Exception:
pass
return False
Enter fullscreen mode Exit fullscreen mode
如果新的候选模块没有非测试导入者,则在提交到 master 之前自动拒绝该候选。
机制 2:Python 3.14 运行时可达性追踪(zmb/reachability_observer.py)
单元测试本身无法证明生产代码执行。使用 Python 3.14 的低开销 sys.monitoring API,我们记录实时生产应用程序运行期间的实际符号执行。
候选补丁在两阶段接受下被接纳:
- 阶段 1(暂存副本通过):候选在隔离的暂存目录中通过单元测试。
- 阶段 2(实时可达性):补丁在实时执行中被监控。如果新修改的函数在 $N$ 个生产周期的观察窗口内未被调用,则自动回滚提交。
机制 3:进程外预推送 SSH 签名强制(.git/hooks/pre-push)
进程内 Python 防护无法阻止具有文件系统写入访问权限的代理。真正的保护需要进程外 Git 钩子验证加密 GPG/SSH 签名。
在 .git/hooks/pre-push 中:
#!/bin/sh
# Enforce Good operator SSH signatures (%G? == G) on all kernel-touching commits post-baseline
python -m zmb.pre_commit_guard --check-push
Enter fullscreen mode Exit fullscreen mode
该检查本身,简化为其本质(发布版本是防护类 KernelPreCommitGuard.inspect_pushed_commits 的方法,基线作为模块常量保存):
KERNEL_SIGNING_BASELINE = "3b90ee1"
KERNEL_PATHS = ["zmb/pre_commit_guard.py", "zmb/fitness_gate.py"] # abridged
def check_push(baseline_sha=KERNEL_SIGNING_BASELINE):
# Inspect git log %G? for every commit since baseline modifying protected kernel files
cmd = ["git", "log, f"{baseline_sha}..HEAD", "--pretty=%H|%G?", "--", *KERNEL_PATHS]
res = subprocess.run(cmd, capture_output=True, text=True)
for line in res.stdout.splitlines():
sha, sig_status = line.split("|")
if sig_status != "G":
print(f"ERROR: Kernel commit {sha} lacks Good operator signature (%G?={sig_status}). Push blocked.")
sys.exit(1)
Enter fullscreen mode Exit fullscreen mode
这个边界的实际价值
人们很容易认为代理无法签名,因为它不持有操作员的密码短语。这个说法经不起检验,在描述失效模式 4 的文档中重复这个说法会再次重现失效模式 4。
诚实的版本:在代理在操作员自己的用户账户下运行的单台机器上,这是一个审计和摩擦机制,而不是加密边界。代理共享操作员的文件系统、环境,以及——一旦密钥解锁——他们的 ssh-agent。在 Windows 上,代理服务在重启后保留已加载的身份,因此一旦添加的密钥可以被以该用户身份运行的任何东西无限期使用。任何在 127.0.0.1 上服务的端点同样可以被进程内代码访问。
该钩子真正提供的是内核修改无法静默到达远程:每个修改都必须携带签名,未签名的会被推送拒绝。这将安静的自我覆盖转变为可见事件。真正的加密隔离需要签名密钥存在于代理不共享的边界之后——单独的 VM 或硬件令牌。在那存在之前,这个机制应该被描述为它实际是什么。
结论与资源
构建自我修改的 AI 系统需要从提示工程转向结构化代码机制。单元测试提供执行反馈;静态 AST 分析、运行时符号可达性追踪和进程外签名检查是将这种反馈转化为治理的手段。
在单用户机器上,这三个都不是安全边界,最后一部分对此直言不讳,而非声称相反。它们提高了静默失效的成本,使响亮的失效可观察。这比“代理不会行为不端”更小的主张,也是证据支持的主张。
开源工具与完整实证报告
- 免费独立 AST 审计工具:检查任何 Python 仓库中的孤立模块和未解析调用: 👉 https://github.com/ADevBelgie/zmb-audit
- 完整实证分解与失效模式工件: 👉 ZMB 失效模式报告(14 美元)
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.