Harness Pipeline:给 AI 编程套一条带护栏的跑道

Posted on in Tech

Abstract Harness Pipeline:给 AI 编程套一条带护栏的跑道
Authors Walter Fan
Category Tech
Status v2.0
Updated 2026-10-15
License CC-BY-NC-ND 4.0

大纲

展开看看
  • 漏掉的五件事:意图、契约、体验、本地知识、上线假设——旧流水线一件都不管
  • 四层骨架:SDD 管意图、TDD 管契约、BDD 管体验、MDD 管假设,每层配一件可抄的东西
  • 三道闸门:静态分析的命令、AI Review 的 prompt 原文、三十行的日志隐私检查脚本
  • 接进 CI:Makefile 三个 target + GitHub Actions + pre-commit,顺序就是成本顺序
  • 端到端实战:给 FastAPI 加限流,从 spec delta 到契约测试到指标埋点
  • 业界趟平的路:变异测试、属性测试、契约测试、架构适应度函数,配工具链接
  • 十个常见陷阱:自己审自己、门禁可绕、flaky 糊弄、覆盖率当门禁、spec 被顺手改……
  • 什么时候不值得走全程:按改动类型决定走到哪一关,小团队的最小可用组合
  • 落地顺序:三周五步,每步标清成本和收益
  • 参考资料:标准指南、论文、工具,每条标清值得看什么

周五下午四点,AI 半小时帮我写完一个 feature,单元测试一片绿。

我盯着 diff 看了五分钟,没看出什么毛病。点了合并按钮的那一刻,心里却没底——不是怕它写错了,是怕它“对得太工整”,工整到我没办法判断它有没有偷偷绕过某个边界条件。

这种心虚,过去十几年写代码很少有。以前 Code Review 看一段陌生代码,至少知道“作者大概是怎么想的”,看几眼就能判断要不要追问。AI 写的代码不一样:它没有“想法”,只有“模式”。模式对了,逻辑可能错;模式眼熟,安全可能塌。

AI 让“写代码”变快了,却让“交付代码”变难了。难的不是技术,是责任——谁为这段代码上线后的行为负责?下面这条跑道就是用来接住这份责任的,我把它叫 Harness Pipeline:骨架 SDD → TDD → BDD → MDD,中间穿三道闸门。

  • 每一层都配一件能直接抄走的东西:OpenSpec 命令、契约测试、review prompt、检查脚本、CI 配置
  • 也给判断表:哪些改动根本不值得走全程

一、旧流水线漏掉的五件事

传统 Build Pipeline 回答的问题很朴素:这段代码能不能跑、跑起来对不对。它成立的前提是代码由人写、测试由人维护、出了问题能追到人。AI 编程把这三条前提全抽掉了:代码是生成的,测试可能也是顺手生成的,提交人是你但你只 review 了一部分。

于是风险点整体平移了。新的五件事,旧流水线一件都管不了:

漏掉的 旧流水线为什么管不了 这条跑道里谁来管
意图跑偏 构建成功不等于做对了需求 SDD:spec 先评审
契约被绕过 测试是 AI 自己出的卷子 TDD:核心契约人来写
体验不对路 单测全绿不等于用户舒服 BDD:场景验收
本地知识被无视 linter 不知道你们项目的规矩 规则检查脚本
假设落空 没人回头看上线后的数据 MDD:指标回灌

一句话定义:Harness Pipeline 是 AI 代码生成的约束跑道,前面用四层骨架定方向,中间用三道闸门挡 bug,后面用一个反馈环验假设。

Harness Pipeline 流程图

核心思想只有一句:让 AI 在每一关被卡住,而不是事后让人兜底。因为 AI 修代码比人快,但判断“这段代码值不值得上线”比人差远了。把判断留给人,把修复交给 AI,分工才对。

二、四层骨架:每层配一件可抄的东西

