重构前先封印熵:二十次相同运行换来可信基线

重构混乱仓库前,先封印时钟、RNG、UUID 和哈希顺序四类熵源,用二十次相同运行证明基线可信。
文章把“获得可信基线”视为混乱仓库重构的第零步。核心论点是:特征化测试比较字节,而挂钟时间、未播种的随机数、uuid4 以及 PYTHONHASHSEED 下的集合迭代顺序这四类熵源会让字节无端变化,使基线沦为噪音,进而诱导开发者反复重新录制基线,而重新录制正是回归上线的通道。作者给出六步流程:用 grep 盘点熵源并评估爆炸半径;用一个 autouse fixture 统一封印四类熵源,并在运行器层面设 PYTHONHASHSEED=0;写键排序、无时间戳的规范化 JSON 快照;运行二十次并以唯一摘要证明稳定;在纯快照提交中录制基线并记录命令与种子;最后以约 40 行的 diff 预算、每个提交一个接缝地做最小安全变更。文末坦承封印会掩盖真实时间依赖、模块级补丁有边界等局限。
你的基准文件(golden files)会腐烂,因为代码不是唯一的输入。时钟、随机种子、UUID 和哈希顺序都会泄漏到输出中。在动手重构一个混乱仓库之前,先封印这四类熵源,再证明测试套件稳定,最后才录制基线。
这篇文章是一个混乱仓库重构的第零步,不讨论重构本身,而是讨论如何获得“信任基线”的资格。

