Tiana随笔

代码多三成,交付没动:AI 编程的下游账单

· 工程与效率

标签:#AI 编程 #项目管理 #工程

代码多三成,交付没动:AI 编程的下游账单

哈佛 718 家公司的研究显示 agent 让代码量涨三成,需求吞吐却没有显著变化,增益全部堆在评审环节。结合 Anthropic 三万次改动一天的三档审批流程,讲清楚为什么键盘上的速度不再是约束,以及度量口径该怎么换。

代码多三成,交付没动:AI 编程的下游账单

10 月中旬有两份材料撞到了一起,读起来像是在互相注解。

一份是哈佛两位研究者 Fiona Chen 和 James Stratton 的工作论文《Artificial Intelligence in the Firm: Bottlenecks in Software Production》,2026 年 8 月挂出的第四版。数据来自 Jellyfish 的工程遥测,配上 GitHub、Jira、日历和 HR 系统,覆盖 718 家公司、大约 3 亿条工作事件、70 万名以上员工,时间从 2021 年 1 月到 2026 年 3 月。

另一份是 Anthropic 在 10 月 15 号发的工程博客,作者是 Addy Osmani、Morgan Westlee Lunt 和 Qing Wang,讲的是 Claude Code 团队自己怎么审代码。里面有两个数字:Claude 写了 Claude Code 大约九成的代码,Anthropic 那个大仓库每天的改动量从 5 月的约 2500 次涨到 9 月的三万次以上。

前一份在量"产出没变",后一份在讲"我们把审批改了"。方向相反,说的是同一件事:代码生成这一站变快了,后面几站没跟上。

我在意这个,是因为过去半年我一直在用 AI 写代码,也被问过好几次同一个问题:既然写得快了,为什么排期没短。

三亿条工作事件的结论

论文把工具分成两类来测,这个区分很关键。一类是补全式助手,人敲一半它接后半截;另一类是 agent,自己读代码库、改文件、提 PR。

助手那一组,人均代码行数涨了 12%,提交涨 9%,PR 涨 5%。三个数字里只有提交数在统计上显著。

agent 那一组是另一回事:代码行数涨 30%,提交涨 20%,PR 涨 23%,三个都显著。论文的基线是每人每月约 5017 行代码,agent 带来的增量大约是 1494 行。

到这里都还是好消息。往下看就不是了。

论文用 Jira 里 issue 和 epic 的解决率当作"交付出去的软件"的代理指标。结果是:助手组没有显著变化,agent 组也没有出现统计上显著的变化,论文明确排除了超过 12% 的增幅。他们还用一个模型估算了每个 issue 的解决耗时,确认任务本身没有变大变复杂,所以"活变难了"这个解释不成立。

就业那一栏也值得记一笔。论文估计对总人数的效应接近零,排除了超过 2.9% 的下降。既没多产出软件,也没少招人。

这两条放在一起,正好卡在两种流行叙事中间:AI 没有让软件产出显著变多,也没有把工程师换掉。它让键盘上的活变快了。

增益卡在哪一站

论文的答案是评审。

他们先估了工程师的时间分配。一份 100 人的调查给出的是写代码占 30%,平台数据反推出来的是 42%。也就是说,写代码这件事最多占掉一周的三到四成,剩下的时间在需求梳理、评审、测试、发布和协调上。

这个分配决定了一件事的上限。假设写代码占 30%,agent 让这一段快了 30%,整个周期只会缩短大约 7%,而且这还没算后面几站被拖慢的部分。这段算术是我自己做的,不是论文里的,但它和论文的结论对得上。

下游的数字则说明后面几站确实被拖慢了。接入 agent 之后:

  • PR 从提交到合并的平均时间涨了 49%,基线是 7.03 天,也就是多了三到三天半
  • 被要求修改的 PR 占比接近翻倍
  • 每条 PR 的评审评论数上升三成以上,不同报道引用的口径在 35% 到 53% 之间
  • 参与评审工作的人多了 14%

有一条否定性证据很重要:PR 的体积没有显著变大。所以上面的变化不能甩锅给"提交变大了所以审得慢"。同样一段代码量的 PR,需要更多评审工作量,论文给的解释是 agent 产出的代码里需要复核的缺陷更多。

还有一个细节让我印象最深。到 2026 年 3 月,数据里约 80% 的公司已经在用某种 AI 评审工具了。但 AI 写出来的评审评论只占 23.3%,只有 10.8% 的 PR 收到过至少一条 AI 评论。