层 在 AI 之前/之后 防什么 谁说了算
SDD(Spec-Driven) 之前 防意图跑偏 人写规格
TDD(Test-Driven) 之前 防契约被绕过 人写核心、AI 补边角
BDD(Behavior-Driven) 之中 防用户体验跑偏 人定场景
MDD(Metrics-Driven) 之后 防假设落空 数据说话

SDD:规格即合同(OpenSpec 装载体,DDD 填内容)

最早我也只是在项目里塞一个 spec.md。能用,但规格散在各处、没人 review、改了没人知道,更别说追溯“上一版我们承诺了什么”。规格要当第一道闸门,得满足三件事:可工件化、可 diff、可归档。

OpenSpec 做的就是这个(GitHub 官方的 spec-kit 是另一条路,流程更重、阶段门更硬,按口味选)。装和用都很短,需要 Node.js 20.19 以上:

npm install -g @fission-ai/openspec@latest   # 或 brew install openspec
cd your-project && openspec init             # 生成 openspec/ 和各家 agent 的指令

# 然后在你的 AI 工具里走四个命令
# /opsx:explore              还没想清楚时,先当思考搭子
# /opsx:propose add-rate-limit   生成 proposal.md + specs/ + design.md + tasks.md
# /opsx:apply                按 tasks 逐条实现
# /opsx:archive              归档进 specs/ 基线

一个 change 落在磁盘上长这样:

openspec/
  changes/
    add-rate-limit/
      proposal.md        # 这次改什么、为什么
      design.md          # 关键设计决策
      tasks.md           # 拆到可执行任务
      specs/             # 本次引入的需求与场景
  specs/                 # 已发布的规格基线
  changes/archive/       # 归档后的历史 change

好处是立等可见的:AI 读 proposal.md 知道意图、读 specs/ 知道边界、读 tasks.md 知道怎么拆步骤;人 review 的是 spec 变更,跟看 git diff 一样直观;change 落地后进基线,下次改动有据可查。

那 DDD 在哪里?DDD 不是工具,是教你“规格里到底该写什么”。OpenSpec 给你表格,DDD 告诉你怎么把表格填对,具体就三件:

  • 限界上下文:先问“这事属于哪个上下文?网关?账单?身份?”想清楚再下笔,AI 才不会把限流逻辑揉进业务 service
  • 聚合:明确这次改动的一致性边界。限流计数器是网关上下文里的聚合,TTL 一过就重置,不需要落核心业务库
  • 领域事件:把“发生了什么”显式化。RateLimitExceeded 是个事件,不是一个 if 分支——写进 spec,下游(告警、计费、风控)才有对接口子

我自己的习惯:写 design.md 之前,先在纸上画这三件——上下文图、聚合边界、关键领域事件。十分钟一张草图,AI 生成的代码就带着领域感,而不是对着 REST URL 拍脑袋。

TDD:人写核心契约,AI 填实现

TDD 在这条跑道上是第一道客观闸门:红不变绿,不许往下走。

这里有个坑:让 AI 同时写代码和写测试,等于让它自己出卷自己批改。分工应该是——人写 3 到 5 条核心契约(关键路径、边界值、异常分支、安全约束),AI 填实现,剩下的辅助用例再交给 AI 补。补完人只 review 一个问题:这条测试是在测行为,还是在测实现?改个私有变量名就红的,删掉。

还有一件常被忽略的事:护具自己得是确定性的。限流这类带状态的功能,不加重置夹具,测试之间会互相投毒,执行顺序一变就红。两个夹具放 tests/conftest.py,单测和 BDD 共用:

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app


@pytest.fixture
def client():
    return TestClient(app)


@pytest.fixture(autouse=True)
def _reset_limiter():
    from app.middleware.rate_limit import limiter
    limiter.reset()
    yield

BDD:把验收写成能跑的场景

单测全绿,不代表用户走进来不别扭。BDD 用 Given-When-Then 挡的是“通过测试但不对路”那一类:限流单测全绿,可第 101 次请求返回的是 500 还是友好提示?响应体长什么样?单测经常压根没管。

