编程智能体很容易用提交次数和新增行数制造忙碌假象。能稳定构建、通过测试、合进主干、并在回滚窗口内存活的工作,通常只是生成量里的一截。本文不评模型强弱,只从 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 维度;口径稳定,比再做一个更炫的仪表盘重要。
评论