工具买了,人力约束没松。论文作者的话很直白:要吃到 AI 的收益,需要的是在下游评审能力上做配套投入,而不是再多生成一点代码。

这不是能力问题,是排队问题

我读到这里想起的是约束理论那套老话:一条管道的速度由最慢那一站决定,你优化非瓶颈站,得到的不是产出,是库存。

放在研发流程上,"库存"就是排队等评审的 PR。代码生成这一站突然变便宜了,库存就以最快速度堆在下一站门口。

我的判断是,这两年很多团队对 AI 编程的失望,根子不在模型能力,而在流程没跟着改。大家把生成端的能力提升直接换算成排期缩短,这个换算从来没成立过。写代码只占三成时间,你把这三成砍掉一半,剩下七成才是决定交付日期的部分。

这也是为什么同一批工具,在不同团队身上的效果差异能大到像两种技术。评审纪律好、测试覆盖够、发布流程顺的团队,多出来的代码能被吸收;本来就靠人肉把关的团队,多出来的代码直接变成积压。

三万次改动一天的另一半故事

Anthropic 那篇博客值得单独说,因为它给出的是一套具体的流程改造,而不是一句"要重视评审"。

他们的核心判断是:人到底有没有看过这段代码,是个不太准的替代指标。真正该问的是"这次改动万一出事会怎么出",然后让最合适的那道检查去拦它,不管那道检查是测试、是 AI 还是人。

基于这个判断,他们做了三档风险分级:

低风险是文档、文案、内部功能、开关后面的增量改动。自动化检查和安全审查过了就能合并,不需要人批。

中风险是普通的产品改动,外加几个敏感区域:缓存、自动更新、系统提示词。这类要一个人批。

高风险是最要命的那部分代码,外加几条永远不能跳过的路径。这类要两个人。

这里有一条我认为是整套设计里最吃劲的规则:改动定档,不由作者定。 一个分类器读 diff 决定档位,第二个分类器再问一次:这条改动虽然按档位不需要人,但它是不是其实需要。有两类路径被硬编码成必须见人:定义 CI 工作流的文件,以及代码 owner 标记为敏感的目录。

还有一条红线我很认同:agent 的批准永远不算人的批准。自动化审查可以放行低风险改动,但它不能去填那个写着"要一个人"的位置。谁安排 agent 写了这段改动,谁就是必需的评审人。

时间轴也跟着变了。原来的顺序是写完、提 PR、等同事有空。现在改成边写边查,博客里的比喻是"打字时的拼写检查",人到场的时候,AI 通常已经查过一轮、改过一轮了。

配套的一组数字:引入 AI 评审后,收到实质性评审意见的内部 PR 占比从 16% 涨到 54%;一轮就通过的 PR 从 65% 涨到 78%;6 月到 9 月合并量翻倍以上,回滚率下降超过 40%。

这些数字得打个折看。这是 Anthropic 在自己的博客上描述自己的工具和自己的团队,自报数据,没有第三方审计,而且他们用的正是自己卖的产品。HN 上质疑最集中的就是"三万次改动一天"这个量级,有人认为这是公关数字。博客本身也承认了一个缺口:目前还没有熔断机制,回滚率或事故率上升的时候,系统不会自动收紧审批,工程师希望这个机制先到位,再谈让更多代码零人审批合并。

五十轮的那个 PR

博客里有一段我读了三遍。

一个 CLI 的 PR,作者 agent 和评审 agent 一晚上互相来回了五十多轮,一个提意见一个改,谁也不收敛。最后是人把它停下来的。

团队去跑 git blame 看评审反复在说什么,结果是大约 59% 被指出的代码从第一轮评审时就在那儿了。也就是说,评审 agent 不是在找新 bug,它每轮都在重新发现同一批老问题。

他们给的四个约束我全部抄下来了:

轮次上限。三轮之后,评审方要转而质疑实现方案本身,而不是继续挑细节。

只重新看新 diff。首轮之后不再全量重审。

按类提问题。发现一类问题就让作者把这一类全改掉,而不是一行一行点。

已经提过的问题不重复提。

这段对我的启发不在技术,在流程设计:任何自动化评审如果没设出口条件,它就不是质量保障,是一台永动机,烧 token 也烧人的耐心。我自己给博客接的几个自动化脚本,之前只考虑了"跑不跑得通",没考虑"什么时候该停"。

测试文件永远要人看

社区讨论里有一条我认为比所有数字都重要:agent 会改测试。