场景不要停在文档里,pytest-bdd 几行就能让它真跑起来:

# features/rate_limit.feature
Feature: API 限流给用户友好的提示

  Scenario: 超过限制时返回友好响应
    Given 客户端 "c3" 已经请求 60 次
    When 客户端 "c3" 再次请求 "/api/v1/search"
    Then 响应状态码是 429
    And 响应不包含 "algorithm" 字段
# tests/test_rate_limit_bdd.py
from pytest_bdd import scenarios, given, when, then, parsers

scenarios("../features/rate_limit.feature")

@given(parsers.parse('客户端 "{cid}" 已经请求 {n:d} 次'))
def _warm_up(client, cid, n):
    for _ in range(n):
        client.get("/api/v1/search", headers={"X-Client-Id": cid})

@when(parsers.parse('客户端 "{cid}" 再次请求 "{path}"'), target_fixture="resp")
def _again(client, cid, path):
    return client.get(path, headers={"X-Client-Id": cid})

@then(parsers.parse("响应状态码是 {code:d}"))
def _status(resp, code):
    assert resp.status_code == code

@then(parsers.parse('响应不包含 "{field}" 字段'))
def _no_field(resp, field):
    assert field not in resp.json()

MDD:把假设写成指标和阈值

做 feature 的时候背后都有假设:“加了限流,错误率会降”“换了算法,P99 会从 200ms 掉到 100ms”。Build Pipeline 不管这个,BDD 也不管。MDD 的任务是把假设变成指标,再定一个事先说好的判定阈值——不然上线后看大盘,只会得出“嗯,挺好”。

写 spec 的时候就把这张表填了,上线一周后照着对:

假设 看哪个指标 判定阈值 不达标怎么办
能挡住刷接口的客户端 api_rate_limit_blocked_total by client 被挡请求 95% 以上集中在 top 3 client 分散 → 阈值定低了,窗口或配额要调
不误伤正常用户 正常客户端的 429 数 接近 0 有误伤 → 给白名单或分层配额
搜索接口稳住 /api/v1/search 的 P99 高峰 < 300ms 没改善 → 瓶颈不在这儿,回 SDD 重想

指标不对就回灌下一轮 SDD:要么规格写错了,要么实现没达到承诺。这个闭环一跑起来,AI 写的代码就不是“交付即终点”,而是“交付即开始”。

三、三道闸门:每道都给你现成的东西

骨架定方向,闸门防细节。三道按成本从低到高排。

第一道:静态分析(最便宜、最确定)

ruff check app tests          # 风格、import、明显 bug
mypy --strict app             # 类型
bandit -q -r app              # 硬编码密钥、不安全的 yaml.load 之类

确定性工具,秒级出结果。AI 写的代码连这关都过不了,根本没必要往后送。

第二道:AI Review(用 AI 防 AI)

让另一个模型、换一套 prompt 审同一份 diff。第一遍的 AI 容易自卖自夸,换个视角才会挑毛病。prompt 别写成“请帮我 review 一下”,直接抄这份:

<!-- scripts/review-prompt.md -->
你在审一份 AI 生成的 diff。先读规格,再读 diff,只报实质问题。

规格位置:openspec/changes/<change>/specs/

按顺序检查:
1. 实现与 spec 的每条 Requirement / Scenario 是否逐条对应?缺了哪条、哪条被偷换?
2. 有没有"看起来对、其实绕过边界"的地方?重点看 off-by-one、空值、并发、超时。
3. 错误处理是真处理,还是 catch 完就吞?
4. 日志、响应体、异常信息里有没有敏感字段或内部实现细节?
5. 测试测的是行为还是实现(改个变量名就红的那种)?

输出格式,每条一行:
<BLOCKER|MAJOR|MINOR> 文件:行号 — 问题 — 建议改法
没有问题就只输出一行:NO ISSUES FOUND
不要夸奖,不要复述代码,不要给无关建议。

