3170 字
约 10 分钟
3
第八讲. 用功能清单约束 agent 该做什么

第八讲. 用功能清单约束 agent 该做什么

一个常见的场景:让 agent 做一个电商网站,跑完之后它告诉你"做完了"。但你打开代码一看,用户认证有了,但购物车的结算按钮点了没反应,支付流程根本没接上。问题的根源在于:没有告诉过它"做完"的具体标准,所以它用自己的标准来判断——"代码写了不少,看起来挺完整"。

功能清单(feature list)在很多人眼里就是个备忘录,写下来怕忘了,写完扔在一边。但在 harness 的世界里,功能清单是整个 harness 的基础结构。调度器靠它选任务,验证器靠它判完成,交接器靠它生成报告。没有它,这些组件就没有可以依赖的共识。

Anthropic 和 OpenAI 都强调:工件必须外部化。功能状态必须是仓库里机器可读的文件,不能是对话里的非结构化描述。

Agent 缺少明确的完成标准

Claude Code 和 Codex 都不会自动知道你心目中的"做完"是什么意思。你说"加一个购物车功能",模型的理解可能是"写一个 Cart 组件和 addToCart 方法"。而你的意思是"用户能从浏览商品到下单支付完整走通"。

这个理解鸿沟在没有功能清单的情况下会持续存在。agent 用自己的隐式标准判断完成,通常是"代码没有明显的语法错误"。而你需要的是端到端的行为验证。没有清单,双方对"做完"的理解始终是对不上的。

看看这种常见的进度记录:

做了用户认证、购物车基本完成了、还需要做支付

新的 agent 会话看到这个记录,能回答以下问题吗?"基本完成"意味着什么?购物车通过了哪些测试?支付的阻塞条件是什么?答案都是"不知道"。

结果是:新会话花 20 分钟推断项目状态,最终可能重复实现已完成的功能。Anthropic 的工程实践数据表明,好的进度记录可以减少 60-80% 的会话启动诊断时间。

功能状态机

flowchart LR
    Feature["一行功能项"] --> Behavior["行为<br/>例如:POST /cart/items 返回 201"]
    Feature --> Check["验证命令<br/>具体要跑什么检查"]
    Feature --> State["状态<br/>not_started / active / blocked / passing"]

    Behavior --> Complete["三列都齐了<br/>这行功能项才能用"]
    Check --> Complete
    State --> Complete
flowchart LR
    List["feature_list.json / features.md"] --> Scheduler["选下一个 not_started"]
    Scheduler --> Agent["agent 只做这一项"]
    Agent --> Verifier["跑这一项自己的验证命令"]
    Verifier -->|通过| Passing["写成 passing<br/>并补上验证证据"]
    Verifier -->|失败| Active["继续保持 active"]
    Verifier -->|依赖问题| Blocked["标成 blocked"]
    Passing --> Handoff["更新交接说明<br/>和当前进度"]
    Active --> Agent

核心概念

  • 功能清单是 harness 原语:它是所有 harness 组件依赖的基础数据结构。调度器、验证器、交接器都要读取它才能工作。
  • 三元组结构:每个功能项包含三个要素:(行为描述, 验证命令, 当前状态)。行为描述告诉 agent 做什么,验证命令告诉它怎么算做完,状态告诉它现在到哪了。缺了任何一项,这个功能项就不完整。
  • 状态机模型:每个功能项有四种状态:not_startedactiveblockedpassing。状态转移由 harness 控制,不是 agent 想改就能改。
  • 通过状态门控:功能从 active 变成 passing 的唯一方式是验证命令执行成功。这个转移是不可逆的,passing 了就不能退回去。
  • 单一权威来源:项目里关于"该做什么"的所有信息,必须从一个功能清单派生。不能出现功能清单和对话记录矛盾的情况。
  • 反向压力:还没通过的功能项数量就是 harness 对 agent 施加的压力。压力归零 = 项目完成。

为什么功能清单必须是原语

文档是给人看的,原语是给系统用的。文档可以被忽略,原语不能被绕过。

