3108 字
约 10 分钟
5
第七讲. 给 agent 划清每次任务的边界

第七讲. 给 agent 划清每次任务的边界

Agent 经常会越界,同时做太多事情。在一个典型场景中,你让它给项目加上用户认证功能,结果它同时开始改数据库 schema、写路由、改前端组件,还顺手重构了错误处理中间件。两个小时后一看,12 个文件被修改,800 行新代码,但没有一个功能是端到端跑通的。

Agent 天生就有"多做一点"的冲动:看到相关的事情就顺手一起做了。问题是,同时做太多事情,结果往往每一件都做不好。

Anthropic 在 "Effective harnesses for long-running agents" 工程博客中明确指出:当提示太宽泛时,agent 倾向于"同时启动多件事"而非"先做完一件事"。OpenAI 在 Codex 工程实践中也发现,没有显式范围控制的任务,完成率会暴跌。这本质上是一个 harness 设计问题:没有给 agent 划清边界。

注意力是有限的资源

这本质上是一个数学问题。假设 agent 的上下文容量为 C,同时激活 k 个任务,每个任务平均获得 C/k 的推理资源。当 C/k 低于完成单个任务所需的最小阈值时,所有任务都做不完。

Claude Code 的真实行为很说明问题。你让它"添加用户注册功能",它很可能这样做:

  1. 创建 User model
  2. 写注册路由
  3. 发现需要邮箱验证,于是加邮件服务
  4. 看到密码需要加密,于是引入 bcrypt
  5. 注意到错误处理不统一,于是重构全局错误中间件
  6. 看到测试文件结构不清晰,于是重组目录结构

6 步之后,每一个都是半成品。没有端到端验证,代码之间耦合复杂,下一个会话来接手时会一脸懵。

Anthropic 的实验数据直接支持这一点:使用"小下一步"策略(等价于 WIP=1)的 agent,任务完成率比使用宽泛提示的 agent 高 37%。更有意思的是,agent 生成的代码行数和实际完成的功能数量呈弱负相关,写得越多,完成得越少。贪多嚼不烂,数据为证。

WIP=1 工作流

flowchart LR
    Queue["功能队列"] --> Pick["只选一个任务"]
    Pick --> Active["仅允许一个 active"]
    Active --> Verify["跑端到端验证"]
    Verify -->|通过| Commit["提交并解锁下一个任务"]
    Verify -->|失败| Active
    Commit --> Queue
flowchart TB
    Budget["可用推理预算 = C"] --> One["WIP = 1<br/>每个任务拿到 C / 1"]
    Budget --> Many["WIP = 5<br/>每个任务只有 C / 5"]

    One --> Finish["一个功能进入 passing"]
    Many --> Partial["五个功能都只做了一半"]
    Partial --> VCR["已验证完成率低<br/>下一会话返工高"]

核心概念

  • 过度延伸(Overreach):agent 在一次会话中激活的任务数量超过最优值。这是可以量化的:同时做 5 个功能但 0 个跑通,就是 overreach。
  • 不足完成(Under-finish):已启动的任务中,通过端到端验证的比例低于阈值。写了代码但没跑通测试,就是 under-finish。
  • WIP 限制(Work-in-Progress Limit):来自 Kanban 方法论,核心思想是限制同时在进行的任务数量。对于 agent,WIP=1 是最安全的默认值,做完一个再做下一个。
  • 完成证据(Completion Evidence):一个任务从"进行中"变成"已完成"必须满足的可验证条件。没有这个,agent 会用"代码看起来没问题"代替"行为通过测试"。
  • 范围表面(Scope Surface):一个 DAG 结构,每个节点是一个工作单元,边是依赖关系。状态只有四种:未开始、进行中、阻塞、已通过。
  • 完成压力(Completion Pressure):harness 通过 WIP 限制和完成证据要求共同产生的约束力,迫使 agent 先完成当前任务再开始新任务。

过度延伸与不足完成

这两个问题互相加剧。overreach 导致注意力分散,注意力分散导致 under-finish,under-finish 留下的半成品代码又增加了系统复杂度,进一步导致下一个任务的 overreach,形成恶性循环。

用 Kanban 的语言说:Little 法则告诉我们 L = lambda * W。如果在制品数量 L 过大(同时做太多事),每个任务的前置时间 W 必然增加。对 agent 来说,这意味着每个功能从开始到验证通过的时间被拉长,失败概率被放大。

这在人类世界也是老问题了。Steve McConnell 在《Rapid Development》中记录,范围蔓延是项目失败的首要原因。但人类至少有"我已经做得够多了"的直觉,agent 完全没有。生成下一个想法的成本对模型来说太低了,写一行"顺便把这个也改了"几乎不消耗额外 token,但每个额外的修改都会稀释 agent 的注意力。

实施方法

1. 强制 WIP=1

这是最直接有效的方法。在你的 harness 里,明确告诉 agent:任何时刻只允许一个任务处于"进行中"状态。 在 Claude Code 的 CLAUDE.md 或 Codex 的 AGENTS.md 里写:

## 工作规则
- 每次只做一个功能点
- 当前功能点端到端验证通过后,才能开始下一个
- 不要在实现功能 A 时"顺便"重构功能 B

2. 给每个任务定义显式的完成证据

完成指的是"行为验证通过了"。在你的功能列表里,每个条目都要有验证命令:

F01: 用户注册
  验证: curl -X POST /api/register -d '{"email":"test@example.com","password":"123456"}' | jq .status == 201
  状态: passing

3. 把范围表面外部化

用一个机器可读的文件(JSON 或 Markdown)记录所有任务的状态。任何新会话都能直接读这个文件,知道:哪个任务在做?什么行为算完成?已经通过了什么验证?

4. 监控验证完成率

harness 应该持续跟踪 VCR(Verified Completion Rate)= 已通过验证的任务数 / 已启动的任务数。VCR < 1.0 时,阻止新任务启动。

实际案例

一个 8 个功能点的 REST API 项目,两种策略对比:

无约束模式:agent 在第一个会话同时启动 5 个功能。产出约 800 行代码,涉及 12 个文件。端到端测试通过率只有 20%,只有用户注册跑通了。其余 4 个功能:数据库 schema 建了但缺验证逻辑,路由定义了但返回格式错误。到第 3 个会话结束,8 个功能只完成 3 个。

WIP=1 模式:agent 在第一个会话只做用户注册。产出约 200 行代码,涉及 4 个文件。端到端测试 100% 通过。提交干净的、已验证的实现。到第 4 个会话结束,8 个功能完成 7 个(第 8 个因外部依赖被阻塞)。

结果:总代码量更少(800 行 vs 1200 行),但有效代码更多。完成率 87.5% vs 37.5%。

核心要点

  • WIP=1 是 agent harness 的默认安全设置:做完一个再做下一个,不要试图并行。
  • 完成证据必须是可执行的:"代码看起来没问题"不算完成,"curl 返回 201"才算。
  • 范围表面必须外部化为文件:不能只在对话里说,必须在仓库里有机器可读的记录。
  • overreach 和 under-finish 是共生问题:解决一个就解决了另一个。
  • "少做但做完"永远优于"多做但做半":agent 代码行数和功能完成率呈负相关,质量永远比数量重要。

延伸阅读

练习

  1. 任务原子化练习:选一个宽泛需求(如"实现用户管理系统"),把它拆成至少 5 个原子工作单元。每个单元写清楚:(a) 单一行为描述,(b) 可执行的验证命令,(c) 依赖关系。检查是否满足 WIP=1 的约束。
  2. 对比实验:在同一个项目上跑两次,一次不给约束,一次强制 WIP=1。比较验证完成率、总代码行数、有效代码比例。
  3. 完成证据审计:回顾一个最近的 agent 运行结果,把每个代码变更分类为"已完成行为"、"未完成行为"或"脚手架"。给每个未完成行为补充缺失的验证命令。

代码示例

下一个任务模板

下一个任务模板

  • 当前最高优先级功能:
  • 为什么这个功能是下一个:
  • 什么算作通过:
  • 在此步骤中不能更改什么:

范围界定示例

范围界定示例

任务:

  • 为 Electron 知识库应用添加索引功能

糟糕的范围定义:

  • "实现索引"

更好的范围定义:

  • 解析导入的文档
  • 将文档拆分为块
  • 持久化块元数据
  • 在 UI 中暴露索引状态
  • 添加重新索引操作

scope-tracker.ts

/**
 * scope-tracker.ts
 *
 * Reads a feature list and a change log. Enforces single-active-feature
 * policy. Given a log of changes, flags any changes outside the active
 * feature scope. Demonstrates how scope drift happens and how the tracker
 * catches it.
 *
 * Run: npx tsx docs/lectures/lecture-07-why-agents-overreach-and-under-finish/code/scope-tracker.ts
 */

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

interface Feature {
  id: string;
  name: string;
  status: "active" | "pending" | "done";
}

interface ChangeLogEntry {
  step: number;
  file: string;
  description: string;
  featureId: string; // The feature this change claims to belong to
}

// ---------------------------------------------------------------------------
// Sample data
// ---------------------------------------------------------------------------

const features: Feature[] = [
  { id: "F-001", name: "Search endpoint", status: "active" },
  { id: "F-002", name: "Delete endpoint", status: "pending" },
  { id: "F-003", name: "Rate limiting", status: "pending" },
  { id: "F-004", name: "User dashboard", status: "pending" },
];