挂成脚本,BLOCKER 直接卡住流水线:

#!/usr/bin/env bash
# scripts/ai_review.sh —— 换个模型审 diff
set -euo pipefail
base="${1:-origin/main}"
git diff "$base...HEAD" > /tmp/review.patch

# 三选一:claude -p / codex exec / opencode run,关键是跟写代码的那个模型换开
{ cat scripts/review-prompt.md; echo; cat /tmp/review.patch; } \
  | claude -p --model "$REVIEW_MODEL" > review-report.md

if grep -q "^BLOCKER" review-report.md; then
  cat review-report.md
  exit 1
fi
echo "AI review passed"

第三道:规则检查(项目级硬规则)

每个项目都有自己的规矩:命名、目录、安全基线、日志合规。这些是 AI 永远猜不到的本地知识,写成脚本才算数。比如这条——“打到 INFO 的日志不许带手机号、邮箱、token、client_id”:

#!/usr/bin/env python3
"""scripts/check_log_privacy.py —— 扫描 logging 调用,禁止敏感字段进日志。"""
import ast
import sys
from pathlib import Path

BANNED = {"phone", "mobile", "email", "token", "password", "id_card", "client_id"}
LOG_FUNCS = {"debug", "info", "warning", "error", "critical", "exception"}


def banned_hits(node: ast.AST) -> set[str]:
    hits: set[str] = set()
    for sub in ast.walk(node):
        if isinstance(sub, ast.Name) and sub.id.lower() in BANNED:
            hits.add(sub.id)
        elif isinstance(sub, ast.Attribute) and sub.attr.lower() in BANNED:
            hits.add(sub.attr)
        elif isinstance(sub, ast.Constant) and isinstance(sub.value, str):
            hits |= {w for w in BANNED if w in sub.value.lower()}
    return hits


def check(path: Path) -> list[str]:
    problems = []
    tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        if not (isinstance(node.func, ast.Attribute) and node.func.attr in LOG_FUNCS):
            continue
        hits: set[str] = set()
        for arg in [*node.args, *(kw.value for kw in node.keywords)]:
            hits |= banned_hits(arg)
        if hits:
            problems.append(f"{path}:{node.lineno}: 日志疑似包含敏感字段 {sorted(hits)}")
    return problems


if __name__ == "__main__":
    root = Path(sys.argv[1] if len(sys.argv) > 1 else "app")
    found = [p for f in root.rglob("*.py") for p in check(f)]
    print("\n".join(found) if found else "log privacy check passed")
    sys.exit(1 if found else 0)
$ python scripts/check_log_privacy.py app
app/middleware/rate_limit.py:5: 日志疑似包含敏感字段 ['client_id']
app/middleware/rate_limit.py:6: 日志疑似包含敏感字段 ['email']

挡的就是这种事:client_id 被打进 INFO 日志——单测不会红,AI Review 也经常放过(它不知道你们把 client_id 当敏感字段),只有项目自己的脚本知道。

边界说清楚:这是防呆不防贼。AST 只抓得住直接传进日志的名字和字面量,logger.info(f"{payload}") 这种整包塞进去的抓不到。真要严,配合日志侧的脱敏中间件,两头一起管。

四、把三道闸门接进 CI

闸门散在本地没意义,得装到提交和合并的路上。顺序就是成本顺序:快的先跑,挂了立刻停。

.PHONY: gate-fast gate-pr gate-merge

gate-fast:        # 秒级,pre-commit 里跑
    ruff check app tests
    ruff format --check app tests

gate-pr:          # 分钟级,PR 流水线
    mypy --strict app
    bandit -q -r app
    python scripts/check_log_privacy.py app
    pytest -q tests

gate-merge:       # 合并前,含 BDD 和 AI Review
    pytest -q tests features
    bash scripts/ai_review.sh origin/main
