17.1 流水线:lint/test/评测/构建串联
1.3 立起了一个最小 CI:pytest 跑通、tsc --noEmit 过、node --test 绿。但那只回答了「代码能不能跑」,没回答「产品还好不好用」。Agent 服务有一个普通 Web 服务没有的问题:代码一行没改,质量也可能崩——因为你改了提示词、换了模型 ID、动了检索参数。这些东西不会被单元测试覆盖,却直接决定用户拿到的是不是好答案。所以 ResearchCopilot 的流水线必须在「测试」和「构建」之间,再插一道评测门。
本节把 ResearchCopilot 推进到「有完整 CI/CD 流水线」:把静态检查、单元测试、评测质量门、镜像构建四段按代价排序串起来,逐段在本机真跑并打印真实退出码,再给出一键脚本与 GitHub Actions 的对应形态。
17.1.1 Agent 的 CI 比普通服务多一道门
先看两种流水线的形状差异:
| 环节 | 普通 Web 服务 | Agent 服务 |
|---|---|---|
| 静态检查 | 有 | 有 |
| 单元测试 | 有 | 有 |
| 评测 | 无 | 必须有(第 12 章的质量门) |
| 构建镜像 | 有 | 有 |
多出来的这一道门,管的正是「代码没动、质量却变了」的情况。第 12 章已经把评测集、评分器、离线回放夹具都建好了,本节要做的是把 eval 当成流水线里的一个普通阶段——它和 pytest 一样,跑失败就退出码非零、就拦住合并。
评测门的价值在于它拦的是测试测不到的回归。单元测试断言的是「函数返回什么」,而评测断言的是「整条 Agent 链路产出的答案是否可接受」——引用可不可回溯、有没有调错工具、成功率高不高。这两类断言正交,缺一不可。
17.1.2 阶段顺序:快的先跑,失败早暴露省钱
四段的代价差一个数量级,顺序不是随便排的:
| 阶段 | 典型耗时 | 失败代价 | 建议位置 |
|---|---|---|---|
| 静态检查(compileall / ruff / tsc) | 秒级 | 极小 | 第 1 |
| 单元测试(pytest) | 秒~分钟 | 小 | 第 2 |
| 评测(夹具回放) | 分钟 | 中 | 第 3 |
| 构建镜像(docker build) | 分钟~十分钟 | 大 | 第 4 |
排序原则只有一条:单位耗时越短、越便宜的阶段越靠前。理由是「失败早暴露」——如果静态检查就能发现一个语法错误,就没必要再花几分钟跑评测和构建。反过来,如果把最贵的构建放第一个,一个拼写错误也要先付十分钟构建费才被拦住。
这条原则在大项目里能省下真金白银:CI 分钟数是按量计费的,让便宜阶段先淘汰掉大多数「明显坏掉」的提交,只有真正走到最后的提交才配花评测与构建的钱。流水线的经济性,全在排序上。
17.1.3 缓存:依赖缓存与层缓存
排序之外,缓存是第二个省钱杠杆。两处最值得缓存:
- 依赖缓存:
pip/npm的下载缓存。CI 里缓存~/.cache/pip或node_modules,避免每次提交都从源上重新拉几百 MB 依赖。注意本机实测 pip 必须走中科大源(见 1.3),缓存能顺带把源不稳定的影响降到最低。 - Docker 层缓存:Dockerfile 里把「装依赖」和「拷源码」拆成两层,且依赖层在前。因为 Docker 逐层比对,只要依赖清单没变,依赖层直接命中缓存;源码改动只让最后一层失效。
本机实测能直接看到层缓存的效果:第二次 docker build 时,除 FROM 外的每一步都打印 ---> Using cache(见 17.1.5)。这背后的纪律是把变化最频繁的东西放到 Dockerfile 最后——源码天天改,依赖清单几个月才动一次。
17.1.4 评测必须靠夹具回放才能在 CI 里跑
评测门有个现实约束:CI 里不能真调模型。真调要密钥、要花钱、每次几分钟,而且供应商抖一下 CI 就红。所以 CI 里的评测必须走离线夹具回放——这正是第 12.2 节 离线回放 建立的机制:把一次真实调用录下来的轨迹存成夹具,CI 里逐条重放、逐条打分。
夹具就是一个普通的 JSON 文件,本机示例:
[
{"name": "cited_answer", "answer": "见 [doc1]。", "citations": ["doc1"], "expect_cite": true},
{"name": "orphan_citation","answer": "见 [doc9]。", "citations": ["doc1"], "expect_cite": false},
{"name": "allowed_tool", "tool": "search", "expect_tool_ok": true},
{"name": "blocked_tool", "tool": "rm_rf", "expect_tool_ok": false},
{"name": "two_cites", "answer": "[doc1][doc2]", "citations": ["doc1", "doc2"], "expect_cite": true}
]
打分脚本 eval/run_eval.py 把夹具逐条过一遍,算成功率,低于阈值就返回退出码 1:
import json, pathlib, sys
from researchcopilot.guard import cite_ok, tool_allowed
FIXTURES = pathlib.Path(__file__).parent.parent / "fixtures" / "cases.json"
THRESHOLD = 0.80
def score(case: dict) -> bool:
ok = True
if case.get("expect_cite") is not None:
ok = ok and (cite_ok(case["answer"], case["citations"]) == case["expect_cite"])
if case.get("expect_tool_ok") is not None:
ok = ok and (tool_allowed(case["tool"]) == case["expect_tool_ok"])
return ok
def main() -> int:
cases = json.loads(FIXTURES.read_text(encoding="utf-8"))
passed = sum(score(c) for c in cases)
rate = passed / len(cases)
print(f"成功率 {rate:.1%}({passed}/{len(cases)}),阈值 {THRESHOLD:.0%}")
return 0 if rate >= THRESHOLD else 1
为什么夹具回放是 CI 里跑评测的唯一现实解:它零网络、零费用、秒级完成、结果确定。同一份夹具跑一百次,分数一模一样——这正是流水线需要的可复现性。真实模型的随机性留到发布前的验收阶段(第 12.3 节 CI 质量门 讲过两段式),CI 只跑便宜的夹具。
17.1.5 真跑:四段逐个执行
把四段在一台真机上逐个跑一遍。本机 Python 3.14.6、Docker 29.7.2。
阶段 1 静态检查。本机没装 ruff,用标准库的 compileall 做等价语法检查:
python -m compileall -q src
真实结果:退出码 0,无输出(无语法错误时静默)。故意塞一个坏文件 def broken(: 后重跑:
*** Error compiling 'src/researchcopilot/_broken.py'...
File "src/researchcopilot/_broken.py", line 1
def broken(:
^
SyntaxError: invalid syntax
退出码变成 1。这就是「便宜阶段挡住明显错误」——它连一个字节的测试都没跑就拦住了。
阶段 2 单元测试:
python -m pytest -q tests
真实输出与退出码:
... [100%]
3 passed in 0.07s
阶段 3 评测:
python eval/run_eval.py
真实输出与退出码:
PASS cited_answer
PASS orphan_citation
PASS allowed_tool
PASS blocked_tool
PASS two_cites
成功率 100.0%(5/5),阈值 80%
质量门:PASS
退出码 0。再验证「门真的会拦」——喂一条劣化用例(回答引用了不存在的 [doc9] 却期望通过):
模拟一次劣化:成功率 0%
退出码 1。评测门的退出码和 pytest 一样硬,这是它能当 CI 门的前提。
阶段 4 构建镜像:
docker build -t researchcopilot:ci .
真实结果:Successfully built 08bbccb2cc42 / Successfully tagged researchcopilot:ci,退出码 0。紧接着再跑一次,观察层缓存:
Step 2/5 : WORKDIR /app
---> Using cache
Step 3/5 : COPY src/ ./src/
---> Using cache
Step 4/5 : ENV PYTHONPATH=/app/src
---> Using cache
Step 5/5 : CMD [...]
---> Using cache
除 FROM 外每层都命中缓存。跑一下镜像验证它是可用的:
docker run --rm researchcopilot:ci
输出 0.1.0(包里定义的版本号),退出码 0。
17.1.6 真跑:一键串起来
把四段写成一个本地脚本 ci_local.sh,用 set -euo pipefail 保证任一段失败即中止(等价于 CI 里后续 job 不跑):
#!/usr/bin/env bash
set -euo pipefail
PY="${PY:-/tmp/agentbook/venv/bin/python}"
export PYTHONPATH="$PWD/src"
fail() { echo "✗ 阶段失败:$1(退出码 $2)"; exit "$2"; }
echo "== 阶段 1/4 静态检查 =="
"$PY" -m compileall -q src || fail "compileall" $?
echo "== 阶段 2/4 单元测试 =="
"$PY" -m pytest -q tests || fail "pytest" $?
echo "== 阶段 3/4 评测(质量门)=="
"$PY" eval/run_eval.py || fail "eval" $?
echo "== 阶段 4/4 构建镜像 =="
docker build -q -t researchcopilot:ci . >/dev/null || fail "docker build" $?
echo "✓ 全部阶段通过"
真跑一次:
== 阶段 1/4 静态检查 ==
compileall 通过
== 阶段 2/4 单元测试 ==
... [100%]
3 passed in 0.01s
== 阶段 3/4 评测(质量门)==
PASS cited_answer
...
成功率 100.0%(5/5),阈值 80%
质量门:PASS
== 阶段 4/4 构建镜像 ==
镜像构建成功
✓ 全部阶段通过
整体退出码 0。这个脚本是 CI 的本地镜像——它的阶段顺序、退出码语义与 CI 完全一致,所以「本地绿了」才有资格说「CI 大概率也绿」。
17.1.7 GitHub Actions:把四段搬进 CI
本地跑通后,把它固化成 workflow。下面这份 YAML 给出形态——本节未在本机执行过它,它需要推到 GitHub 后由 Actions 运行,这里只保证结构与上面真跑的阶段一一对应:
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
pipeline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.14"
- uses: actions/cache@v4 # 依赖缓存
with:
path: ~/.cache/pip
key: pip-${{ hashFiles('pyproject.toml') }}
- run: python -m pip install --index-url https://mirrors.ustc.edu.cn/pypi/simple -e ".[dev]"
- run: python -m compileall -q src # 阶段 1 静态检查
- run: python -m pytest -q tests # 阶段 2 单元测试
- run: python eval/run_eval.py # 阶段 3 评测质量门
- run: docker build -t researchcopilot:${{ github.sha }} . # 阶段 4 构建
三点与本地实测对应:步骤顺序与 ci_local.sh 完全一致;actions/cache 对应 17.1.3 的依赖缓存;docker build 步骤依赖 runner 自带的 Docker。注意这份 YAML 只是形态,未经本机执行——CI 的真实运行要在推送到 GitHub 后由 Actions 完成。
17.1.8 验收与真实环境
本节把 CI 从「能跑测试」升级成「能拦质量回归」:
- 四段流水线:静态检查 → 单元测试 → 评测 → 构建,按代价从低到高排序。
- 真跑退出码:compileall(0 / 坏代码 1)、pytest(0,3 passed)、评测(0 / 劣化 1)、docker build(0,二次全命中缓存)。
- 评测走夹具回放:零网络、零费用、确定性,是 CI 里跑评测的唯一现实解。
- 一键脚本:
ci_local.sh的退出码语义与 CI 一致。
验收清单:
- 流水线阶段按「便宜在前」排序
- 评测门有明确阈值,低于阈值退出码非零
- 依赖目录与 Docker 依赖层都被缓存
- 本地一键脚本与 CI 步骤一一对应
真实环境 / 真实模型下会怎样:本节的评测用的是夹具回放,确定性 100%;真实项目里,CI 上的评测门通常是两段式——先跑夹具快筛(秒级、每次提交),发布前再跑一小批真实模型验收(分钟级、要密钥)。此外真实 CI 还会加两个本节没展开的东西:变更检测(monorepo 里只改了 packages/web/ 就跳过 Python 阶段,见 1.3)与构建产物的版本标签(用 git sha 而非 latest,才能回溯到具体提交)。这两件事第 17.2 节的灰度会直接用到——没有可回溯的镜像版本,回滚就无从谈起。想回看最小 CI 是怎么起的,翻 1.3 仓库与工程骨架
;想回看评测集与夹具的定义,翻 12.2 离线回放
。
阅读导航:上一节:16.3 配置注入、健康检查与优雅退出 · 下一节:17.2 灰度与回滚:新提示/新模型小流量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。