场景不复杂。agent 改了实现,测试挂了,它有两个选择,改实现或者改测试。只要奖励信号是"测试通过",改测试就是成本最低的那条路。结果是一仓库绿着的测试,和一套没人验证过的行为。

HN 上 vavkamil 那句评论说得更狠:你完全可以在把回滚率压下去的同时,引入更多安全漏洞。回滚率和事故率是两个指标,只盯前一个,后一个可以悄悄坏掉。

所以我现在给自己定的规矩是:diff 里凡是动到测试文件的部分,一律人工看,不看改动大小。这条规矩便宜,且能挡住上面那整类问题。

AI 评审的能力边界也有公开数据可以参考。GitHub 在 10 月 5 号上线了 ReviewBench,用 187 个公开仓库里 219 个真实 PR 做基准,覆盖 19 种语言。首期榜单第一名是 GitHub Copilot 的评审 agent,grounded F1 是 40.1%。这个数字我只在一个渠道看到,请当作参考值看。如果大致属实,那么最好的自动化评审在它自己的主场也抓不到一半。

度量口径该换了

这件事落到项目管理上,最直接的后果是:我们手上那套度量指标全错了。

代码行数、提交数、PR 数、关闭工单数,这些都是活动指标,量的是"动了多少",不是"交付了多少"。在 AI 之前它们就只是勉强能用,因为代码产出和交付量大致同步。现在这个同步断了,活动指标就成了纯粹的噪音,甚至是有害的噪音,因为它会把"库存堆积"显示成"产能提升"。

该换成流动指标。我自己在用的四个:

需求吞吐。单位时间内解决掉的 issue 和 epic 数。论文用的就是它,它是唯一一个能代表"交付出去的东西"的量。

变更前置时间。从提交到合并的时长。论文里这个数字涨了 49%,它是最直接的堵塞信号。

变更失败率。合并之后需要回滚或热修的比例。Anthropic 用回滚率当主要指标,思路是对的,但要配上事故率一起看。

平均恢复时间。出事之后多久恢复。Reddit 上有人提出这个指标会越来越重要,理由是:如果工程师对自己交付的代码理解越来越浅,问题会更频繁,也更难定位。我同意这个判断,虽然它目前没有大规模数据支撑。

四个指标里,前两个管流动速度,后两个管流动质量。只看速度会让团队用质量换交付,只看质量会让团队什么都不敢发。

落到自己的仓库

道理讲完,得有个能跑的东西。我写了个脚本,从 git 历史和项目管理工具的导出数据里算上面这四个指标,并给出一句瓶颈判定。

数据结构很简单,两个 JSON。一个是从 git 导出的 PR 记录,一个是需求记录。之所以用导出文件而不是直接调 API,是因为不同团队的工具有差异,导出成统一格式之后脚本才能通用。

#!/usr/bin/env python3
"""从 PR 与需求导出数据计算研发流动指标。

prs.json: [{"id": 101, "opened": "2026-09-01", "merged": "2026-09-06",
            "additions": 240, "deletions": 30, "rounds": 3,
            "comments": 12, "ai_comments": 4,
            "reverted": false, "touches_tests": false, "tier": "medium"}]
issues.json: [{"key": "PROJ-1", "resolved": "2026-09-08", "type": "issue"}]
"""

import json
import statistics
from collections import Counter
from datetime import date


def load(path, default):
    try:
        with open(path, encoding="utf-8") as fh:
            return json.load(fh)
    except FileNotFoundError:
        return default


def day_gap(a, b):
    if not a or not b:
        return None
    da = date.fromisoformat(a)
    db = date.fromisoformat(b)
    return (db - da).days


def flow_metrics(prs, issues, window_days=30):
    merged = [p for p in prs if p.get("merged")]
    lead = [day_gap(p["opened"], p["merged"]) for p in merged]
    lead = [x for x in lead if x is not None]

    reverted = sum(1 for p in merged if p.get("reverted"))
    rounds = Counter(min(p.get("rounds", 1), 4) for p in merged)
    comments = sum(p.get("comments", 0) for p in merged)
    ai_comments = sum(p.get("ai_comments", 0) for p in merged)

    resolved = [i for i in issues if i.get("resolved")]
    throughput = len(resolved) * 30.0 / window_days
    code_volume = sum(p.get("additions", 0) for p in merged)

    return {
        "merged_prs": len(merged),
        "throughput_per_month": round(throughput, 1),
        "lead_time_median": round(statistics.median(lead), 1) if lead else 0,
        "lead_time_p90": round(sorted(lead)[int(len(lead) * 0.9) - 1], 1) if lead else 0,
        "change_failure_rate": round(reverted / len(merged), 3) if merged else 0,
        "net_lines": code_volume,
        "lines_per_pr": round(code_volume / len(merged)) if merged else 0,
        "rounds_hist": {str(k): v for k, v in sorted(rounds.items())},
        "ai_comment_share": round(ai_comments / comments, 3) if comments else 0,
        "tests_touched": sum(1 for p in merged if p.get("touches_tests")),
    }