# .github/workflows/harness.yml
name: harness
on: [pull_request]
jobs:
  gates:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }        # AI Review 要 diff,必须全量 clone
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install -r requirements-dev.txt
      - run: make gate-fast
      - run: make gate-pr
      - run: make gate-merge
        env:
          REVIEW_MODEL: ${{ vars.REVIEW_MODEL }}
# .pre-commit-config.yaml —— 本地这一道别省,省了就全堵在 CI 上
repos:
  - repo: local
    hooks:
      - id: gate-fast
        name: gate-fast
        entry: make gate-fast
        language: system
        pass_filenames: false
      - id: log-privacy
        name: log-privacy
        entry: python scripts/check_log_privacy.py app
        language: system
        pass_filenames: false

每关该花多少时间、挂了谁修,心里要有数:

关卡 跑在哪 时间预算 挂了谁修
ruff / format pre-commit < 5s AI 自己修
mypy / bandit / 规则脚本 PR < 1min AI 修,人瞄一眼
单测 + BDD PR < 3min AI 修
AI Review PR(可异步) 30-60s 人判断
指标回看 上线一周后 人工 15min 人决策

超出预算就会有人想绕过去,而绕过一次的闸门,等于没有。

五、端到端实战:给 API 加限流

需求很简单:给一个 FastAPI 接口加限流。走一遍完整的五步。

Step 1. SDD:先画 DDD 三件套,再落 OpenSpec

  • 限界上下文:API 网关,不污染下游业务
  • 聚合:RateLimitWindow(key = client_id,TTL 60s)
  • 领域事件:RateLimitExceeded(client_id, endpoint, at),将来被告警和风控订阅
# openspec/changes/add-rate-limit/proposal.md

## Why
高峰期 /api/v1/search 被少量客户端打爆,影响其他用户。

## What Changes
- 引入 API 网关层的限流中间件(单机滑动窗口)
- 暴露 RateLimitExceeded 领域事件供下游订阅
- 限流触发时不暴露算法细节

## Out of Scope
- 分布式限流(下一个 change)
- 配额管理 UI

## Impact
- Affected spec: api-gateway(新增 rate-limit 能力)
- Affected code: app/middleware/, app/events/
# openspec/changes/add-rate-limit/specs/api-gateway/spec.md

## ADDED Requirements

### Requirement: 单客户端限流
系统 SHALL 限制单个客户端在 60s 内对 /api/v1/search 的请求次数。

#### Scenario: 在限制内
- **WHEN** 客户端 60s 内请求不超过 60 次
- **THEN** 全部正常处理(200)

#### Scenario: 超出限制
- **WHEN** 客户端 60s 内请求超过 60 次
- **THEN** 返回 429
- **AND** 响应体为 {"error": "rate_limited", "retry_after": <秒>}
- **AND** 响应体 MUST NOT 包含算法、窗口大小等内部细节
- **AND** 触发 RateLimitExceeded 事件

### Requirement: 匿名客户端
未携带 X-Client-Id 的请求 SHALL 按匿名身份限流,限制为 10 req/min。

### Requirement: 健康检查豁免
/health 路径 SHALL NOT 计入任何限流计数。

这份 spec 拿给 AI,意图、边界、领域事件全在里面。它写出来的就不是“对 URL 加个计数器”,而是“在网关上下文里维护一个 RateLimitWindow 聚合,越界时发领域事件”——后者才是能跟整个系统协作的代码。

Step 2. TDD:人亲手写这三条

# tests/test_rate_limit.py(client / _reset_limiter 来自 conftest.py)


def test_within_limit_returns_200(client):
    for _ in range(60):
        r = client.get("/api/v1/search", headers={"X-Client-Id": "c1"})
        assert r.status_code == 200


def test_over_limit_returns_429_with_retry_after(client):
    headers = {"X-Client-Id": "c2"}
    for _ in range(60):
        client.get("/api/v1/search", headers=headers)
    r = client.get("/api/v1/search", headers=headers)
    assert r.status_code == 429
    body = r.json()
    assert body["error"] == "rate_limited"
    assert body["retry_after"] > 0
    assert "window" not in body and "algorithm" not in body   # 不泄露内部细节