可以类比数据库的触发器约束和应用层的检查逻辑:前者由数据库引擎强制执行,任何 SQL 都无法跳过;后者依赖于应用代码的正确性,可能被意外绕过。功能清单作为 harness 原语,承担的就是数据库级别的约束角色,agent 不能绕过它。

具体来说,功能清单服务四个 harness 组件:

  1. 调度器:读状态,选下一个 not_started 的功能。
  2. 验证器:执行验证命令,判断是否允许状态转移。
  3. 交接报告器:从功能清单自动生成会话交接摘要。
  4. 进度追踪器:统计各状态分布,提供项目健康度指标。

实施方法

1. 定义一个最小化的功能清单格式

不需要复杂的系统,一个结构化的 Markdown 或 JSON 文件就够了。关键是每个条目必须有三元组:

{
  "id": "F03",
  "behavior": "POST /cart/items with {product_id, quantity} returns 201",
  "verification": "curl -X POST http://localhost:3000/api/cart/items -H 'Content-Type: application/json' -d '{\"product_id\":1,\"quantity\":2}' | jq .status == 201",
  "state": "passing",
  "evidence": "commit abc123, test output log"
}

2. 让 harness 控制状态转移

agent 不能直接把状态改成 passing。它只能提交验证请求,harness 执行验证命令,根据结果决定是否允许状态转移。这就是"通过状态门控"。

3. 在 CLAUDE.md 里写清楚规则

## 功能清单规则
- 功能清单文件: /docs/features.md
- 每次只激活一个功能项
- 功能项验证命令必须通过才能标为 passing
- 不要修改功能清单的状态,由验证脚本自动更新

4. 粒度校准

每个功能项应该是"一次会话能完成"的范围。太粗了做不完,太细了管理开销大。"用户可以添加商品到购物车"是一个好粒度,"实现购物车"太粗了,"创建 Cart 模型的 name 字段"太细了。

实际案例

一个电商平台的开发任务,10 个功能项。对比两种追踪方式:

备忘录模式:agent 用非结构化笔记记录进度。3 个会话后,笔记变成了"做了用户认证和商品列表、购物车基本完成但还有 bug、支付没开始"。新会话需要 20 分钟推断状态,最终重复实现了已完成的功能。

结构化模式:每个功能项有明确的状态和验证命令。新会话读取功能清单,3 分钟内知道:F01-F05 是 passing,F06 是 active(正在做),F07-F10 是 not_started。直接从 F06 继续,零重复。

定量结果:使用结构化功能清单的项目,功能完成率比自由形式高 45%,零重复实现。

核心要点

  • 功能清单是 harness 的基础结构,不是给人看的备忘录。调度器、验证器、交接器都依赖它。
  • 每个功能项必须有三元组:行为描述 + 验证命令 + 当前状态。缺一项就不完整。
  • 状态转移由 harness 控制,agent 不能自己改状态。通过验证是唯一的升级路径。
  • 功能清单是项目的单一权威来源,任何关于"该做什么"的信息都从这里派生。
  • 粒度控制在"一次会话能完成"的范围。太粗做不完,太细管不过来。

延伸阅读

练习

  1. 功能清单设计:定义一个最小化的功能清单 JSON schema。包含:id、行为描述、验证命令、当前状态、证据引用。用它描述一个包含 5 个功能的真实项目。
  2. 验证严格性对比:选 3 个功能,分别设计"宽松"验证(如"代码无语法错误")和"严格"验证(如"端到端测试通过")。对比两种验证下的假阳性率。
  3. 单一来源原则审查:审查一个已有的 agent 项目,检查是否存在与功能清单矛盾的范围信息(对话里的隐式需求、代码里的 TODO 注释等)。设计一个方案,把所有信息统一到功能清单中。

代码示例

feature-list-validator.ts

