工程化 / Loop Engineering
2. 先为循环写一份可执行契约
原文备份 · 第 4 / 13 篇 · 查看极客时间原知识库 ↗
本篇目录 · 21 节
预计阅读与练习时间:45~60 分钟
本篇目标:建立 Issue Garden 实验副本,读懂并验证loop.yaml,让缺少预算、验证器或停止条件的循环在第一次调用 Agent 之前就被拒绝。
上一篇,我们用 Loop 候选卡回答了“这个任务值不值得循环化”。但卡片仍然只是给人看的说明。真正开始运行后,如果每一轮都靠提示词临时重申边界,迟早会漏掉一句。
这一篇先不调用 Agent。我们要把任务队列、单轮范围、可写路径、验证器、预算和停止条件写进一份机器可检查的契约。这样,后面的 Runner 不是“尽量遵守约定”,而是在契约无效时根本不启动。
一、准备独立实验副本
课程提供的起始快照位于:
assets/issue-garden-starter/
它同时包含两部分:一个很小的 ShelfCare 搜索模块,以及我们即将搭建的 Loop 控制面。请从课程根目录复制它,不要直接修改起始快照。
macOS 或 Linux:
cp -R assets/issue-garden-starter issue-garden-lab
cd issue-garden-lab
Windows PowerShell:
Copy-Item -Recurse -Force assets/issue-garden-starter issue-garden-lab
Set-Location issue-garden-lab
确认当前位置和关键文件。macOS、Linux 运行:
pwd
python3 --version
node --version
git --version
Windows PowerShell 运行:
Get-Location
py -3.11 --version
node --version
git --version
你应该看到 loop.yaml、requirements-loop.txt、src/、test/ 和 tools/,Python 版本至少为 3.11,Node.js 至少为 20。具体小版本不必和示例相同。
如果 py -3.11 不存在,但 python --version 显示 3.11 或更高,后续把 Windows 命令里的 py -3.11 换成 python 即可。若 Node 或 Git 命令不存在,先补齐课程 Brief 约定的环境;它们分别用于运行被维护项目的验证器和检查真实 Diff,不能用 Agent 的口头报告代替。
建立 Python 虚拟环境
loop.yaml 使用 YAML,是因为它比纯 JSON 更适合人手工审阅。Python 标准库不解析 YAML,因此项目把唯一的 Python 依赖锁定在 requirements-loop.txt 中。
macOS 或 Linux:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-loop.txt
Windows PowerShell:
py -3.11 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -r requirements-loop.txt
成功后,下面的命令应该打印 PyYAML 的版本号:
python -c "import yaml; print(yaml.__version__)"
如果 PowerShell 拒绝执行激活脚本,不必修改整台电脑的执行策略。可以直接使用虚拟环境里的解释器:
.\.venv\Scripts\python.exe -m pip install -r requirements-loop.txt
.\.venv\Scripts\python.exe -c "import yaml; print(yaml.__version__)"
此后把文中的 python 换成 .\.venv\Scripts\python.exe 即可。若安装阶段提示无法连接软件源,先检查网络或公司的 Python 软件源配置;不要从不明网站下载同名安装包。
保存一个干净基线
后面我们会用 Git 判断 Agent 究竟改了什么。现在先建立本地仓库和基线提交:
git init
git add .
git commit -m "chore: start issue garden lab"
git status --short
最后一条命令没有输出,表示工作区与基线一致。这个仓库只在本地使用,不需要添加远程地址。
如果 Git 因作者身份为空而拒绝提交,只为当前实验仓库设置一个占位身份,再重试提交:
git config user.name "Issue Garden Learner"
git config user.email "learner@example.invalid"
git commit -m "chore: start issue garden lab"
这里没有使用 --global,不会改动你其他仓库的身份配置。
二、契约与 Prompt 解决的是两件事
Prompt 会告诉一次 Agent 运行“这次要做什么”,例如:
修复全角空格导致图书搜索失败的问题,只修改 src/,完成后运行测试。
loop.yaml 则约束多次运行共同遵守的控制规则:一次最多跑多久、最多尝试几轮、哪些路径永远不能写、谁负责验证、出现什么情况必须结束。
两者的区别可以压缩成一句话:
Prompt 是本轮工作说明,循环契约是 Runner 的准入条件和裁决依据。
因此,不要把 API Key、Cookie 或一次任务的长篇背景写进 loop.yaml。它会进入版本控制,适合保存稳定、可审阅的规则,不适合保存秘密和易变的会话内容。
三、读懂 loop.yaml 的八组边界
先打开实验目录里的 loop.yaml。你会看到八个顶层部分。
1. schema_version:先声明解释方式
schema_version: 1
配置会随课程演进。显式版本号让 Runner 遇到未知格式时停止,而不是猜测新字段的含义。当前验证器只接受整数 1。
2. project:所有相对路径从哪里计算
project:
root: .
. 表示实验仓库根目录。验证器拒绝绝对路径和包含 .. 的路径,避免一个错误配置把队列或状态写到项目之外。
这仍然不等于操作系统级沙箱。路径检查是第一层防误操作,后面还会加入 Git 工作区隔离和命令边界。
3. queue:任务在不同终态之间怎样移动
queue:
inbox: tasks/inbox
processing: tasks/processing
completed: tasks/done
needs_input: tasks/needs-input
blocked: tasks/blocked
任务不会只有“未完成”和“完成”两种状态:
inbox是尚未领取的任务;processing表示某个 Runner 已经领取;completed保存验证通过的任务;needs_input表示缺少必要信息,等待人补充;blocked表示触碰权限或其他不可自动跨越的边界。
把后两种状态分开很重要。缺少验收样例可以补充后重开;要求读取真实密钥则不是“多试一次”能解决的问题。
4. runner:一轮允许占用多久
runner:
agent_adapter: mock
max_rounds_per_task: 3
round_timeout_seconds: 600
cooldown_seconds: 2
课程先使用 mock 适配器,让同一输入得到可重复结果。max_rounds_per_task 限制一个任务最多经历三轮;每轮超过 600 秒就终止;两轮之间短暂停顿,避免失败后立即高速重启。
注意,cooldown_seconds 不是失败策略。它只能降低重启频率,不能说明下一轮为何值得执行。第 6 篇会为重试增加“必须有新证据或新计划”的条件。
5. budgets:把“别消耗太多”改成可计算上限
budgets:
max_agent_runs_per_task: 3
max_wall_seconds_per_task: 1800
max_changed_files_per_round: 8
max_diff_lines_per_round: 200
这四个数字分别限制 Agent 运行次数、任务总耗时、单轮修改文件数和单轮 Diff 行数。
它们不是普适最佳值,只是适合这个小项目的保守起点。真实项目应根据任务粒度和失败代价调整。一个两行缺陷突然改动 60 个文件,即使测试通过,也更像任务失控而不是效率很高。
课程不把 Token 数作为唯一预算,因为不同 CLI 是否暴露精确用量并不一致。运行次数、墙钟时间和改动规模是 Runner 一定能够观察到的本地指标;第 8 篇再把可获得的费用或 Token 信息作为可选预算加入。
6. permissions:白名单与保护区同时存在
permissions:
allowed_write_paths:
- src/
protected_paths:
- test/
- acceptance/
- tasks/
- .git/
- .env
- loop.yaml
- .loop/
Agent 在本课程里只能修改 src/。测试、验收样例、任务本身、Git 元数据、潜在密钥、循环契约和 Runner 状态都属于保护区。
为什么既要白名单,又要保护名单?白名单回答“通常允许写哪里”,保护名单强调“即使以后扩大白名单,这些地方仍需额外审查”。后面的验证器会以真实 Diff 为准,而不是相信 Agent 声称自己只改了 src/。
7. verification:成功必须有外部证据
verification:
require_clean_baseline: true
baseline_commands:
- [npm, test]
- [npm, run, check]
allow_task_verification_command: true
allow_test_changes: false
命令故意写成参数列表,而不是一整段 Shell 字符串。Runner 后续会直接传递参数,不经 Shell 展开,从而减少 &&、重定向和命令替换带来的意外执行面。
require_clean_baseline 要求处理任务前,项目自身的基础检查已经通过;否则 Loop 无法区分旧故障与本轮新故障。任务还可以附带一条额外验收命令,但必须经过允许列表检查。Agent 不得通过修改测试让结果“变绿”。
参数列表只减少 Shell 解析风险,并不会自动让任意程序变安全。哪些可执行文件和参数可以进入验证器,仍要由 Runner 审核,第 5 篇会真正实现这道门。
8. stop 与 idempotency:结束和去重必须提前定义
stop:
terminal_statuses:
- completed
- needs_input
- blocked
- stopped
same_failure_limit: 2
stop_on:
- budget_exhausted
- timeout
- permission_boundary
- missing_information
- human_cancelled
idempotency:
task_id_field: id
claim_dir: .loop/claims
result_dir: .loop/results
terminal_statuses 告诉调度器哪些结果不应自动开始下一轮;same_failure_limit 规定相同失败连续出现两次就停止原地重试。预算耗尽、超时、越权、缺少信息和人工取消都是明确停止原因。
幂等性配置则要求每个任务有稳定 id,并为领取记录和最终结果指定持久目录。以后即使进程重启,Runner 也可以判断任务是否已被领取或完成,避免同一任务被重复处理。
仅仅创建一个结果文件还不能保证所有副作用幂等。例如重复发送邮件仍可能发出两次。因此本课程默认没有外部写入;第 4 篇会先解决本地状态恢复,再讨论哪些动作根本不该自动重放。
四、先让机器拒绝不完整契约
现在在 issue-garden-lab/ 根目录运行:
python tools/validate_contract.py
预期输出:
[OK] loop.yaml 契约有效
这条命令会检查必填项、正整数预算、安全相对路径、验证命令结构、四种终态和五类停止原因。退出码为 0,后续 Runner 才可以继续。
课程还准备了一份故意损坏的契约:它把最大轮数设成 0,并删掉了所有基础验证命令。运行:
python tools/validate_contract.py examples/loop.invalid.yaml
预期会看到类似输出,且进程退出码为 2:
[INVALID] examples/loop.invalid.yaml 有 2 个问题:
- runner.max_rounds_per_task 必须是大于等于 1 的整数
- verification.baseline_commands 至少要有一条命令
输出行顺序以实际版本为准。关键不是报错文案,而是一个没有轮次上限或没有验证器的配置无法进入运行阶段。
macOS 或 Linux 可立即查看上一条命令的退出码:
echo $?
Windows PowerShell 使用:
$LASTEXITCODE
应得到 2。这就是“可执行契约”与说明文档的差别:它不仅写下规则,还能阻止无效状态继续传播。
五、用自动化测试检查验证器本身
契约验证器也可能写错,所以不能只拿一份正确配置跑一次。执行:
python -m unittest discover -s loop_tests -p "test_*.py" -v
预期有四项测试通过:有效契约被接受、零轮次与空验证器被拒绝、越出项目的 ../ 路径被拒绝、Windows 绝对路径被拒绝。结尾类似:
Ran 4 tests in ...s
OK
然后确认契约里写下的两条基础命令在当前机器上确实可执行:
npm test
npm run check
第一条应显示两项搜索测试通过;第二条通常没有正文输出,只要退出码为 0 就表示 JavaScript 语法检查通过。
最后检查工作区:
git status --short
它仍应没有输出。虚拟环境、Python 缓存和未来的 .loop/ 状态已经被 .gitignore 排除;验证过程不应悄悄改动课程基线。
这一组检查形成了完整闭环:
写下契约
-> 正确配置通过
-> 错误配置被拒绝并返回非零退出码
-> 单元测试验证拒绝规则
-> 契约声明的项目检查真实运行
-> Git 证明没有额外修改
六、改配置时先问三个问题
以后把这份契约迁移到自己的项目,不要先复制数字。先回答:
- Runner 能否直接观察这个限制? “质量要高”无法计算,“最多改 8 个文件”可以计算。
- 触发边界时应该发生什么? 是整轮失败、转人工审核,还是允许经过批准后继续?
- 证据由谁产生? 如果成功条件仍然只是 Agent 输出一句
done,配置再详细也没有形成独立验证。
还有一条容易忽略:契约验证通过,只证明配置结构和最低安全要求成立,不证明任务一定正确,也不证明操作系统权限已经隔离。它是启动前的门卫,不是整个安全系统。
常见阻塞
YAML 报缩进或类型错误
YAML 使用空格表达层级,不要混用 Tab。列表项前的 - 也要和同级元素对齐。若你加入类似 yes、no、on、off 的字符串,建议加引号,避免不同 YAML 解析规则把它们当成布尔值。
先单独运行 python tools/validate_contract.py,不要让 Runner 在启动后才发现语法问题。
正确配置被提示路径不安全
课程只接受项目内相对路径,不接受 /tmp/tasks、C:\tasks 或 ../tasks。如果真实系统必须使用外部目录,不应该简单删掉检查;应在设计阶段明确挂载点、访问权限和清理策略,再扩展契约与验证器。
npm test 失败,但契约验证通过
这两个检查回答不同问题。契约验证只检查 Loop 规则,npm test 检查被维护项目的基线。如果基线已失败,先修复环境或项目,不要让循环把旧错误当成 Agent 本轮造成的问题。
本篇留下了什么
现在,issue-garden-lab/ 已经是一个带 Git 基线的独立实验项目。loop.yaml 明确了任务队列、单轮上限、资源预算、可写路径、受保护区域、外部验证、终态和幂等性目录;验证器能接受完整契约,也能在运行前拒绝危险缺口。
下一篇,我们会实现第一个单轮 Runner。它仍然只使用确定性的模拟 Agent,但会真正完成一次“领取任务—启动运行—收集结果—验证—输出决策”的流程。到那时,loop.yaml 不再只是被单独检查的文件,而会开始控制程序行为。