def test_health_check_not_counted(client):
    for _ in range(100):
        assert client.get("/health").status_code == 200

三条测试对应 spec 里的三条 Requirement,一一对得上。丢给 AI:让它们绿。

Step 3. 过三道闸门

make gate-fast && make gate-pr && make gate-merge

Step 4. BDD 场景:上面第二节那份 rate_limit.feature 直接跑。

Step 5. MDD:埋指标,一周后对阈值

# app/middleware/rate_limit.py(节选)
from prometheus_client import Counter

blocked_total = Counter(
    "api_rate_limit_blocked_total",
    "Requests blocked by rate limiter",
    ["endpoint", "client_tier"],
)


async def rate_limit_middleware(request, call_next):
    if is_over_limit(request):
        blocked_total.labels(
            endpoint=request.url.path,
            client_tier=classify(request),
        ).inc()
        return JSONResponse(
            status_code=429,
            content={"error": "rate_limited", "retry_after": 60},
        )
    return await call_next(request)

一周后拿第二节那张表对:被挡的请求集中在哪几个 client?正常用户有没有被误伤?P99 降了没有?结论回灌下一轮 SDD——可能要给某个 client 单独配额,可能要把窗口从 60s 调到 30s。

整条跑下来,AI 干了八成体力活,人只在四处做判断:写 spec、定核心契约、定验收场景、看指标决策。判断密度高,但判断量不大——这才是健康的人机分工。

六、业界已经趟平的路

上面那套是骨架。骨架之外,有几件事业界已经验证过,拿来就能用,不必自己从头摸。

用变异测试代替覆盖率,衡量测试到底有没有用。变异测试往代码里注入小改动(把 > 改成 >=、把 return True 改成 return False),再跑你的测试套件:测试红了叫“杀死变异”,没红说明这行代码虽然被覆盖了,但没人真在检查它。这是“覆盖率 90% 却照样出事”的直接解药,对 AI 写的测试尤其对症——AI 很擅长写那种“调用到了但断言空洞”的用例。Python 用 mutmut 或 cosmic-ray,Java 用 PIT。别全量跑,挑核心模块跑,当季度体检。

让机器去想边界,别指望 AI 想全。属性测试不写具体用例,只声明“什么性质必须永远成立”,由框架生成大量输入去反驳你,找到反例还会自动缩小到最小复现。Hypothesis 是 Python 这边的事实标准。限流这个例子里,可以声明一条性质:任意请求序列下,被放行的次数都不该超过配额——这种话人写不出一百个用例,机器能。

从 spec 直接生成测试,让规格成为唯一事实源。如果你的接口有 OpenAPI 文档,Schemathesis 能直接按 schema 生成请求去打,顺便校验响应是否符合契约。这和 SDD 天然配套:spec 改了,测试自动跟着变,agent 想绕都没处绕。

把架构约束写成测试,AI 一越界就红。这是 OpenAI 那篇 harness 文章里的重点做法,开源世界也有现成的:Python 用 import-linter,Java 用 ArchUnit。分层规则写成几行配置就完事:

# .importlinter
[importlinter]
root_package = app

[importlinter:contract:layers]
name = 分层依赖:上层可依赖下层,反过来不行
type = layers
layers =
    app.api
    app.service
    app.repository
    app.types

lint-imports 挂进 gate-pr,AI 再想从 repository 层反向 import service,CI 直接红。这比在 AGENTS.md 里写一百句“请遵守分层”都管用——规则写在文档里是标语,写成检查才是约束。

断言别只看 HTTP 码,把可观测性也当测试预言。一次请求对不对,不只看响应体,还要看该发的事件发了没、该记的指标涨了没、trace 里有没有多出一次重试。OpenTelemetry 这套埋下去,集成测试里就可以断言“这次调用只打了一次下游”。