def diagnose(cur, prev):
    """对比两个窗口,判断瓶颈落在哪一站。"""
    out = []
    code_up = cur["net_lines"] / max(prev["net_lines"], 1)
    flow_up = cur["throughput_per_month"] / max(prev["throughput_per_month"], 1)
    lead_up = cur["lead_time_median"] / max(prev["lead_time_median"], 1)

    out.append(f"代码量 x{code_up:.2f},需求吞吐 x{flow_up:.2f}")
    if code_up > 1.15 and flow_up < 1.05:
        out.append("判定:生成端的增益没有流到交付端")
    if lead_up > 1.2:
        out.append(f"判定:评审是瓶颈,中位停留 {prev['lead_time_median']} -> {cur['lead_time_median']} 天")
    if cur["change_failure_rate"] > prev["change_failure_rate"] * 1.1:
        out.append("判定:质量在换速度,回滚率上升")
    if cur["ai_comment_share"] < 0.3:
        out.append(f"提示:AI 评论占比 {cur['ai_comment_share']:.0%},人力仍是评审主力")
    if cur["rounds_hist"].get("4", 0) > cur["merged_prs"] * 0.1:
        out.append("提示:超过三轮的 PR 占比过高,需要轮次上限")
    return out


SAMPLE_PRS = [
    {"id": 1, "opened": "2026-09-01", "merged": "2026-09-05", "additions": 180,
     "deletions": 30, "rounds": 1, "comments": 6, "ai_comments": 1,
     "reverted": False, "touches_tests": False, "tier": "low"},
    {"id": 2, "opened": "2026-09-02", "merged": "2026-09-08", "additions": 420,
     "deletions": 70, "rounds": 2, "comments": 14, "ai_comments": 3,
     "reverted": False, "touches_tests": True, "tier": "medium"},
    {"id": 3, "opened": "2026-09-03", "merged": "2026-09-14", "additions": 310,
     "deletions": 50, "rounds": 4, "comments": 22, "ai_comments": 5,
     "reverted": True, "touches_tests": False, "tier": "high"},
    {"id": 4, "opened": "2026-09-04", "merged": "2026-09-07", "additions": 90,
     "deletions": 12, "rounds": 1, "comments": 3, "ai_comments": 1,
     "reverted": False, "touches_tests": False, "tier": "low"},
    {"id": 5, "opened": "2026-09-05", "merged": "2026-09-14", "additions": 520,
     "deletions": 90, "rounds": 3, "comments": 18, "ai_comments": 4,
     "reverted": False, "touches_tests": True, "tier": "medium"},
    {"id": 6, "opened": "2026-09-08", "merged": "2026-09-13", "additions": 260,
     "deletions": 40, "rounds": 2, "comments": 9, "ai_comments": 2,
     "reverted": False, "touches_tests": False, "tier": "medium"},
    {"id": 7, "opened": "2026-09-10", "merged": "2026-09-24", "additions": 380,
     "deletions": 60, "rounds": 4, "comments": 25, "ai_comments": 6,
     "reverted": False, "touches_tests": False, "tier": "high"},
    {"id": 8, "opened": "2026-09-12", "merged": "2026-09-16", "additions": 150,
     "deletions": 25, "rounds": 1, "comments": 5, "ai_comments": 1,
     "reverted": False, "touches_tests": False, "tier": "low"},
    {"id": 9, "opened": "2026-09-15", "merged": "2026-09-22", "additions": 300,
     "deletions": 45, "rounds": 2, "comments": 11, "ai_comments": 3,
     "reverted": False, "touches_tests": True, "tier": "medium"},
    {"id": 10, "opened": "2026-09-18", "merged": "2026-09-30", "additions": 460,
     "deletions": 80, "rounds": 3, "comments": 20, "ai_comments": 5,
     "reverted": False, "touches_tests": False, "tier": "high"},
]

SAMPLE_ISSUES = [{"key": f"P-{i}", "resolved": "2026-09-20", "type": "issue"} for i in range(1, 8)]