/**
 * feature-list-validator.ts
 *
 * Reads a feature_list.json, validates its schema, and checks for features
 * marked "pass" without verification evidence. Outputs a structured report.
 * Can run against any project directory that has a feature_list.json.
 *
 * Usage:
 *   npx tsx docs/lectures/lecture-08.../code/feature-list-validator.ts [path-to-dir]
 *   (defaults to the directory containing this script)
 *
 * Run: npx tsx docs/lectures/lecture-08-why-feature-lists-are-harness-primitives/code/feature-list-validator.ts
 */

import * as fs from "node:fs";
import * as path from "node:path";

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

interface FeatureEntry {
  id?: string;
  category?: string;
  description?: string;
  verification?: string[];
  passes?: boolean;
  // Allow unknown fields for flexibility
  [key: string]: unknown;
}

interface ValidationResult {
  featureId: string;
  schemaValid: boolean;
  schemaErrors: string[];
  hasVerification: boolean;
  markedPassWithoutEvidence: boolean;
  passes: boolean;
  verificationCount: number;
}

// ---------------------------------------------------------------------------
// Schema validation
// ---------------------------------------------------------------------------

function validateSchema(entry: FeatureEntry, index: number): string[] {
  const errors: string[] = [];
  const label = entry.id ?? `entry ${index + 1}`;

  if (!entry.id || typeof entry.id !== "string") {
    errors.push(`[${label}] Missing or invalid 'id' field`);
  }
  if (!entry.category || typeof entry.category !== "string") {
    errors.push(`[${label}] Missing or invalid 'category' field`);
  }
  if (!entry.description || typeof entry.description !== "string") {
    errors.push(`[${label}] Missing or invalid 'description' field`);
  }
  if (entry.verification !== undefined && !Array.isArray(entry.verification)) {
    errors.push(`[${label}] 'verification' must be an array if present`);
  }
  if (entry.passes !== undefined && typeof entry.passes !== "boolean") {
    errors.push(`[${label}] 'passes' must be a boolean if present`);
  }

  return errors;
}

// ---------------------------------------------------------------------------
// Evidence validation
// ---------------------------------------------------------------------------

function checkEvidence(entry: FeatureEntry): {
  hasVerification: boolean;
  markedPassWithoutEvidence: boolean;
} {
  const hasVerification = Array.isArray(entry.verification) && entry.verification.length > 0;
  const markedPassWithoutEvidence = entry.passes === true && !hasVerification;

  return { hasVerification, markedPassWithoutEvidence };
}

// ---------------------------------------------------------------------------
// Process feature list
// ---------------------------------------------------------------------------

function processFeatureList(entries: FeatureEntry[]): ValidationResult[] {
  return entries.map((entry, index) => {
    const schemaErrors = validateSchema(entry, index);
    const evidence = checkEvidence(entry);

    return {
      featureId: (entry.id as string) ?? `entry-${index + 1}`,
      schemaValid: schemaErrors.length === 0,
      schemaErrors,
      hasVerification: evidence.hasVerification,
      markedPassWithoutEvidence: evidence.markedPassWithoutEvidence,
      passes: entry.passes === true,
      verificationCount: Array.isArray(entry.verification) ? entry.verification.length : 0,
    };
  });
}

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

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