flaky 测试要当缺陷管,不能靠重跑糊弄。Google 的做法是显式标记、隔离、跟踪,不让它们堵住主干;学术上 Luo 等人对 flaky 根因的实证分析(FSE 2014)把原因归到异步等待、并发、测试顺序依赖这几类——正好对应前面说的确定性和重置夹具。知道根因在哪,就不会满足于“retry 一次就绿了”。

连护具自己也要被审。2026 年 5 月有篇论文把这件事做到了极致:QuartetFuzz 给 LLM 生成的 fuzz harness 定了四条正确性原则——逻辑正确、API 协议合规、安全边界、入口充分——在开跑之前用 generate-check-fix 循环先把 harness 自己审一遍。两个数字值得记:光 P1/P2 两条检查就拦下 58 个由 harness 自身缺陷导致的假崩溃;拿同一套标准去审 70 个项目里 586 个已在生产跑的 harness,查出 53 处违规。论文摘要里那句话适合贴在工位上——LLM 把 harness 生成规模化之后,失控的质量会把规模变成负债。

一张表收口,按“你现在卡在哪”去挑:

你的问题 业界做法 工具
覆盖率高但还是出事 变异测试 mutmut、cosmic-ray、PIT
边界用例想不全 属性测试 Hypothesis
接口契约和实现漂移 从 OpenAPI 生成测试 Schemathesis
服务间集成只能全量联调 消费者驱动契约测试 Pact
AI 偷偷打破分层 架构适应度函数 import-linter、ArchUnit
测试环境不确定 容器化夹具 Testcontainers
依赖和密钥风险 SCA + 密钥扫描 pip-audit、gitleaks、Scorecard
本地和 CI 不一致 同一套 hook pre-commit
规则检查想写得更狠 语义级静态规则 Semgrep

七、十个把跑道跑废的方式

跑道搭起来只是第一步,更常见的死法是“搭了但形同虚设”。下面十条,每一条我都见过或听过:

陷阱 典型症状 怎么破
自己审自己 同一个模型既写代码又做 review,报告全是“看起来不错” 换模型、换 prompt,审的那个不看生成过程
门禁可以绕 --no-verify、[skip ci] 成了团队口头禅 服务端分支保护才算门禁,本地 hook 只是提速
闸门太慢 PR 等二十分钟,大家开始攒批提交 分层跑、并行跑;超预算就拆
flaky 靠重跑糊弄 红了点一次 retry,绿了就合 当缺陷管:标记、隔离、限期修、跟踪 flaky 率
Review 噪音淹没信号 一次吐 40 条 MINOR,没人看 只有 BLOCKER 卡流水线,其余进 issue
覆盖率当门禁阈值 为凑数字写一堆空断言测试 覆盖率只看趋势,有效性交给变异测试
spec 被顺手改 agent 改不动实现,就改 spec 让它“对上” spec 变更单独 review,不许和实现同一个 commit
BDD 只有 happy path 场景全是“正常下单成功” 每条 Requirement 至少配一条异常或边界场景
指标埋了没人看 大盘多一条曲线,没有阈值没有复盘 上线前就写死判定阈值和复盘日期
一次三千行的大 MR AI 一口气交完,人只能抽查 按 tasks.md 拆 MR,一个 change 一个 MR

最后一条值得多说一句。AI 让单个 MR 的体量变大是自然趋势,而评审质量随 diff 行数断崖式下降——能不能把活拆小,决定了后面所有闸门是真关还是摆设。OpenSpec 的 tasks.md 存在的意义,一半在这儿。

八、什么时候不值得走全程

全套跑完有成本。给一个改文案的 PR 写 spec delta,纯属自虐。按改动类型决定走到哪一关:

改动类型 走到哪 理由
改文案、调样式、加日志 ruff + 单测 错了代价小,可回滚
内部脚本、一次性数据迁移 + 规则脚本 不值得上 spec,但别泄密
新接口、改契约、动数据模型 全程 对外承诺,改不回来
涉及钱 / 权限 / 用户数据 全程 + 人 sign-off AI 不能拍板

