同一份 WorkForge 任务定义在不同机器上跑出不同结果,通常不是单一原因。可以先做一次归因:任务定义里有没有字段给的是模糊描述或可选值、依赖有没有被锁到具体版本、随机种子有没有被显式固定。这三项里任何一项不稳定,复现都会失败。建议的处理顺序是:先把任务定义字段逐条对齐,再写出能重建环境的依赖锁定文件,然后用固定种子加固定依赖跑一次基线,最后把一致性检查脚本放进提交前流程。
任务结果不可复现时,先分清是任务描述太模糊还是依赖版本漂移,不要直接归因于模型或数据。操作上:字段层面,把必须给范围的和必须给固定值的字段分开标注;环境层面,写出只含直接依赖的锁定文件,并在目标机器上用安装命令重建后比对;验证层面,切换依赖后重跑一组最小任务集,观察输出数量、字段结构和统计量是否变化。边界在于:依赖锁只能保证代码环境一致,数据集版本、硬件差异和随机性仍需分别固定,无法通过一个锁定文件全部覆盖。
逐条对照任务定义字段的模糊点
先把 WorkForge 任务定义当成一份可执行的契约来读。凡是读完之后不同人会有不同理解的字段,都属于模糊点。常见模糊来源有三类:只写了类型没写范围、只写了默认行为没写固定值、写了一个枚举但没有说明非法值怎么处理。建议把任务定义里的字段按「必须给范围」「必须给固定值」两类标注,前者允许在区间内变动但要有边界,后者一旦变动结果就不可比。
| 字段类别 | 典型字段 | 要求 | 判断依据 |
|---|---|---|---|
| 采样与随机 | 随机种子、采样温度、采样数量 | 必须给固定值 | 同一种子在不同机器上应产出同构输出;温度变动会直接改变结果分布 |
| 规模与截断 | 最大输入长度、最大输出长度、批大小 | 必须给固定值或明确上限 | 截断位置不同会导致任务输入内容不同 |
| 阈值与判定 | 相似度阈值、通过判定的分数线 | 必须给范围并写明默认取值 | 阈值是连续量的分界,只写「较高」无法执行 |
| 数据切分 | 训练/验证划分比例、划分策略 | 必须给固定值与策略名 | 随机划分与按时间划分结果不同 |
| 重试与超时 | 最大重试次数、单次超时 | 必须给固定值 | 重试次数影响最终采到的样本数量 |
| 输出格式 | 输出字段名、字段顺序、空值表示 | 必须给固定值 | 下游按字段解析,命名不一致会导致比对失败 |
对照表填完后,建议把每个字段的当前取值写进任务配置文件本身,而不是依赖代码里的默认值。默认值会随代码修改而变,写进配置才是可追溯的。改完字段后,先确认同一份配置在本地能完整跑通一次,再往下做依赖锁定。
写出可重建的依赖锁定文件
依赖锁定的目标不是把所有包都写进一个文件,而是让别人能据此重建出行为一致的环境。建议区分两层:一层记录直接依赖及其版本,一层记录由直接依赖推导出的完整依赖树。前者给人看,后者给安装工具用。锁定时不要把版本写成范围,范围在解析时可能落到不同版本。
# 直接依赖清单示例(文件名按项目约定,例如 requirements-direct.txt)
workforge-core==<固定版本>
workforge-tasks==<固定版本>
data-utils==<固定版本>
# 完整锁定文件示例(由工具生成后提交,不要手工改版本行)
# 生成方式示例:
# pip freeze > requirements-lock.txt
# 或使用项目约定的锁定工具生成带哈希的锁定文件
# 安装验证(在新机器上执行,安装完成后比对依赖树)
# pip install -r requirements-lock.txt
# pip check
# pip list `--format`=freeze > installed-after.txt
# diff requirements-lock.txt installed-after.txt上面片段里的版本号需要替换成项目当前实际使用的版本,不要照抄。安装验证的关键动作是「装完后重新导出一份依赖清单,和锁定文件做比对」,差异为空或只出现工具自身说明行,才说明环境被重建到了预期状态。如果比对出现差异,通常说明锁定文件里有未固定的传递依赖,需要回到生成步骤补齐。运行环境方面还要注意解释器版本和系统级依赖,这两项建议单独写在环境说明里,因为它们不总是由包管理工具锁定。
观察版本差异带来的行为变化
判断结果波动是否来自依赖,不要直接全量重跑。先准备一组最小对比集:挑几个输入短、路径简单、不涉及大规模并发的任务,保证单个任务能在短时间内跑完。在这组任务上分别用旧依赖和新依赖各跑一次,只比较固定几个观察字段。
- 输出条数与字段结构:是否出现字段缺失、类型变化、条数不一致。
- 数值统计:同一输入的输出长度分布、分数分布是否落在同一区间。
- 错误与警告日志:重试次数、超时次数、被跳过任务的数量。
- 任务耗时:只作为是否发生显著行为变化的辅助信号,不作为性能结论。
如果最小对比集上输出结构或数值分布已经不同,说明依赖变化影响了行为,应优先确认是哪一层依赖引起的,可以逐个回退直接依赖定位。如果最小对比集结果一致、只有全量结果不同,问题更可能出在数据规模、并发或任务定义字段而非依赖版本,需要回到第一步重新检查字段。
用固定种子与固定依赖跑一次基线
基线的作用是给后续改动留一个参照点。没有基线的项目,任何一次结果变化都无法判断是改进还是回归。基线应使用固定种子加固定依赖跑一次完整任务,然后把结果和当时的环境一起记录下来。
# 基线记录模板(建议以文件形式随任务配置一起提交)
baseline_id: <可读标识>
task_config: <任务配置文件名>
task_config_hash: <配置文件的哈希值>
seed: <实际使用的种子值>
dependency_lock: <锁定文件名>
dependency_lock_hash: <锁定文件的哈希值>
env: <解释器版本与系统级依赖说明>
result_summary:
total_tasks: <任务总数>
success: <成功数>
failed: <失败数>
skipped: <跳过数>
output_count: <输出条数>
field_schema: <输出字段列表>
stat_notes: <需要对照的统计量及其取值>记录里的统计量不必复杂,关键是每次改动后能按同一字段对照。种子、依赖清单、任务配置、结果统计这四类信息缺一项,基线的可比性就会下降。需要提醒的是,基线只代表记录那一刻的环境和配置,跨机器复用基线前建议先确认目标机器的解释器版本与系统依赖是否一致。
提交前跑一致性检查脚本
字段和依赖的问题靠人工复核容易被漏掉,建议把检查动作写成一个脚本,在提交前执行。脚本不负责证明结果正确,只负责拦住明显的不一致:锁定文件被改动、任务配置被改动、种子被改动。
# 一致性检查脚本骨架(放在项目脚本目录,提交前手动或由钩子执行)
import hashlib, json, pathlib, sys
BASE = pathlib.Path("baseline")
LOCK = pathlib.Path("requirements-lock.txt")
CONFIG = pathlib.Path("task_config.yaml")
def sha256(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
problems = []
try:
record = json.loads((BASE / "baseline.json").read_text(encoding="utf-8"))
except FileNotFoundError:
print("缺少基线记录,先跑一次基线再提交")
sys.exit(1)
if sha256(LOCK) != record["dependency_lock_hash"]:
problems.append("依赖锁定文件与基线记录不一致")
if sha256(CONFIG) != record["task_config_hash"]:
problems.append("任务配置与基线记录不一致")
# 种子是否被改动:从任务配置中读取当前种子并与基线比对
current_seed = record.get("seed")
# 读取任务配置中种子的逻辑按项目实际配置格式补充
# 例如:current_seed = read_seed(CONFIG)
if current_seed != record["seed"]:
problems.append("随机种子与基线记录不一致")
if problems:
print("一致性检查未通过:")
for item in problems:
print("-", item)
sys.exit(1)
print("一致性检查通过,可以提交")脚本里的文件路径和读取配置的函数需要按项目实际情况替换。检查通过不等于结果正确,它只保证提交的内容和基线记录的是同一套配置。如果确实需要改种子或改依赖,建议先更新基线记录再提交,并在提交说明里写清改动原因,避免下一个人看到不一致的记录时无法判断是遗漏还是有意调整。