编程智能体很容易用提交次数和新增行数制造忙碌假象。能稳定构建、通过测试、合进主干、并在回滚窗口内存活的工作,通常只是生成量里的一截。本文不评模型强弱,只从 Git 历史、CI 结论和回滚记录出发,用可运行的 Node.js 脚本把「生成 → 可构建 → 测试通过 → 合并 → 存活」做成不依赖智能体自评的漏斗。

为什么生成量会看起来像交付量

看智能体周报时,最容易到手的数字往往是:开了多少次会话、提了多少次交、新增了多少行。这些指标采集成本低,也确实能反映「机器在写」。但软件交付问的是另一件事:这段改动有没有变成主干上可运行、可验证、并且短时间内站得住的软件。

提交次数会被拆分策略放大。一次功能可能被切成十几个「先通过、再整理」的小提交;新增行数会被生成代码、锁文件、快照测试和格式化改动同时抬高。更关键的是,Git 默认并不知道一次构建红没红、测试过没过。把生成量直接当成交付量,等于用过程噪音回答结果问题。

所以度量起点不该是模型自己的总结,而应是仓库里已经发生的事实:提交是否被识别为智能体产物、CI 是否给出过结论、该提交是否成为主干祖先、以及在约定窗口内有没有被 git revert 撤掉。漏斗不证明谁更强,只回答:「多写了代码,是否真的多交付了软件」。

漏斗五段,每段只认仓库事实

指标要可复现,定义就必须能从同一份仓库重复算出来,而不是开会时口头对齐。下面五段是最小可用集合,团队可以改窗口和识别规则,但不建议改语义。

生成:统计周期内、按约定规则识别出的智能体提交。规则应写在脚本参数里,而不是写死某家产品。常见做法是提交说明里的 trailer(例如某些工具会写 Co-authored-by:)、约定前缀,或机器人邮箱。没有统一行业标准,以你们仓库的实际约定为准。

可构建:该提交对应的构建任务结论为成功。Git 对象本身不含这一信息,必须另接 CI 导出或检查记录。

测试通过:在可构建的前提下,测试任务结论为成功。构建绿、测试红,不应记入这一段。

合并:该提交已经是主干(通常是 main)的祖先。注意:若团队普遍 squash 合并,原提交哈希不会出现在主干历史上,按祖先判断会偏低;那种工作流需要另接合并提交或 PR 记录。本文示例按「原提交是否已是主干祖先」计算,不假装覆盖 squash。

存活:已合并,且在回滚窗口(例如 7 天,由团队约定)内没有针对该提交的 git revert。存活不是「这段代码永远正确」,只表示它没有在短窗口里被显式撤掉。

这五段是单向收窄的:后一段是前一段的子集。缺 CI 文件时,不应把「可构建 / 测试通过」默认为成功,否则漏斗会假装质量很好。

从 Git 能稳定拿到的三件事

Git 擅长三件事:列出提交、判断祖先、识别 revert。这正好覆盖生成、合并、存活。CI 两段必须外挂。

识别生成时,用可配置正则扫 subject 和 body,避免把人工提交误计入漏斗。判断合并用 git merge-base --is-ancestor:退出码 0 表示已在主干历史中。识别回滚用 git revert 的标准说明正文 This reverts commit <sha>.,再比较原提交时间与 revert 时间是否落在窗口内。手工改回功能、而不打 revert 的情况,本脚本不会算作回滚——这是有意留白,避免把普通后续提交都当成失败。

建议在仓库根目录跑统计,并明确 --since、--main、--window-days。周期一变、主干名一变,数字就应跟着变;这才是可复现,而不是一张截图。

用 Node.js 把漏斗算出来

下面脚本只使用 Node.js 内置模块,不依赖第三方包。把 agent-funnel.mjs 放在仓库根目录。CI 结论用一份你们自己导出的 JSON(键为完整 SHA 或 7 位短 SHA),格式如下:

{
  "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2": {
    "build": "success",
    "test": "failure"
  }
}

build / test 只认字符串 success。文件可以从 GitHub Actions、GitLab CI 或自建流水线在结论文档里生成;脚本不绑定某一家 CI 产品。无该文件时,构建和测试两段记 0,并在 stderr 提示——因为 Git 不会替你保存 CI 结论。