人手紧的小团队,最小可用组合只有三件:一个项目规则脚本、三到五条核心契约测试、CI 里一道真门禁。其余的等疼过一次再补——疼过的地方加规则,比照着清单全上有用得多。

九、落地顺序:三周,五步

不必一次到位,按性价比排:

  1. 第一周:CI 挂上 ruff + mypy + bandit。半小时配完,拦截率立竿见影。顺手把 import-linter 的分层契约也加上,十行配置,从此架构边界由机器守。
  2. 第一周:写一个项目规则脚本。就一两条规矩,比如日志隐私。抄上面那三十行改改字段名就能用。
  3. 第二周:核心路径的测试自己写。一个项目挑 3 到 5 条契约,别全交给 AI。顺手把重置夹具加上。
  4. 第二周:试一次 OpenSpec。哪怕只走通 proposal + specs 两份文件,也比散落的 spec.md 强一档。写之前先画 DDD 三件套。
  5. 第三周:AI Review 换模型跑第二遍 + 给关键 feature 埋一个指标。前者是一个 shell 脚本的事,后者让你养成“上线不是终点”的习惯。

再往后有余力,拿变异测试给核心模块做一次体检——你会第一次知道那些“全绿”的测试里,有多少是真在检查东西。

总结:决定产能的不是 AI 写多少,是你敢让它写多少

很多人讨论 AI 编程,关注点都在“AI 能写多少代码”。真正决定生产力的,是你敢让 AI 写的代码占多少——而敢不敢,取决于护栏够不够硬。

护栏硬不硬,就看三件事能不能回答上来:规格在哪儿、核心契约谁写的、上线后谁看指标。三个都答不上来,AI 写得越快,你周五下午四点就越心虚。

明天先挑第一条:CI 里挂上静态分析,再写一条你们项目自己的规则脚本。两件加起来不超过一小时,但从此 AI 交上来的代码,至少不用你拿肉眼去数括号。

参考资料

标准与指南

论文

工具

全文思维导图

@startmindmap
<style>
mindmapDiagram {
  node {
    BackgroundColor #F8F9FA
    RoundCorner 10
    Padding 10
    FontSize 13
  }
  :depth(0) {
    BackgroundColor #1E3A5F
    FontColor white
    FontSize 18
    FontStyle bold
  }
  :depth(1) {
    FontSize 15
    FontStyle bold
  }
}
</style>

* Harness Pipeline
** 旧流水线漏掉的五件事
*** 意图跑偏
*** 契约被绕过
*** 体验不对路
*** 本地知识被无视
*** 假设落空
** 四层骨架
*** SDD\nOpenSpec + DDD 三件套
*** TDD\n人写 3-5 条核心契约
*** BDD\npytest-bdd 跑场景
*** MDD\n假设→指标→阈值
** 三道闸门
*** 静态分析\nruff/mypy/bandit
*** AI Review\n换模型 + prompt 原文
*** 规则检查\n日志隐私脚本
** 接进 CI
*** Makefile 三个 target
*** pre-commit + Actions
*** 时间预算,超了就会被绕过
** 业界趟平的路
*** 变异测试\n替代覆盖率
*** 属性测试\nHypothesis
*** 契约测试\nSchemathesis/Pact
*** 架构适应度函数\nimport-linter
*** 护具自己也要审\nQuartetFuzz 四原则
** 十个陷阱
*** 自己审自己
*** 门禁可绕 / 太慢
*** flaky 靠重跑
*** 覆盖率当门禁
*** spec 被顺手改
*** 三千行大 MR
** 不走全程的情况
*** 文案样式:只跑单测
*** 钱/权限/数据:全程 + 人拍板
*** 最小组合:规则+契约+门禁
@endmindmap

Harness Pipeline mindmap


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。

相关文章