// A realistic change log where the agent gradually drifts from the active feature
const changeLog: ChangeLogEntry[] = [
  { step: 1, file: "src/routes/search.ts", description: "Add search route handler", featureId: "F-001" },
  { step: 2, file: "src/routes/search.ts", description: "Add query parameter validation", featureId: "F-001" },
  { step: 3, file: "src/routes/search.ts", description: "Add search results pagination", featureId: "F-001" },
  { step: 4, file: "src/routes/delete.ts", description: "Add delete route handler", featureId: "F-002" }, // DRIFT
  { step: 5, file: "src/middleware/rate-limit.ts", description: "Add rate limiter middleware", featureId: "F-003" }, // DRIFT
  { step: 6, file: "src/routes/search.ts", description: "Integrate rate limiter into search", featureId: "F-001" },
  { step: 7, file: "src/dashboard/ui.tsx", description: "Create dashboard layout component", featureId: "F-004" }, // DRIFT
  { step: 8, file: "src/routes/search.ts", description: "Add search response formatting", featureId: "F-001" },
  { step: 9, file: "src/routes/delete.ts", description: "Add delete confirmation logic", featureId: "F-002" }, // DRIFT
  { step: 10, file: "src/routes/search.ts", description: "Add search tests", featureId: "F-001" },
];

// ---------------------------------------------------------------------------
// Scope tracker
// ---------------------------------------------------------------------------

interface ScopeCheckResult {
  step: number;
  file: string;
  description: string;
  featureId: string;
  inScope: boolean;
  activeFeature: string;
}

function trackScope(
  featureList: Feature[],
  changes: ChangeLogEntry[]
): ScopeCheckResult[] {
  const activeFeatures = featureList.filter((f) => f.status === "active");
  const activeIds = new Set(activeFeatures.map((f) => f.id));
  const activeNames = activeFeatures.map((f) => f.name).join(", ");

  return changes.map((change) => ({
    step: change.step,
    file: change.file,
    description: change.description,
    featureId: change.featureId,
    inScope: activeIds.has(change.featureId),
    activeFeature: activeNames,
  }));
}

// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------

function pad(s: string, len: number): string {
  return s.length >= len ? s : s + " ".repeat(len - s.length);
}

function run(): void {
  const results = trackScope(features, changeLog);

  console.log("\n" + "=".repeat(100));
  console.log("  SCOPE TRACKER -- Single Active Feature Enforcement");
  console.log("=".repeat(100));

  console.log("\n  Active feature: " + features.filter((f) => f.status === "active").map((f) => `${f.id} (${f.name})`).join(", "));
  console.log("  Pending features: " + features.filter((f) => f.status === "pending").map((f) => `${f.id} (${f.name})`).join(", "));

  // Detailed change log
  console.log("\n" + "-".repeat(100));
  const header = `| ${pad("Step", 5)}| ${pad("File", 35)}| ${pad("Description", 40)}| ${pad("Feature", 8)}| ${pad("In Scope", 10)}|`;
  const sep = `|${"-".repeat(7)}|${"-".repeat(37)}|${"-".repeat(42)}|${"-".repeat(10)}|${"-".repeat(12)}|`;
  console.log(header);
  console.log(sep);

  let inScopeCount = 0;
  let driftCount = 0;

  for (const r of results) {
    const scopeLabel = r.inScope ? "OK" : "DRIFT";
    if (r.inScope) inScopeCount++;
    else driftCount++;

    const marker = r.inScope ? "  " : ">>";
    console.log(`${marker}| ${pad(String(r.step), 5)}| ${pad(r.file, 35)}| ${pad(r.description, 40)}| ${pad(r.featureId, 8)}| ${pad(scopeLabel, 10)}|`);
  }

  // Summary
  console.log("\n" + "=".repeat(100));
  console.log("  SCOPE DRIFT SUMMARY");
  console.log("=".repeat(100) + "\n");

  const sHeader = `| ${pad("Metric", 40)}| ${pad("Value", 15)}|`;
  const sSep = `|${"-".repeat(42)}|${"-".repeat(17)}|`;
  console.log(sHeader);
  console.log(sSep);
  console.log(`| ${pad("Total changes", 40)}| ${pad(String(results.length), 15)}|`);
  console.log(`| ${pad("Changes within active scope (F-001)", 40)}| ${pad(String(inScopeCount), 15)}|`);
  console.log(`| ${pad("Changes outside active scope (DRIFT)", 40)}| ${pad(String(driftCount), 15)}|`);
  console.log(`| ${pad("Features touched (total)", 40)}| ${pad(String(new Set(results.map((r) => r.featureId)).size), 15)}|`);

  // Drift detail
  const driftFeatures = [...new Set(results.filter((r) => !r.inScope).map((r) => r.featureId))];
  if (driftFeatures.length > 0) {
    console.log("\n  DRIFTED FEATURES:");
    for (const fid of driftFeatures) {
      const feat = features.find((f) => f.id === fid);
      const driftChanges = results.filter((r) => r.featureId === fid);
      console.log(`    ${fid} (${feat?.name}): ${driftChanges.length} unauthorized changes`);
    }
  }

  console.log("\n  Without a scope tracker, the agent silently worked on " + driftFeatures.length + " unrelated features.");
  console.log("  The tracker catches this drift and enforces the single-active-feature policy.\n");
}

run();

第七讲. 给 agent 划清每次任务的边界
http://clxhxhhr.top/posts/272/
作者
clxstart
发布于
2026-07-26
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。