function run(): void {
  // Resolve target directory
  const scriptDir = path.dirname(new URL(import.meta.url).pathname);
  const targetDir = process.argv[2]
    ? path.resolve(process.argv[2])
    : scriptDir;

  const filePath = path.join(targetDir, "feature_list.json");

  console.log("\n" + "=".repeat(90));
  console.log("  FEATURE LIST VALIDATOR");
  console.log("=".repeat(90));
  console.log(`  Reading: ${filePath}\n`);

  if (!fs.existsSync(filePath)) {
    console.error(`  ERROR: feature_list.json not found at ${filePath}`);
    console.error("  Usage: npx tsx feature-list-validator.ts [path-to-directory-containing-feature_list.json]\n");
    process.exit(1);
  }

  let entries: FeatureEntry[];
  try {
    const raw = fs.readFileSync(filePath, "utf-8");
    entries = JSON.parse(raw);
  } catch (err) {
    console.error(`  ERROR: Could not parse feature_list.json: ${err}`);
    process.exit(1);
  }

  if (!Array.isArray(entries)) {
    console.error("  ERROR: feature_list.json must contain a JSON array at the top level.");
    process.exit(1);
  }

  // For demo purposes, also validate an extended test set
  const demoEntries: FeatureEntry[] = [
    ...entries,
    {
      id: "qna-002",
      category: "import",
      description: "User can import a PDF document.",
      verification: ["Upload a PDF file", "Verify it appears in the document list"],
      passes: true,
    },
    {
      id: "qna-003",
      category: "grounded_qa",
      description: "System hallucination rate is below 5%.",
      verification: [], // Empty -- no evidence
      passes: true, // Marked as pass WITHOUT evidence
    },
    {
      id: "missing-fields",
      // Missing 'category' and 'description'
      passes: true,
    } as FeatureEntry,
  ];

  const results = processFeatureList(demoEntries);

  // Print report
  const header = `| ${pad("Feature ID", 14)}| ${pad("Schema", 8)}| ${pad("Passes", 7)}| ${pad("Verifications", 14)}| ${pad("Evidence?", 12)}| Notes`;
  const sep = `|${"-".repeat(16)}|${"-".repeat(10)}|${"-".repeat(9)}|${"-".repeat(16)}|${"-".repeat(14)}|${"-".repeat(30)}`;
  console.log(header);
  console.log(sep);

  for (const r of results) {
    const schemaLabel = r.schemaValid ? "OK" : "INVALID";
    const passesLabel = r.passes ? "PASS" : "FAIL";
    const evidenceLabel = r.hasVerification ? "Present" : "MISSING";
    const notes = r.markedPassWithoutEvidence
      ? "FLAGGED: passes without evidence!"
      : r.schemaErrors.length > 0
        ? r.schemaErrors[0]
        : "";

    const marker = r.markedPassWithoutEvidence ? ">>" : r.schemaValid ? "  " : "!!";
    console.log(
      `${marker}| ${pad(r.featureId, 14)}| ${pad(schemaLabel, 8)}| ${pad(passesLabel, 7)}| ${pad(String(r.verificationCount), 14)}| ${pad(evidenceLabel, 12)}| ${notes}`
    );
  }

  // Summary
  const total = results.length;
  const schemaOk = results.filter((r) => r.schemaValid).length;
  const passing = results.filter((r) => r.passes).length;
  const flagged = results.filter((r) => r.markedPassWithoutEvidence).length;
  const withEvidence = results.filter((r) => r.hasVerification).length;

  console.log("\n" + "-".repeat(90));
  console.log("  SUMMARY");
  console.log("-".repeat(90));
  console.log(`  Total features:                     ${total}`);
  console.log(`  Schema valid:                       ${schemaOk}/${total}`);
  console.log(`  Marked as passing:                  ${passing}/${total}`);
  console.log(`  With verification evidence:         ${withEvidence}/${total}`);
  console.log(`  Flagged (pass without evidence):    ${flagged}`);

  if (flagged > 0) {
    console.log(`\n  WARNING: ${flagged} feature(s) marked as "pass" without any verification evidence.`);
    console.log("  These features need verification before they can be trusted.\n");
  } else {
    console.log("\n  All passing features have verification evidence. Feature list is healthy.\n");
  }
}

run();

feature_list.json

[
  {
    "id": "qna-001",
    "category": "grounded_qa",
    "description": "User can ask a question about an imported document and receive an answer with visible citations.",
    "verification": [
      "Import a markdown document",
      "Open the Q&A panel",
      "Ask a question about known content",
      "Verify the answer is returned",
      "Verify one or more citations are displayed"
    ],
    "passes": false
  }
]

通过门控策略

通过门控策略

一个功能只有在满足以下条件时,才能从 passes: false 变为 passes: true

  • 预期的工作流已被执行
  • 成功的证据已被记录
  • 被测试的路径中不存在阻塞性错误
  • 实现不会使应用处于损坏或模糊的状态
第八讲. 用功能清单约束 agent 该做什么
http://clxhxhhr.top/posts/273/
作者
clxstart
发布于
2026-07-26
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。