CI 流水线与治理闸门
前面讲的"可信"都是能力;CI 是把这些能力变成"合并前强制执行"的地方。不达标,代码进不来。昨天(Day 17)我们把楼盖好、部署上了生产;今天讲交付前那道「竣工 + 消防验收」——把 Critic、闸门、评测、成本、BOM 这些前 17 天学的"设施",在合并前挨个查一遍,不合格就贴封条不发钥匙。这是"可信靠机制不靠自觉"这句话的落地现场。
CI = 把"可信"变成硬约束
前 17 天讲了 Critic、闸门、评测、成本治理、BOM 版本锁……但这些如果只是"能力",总有人偷懒不用。CI(持续集成,.gitlab-ci.yml)的作用就是在合并前强制跑一遍,不达标就拦住。
核心原则叫 R-CI-DEFAULT-FAIL:任何一道 gate 失败都阻止 merge(allow_failure: false)。
默认失败原则(R-CI-DEFAULT-FAIL)
闸门不是"建议",是"硬门"。评测掉了、成本涨了、BOM 版本漂了、敏感度红线碰了——任一触发,流水线红、合并按钮点不动。可信从"靠自觉"变成"靠机制"。
.gitlab-ci.yml 走读:stage 与"默认失败"
CI 的一切写在根目录 .gitlab-ci.yml。先看它声明的 stage 顺序(.gitlab-ci.yml:28):
stages:
- lint # BOM 审计
- test # pytest
- ci-gates # 核心治理闸门群(最凶,L03-L06 拆它)
- canary # 金丝雀(L08)
- publish-internal # tag 触发发 Nexus wheel
- build-publish-portal-frontend # 门户前端镜像
- nightly-sync # 仅 schedule 触发 · UI 仓 → monorepo 同步
PR 从提交到合并,要顺序穿过这些 stage,前一个全绿才进下一个。真正的治理重头戏在 ci-gates 这一层。它的"默认失败"是靠一个共享模板 .ci-gate-base 实现的(.gitlab-ci.yml:57):
.ci-gate-base: # 所有闸门 extends 它
stage: ci-gates
image: python:3.12-slim
before_script:
- pip install --quiet "uv==${UV_VERSION}.*"
- uv sync --all-packages # 和 Day 02/17 本地环境一致
allow_failure: false # ← R-CI-DEFAULT-FAIL · 任一 fail 阻 merge
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "master"'
# 每道闸只写 script 一行,其余全继承上面
ci-gate-eval:
extends: .ci-gate-base
script:
- uv run python scripts/ci/full_agent_eval.py --changed
逐行讲:allow_failure: false 是灵魂——GitLab 里如果某个 job 设 allow_failure: true,它挂了流水线仍算通过(只是黄一下);设 false,它一挂整条流水线红、merge 按钮就点不动。所有闸门都 extends: .ci-gate-base 继承这个 false,于是每道闸都是"硬门"。每个具体闸门自己只需写一行 script,环境准备(装 uv、sync)全从基模板继承——DRY。
allow_failure:false 的闸不达标,整条流水线变红、merge 锁死。allow_failure: false 放在共享基模板里,而不是每个闸各写一遍?如果让每道闸自己写 allow_failure,只要有一个人漏写(默认就是 false?不,GitLab 默认 false,但显式声明+集中管理更安全),或者某天有人图省事偷偷改成 true 放自己过,"硬门"就破了。集中在基模板声明一次,等于把"闸门必须是硬的"这条元规则本身也锁死了——想放水得改基模板,改基模板 review 时一眼就能看见。这是"用结构强制策略"的思路。sre-rca 一段取证逻辑,想合进主干——① 先撞 lint 门:
bom-audit 查你有没有偷偷绕过 BOM 直接 pin 版本(L03)。② test 门:全套 pytest(FakeLLM、不联网)跑一遍。
③ ci-gates 门(最凶):eval 掉分没?成本涨了没?碰红线没?——任一
allow_failure:false 的 job 挂,当场贴封条。④ 全过才到 canary 小流量试跑(L08)。想合并不是'我觉得没问题',而是'每一道机器闸都说没问题'——这就是可信靠机制。」
BOM 审计闸走读(呼应 Day 05/17)
第一道 stage lint 只有一个 job:bom-audit,它跑 scripts/check_bom_lockfile.py(.gitlab-ci.yml:41)。这个脚本文件头把它干的三件事和退出码写得清清楚楚(check_bom_lockfile.py:22):
# 三件事:
# 1. Adoption audit(默认 strict)
# 扫 14 个 apps//pyproject.toml + portal backend,看是否引 ai-trust-toolkit-bom
# 2. Drift check(永远 fail)
# BOM manifest.py 的 LOCKED_VERSIONS dict 必须跟 BOM pyproject.toml extras 一致
# 3. Toolkit version pin(永远 fail)
# uv.lock 里 ai-trust-toolkit 锁定版本必须 = BOM 的 ==0.5.0(否则有人装了野生 toolkit)
#
# Exit code:
# 0 = 全过 / 1 = drift OR pin 不齐 / 2 = strict 下 adoption 不齐
大白话:① 数一遍所有 agent(AGENTS_DIRS 列表,check_bom_lockfile.py:45)是不是都引了 BOM——少一个都不行。② 校验 BOM 自己的版本清单(Python 里的 LOCKED_VERSIONS dict)和它的 pyproject.toml 声明一致。③ 校验 uv.lock 里锁的 toolkit 版本 = BOM 规定的版本,防止有人绕过 BOM 装了个野生版本。
allow_failure:false → 阻 merge。.gitlab-ci.yml:49 注释原话"adoption < 15/15 → exit 2 → fail"。ci-gate-bom-pin(.gitlab-ci.yml:125,禁直 pin 子模块)和 ci-gate-bom-version-sync(:131,三方版本号一致),跟 bom-audit 分工。exit 2(adoption 不齐)在 --no-strict 的 dev 调试模式下可以退化成 warning(方便本地开发),而 exit 1(drift / pin 错)"永远 fail"绝不放水。用细分的退出码把"这是硬错误"和"这个可以视场景放宽"区分开——同一个脚本既能当 CI 硬门,也能当本地宽松自检。评测回归闸走读(呼应 Day 10)
Day 10 的评测能力,在这里变成硬门 ci-gate-eval,跑 scripts/ci/full_agent_eval.py --changed(.gitlab-ci.yml:86)。先看它评哪几维、用什么标准判"退步"(full_agent_eval.py:52):
DIMENSIONS = ["latency", "quality", "cost", "red_line", "critic_pass"] # 5 维
REGRESS_SIGMA = 2.0 # 超 2σ 算退步
def detect_regress(agent, dim, pr_value, baseline_mean, baseline_std, sigma=2.0):
"""判定 pr_value 相对 baseline 是否 regress > sigma。"""
if baseline_std <= 0:
return None # 没 baseline std → 跳过
deviation = abs(pr_value - baseline_mean) / baseline_std # 偏离几个标准差
if deviation > sigma:
return f"{agent}.{dim} regress · pr={pr_value:.3f} ... ({deviation:.2f}σ > {sigma}σ)"
return None # 在 2σ 内 → 不算退步
逐行讲:对每个 agent 的 5 个维度,拿这次 PR 的分数 pr_value 和历史基线的均值/标准差比。deviation = |pr - mean| / std 就是"偏离了几个标准差"。超过 2σ 就判定退步——为什么用统计学的 σ 而不是"掉了 5% 就算"?见下面取舍。
另一半亮点是 --changed 模式怎么"只验改动的 agent"(full_agent_eval.py:139):
def _get_changed_agents() -> list[str]:
"""`--changed` 模式 · grep git diff 看哪些 apps/<agent>/ 改了。"""
target = os.environ.get("CI_MERGE_REQUEST_DIFF_BASE_SHA") or "HEAD~1"
out = subprocess.check_output(["git", "diff", "--name-only", f"{target}..HEAD"], ...)
changed = set()
for line in out.splitlines():
if not line.startswith("apps/"): # 只看 apps/ 下的改动
continue
agent_dir = line.split("/")[1] # apps/doc-checker/xxx → doc-checker
if agent_dir in AGENTS:
changed.add(agent_dir)
return sorted(changed)
apps/doc-checker/...:→
_get_changed_agents 跑 git diff --name-only,发现只有 doc-checker 变了 → 返回 ["doc-checker"]→ 只重跑
doc-checker 的 5 维评测(不必把 14 个 agent 全验一遍,省几十分钟)→ 任一维
deviation > 2σ → 🚫 流水线红。就像验房只重验你刚装修那一户,没动的邻居不用重验。2σ(偏离两个标准差)意味着"这次的分数已经明显超出了历史正常波动范围"——把'真退步'和'正常噪声'区分开,误报率低、可信度高。baseline_std <= 0 时直接跳过(:93),是防止除零的边界处理。case-to-eval-sample skill 把那个 case 变成 eval 样本追进 golden set → 以后任何 PR 都会跑这条样本 → 同样的错误不会再溜过去。每一次线上事故都变成一道永久的回归防线——这是评测进 CI 最有价值的地方。成本闸走读:diff 拦截 + label 放行
ci-gate-cost 跑 scripts/ci/cost_impact.py(.gitlab-ci.yml:118),拦的是 Day 11 那个 cost-routing.json 的改动。核心算法就一个除法(cost_impact.py:120):
def compute_diff(baseline: dict, pr: dict) -> float:
"""返 (pr_total - baseline_total) / baseline_total · 单位 1 = 100%。"""
baseline_total = sum(baseline.values())
pr_total = sum(pr.values())
if baseline_total <= 0:
return 0.0 # 防除零边界
return (pr_total - baseline_total) / baseline_total # 涨幅比例
它拿 master 的成本向量(baseline)和这次 PR 的成本向量(都用 estimate_cost_vector 从 cost-routing.json 算出),算出总成本涨了百分之几。判定逻辑(cost_impact.py:187 起):
DEFAULT_THRESHOLD = 0.10 # 默认阈值 10%
REQUIRED_LABEL = "cost-impact-reviewed" # 放行标签
if abs(diff) <= threshold:
print(f"[OK] diff ≤ {threshold*100:.0f}% · PASS")
return 0 # 涨幅在 10% 内 → 过
labels = ... _get_labels_from_env() # 读 CI_MERGE_REQUEST_LABELS
if REQUIRED_LABEL in labels: # 超阈值,但 MR 打了"已审查"标签
print(f"[OK] diff > 阈值 但已含 `{REQUIRED_LABEL}` label")
return 0 # → 放行(人工审查过了)
# 否则列出 top 成本涨幅的 agent,fail(enforce 模式下 return 1)
cost-impact-reviewed 标签就放行",为什么留这个口子?因为成本涨有时是合理的——比如某 agent 确实需要升到更强的模型才能达标。一刀切拒绝会挡住正当需求。设计成"超阈值 → 默认拦 → 但人工审查确认后打个标签就能过",等于把'异常涨幅'从'静默通过'变成'必须有人显式签字'。既拦住手滑的误改,又给正当变更留了合规通道。红线闸(L06)也是同款 label-escape 机制。enforce=False(observe-only)模式(cost_impact.py:142/207),为什么?这是灰度上线闸门本身的手法。一道新闸刚加时,先跑 observe-only:算 diff、打印出来,但不真的 fail。观察一两周,确认阈值合理、不误伤,再切成 enforce=True 真拦。闸门自己也要"金丝雀"——上来就硬拦,很可能因阈值没调好把一堆正常 PR 挡在外面,反而逼大家想办法绕过它。cost-routing.json 把 doc-checker 从便宜的 haiku 改成贵的 sonnet:→
estimate_cost_vector 算出 pr 总成本比 baseline 涨了 40%→
compute_diff 返回 0.40,abs(0.40) > 0.10 阈值→ MR 没打
cost-impact-reviewed 标签 → 🚫 fail,逼你要么说明理由加标签、要么改回去。相当于"想动承重墙(高危配置),验收员立刻要专项审批"。敏感度红线闸走读(呼应 Day 08)
ci-gate-red-line 跑 scripts/ci/red_line_regression.py(.gitlab-ci.yml:112),守 DD-001 数据主权/敏感度红线。它不看"分数退步",而是看"这次 PR 有没有碰红线文件/代码"。检测逻辑(red_line_regression.py:100):
RED_LINE_KEYWORD_RE = re.compile(
r"(SENSITIVITY_LEVEL|sensitivity_level\s*=|red_line\s*=|RED_LINE_SERVICES)")
REQUIRED_LABEL = "red-line-reviewed"
def detect_red_line(files: list[str], diff_text: str) -> list[str]:
reasons = []
# ① 改动的文件路径命中红线目录/文件名(如含 sensitivity)
for f in files:
if _path_matches_red_line(f):
reasons.append(f"red-line file: {f}")
# ② diff 内容里新增/删除行命中红线关键词
for line in diff_text.splitlines():
if line.startswith(("+", "-")) and RED_LINE_KEYWORD_RE.search(line):
reasons.append(f"red-line keyword in diff line: {line[:80]!r}")
break # 一次足够触发,不刷屏
return reasons # 空 = 没碰红线
大白话:两条探针——① 看这次改的文件路径有没有命中红线目录(business-data 包、文件名含 sensitivity 等);② 看 diff 的增删行里有没有出现 SENSITIVITY_LEVEL、red_line= 这些敏感关键词。任一命中就返回一条 reason。判定跟成本闸同款(red_line_regression.py:123):碰了红线 + 没打 red-line-reviewed 标签 = FAIL;没碰 = 直接过。
:113-115 特意跳过 diff 的 +++/--- 文件头行——否则文件名里带 sensitivity 的 diff header 会误触。还有 break(:117):命中一次关键词就够触发了,不再遍历全部 diff 行刷屏。这些都是"让检测既灵敏又不啰嗦"的细节处理。DB 迁移 & 兼容矩阵
还有几道闸值得一提。ci-gate-db-dryrun(.gitlab-ci.yml:96)会起一个临时的真数据库来验迁移脚本:
ci-gate-db-dryrun:
extends: .ci-gate-base
services:
- name: pgvector/pgvector:pg16 # 起一个带 pgvector 的临时 PG
alias: postgres
variables:
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/test"
PGVECTOR_DIM: "1536"
script:
- uv run python scripts/ci/db_migration_dryrun.py # 真跑 alembic upgrade + downgrade
它用 GitLab CI 的 services 起一个真的 pgvector/pgvector:pg16 容器,然后真跑一遍 alembic upgrade + downgrade——保证数据库 schema 迁移脚本不仅能升、还能回滚。
.gitlab-ci.yml:76:14 agent × 5 extras 兼容矩阵,用 parallel: matrix: SHARD:[1..5] 分 5 片并行跑(当前是 --dry-run 占位)。:142/155:门户前端 Playwright e2e + Lighthouse 性能预算、Day 16 讲的双仓 drift 检测(ci-gate-frontend-sync)和镜像只读校验(ci-gate-frontend-readonly)。toolkit-vX.Y.Z tag 触发,把 wheel 包发到公司内部 Nexus(供独立仓 agent 用一行 BOM 依赖拉取,Day 19 会提)。金丝雀 phase.py 走读
scripts/canary/ 是一套金丝雀发布工具包(cli/monitor/phase/prom/rollback/override/state)。最能看清"分阶段放量"逻辑的是 phase.py。先看它的状态和放量档位(phase.py:23):
PHASE_DURATION_HOURS: int = 24
NEXT_PHASE_DEFAULT = {0: 10, 10: 50, 50: 100, 100: 100} # 放量档:0%→10%→50%→100%
@dataclass(frozen=True)
class PhaseState: # canary 当前状态(持久化到 sqlite ledger)
phase: Phase # 当前放量百分比
bom_version_current: str
bom_version_target: str # 要发的新版本
bom_version_baseline: str
phase_start_time: float # 这一阶段开始的时刻
baseline_metrics: dict = ...
核心是"能不能推进到下一档"的三个条件(phase.py:63):
def can_advance(state, monitor, *, pre_flight_pass=True, now=None) -> tuple[bool, str]:
if state.is_terminal(): # 已到 100%
return False, "already_terminal"
# ① 这一档得跑够 24 小时
elapsed = (now or monotonic()) - state.phase_start_time
if elapsed < PHASE_DURATION_HOURS * 3600:
return False, "duration_not_met"
# ② 监控 3 维全绿(错误率/延迟/敏感度都没恶化)
if not monitor.all_green(state.baseline_metrics):
return False, f"monitor_not_green: {monitor.red_dims()}"
# ③ pre-flight 门(BOM 审计 + ci-gates 历史都 PASS)
if not pre_flight_pass:
return False, "pre_flight_fail"
return True, "ready"
逐条讲:要从当前放量档(比如 10%)升到下一档(50%),必须同时满足:① 这一档已经稳定跑够 24 小时;② monitor.all_green——盯着 Prometheus 的错误率/延迟/敏感度三维,跟 baseline 比没有恶化;③ pre-flight 那批 CI 闸历史上是 PASS 的。三条缺一就不推进,还返回具体原因(duration_not_met / monitor_not_green)方便排查。真正切流量的 advance(phase.py:94)会调注入进来的 apply_overlay(切 K8s overlay)+ 写 Prometheus 指标。
can_advance 三条件;出问题沿红线一键回滚。rollback。名字来自"矿工带金丝雀下矿"——用小代价先探风险。这里的 NEXT_PHASE_DEFAULT 就是那条放量路线图。advance 把"真正 kubectl apply 切流量"做成注入参数(apply_overlay),而不在函数里直接调?看 phase.py:103 注释原话"实际 kubectl apply 由调用方注入 · 本模块仅负责状态转移 · 利于单测"。把"纯状态机逻辑"和"真去操作 K8s 的副作用"分离——phase.py 只算"该不该推进、推进到哪一档",测试时传个假的 apply_overlay 就能验全部逻辑,不用真连集群。这跟 Day 05 那些"业务只填配置、副作用交给框架"是同一种"纯函数 + 依赖注入"的可测试性思维。PhaseState 用 frozen=True(不可变)也是为此:状态转移产生新对象,而非原地改。allow_failure:false 就贴封条。canary(L08) 是验收之后再让一小拨住户试住,24h 全绿才多放。今日小结 + 动手
🧠 今天你应该能回答
- R-CI-DEFAULT-FAIL 靠什么落地?(
.ci-gate-base的allow_failure: false,.gitlab-ci.yml:63) bom-audit的三件事 + exit 0/1/2 分别代表什么?- eval 闸为什么用 2σ 而不是固定百分比?
--changed怎么只验改动的 agent? - 成本闸 / 红线闸的 label-escape(打标签放行)为什么合理?
- DB 迁移为什么要连 downgrade 一起验?
- 金丝雀推进的三个条件?为什么
apply_overlay做成注入参数?
✋ 动手:读今天走读过的真脚本
# 1. stage + 默认失败基模板
sed -n '28,73p' .gitlab-ci.yml
# 2. BOM 审计(看文件头三件事 + exit code)
sed -n '1,55p' scripts/check_bom_lockfile.py
# 3. 评测回归:5 维 + 2σ + --changed
sed -n '52,105p' scripts/ci/full_agent_eval.py
sed -n '139,164p' scripts/ci/full_agent_eval.py
# 4. 成本闸 + 红线闸
sed -n '120,210p' scripts/ci/cost_impact.py
sed -n '100,157p' scripts/ci/red_line_regression.py
# 5. 金丝雀 phase 状态机
sed -n '23,128p' scripts/canary/phase.py
# 6. 本地跑等价门禁(提交前自检)
uv run pytest -q && uv run ruff check .