#!/usr/bin/env node
import { execSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";

function parseArgs(argv) {
  const out = {
    since: "90.days",
    main: "main",
    windowDays: 7,
    agent: "Co-authored-by:.*(bot|copilot|cursor|codex|agent)",
    ci: "",
  };
  for (const item of argv.slice(2)) {
    const eq = item.indexOf("=");
    if (eq === -1) continue;
    const k = item.slice(0, eq);
    const v = item.slice(eq + 1);
    if (k === "--since") out.since = v;
    else if (k === "--main") out.main = v;
    else if (k === "--window-days") out.windowDays = Number(v);
    else if (k === "--agent") out.agent = v;
    else if (k === "--ci") out.ci = v;
  }
  return out;
}

function git(args) {
  return execSync("git " + args, {
    encoding: "utf8",
    stdio: ["ignore", "pipe", "pipe"],
    maxBuffer: 32 * 1024 * 1024,
  }).trim();
}

function loadCi(path) {
  if (!path || !existsSync(path)) return {};
  return JSON.parse(readFileSync(path, "utf8"));
}

const opts = parseArgs(process.argv);
const agentRe = new RegExp(opts.agent, "i");
const ci = loadCi(opts.ci);
const ciLoaded = Object.keys(ci).length > 0;

if (!ciLoaded) {
  console.error(
    "未提供有效 --ci=ci-results.json。可构建 / 测试通过 将记为 0(Git 不保存 CI 结论)。"
  );
}

const raw = git(
  "log --all --since=" +
    opts.since +
    " --pretty=format:%H%x09%cI%x09%s%n%b%x1e"
);

const commits = raw
  ? raw
      .split("\x1e")
      .map((chunk) => chunk.trim())
      .filter(Boolean)
      .map((chunk) => {
        const lines = chunk.split("\n");
        const head = lines[0].split("\t");
        const sha = head[0];
        const date = head[1];
        const subject = head.slice(2).join("\t");
        const body = lines.slice(1).join("\n");
        return { sha, date, subject, body, text: subject + "\n" + body };
      })
  : [];

const reverts = [];
for (const c of commits) {
  const m = c.body.match(/This reverts commit ([0-9a-f]{7,40})/i);
  if (m) reverts.push({ target: m[1].toLowerCase(), date: c.date });
}

function isMerged(sha) {
  try {
    git("merge-base --is-ancestor " + sha + " " + opts.main);
    return true;
  } catch {
    return false;
  }
}

function survived(commit) {
  const t0 = Date.parse(commit.date);
  const windowMs = opts.windowDays * 86400000;
  const short = commit.sha.slice(0, 7).toLowerCase();
  for (const r of reverts) {
    const hit =
      commit.sha.toLowerCase().startsWith(r.target) ||
      r.target.startsWith(short);
    if (!hit) continue;
    const t1 = Date.parse(r.date);
    if (!Number.isNaN(t0) && !Number.isNaN(t1) && t1 >= t0 && t1 - t0 <= windowMs) {
      return false;
    }
  }
  return true;
}

function ciOf(sha) {
  return ci[sha] || ci[sha.slice(0, 7)] || {};
}

const generated = commits.filter((c) => agentRe.test(c.text));
const rows = generated.map((c) => {
  const rec = ciOf(c.sha);
  const buildable = rec.build === "success";
  const testsPass = buildable && rec.test === "success";
  const merged = isMerged(c.sha);
  return {
    generated: true,
    buildable,
    testsPass,
    merged,
    survived: merged && survived(c),
  };
});

const n = rows.length;
const count = (key) => rows.filter((r) => r[key]).length;
const pct = (x) => (n === 0 ? "n/a" : ((100 * x) / n).toFixed(1) + "%");

const funnel = [
  ["生成", n],
  ["可构建", count("buildable")],
  ["测试通过", count("testsPass")],
  ["合并", count("merged")],
  ["存活", count("survived")],
];

console.log(
  "范围 since=" +
    opts.since +
    " main=" +
    opts.main +
    " window=" +
    opts.windowDays +
    "d"
);
console.log("识别 /" + opts.agent + "/i");
console.log("");
console.log("阶段\t数量\t占生成量");
for (const [name, x] of funnel) {
  console.log(name + "\t" + x + "\t" + pct(x));
}

在仓库根目录执行:

node agent-funnel.mjs --since=2025-01-01 --main=main --window-days=7 --ci=ci-results.json

把 --agent 换成你们真实的 trailer 或邮箱模式。输出是五段数量及其占「生成」的比例。不要把某一周的比例写成行业基线;它只描述本仓库、本规则、本时间窗。

读数时先排除三种误用

第一,缺 CI 导出时不要解释转化率。构建、测试两段为 0,优先检查文件有没有接上,而不是结论「智能体不会写能编译的代码」。

第二,识别规则过宽会把人工提交算进去,过窄会漏掉真实智能体提交。改正则前后应各跑一次,看「生成」数量的跳变是否合理。漏斗的分母错了,后面每段都会错。

第三,合并段要对照分支策略。线性合并或 merge commit 下,祖先判断和直觉一致;squash、rebase 后改写哈希,则必须承认本脚本的合并段会低估,不能反过来指责智能体「从不进主干」。存活段同样只覆盖显式 git revert,不覆盖「后续提交把功能删了但没写 revert」。

漏斗适合做趋势,不适合做排行榜。同一仓库、同一规则下,本周生成上升而合并、存活不动,更值得问的是:任务是不是拆得太碎、CI 是不是没跑到智能体分支、还是合并门槛和生成速度不匹配。它不能单独回答模型好不好。

总结

交付度量应落在仓库事实上,而不是落在智能体自己的忙碌报告上。用约定规则圈出生成提交,用 CI 导出补上构建与测试,用祖先关系判断是否进入主干,用 revert 窗口判断是否存活——这五段已经能回答「多写了代码,是否真的多交付了软件」。Git 不保存 CI 结论,squash 会打断哈希,存活也不等于长期正确:把这些限制写进口径,数字才可复现。先让脚本在你们自己的仓库跑通,再决定要不要加 PR 维度;口径稳定,比再做一个更炫的仪表盘重要。