范围契约与任务边界 本节摘要:模型不知道工作在哪结束。范围契约(scope contract)是一份按任务的文件,说明工作从哪开始、到哪结束、溢出时怎么回滚。它把「待在范围内」从愿望变成检查。诊断很经典:Agent 会蔓延——任务是「修登录 bug」,diff 却动了登录路由、邮件助手、数据库驱动、README、发布脚本;每处触碰当时都有貌似合理的理由,合在一起却是一个与被评审的不同的改动。范围蔓延是 Agent 工作里最被低估监控的失败模式,因为 Agent 每一步都以善意叙述。修法不是更严的提示,而是一份盘上的契约说明承诺了什么、加一个检查把结果对比承诺。
本节摘要:模型不知道工作在哪结束。范围契约(scope contract)是一份按任务的文件,说明工作从哪开始、到哪结束、溢出时怎么回滚。它把「待在范围内」从愿望变成检查。诊断很经典:Agent 会蔓延——任务是「修登录 bug」,diff 却动了登录路由、邮件助手、数据库驱动、README、发布脚本;每处触碰当时都有貌似合理的理由,合在一起却是一个与被评审的不同的改动。范围蔓延是 Agent 工作里最被低估监控的失败模式,因为 Agent 每一步都以善意叙述。修法不是更严的提示,而是一份盘上的契约说明承诺了什么、加一个检查把结果对比承诺。本节定义范围契约的七字段(task_id/goal/allowed_files/forbidden_files/acceptance_criteria/rollback_plan/approvals_required)——没有
forbidden_files的契约是不完整的,负空间是契约的一半;用 glob 而非裸路径(真实仓库会移文件);把回滚纳入范围(列不出回滚的契约不该被批准)。范围检查是 diff 检查:Agent 写 diff,检查器读 diff+允许 glob+禁用 glob+验收命令,每个违规是一条验证门可拒的标记发现。本节还给出两层高度:任务契约约束一个任务,不约束项目——所以需要第二原语feature_list.json(Agent 会话开始读的项目 backlog),「一次一个功能」从提示里一行 Agent 能自圆其说绕过的文字,变成它从盘读的值、验证门强制的检查。最后给四条让收益持久的生产模式:违规预算(非二元失败,Claude Code/Cursor 用的 agent-guardrails)、按路径族严重度不对称(docs 警告 vs scripts/migrations/config-prod 阻断)、时间与网络预算(范围不只文件)、多契约最小权限合并语义(允许交集/禁用并集/时间取最小)。一位跑 specsmaxxing 的实践者报告:三周内兔洞率从 52% 降到 21%,没换 Agent——契约干了活,不是模型。
对应原课程:Phase 14 · Lesson 36 ·
scope-contracts(原英文phases/14-agent-engineering/36-scope-contracts/docs/en.md)。前置:第 32 节(最小工作台)、第 33 节(规则即约束)。
阅读完本节,你应当能够:
Agent 会蔓延。任务是「修登录 bug」。diff 却动了登录路由、邮件助手、数据库驱动、README、发布脚本。每处触碰当时都有貌似合理的理由。合在一起,它们是一个与被评审的不同的改动。
范围蔓延是 Agent 工作里最被低估监控的失败模式,因为 Agent 每一步都以善意叙述。修法不是更严的提示。修法是一份盘上的契约说明承诺了什么,加一个检查把结果对比承诺。
| 字段 | 目的 |
|---|---|
task_id |
链到板上的任务 |
goal |
审查者能核验的一句话 |
allowed_files |
Agent 可写的 glob |
forbidden_files |
Agent 即便无意也不能碰的 glob |
acceptance_criteria |
证明完成的测试命令或断言行 |
rollback_plan |
需停机时操作员能执行的一段话 |
approvals_required |
范围外需显式人签字的动作 |
没有 forbidden_files 的契约是不完整的。负空间是契约的一半。
真实仓库会移文件。把契约钉到 glob(app/**/*.py、tests/test_signup*.py),这样会话间的重构不会让契约失效。
列出怎么回滚,强制契约作者思考什么可能出错。一份你无法回滚的契约,是一份不该被批准的契约。
Agent 写 diff。检查器读 diff、允许 glob、禁用 glob、跑过的验收命令列表。每个违规是一条验证门可拒的标记发现。
范围契约约束一个任务。它不约束项目。一个 Agent 可以完美待在登录修复的契约内,却仍可能在下一轮决定项目还需要设置页、深色模式开关、路由重写。契约从没被问「项目范围内是哪些工作」,只被问「任务范围内是哪些文件」。
这第二高度需要自己的原语:一个 Agent 会话开始读的 feature_list.json。它是项目 backlog,机器可读、有序。Agent 挑恰好一个 status 为 todo 的功能,把它的 id 写进活动范围契约,被禁止在同一会话开始第二个功能。「一次一个功能」不再是提示里 Agent 能自圆其说绕过的一行,而变成它从盘读的值、验证门强制的检查。
{ "project": "knowledge-base", "active": "import-pdf", "features": [ { "id": "import-pdf", "status": "in_progress", "goal": "把 PDF 导入库", "done_when": "pytest tests/test_import.py 且示例 PDF 出现在库视图" }, { "id": "full-text-search", "status": "todo", "goal": "搜文档文本并排序", "done_when": "查询返回带摘录的排序结果" }, { "id": "cite-answers", "status": "todo", "goal": "答案带来源引用", "done_when": "每个答案渲染至少一个可点引用" } ] }
| 字段 | 目的 |
|---|---|
active |
当前会话可碰的单一功能;空表示挑一个并设上 |
features[].id |
范围契约 task_id 指向的稳定 slug |
features[].status |
todo/in_progress/done/blocked;同时只有一个 in_progress |
features[].goal |
审查者能核验的一句话 |
features[].done_when |
把 in_progress 翻成 done 的验收行 |
两条规则让列表承重而非装饰。第一,「至多一个 in_progress」这条不变量本身是一条启动检查(第 33 节):列表显示两个时,会话拒绝启动,直到人解决。第二,功能列表是文件,不是聊天消息——聊天会滚出上下文,文件跨会话跨 Agent 持久。交接(第 40 节)把完成功能的状态写回 done,让下次会话打开的是准确的板而非重新推导还剩什么。
契约与列表按最小权限组合(同下面的合并):任务契约的 allowed_files 必须坐在活动功能所碰之内,绝不在其外。
原课程 code/main.py 实现:
scope_contract.json 模式(JSON Schema 子集,glob 数组)。RunSummary。(violations, in_scope, off_scope) 的 scope_check。{ "task_id": "T-102", "goal": "给登录路由加输入校验", "allowed_files": ["app/auth/login.py", "tests/test_login.py"], "forbidden_files": ["scripts/release.sh", "migrations/**", "config/prod/**"], "acceptance_criteria": ["pytest tests/test_login.py"], "rollback_plan": "git checkout app/auth/login.py tests/test_login.py", "approvals_required": ["新增依赖"] }
def scope_check(diff_files, run_cmds, contract): allowed = contract["allowed_files"]; forbidden = contract["forbidden_files"] off_scope = [f for f in diff_files if not any(fnmatch(f, g) for g in allowed)] violated = [f for f in diff_files if any(fnmatch(f, g) for g in forbidden)] return {"off_scope": off_scope, "forbidden_hit": violated, "in_scope": not off_scope and not violated}
def pick_feature(feature_list): in_prog = [f for f in feature_list["features"] if f["status"] == "in_progress"] if len(in_prog) > 1: raise StartupCheckFail("多个 in_progress") # 启动检查 if len(in_prog) == 1: return in_prog[0] todo = next(f for f in feature_list["features"] if f["status"] == "todo") todo["status"] = "in_progress"; feature_list["active"] = todo["id"] atomic_write("feature_list.json", json.dumps(feature_list)) return todo
运行 python3 code/main.py 会输出:契约、两次运行、每运行裁决、一份保存的 scope_report.json。
一位跑「specsmaxxing」(调 Agent 前用 YAML 写范围契约)的实践者报告:三周内兔洞率从 52% 降到 21%,没换 Agent。契约干了活,不是模型。三条模式让收益持久。
违规预算,非二元失败。 agent-guardrails(Claude Code、Cursor、Windsurf、Codex 经 MCP 用的 OSS 合并门)按任务发一个 violationBudget:预算内的小范围滑出作为警告浮现;只在预算超时合并门才拒。配 violationSeverity: "error" | "warning"。预算是一道能交付的门与一道被讨厌它的团队关掉的门之间的差别。
按路径族的严重度不对称。 越界写到 docs/** 通常是 warn;越界写到 scripts/**、migrations/**、config/prod/** 恒为 block。这种不对称必须活在契约里,不在运行时里,因为它是项目特定的、每任务变化。
文件预算旁的时间与网络预算。 一个 time_budget_minutes 字段约束墙钟;运行时拒绝无再审批地越过它继续。一个 network_egress 主机名白名单防 Agent 偷偷打一个不属于任务的外部 API。这些也是范围维度;文件 glob 必要但不充分。
多契约合并语义(最小权限)。 当两个范围契约适用时(如项目级契约加任务级契约),合并是:交集 allowed_files(两个契约都允许该路径)、并集 forbidden_files(任一可禁)、time_budget_minutes 取最严(min)、approvals_required 累积。network_egress 为 None 即不强制、[] 即全拒、[...] 即白名单;合并下 None 让位对方、两列表交集、全拒保持全拒。在契约模式里声明这点,让合并机械化、可审查。
💡 设计要点:范围契约把「待在范围内」从语言约束变成diff 检查。这与第 33 节「规则即约束」同源——语言约束是愿望,diff 检查是测试。功能列表则把范围从「任务」抬到「项目」,堵住「Agent 完美待在登录契约内却擅自开设置页」这类越界。两层叠加按最小权限,正好对应第 31 节的「授权策略」原语——允许/禁用 glob 是 ACL,审批是权限格。
| 运行时 | 范围如何用 |
|---|---|
| Claude Code 斜杠命令 | /scope 命令写契约并钉为会话上下文;子智能体行动前读契约 |
| GitHub PR | 契约作 JSON 推进 PR 体或检入工件;CI 对合并 diff 跑范围检查器 |
| LangGraph interrupts | 范围违规触发 interrupt;处理器问人「契约要扩还是 Agent 要退」 |
| agent-guardrails 合并门 | 违规预算 + 严重度分层 |
契约随任务旅行。任务关闭时,契约归档到 outputs/scope/closed/。
原课程 outputs/skill-scope-contract.md:为任务描述生成范围契约,加一个 CI 里对每个 Agent diff 跑的、glob 感知的检查器。
network_egress 字段列允许的外部主机。碰其他主机的运行拒掉。docs/** 软失败、对 scripts/** 硬失败。论证不对称。goal 字段推导 allowed_files。第一个边界情况会出什么问题?time_budget_minutes,墙钟一超即拒继续。下一节,我们做实「反馈」这一面——运行时反馈循环:如何把每次 shell 命令、工具调用的真实输出捕获进循环,让 Agent 看到失败、据此修正,而不是在 400 错误上宣布成功。