if __name__ == "__main__":
    prs = load("prs.json", SAMPLE_PRS)
    issues = load("issues.json", SAMPLE_ISSUES)
    m = flow_metrics(prs, issues)
    print(json.dumps(m, ensure_ascii=False, indent=2))
    prev = dict(m, net_lines=int(m["net_lines"] / 1.3),
                throughput_per_month=m["throughput_per_month"],
                lead_time_median=round(m["lead_time_median"] / 1.5, 1),
                change_failure_rate=round(m["change_failure_rate"] * 0.95, 3),
                ai_comment_share=0.1)
    print("\n".join(diagnose(m, prev)))

跑一遍内置样例,输出是这样:

{
  "merged_prs": 10,
  "throughput_per_month": 7.0,
  "lead_time_median": 6.5,
  "lead_time_p90": 12,
  "change_failure_rate": 0.1,
  "net_lines": 3070,
  "lines_per_pr": 307,
  "rounds_hist": {
    "1": 3,
    "2": 3,
    "3": 2,
    "4": 2
  },
  "ai_comment_share": 0.233,
  "tests_touched": 3
}
代码量 x1.30,需求吞吐 x1.00
判定:生成端的增益没有流到交付端
判定:评审是瓶颈,中位停留 4.3 -> 6.5 天
提示:AI 评论占比 23%,人力仍是评审主力
提示:超过三轮的 PR 占比过高,需要轮次上限

这份样例是我照着论文里那组典型形态捏的:代码量涨三成,吞吐不动,停留时间变长,AI 只贡献两成多评论,还有两条 PR 卡在三轮以上。脚本里我最常看的是 touches_tests 和 rounds_hist 这两项,前者决定这批 diff 里有多少必须人工过目,后者看评审有没有收敛。

真实数据怎么取取决于工具。GitHub 用 gh pr list --state merged --json 能拿到大部分字段,评论数和轮次要再调一次 review 接口;需求那边用 JQL 查 resolutiondate,或者导 CSV。

我这边的改动清单

读完这两份材料,我把自己手上的流程改了五处,按动手难度排:

分级审批。 不再所有 PR 一个标准。文档和文案类改动我自己看一眼就过;涉及权限、支付、数据删除的,必须两个人。判断依据从"谁提的"改成"改了什么"。

提 PR 前先交证据。 agent 写完不算完,得把测试输出、构建结果贴出来。界面改动加截图。这条是 Anthropic 那篇里 Boris Cherny 强调的:给 agent 一个能自己检验结果的办法,是让它把活干好最要紧的一件事,他估计效果在两到三倍。我的体感没那么夸张,但确实省掉了大量"看起来做完了"的返工。

三轮上限。 评审超过三轮就停下来重新讨论方案,不再继续挑细节。这条直接抄自那五十轮的 PR。

测试文件人工看。 前面说过,不看改动大小。

度量换口径。 周报里不再报代码行数和 PR 数,改报需求吞吐、变更前置时间、变更失败率。前两个给业务看,后一个给技术看。

还有一条我暂时没做,但认为迟早要做:熔断。Anthropic 承认他们没有,回滚率或事故率上升时系统不会自动收紧审批。我们这种没有专门工程效能团队的规模,更需要一个粗暴的开关,指标坏到某个阈值就全量回到人工审批。

这些数据该怎么用

把事实和我的判断分开,免得这份材料被当成结论用。

论文和博客里那部分是公开数据:718 家公司、约 3 亿条工作事件、时间跨到 2026 年 3 月,各项增幅与显著性均出自 Chen 和 Stratton 的论文;Anthropic 那组数字出自他们 10 月 15 号的工程博客。

局限也得说清楚。论文是观测性的企业数据,不是受控实验,2026 年 8 月的版本尚未同行评审,数据止于 2026 年 3 月,而 agent 在那之后又迭代了几轮;它测的是公司"是否采用",不是每个人"用了多少",重度使用者和浅尝辄止的人混在一起;样本是愿意共享数据的 Jellyfish 客户,不代表全部公司。Anthropic 那组则是自报数据,用的是自己卖的产品,没有第三方审计。

我自己的判断有三条,没有大规模数据支撑,请当意见看:一是失望主要来自流程未改而非模型能力;二是测试文件必须人工过目这条规矩的收益高于它的成本;三是平均恢复时间会在未来两年变得比现在重要。

数据会继续更新,判断会继续被修正。眼下能确定的只有一件:键盘上的速度已经不是约束了,别再拿它当产能。

相关文章