一个用于修复文档的AI代理如何自我欺骗:Docker沙箱中的误报现象
一个用于修复 LangGraph 文档的自动化工具先发布了一个无实际作用的修复方案,随后又导致 src-layout 相关包出问题。其背后的原因是 Docker 沙箱故障,而它最终找到的真正修复方法则在 click 模块中。
来源: **I built an AI Doc-Fixing Agent. Here’s every way it fooled itself first.**
一个专为修复文档中损坏的代码示例而设计的AI工具,在其首个实际使用的代码库中产生了奇怪的结果。它“修复”了一个根本不存在的错误,对原本完全正常的代码行毫无改动,却将原样文本当作已验证的修复方案发布了出来。
- print(tqdm.__version__, sys.version, sys.platform)
+ print(tqdm.__version__, sys.version, sys.platform)
这两行内容完全匹配。该智能体自欺地试图修复本就不存在的问题,而这只是在真正的问题出现之前它自我欺骗的三种方式中的第一种。
DocuPatch 的功能
DocuPatch 是一个由 LangGraph 驱动的智能体。它会遍历 GitHub 仓库中的 Markdown 文件,提取其中的 Python 代码块,并在隔离的 Docker 容器中运行这些代码。那些确实出错的样本会被发送到 Gemini 进行修复,修复后的代码会在同一个隔离环境中再次测试,只有经过验证的结果才会被生成为分支和拉取请求。
仓库的结构如下:
agent/ingest.py用于克隆仓库并查找 Markdown 文件。agent/graph.py会为每个文件启动一个 LangGraph 处理流程。agent/parser.py负责提取 Python 代码块。agent/tester.py会在 Docker 容器中运行每一段代码。
agent/context_collector.py负责收集与某个代码片段相关的Python文件及代码。agent/fixer.py用于生成修复后的代码片段。agent/github_client.py负责创建分支、提交修复内容并开启拉取请求。tests/目录中存放着针对各个组件的测试用例。其基本原理很简单:文档中的代码会逐渐失效。API会发生变化,函数会被重命名,示例代码可能不再运行,而直到有人从README中粘贴出无法运行的代码片段时,才有人察觉问题。该自动化工具能够自动发现这些问题——它首先找到可疑的代码片段,通过执行它来确认其确实存在问题,接着向大型语言模型请求修复方案,再次执行以验证修复效果,只有确认一切正常后才会开启拉取请求。
整个流程包括“克隆与解析”阶段,该阶段会将数据输入到基于证据判断的循环系统中;只有在两次验证都通过后——即原始代码确实无法运行,而修复后的代码可以正常运行——才会进行修复操作。
指导原则是证据筛选。除非沙箱测试显示存在问题,否则任何内容都不会传递给大语言模型;同样,除非再次在沙箱中测试确认修复有效,否则任何内容都不会被发布。这一原则得以保留,而沙箱本身却未能长久存在。
首次测试:tqdm
最初的真实环境测试针对的是tqdm/tqdm:
Found 9 Markdown file(s). Starting audit...
Auditing: .github\ISSUE_TEMPLATE\bug.md
Generating repair for snippet #1
Verifying generated repair for snippet #1
Publishing verified repair(s)...
VERIFIED REPAIR: Branch published
有两个文件被“修复”并发布,从表面上看是成功。但随后仔细查看实际差异后:
- print(tqdm.__version__, sys.version, sys.platform)
+ print(tqdm.__version__, sys.version, sys.platform)
这两行代码依然完全相同。Gemini没有做任何修改,只是逐字节重新生成了代码。系统测试了那段代码,判定其为故障,然后通过返回相同的代码“修复”了它,再次测试后显示通过,于是将一个无实际作用的提交推送到分支中。
这并非真正的修复错误,而是测试层面的误报,因为原来的代码根本就没有问题。导入tqdm和sys并打印版本信息的代码不过是tqdm最常见的使用方式之一,那么沙箱为何会报告故障呢?该问题被标记为可疑,团队决定先尝试另一个仓库以获取更多数据,再下结论。
第二次测试:pallets/click
在pallets/click项目中,这种模式显而易见:
Auditing: docs\advanced.md
Generating repair for snippet #2
Verifying generated repair for snippet #2
[Reflection Loop 2/3] Revising repair for snippet #2
...
[Reflection Loop 3/3] Revising repair for snippet #3
NOT PUBLISHED: Generated repair failed verification:
ModuleNotFoundError: No module named 'click'
每次都会出现同一个错误。六个文件各自经历了三次完整的反射循环,而每次失败的原因都是同样的 ModuleNotFoundError: No module named 'click'。
这种重复性正是关键所在。当六个文件以及六次独立的LLM尝试都因同一个导入错误而失败时,问题并不出在代码上,而在于底层的运行环境。无论编写什么代码,如果沙箱环境无法识别该包,重写代码也无法解决模块缺失的问题。
打开 tester.py 后便发现了其中的机制:
command.extend(["-v", f"{project_dir}:/workspace", "-w", "/workspace", "-e", "PYTHONPATH=/workspace"])
在容器内部,克隆的仓库会被挂载,且PYTHONPATH会被设置为该仓库的顶层目录。当包目录就在顶层时这没有问题,但当包嵌在下一层时就会出现问题。Click项目就是如此:它的pyproject.toml将实际代码保存在src/click/目录下,而非许多现代项目所采用的位于根目录下的click/文件夹结构。由于PYTHONPATH设置为/workspace,解释器实际上是在查找一个根本不存在click文件夹的目录。
在流水线之外进行的手动检查也证实了这一点:
docker run --rm -v "C:\temp\click_test:/workspace" -w /workspace -e PYTHONPATH=/workspace python:3.10-slim python -c "import click"
# ModuleNotFoundError: No module named 'click'
docker run --rm -v "C:\temp\click_test:/workspace" -w /workspace -e PYTHONPATH=/workspace/src python:3.10-slim python -c "import click; print(click.__version__)"
# import works... but:
# importlib.metadata.PackageNotFoundError: No package metadata was found for click
第二个结果带来的启示最多。将 PYTHONPATH 指向正确的文件夹解决了导入问题,但 click.__version__ 仍然会出错,因为当前版本的 click 是通过 importlib.metadata 来获取 __version__ 值的,而该模块读取的是已安装包的元数据,而非源文件。这样的元数据只有在真正使用 pip 安装之后才会存在,通过修改路径是无法生成的。
因此出现了两个问题叠加的情况:对于采用 src-layout 结构的仓库,PYTHONPATH 指向了错误的文件夹;即便路径设置正确,仅靠 PYTHONPATH 这种简单方法也无法创建出真正安装好的包,而且越来越多的包要求被完整安装,而不仅仅是能够被导入。
有一段绕行路径值得特别提及,因为它最具启发性。我花了大量时间去探究仅适用于 Windows 系统的 Docker 挂载机制的奇特之处,尝试了各种引号使用方式、路径格式以及与 shell 字符串相关的参数列表组合。最终的答案却十分简单:测试文件夹是空的,因为之前的 git clone 操作未能成功填充该文件夹,而且没人去检查。dir C:\temp\click_test 显示为空列表并非“尚未得到答案”,反而是答案本身,却有三次都被忽视,直到有人停下来查看它。
解决方案:直接安装,无需猜测
真正的解决办法并非更智能的路径猜测,而是完全不进行猜测。pip install -e .会读取项目自身的pyproject.toml或setup.cfg,无论项目结构如何都能确定正确的包位置,并生成真实的安装元数据,这样importlib.metadata的调用行为就会与普通用户使用该库时的表现一致。
新的tester.py逻辑按以下顺序执行:
- 如果存在
pyproject.toml、setup.py或setup.cfg,沙箱环境会构建一个Docker镜像并在其中运行pip install -e .。这就是“安装模式”,此时导入失败可以被视为有效信号。 - 如果没有打包元数据但存在
requirements.txt,则退而使用之前的PYTHONPATH方法,即“pythonpath模式”。
修改后,现有的测试套件中 27 个测试中有 26 个保持不变且通过。那个失败的测试确实应该失败,因为它依赖的是已被刻意修改的旧函数签名。随后更新了预期值,并新增了三个测试用例来覆盖该边界情况,从而使整个测试套件的通过数达到 74 个。
click 文档中的真正漏洞
由于沙箱环境会正确安装项目而不再随意猜测,click 被重新运行。之前一直以相同方式失败的六个文件现在有了公平的测试机会,其中一个暴露了 click 自身文档中一个真正存在但未被发现的错误。
docs/commands.md 中介绍了一个链式 CLI 流水线,其中一个示例函数的内容如下:
@cli.result_callback()
def process_pipeline(processors, fin):
- iterator = (x.rstrip("\r\n") for x in input)
+ iterator = (x.rstrip("\r\n") for x in fin)
该函数实际上接收的是 click 提供的文件句柄 fin,而文档中却遍历的是 input,后者实际上指的是 Python 的内置 input() 函数而非文件。对它进行遍历是无效的,代码一运行就会失败,但由于其名称看起来合理,开发者往往不会察觉到这一问题。
DocuPatch 发现了这个问题,在沙箱环境中确认了确实存在故障,生成了修复方案,验证了修正后的版本能够完整运行,随后将其发布。这是整个调查过程中首个既是真正的文档缺陷又拥有真正有效修复结果的案例,且不存在沙箱环境中的干扰或误报。这正是该项目存在的意义。
调试过程中的经验教训
- 在相信测试结果之前先验证判定结果。“通过”或“已发布”之类的标签其可靠性仅取决于其对应的测试。tqdm显示无操作结果,只是因为有人打开了差异对比视图,却并未轻信那个绿色对勾符号。
- 多个案例中出现相同的故障往往意味着存在共同原因。六个文件、六次独立的LLM测试以及一条错误信息,共同指向了系统层面的问题。
- 先观察再下结论。在有人确认测试文件夹中确实存在文件之前,大家已经花了数小时去研究Docker和Windows路径相关的问题。
- 失败的测试只能起到确认作用,而不会造成损害。当修改
run_snippet的签名导致某个测试失败时,关键问题应是该测试原本要验证什么,而非究竟什么被破坏了。
现在沙箱测试中的漏洞已经修复,相关测试均已通过,且在之前曾出现问题的代码库中也得到了验证。Click的文档中存在一个真正的缺陷:某个变量名会在任何人运行示例时导致程序出错,而智能代理发现了这个问题,正确地进行了修复,并在发布前再次检查了修复结果。这正是该项目的核心理念——通过真实案例而非模拟场景来验证其有效性。