为什么基线会说谎
特征化测试(characterization test)比较的是字节。如果字节因为与你的编辑无关的原因而变化,测试就是噪音。噪音测试会教你重新录制基线,而重新录制正是回归上线的通道。
大多数混乱仓库都有四个常见的熵源:
- 挂钟时间读取(
datetime.now、time.time、utcnow)。 - 未播种的随机数生成器(
random、NumPy 生成器、faker 库)。 uuid4,它读取os.urandom且无法播种。- 在随机
PYTHONHASHSEED下的集合与字典迭代顺序。
在设计任何东西之前先找到它们。一次 grep 就足以开始。
rg -n --glob '!tests/**' \
'datetime\.now|utcnow|time\.time|random\.|uuid4|secrets\.' \
src/ | tee entropy-inventory.txt
wc -l entropy-inventory.txt
把这个计数当作你的爆炸半径。十次命中是 fixture 问题;两百次命中是注入问题,你应该为此做预算。
特征化测试(characterization test,由 Michael Feathers 在《修改代码的艺术》中推广)的核心思想是:不预先断言“正确”行为,而是把当前代码的实际输出录制下来作为参照,之后每次运行都与参照比对。它的价值在于为遗留代码建立一道回归护栏——只要字节不变,就说明行为没变。但这也意味着它比较的是字节级输出,任何与业务逻辑无关的变化(时间戳、随机 ID、容器迭代顺序)都会被误判为回归。另一种常见做法是“批准测试”(approval testing),即把输出写入可人工审阅的快照文件,与本篇的规范化 JSON 快照思路一致。
PYTHONHASHSEED 是 CPython 解释器层面的环境变量,控制字符串哈希的随机化。自 Python 3.3 起默认启用哈希随机化,目的是防御哈希碰撞攻击(HashDoS)。副作用是:set 和 dict 的迭代顺序在不同进程间可能不同,因为字符串哈希值变了。这个变量只能在解释器启动前通过环境变量设置,一旦进程运行就无法修改,所以必须在运行器(CI、pytest 启动命令)中设置,而不能在 fixture 里用 monkeypatch。设为 0 即关闭随机化,让迭代顺序在多次运行间保持一致。
第一步:在一个 fixture 中封印四类熵源
下面的测试骨架是示意性的,我没有在你的仓库上运行过它。把它当作模板,然后根据你的调用点调整补丁位置。
`monkeypatch` 是 pytest 内置的 fixture,用于在测试期间临时替换属性、字典项或环境变量,测试结束后自动还原,避免污染其他测试。它的 `setattr` 方法可以替换模块级属性,但有边界:如果被测代码写的是 `from datetime import datetime`,那么模块里已经绑定了原始的 `datetime` 类,替换 `datetime.datetime` 模块属性不会影响那个已绑定的名字。这正是文末“模块级补丁会破坏 `from datetime import datetime` 的库”这条局限性的根源。
`autouse=True` 让 fixture 对所在 conftest 作用域内的每个测试自动生效,无需显式声明。把它放在 `tests/characterization/conftest.py` 中,就把封印范围限定在特征化目录,而不波及整个测试套件——这与第四步“限定在特征化目录”的成本控制策略相呼应。`uuid.UUID(int=...)` 用整数构造 UUID,能生成格式合法但完全确定的 128 位标识符,形状与真实 `uuid4` 一致,从而骗过那些对 UUID 做格式校验的代码。
# tests/characterization/conftest.py
import datetime as dt
import random
import time
import uuid
import pytest
FROZEN = dt.datetime(2026, 1, 1, 12, 0, 0, tzinfo=dt.timezone.utc)
@pytest.fixture(autouse=True)
def seal_entropy(monkeypatch):
# 1. 时钟:同时冻结 naive 和 aware 调用点。
class FrozenDateTime(dt.datetime):
@classmethod
def now(cls, tz=None):
return FROZEN if tz else FROZEN.replace(tzinfo=None)
@classmethod
def utcnow(cls):
return FROZEN.replace(tzinfo=None)
monkeypatch.setattr(dt, "datetime", FrozenDateTime)
monkeypatch.setattr(time, "time", lambda: FROZEN.timestamp())
# 2. RNG:每个测试重新播种,而非每个会话。
random.seed(20260101)
# 3. uuid4:确定性计数器,形状与真实 UUID 相同。
counter = iter(range(10_000))
monkeypatch.setattr(uuid, "uuid4", lambda: uuid.UUID(int=next(counter)))
yield
有两个细节比代码本身更重要。第一,每个测试重新播种。会话级种子会让测试顺序成为基线的一部分。第二,在运行器中设置 PYTHONHASHSEED=0,而不是在 fixture 中——解释器启动后无法更改它。
第二步:写规范化快照,而不是 stdout
不要对 pytest 输出做哈希,它包含合法变化的耗时和顺序。哈希测试产生的工件。
def emit(tmp_path, name, payload):
"""Write one canonical JSON file per characterization case."""
import json
path = tmp_path / f"{name}.json"
path.write_text(json.dumps(payload, sort_keys=True, indent=2) + "\n")
return path
键排序、一个尾部换行、文件中不含时间戳。如果快照需要时间,从冻结时钟读取,使其保持恒定。
第三步:用二十次运行证明稳定性
一次通过的运行证明不了什么。运行特征化目录二十次,每次对拼接后的快照做哈希。你必须只看到一个唯一摘要。
for i in $(seq 1 20); do
rm -rf /tmp/snap && mkdir -p /tmp/snap
PYTHONHASHSEED=0 pytest -q tests/characterization \
--snap-dir=/tmp/snap >/dev/null 2>&1
find /tmp/snap -name '*.json' -print0 \
| sort -z | xargs -0 cat | sha256sum
done | sort -u | tee digests.txt
wc -l < digests.txt # 必须打印 1
如果打印多于一个,说明你还没有封印所有熵源。按测试文件逐个运行来二分定位。这个阶段的大多数失败是第三方库内部的未播种 RNG,而不是你的代码。
这个循环的成本是测试套件运行时间的二十倍。把它限定在特征化目录,而非整个测试套件。
sort -z 与 xargs -0 配合处理以 NUL 分隔的文件名,是为了防止路径中含空格或换行时 xargs 错误拆分参数;find -print0 输出的就是 NUL 分隔列表。sha256sum 对整个拼接后的字节流计算摘要,sort -u 去重后 wc -l 统计唯一摘要数量,结果必须为 1。
为什么是二十次而不是两次?这是一个经验阈值:单次运行无法区分“确定性”与“碰巧一致”,而多数熵源(哈希种子、按秒取整的时间、重置的 RNG)在少量运行中未必暴露。二十次能让常见的不稳定性以较高概率显形,同时把成本控制在可接受范围。需要强调,这个循环验证的是同一台机器、同一解释器版本下的稳定性;跨平台或跨依赖版本的差异仍需另外处理。若打印出多个摘要,二分定位的具体做法是:先单独运行每个测试文件,找出摘要不稳定的文件,再在该文件内逐测试排查,通常问题出在第三方库内部的全局 RNG 或缓存。
第四步:现在才录制基线
在二十个相同摘要之后,把快照复制进仓库。用一个只做这一件事的一行 diff 提交它们。那个提交就是你在后续重构中的参照点。
基线提交的规则:
- 同一提交中不做源码修改。
- 在提交信息中记录确切的命令和种子。
- 注明哪些检查被跳过,以及原因。
稳定的基线仍然可能是错的。确定性并不意味着正确性,它只意味着下一个 diff 是可归因的。
第五步:做最小安全变更
现在开始重构。每个提交一个接缝。特征化摘要必须在变更前后字节完全相同。如果不是,要么变更没有保持行为,要么基线泄漏了。
| 候选变更 | 涉及行数 | 门禁 | 结论 |
|---|---|---|---|
| 给一个函数添加时钟或 RNG 参数 | ~10 | 摘要相同 | 先做 |
| 把一个调用点移到接口后面 | ~30 | 摘要相同 | 第二做 |
| 跨模块用 dataclass 替换 dict | ~150 | 摘要相同 | 拆成三个提交 |
| 重命名被 40 个调用点使用的公共函数 | ~90 | 摘要相同 + 导入检查 | 仅当调用者都在仓库内 |
| 重排一个函数内部的副作用调用 | 任意 | 摘要相同 + 调用日志 diff | 需要第二道门禁 |
使用 diff 预算。如果一个提交超过大约 40 行源码变更,停下来拆分它。大提交会隐藏破坏摘要的那一行。
第六步:免费模型和免费服务器适合放在哪里
候选最小 diff 生成起来便宜,信任起来昂贵。我使用 MonkeyCode 的免费模型访问来为单个函数起草两到三个候选接缝。决定权在确定性循环,而不是模型。免费服务器选项是我运行二十次循环的地方,这样重复成本不会落在我的笔记本上。
披露:本文是作为 MonkeyCode 产品推广的一部分准备的。
这种分工是刻意的。模型提议,摘要门禁处置。任何无法通过二十次相同运行的候选,无论建议读起来多么干净,都不是候选。
局限性与谁应该跳过
- 封印会隐藏真实的时间依赖。保留窗口和过期逻辑需要单独的、不冻结的集成套件。
- 模块级补丁
datetime会破坏那些from datetime import datetime的库。在能添加的地方优先使用显式注入。 - 二十次运行能捕获不稳定性,但不能捕获错误期望。手动审查一次快照。
- 循环会成倍增加运行时间。在慢速套件上,限制为十次并在提交信息中说明。
- 如果行为合法地不确定且未测试,跳过此方法。封印你能封印的,隔离其余的。
如果你的测试套件无法承受二十次相同运行,重构就无法通过审查。先修复基线,再切出一个小 diff。
相关推荐

Workbuddy零基础入门:桌面AI智能体如何落地工作流
Workbuddy桌面AI智能体零基础教程解读:从安装、积分领取到文件管理与自动化工作流搭建,对比CodeX的适用场景与成本差异,帮助职场人完成从AI提问者到AI管理者的思维转变。

OpenAI智能体新栈:计算机操作、决策API与超速工作流
OpenAI Dev Day发布智能体新栈:Computer Use操作速度超越普通人,Decisions API基于Luna实现极低延迟分类,Agents API开放同源计算机操作能力,GPT-6.1 Sol成本仅为Astra七分之一。本文梳理核心更新、实测数据与开发者落地要点。

梁文峰开源推理框架DeepInfo,硬刚美国禁令
DeepSeek在开源模型后再开源推理框架DeepInfo,包含GPU/CPU交叉流水线、小模型预判大模型复核、华为昇腾与英伟达芯片全面适配等技术,直面美国联邦禁令。本文解析其对AI产业落地的意义与开源策略。