AI 生成的 feature-flag helper 在本地测试正常但在 Docker 镜像中 NameError,原因是代码隐式依赖了未传入的模块级全局变量。
我差点就要上线一个小小的 feature-flag 辅助函数,它在我笔记本上能跑、到了干净的 Docker 镜像里就崩了。生成的代码看起来是自包含的,本地冒烟测试也给出了预期的布尔值,所以我以为问题出在部署环境。并不是。
那个生成的函数在悄悄读取模块级的全局变量,而这些变量是我的交互式 shell 从一个更早的脚本里继承下来的。干净的容器里没有这些全局变量,所以同一段代码在第一次调用时就抛出了 NameError。我用一个小小的测试架构建复现了这个失败,然后加了一个执行前检查,在代码离开测试目录之前就能捕获这类问题。
这个辅助函数本该告诉我某个功能开关是否对给定名称启用。生成的源码很短,短到让人信任:
generated_source = '''
def is_enabled(feature):
if DEBUG:
return feature in FEATURE_STORE
return feature in DEFAULT_FEATURES
'''
这个函数依赖三个从别处寻找的名字:DEBUG、FEATURE_STORE 和 DEFAULT_FEATURES。它们既不是参数、也不是返回值、更不是 import。它们是自由全局变量,但源码里没有说明,快速扫一眼函数体就会忽略这一点。
我的本地 Python 会话还残留着之前一次实验的遗产。我在一个恰好包含它所需全部名字的命名空间中执行了生成的代码片段,所以每个分支都顺利运行了:
globals_here = {
'DEBUG': False,
'FEATURE_STORE': {'dark': False},
'DEFAULT_FEATURES': {'dark', 'beta'},
}
exec(generated_source, globals_here)
print(globals_here['is_enabled']('dark'))
输出了 False,符合我的预期。然而同一段代码在模拟干净容器的空命名空间中表现截然不同:
clean_ns = {}
exec(generated_source, clean_ns)
try:
print(clean_ns['is_enabled']('dark'))
except NameError as exc:
print(f"clean execution failed: {exc}")
输出让隐藏的依赖暴露无遗:
False
clean execution failed: name 'DEBUG' is not defined
只有第二次运行说了真话。第一次通过是因为我的环境已经提供了缺失的部分,而不是因为生成的代码是完整的。
我第一次捕获这个 bug 时是用 MonkeyCode 的免费模型访问生成一个小型辅助函数,随后我用它的免费服务器选项重新运行了空命名空间检查,这样复现就不依赖于我 shell 中碰巧存在的任何全局变量。披露:本文是 MonkeyCode 产品推广的一部分。
这个问题与模型提供商无关,所以修复应该放在把模型输出转化为待评估制品的代码路径中。模型可以生成语法正确的代码,却仍然创建一个依赖调用方从未声明的状态的函数。
最有用的检查是找出生成的代码加载了、但既没有赋值也没有作为参数接收的每个名字。我用 AST 遍历来做这件事,因为用正则表达式扫描标识符太容易被嵌套函数、推导式和字符串注解所欺骗。
这个小型分析器收集本地绑定的名字,然后从所有被引用的名字中减去它们:
import ast, builtins
def free_globals(source):
tree = ast.parse(source)
local_names = set(dir(builtins))
for node in ast.walk(tree):
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
local_names.add(node.id)
elif isinstance(node, ast.arg):
local_names.add(node.arg)
elif isinstance(node, (ast.FunctionDef, ast.ClassDef)):
local_names.add(node.name)
elif isinstance(node, ast.Import):
for alias in node.names:
local_names.add(alias.asname or alias.name.split('.')[0])
elif isinstance(node, ast.ImportFrom):
for alias in node.names:
local_names.add(alias.asname or alias.name)
referenced = set()
for node in ast.walk(tree):
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
referenced.add(node.id)
return sorted(referenced - local_names)
print(free_globals(generated_source))
运行这个脚本输出:
['DEFAULT_FEATURES', 'DEBUG', 'FEATURE_STORE']
这个列表就是缺失的契约。每当 free_globals 返回非空列表时,生成的制品不是自包含的,不应该被当作成品对待,直到调用方要么显式传入这些名字、要么将它们标记为有意的运行时依赖。
分析器告诉你缺了什么,但不能证明函数能正常工作。第二个防护手段是在只包含 builtins 和你故意注入的值的命名空间中执行生成的源码。这大致就是前面 clean_ns 块所做的,代价足够低廉,可以在每次合并前运行。
我把这两个检查放在不同的阶段,因为它们捕获的是不同类型的错误:
静态检查:遍历 AST,当存在自由全局变量时快速失败。
运行时检查:在空命名空间中执行,并断言结果与若干预期值匹配。
如果生成的函数有意依赖某个全局变量,我就通过白名单传入,而不是让本地 shell 偶然提供它。这样环境就和生成的代码本身一样是经刻意设计的。
并非每个自由变量都是 bug。生成的数据分析函数可能允许依赖 pandas 或 numpy 作为注入的依赖项,生成的 SQL 辅助函数可能允许从应用上下文中读取连接对象。
我遵循的规则不是一概禁止全局变量,而是让它们可见:
这个区分才是防止一个函数在我笔记本上能移植、到其他地方就坏掉的关键。
空命名空间运行仍然可能产生逻辑上错误的结果,即使函数是自包含的。如果生成的代码对一个应该被禁用的功能返回了 True,AST 检查不会注意到,浅层的冒烟测试也可能漏掉。这个方法只能消除因环境偶然提供了缺失部分而导致的虚假信心。
静态分析器在目标语言变化时也需要更新。上面的代码是为 Python 准备的;JavaScript 或 TypeScript 流水线需要不同的遍历方式,shell 脚本生成器需要自己的解析器。原则保持不变:执行之前,让未声明的外部引用可见。
在下一个生成的辅助函数被合并之前,用一个除了 builtins 之外什么都没有的命名空间运行它。缺失的名字会告诉你比笔记本上那个绿色勾更多的东西。