第 10 课:测试 + 评测 + 部署 —— 从"能跑"到"能上线"
本节目标:掌握 pytest 三层测试金字塔 + mock 体系、Golden Dataset 评测框架、Docker 打包与 CI/CD 流水线,打通从"开发机能跑"到"上线不出事"的三道关。
学完本课后,推荐阅读 附10:LLM 评估体系 —— RAG 三角评估、RAGAS 四大指标、LLM-as-Judge 最佳实践、Bad Case 闭环。
前 9 课做完了一个功能完整、有权限、有监控的 RAG Agent。但"开发机上能跑"和"上线不出事"之间隔着三道关:测试、评测、部署。
1. 全局视角:三道关
┌─────────── 第 1 关:单元/集成测试(pytest) ───────────────┐
│ "每个零件都对吗?拼起来对吗?" │
│ 确定性 mock → 可重复 → CI 里自动跑 │
└───────────────────────┬─────────────────────────────────────┘
│ 全绿 ┌───────────────────────┴─── 第 2 关:评测(Eval) ──────────┐
│ "端到端的质量达标吗?" │
│ Golden dataset → 打分 → pass_rate ≥ 阈值 │
│ 不是"对不对"而是"好不好" │
└───────────────────────┬─────────────────────────────────────┘
│ 通过阈值 ┌───────────────────────┴─── 第 3 关:部署(Deploy) ────────┐
│ "生产环境能安全跑起来吗?" │
│ Docker 打包 → CI/CD 流水线 → 健康检查 → 灰度上线 │
└──────────────────────────────────────────────────────────────┘
| 关卡 | 回答的问题 | 失败代价 |
|---|---|---|
| 测试 | 代码逻辑对不对 | Bug 上线 → 用户看到 500 |
| 评测 | RAG 回答质量好不好 | 答案不准 → 用户不信 → 产品死 |
| 部署 | 打包/发布流程可靠吗 | 发版崩溃 → 回滚混乱 → 半夜被叫醒 |
2. pytest 测试架构
[pytest]
testpaths = tests
asyncio_mode = strict
addopts = -v --strict-markers --tb=short --disable-warnings
markers =
slow: 耗时超过 1s 的测试(默认跳过用 -m "not slow")
integration: 依赖外部服务(OpenAI / DB 等)的集成测试
unit: 纯逻辑单元测试(默认跑这一组)
| 标记 | 含义 | CI 里跑? |
|---|---|---|
@pytest.mark.unit | 纯逻辑,不依赖外部 | 每次 PR 都跑 |
@pytest.mark.integration | 需要真实 DB/API | 注意:定时跑或手动触发 |
@pytest.mark.slow | 超过 1s | 注意:release 前跑 |
每章对应一个测试文件,和课程章节严格对齐:
tests/
├── conftest.py # 公共 fixture
├── test_chat.py / test_qa.py / test_agent.py / test_knowledge.py
├── test_rag.py / test_hybrid.py / test_rag_tuning.py
├── test_rag_graph.py / test_verified_qa.py / test_verifier.py
├── test_auth_rbac.py / test_observability_ch16.py
└── eval/ # 评测框架
├── golden/ # 黄金数据集
├── metrics.py # 打分函数
└── run_eval.py # 评测入口
3. Mock 体系:测试的核心基础设施
原则:测试不花钱、不联网
mock_openai:LLM 替身
@dataclass
class FakeMessage:
content: str = "mock-answer"
@dataclass
class FakeChoice:
message: FakeMessage = field(default_factory=FakeMessage)
@dataclass
class FakeChatCompletion:
choices: list[FakeChoice] = field(default_factory=lambda: [FakeChoice()])
usage: FakeUsage = field(default_factory=FakeUsage)
@pytest.fixture
def mock_openai(monkeypatch: pytest.MonkeyPatch) -> None:
async def _fake_create(*_args, **_kwargs):
return FakeChatCompletion()
monkeypatch.setattr(
llm_service._client.chat.completions, "create", _fake_create
)
设计要点:用 dataclass 模拟 OpenAI SDK 返回结构;monkeypatch.setattr 替换底层 create 方法;默认返回 "mock-answer" 用于 happy path。
三种 LLM mock 变体
| Fixture | 行为 | 测试场景 |
|---|---|---|
mock_openai | 返回 "mock-answer" | happy path |
mock_openai_empty | 返回空字符串 | LLM 返回空 → 502 |
mock_openai_timeout | 抛 APITimeoutError | 重试耗尽 → 502 |
mock_embedding:Embedding 替身
@pytest.fixture
def mock_embedding(monkeypatch: pytest.MonkeyPatch) -> None:
import hashlib
def _fake_vector(text: str) -> list[float]:
seed = int(hashlib.sha256(text.encode()).hexdigest()[:8], 16)
base = [(seed >> (i * 2)) & 0xFF for i in range(16)]
norm = [b / max(sum(b*b for b in base)**0.5, 1e-6) for b in base]
return (norm * (1536 // 16))[:1536]
monkeypatch.setattr(embedding_service, "embed", _fake_embed)
用 sha256(text) 生成确定性向量 → 相同输入永远相同向量、不同输入不同向量、维度 1536。不发任何网络请求。
temp_vector_store:临时 ChromaDB + SQLite
@pytest.fixture
def temp_vector_store(monkeypatch, tmp_path):
store = VectorStore(persist_dir=str(tmp_path / "chroma"))
monkeypatch.setattr(_vs, "vector_store", store)
# FTS + conversations + approvals + users + ACL 全部用 tmp_path/db
fts_db_path = str(tmp_path / "fts.db")
_fts.init_fts(db_path=fts_db_path)
_conv_db.init_conversations(db_path=fts_db_path)
_users_db.init_users(db_path=fts_db_path)
_acl_db.init_acl(db_path=fts_db_path)
yield store
tmp_path:pytest 内置 fixture,每个测试独立临时目录 → 测试间完全隔离。
4. 测试三层金字塔
第 1 层:单节点测试(最细粒度、最快)
@pytest.mark.unit
async def test_rewrite_node_skips_when_disabled() -> None:
out = await rewrite_node(
{"question": "什么是 RAG?", "enable_rewrite": False}
)
assert out["rewritten_query"] == "什么是 RAG?"
assert out["meta"]["rewrite_skipped"] is True
直接调用节点函数,传 state dict → 检查返回值。不启动 FastAPI,不构建图。
第 2 层:整图 e2e(中粒度)
async def test_graph_hit_path(monkeypatch) -> None:
monkeypatch.setattr(nodes_mod.hybrid_retrieval_service, "retrieve", ...)
monkeypatch.setattr(nodes_mod.llm_service, "chat_messages", _fake_chat)
out = await rag_graph_service.ask("什么是 RAG?")
assert out["finish_reason"] == "answered"
第 3 层:API 集成(最粗粒度)
def test_api_rag_graph_ask_happy(client, monkeypatch) -> None:
resp = client.post("/api/v1/rag-graph/ask", json={"question": "什么是 RAG?"})
assert resp.status_code == 200
△ API 测试(少量)→ 验证 HTTP + 中间件
╱ ╲
╱ ╲
╱ 整图 ╲ → 验证节点串联 + 路由
╱ e2e ╲
╱───────────╲
╱ 单节点测试 ╲ → 验证函数输入/输出(最多最快)
╱─────────────────╲
5. 测试模式:Happy / Edge / Error
以 test_chat.py 为例——DoD 要求每章 ≥ 3 条:
| 类型 | 例子 | 验证什么 |
|---|---|---|
| Happy | 正常问题 → 200 + answer | 核心流程正确 |
| Edge | 空字符串/超长/全空白 → 422 | 输入校验 |
| Error | LLM 超时/空响应 → 502 | 错误处理 |
6. 评测框架(Eval)
测试 vs 评测
| 维度 | 测试(pytest) | 评测(Eval) |
|---|---|---|
| 目标 | 代码正确性 | 回答质量 |
| 输入 | mock 数据 | 真实问题 |
| 判定 | assert True/False | 打分(pass_rate) |
| LLM | mock,不花钱 | 真实调用,花钱 |
| 运行频率 | 每次 PR | 每次发版 / 每周 |
Golden Dataset
{"id":"rag-1","question":"什么是 RAG?","expected_keywords":["检索","生成"],"expected_citations_min":1,"min_trust_level":"medium"}
{"id":"rag-4","question":"哪里能买到月亮?","expected_keywords":["无法回答"],"expected_citations_min":0,"min_trust_level":"low"}
注意:
rag-4验证系统能正确拒绝无法回答的问题——很多团队只测"能回答"的,不测"应该拒绝"的。
打分函数
def rag_score(sample: dict, resp: dict) -> dict:
ans = (resp.get("answer") or "").lower()
keywords = [k.lower() for k in sample.get("expected_keywords", [])]
kw_hit = all(k in ans for k in keywords)
cit_ok = len(resp.get("citations") or []) >= sample.get("expected_citations_min", 0)
trust_ok = _trust_ge(resp.get("summary", {}).get("trust_level", "low"),
sample.get("min_trust_level", "low"))
return {"id": sample["id"], "passed": kw_hit and cit_ok and trust_ok,
"kw_hit": kw_hit, "cit_ok": cit_ok, "trust_ok": trust_ok}
三个维度 全 pass 才算 pass:关键词命中 AND 引用数达标 AND 信任等级达标。
ACL 评测:检查"必须不出现"
def acl_score(sample: dict, resp: dict) -> dict:
blob = resp.get("answer", "") + " ".join(e.get("text","") for e in resp.get("evidences", []))
leaked = [w for w in sample.get("must_not_contain", []) if w in blob]
return {"id": sample["id"], "passed": len(leaked) == 0, "leaked": leaked}
ACL 评测和安全相关,pass_rate 必须是 100%。
评测分层运行
| 子集 | 走什么路径 | 验证什么 |
|---|---|---|
verifier | 直接调 service(不走 HTTP) | verdict 精确匹配 |
rag | HTTP → verified-qa | 关键词 + 引用 + 信任 |
acl | HTTP + 多租户 token | 信息不泄漏 |
Verifier 是纯函数,直接调 service 更快更稳。RAG 必须走 HTTP——要验证整条链路(auth → RBAC → 检索 → ACL → 生成 → 验证)。
7. 部署:从代码到容器
Docker 多阶段构建
FROM python:3.11-slim AS deps
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM deps AS runtime
COPY . .
ENV PYTHONPATH=/app
HEALTHCHECK --interval=30s --timeout=5s \
CMD python -c "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
CI/CD 流水线
代码推送 → pytest -m unit → 全绿 → 构建 Docker 镜像
→ 跑评测 --subset verifier → 推送镜像 → 灰度部署
环境变量管理
开发: .env 文件(gitignore) 测试: CI secrets 生产: K8s ConfigMap + Secret
OPENAI_API_KEY=sk-... # 必须从 Secret 注入,禁止硬编码
JWT_SECRET=... # 必须覆盖默认值
ENABLE_AUTH=true # 生产必须开启
8. 5 条工程教训
1. 测试不花钱是底线
# 每次 CI 跑 = 花钱
resp = await openai.chat.completions.create(...)
# mock = $0
monkeypatch.setattr(llm_service._client.chat.completions, "create", _fake_create)
一个 PR × 5 次 CI × 50 个测试 × $0.003/次 = $0.75/PR。一年 2000 PR = $1500。
2. 评测不用 LLM 判分 — 同一个答案两次打分可能不一样。用确定性方法:关键词 + 引用数 + 信任等级。
3. Golden dataset 覆盖"拒绝回答" — 只测"能回答"不测"应该拒绝" → 幻觉灾难。
4. ACL 评测比功能评测更重要 — 功能 bug = 体验差,安全 bug = 数据泄露 = 法律问题。ACL pass_rate 必须 100%。
5. fixture 隔离是测试可靠性的基础 — 共享 DB → 测试 A 影响测试 B → flaky test → 开发者不信任测试 → bug 上线。
9. 小练习(5 题)
练习 1:mock_openai 默认返回 "mock-answer"。要测试生成节点是否正确解析 [1] 引用,怎么让 mock 返回带引用的答案?
练习 2:新增了 document_tags 表但忘了在 temp_vector_store fixture 里初始化,会出现什么症状?怎么排查?
练习 3:Golden dataset 里怎么构造"同一个 claim 既 supported 又 unsupported → 期望 conflict"的样本?
练习 4:RAG 评测 pass_rate 从 80% 降到 40%,但 Verifier 评测还是 100%。问题出在哪一层?怎么排查?
练习 5(最重要):LLM 把"检索、生成、增强"说成"获取、创建、强化"——语义等价但关键词不匹配。怎么在不引入 LLM 判分的前提下解决?
答案与解析
练习 1
在测试里用 monkeypatch.setattr 再覆盖一次默认 fixture:
async def test_generate_node_parses_citations(mock_openai, monkeypatch):
async def _fake_create(*a, **kw):
return FakeChatCompletion(choices=[FakeChoice(message=FakeMessage(
content="RAG 是检索增强生成 [1]。结合检索和生成 [1][2]。"))])
monkeypatch.setattr(llm_service._client.chat.completions, "create", _fake_create)
out = await generate_node({"question": "...", "hits": [...]})
assert "[1]" in out["answer"]
Fixture 是"默认值",测试里的 monkeypatch 是"特殊值"——分层覆盖。
练习 2
症状:sqlite3.OperationalError: no such table: document_tags。排查:看错误消息 → 检查 fixture 是否调了 init_document_tags(db_path=...) → 补一行。隐蔽情况:新表只在特定条件下查询 → 忘记初始化不会马上暴露。
教训:每加一个新 DB 表,同步更新 fixture。PR checklist 加一条:"新增 DB 表 → 更新 conftest fixture"。
练习 3
{"id":"v-conflict-3","question":"向量库需要GPU吗","answer":"看场景",
"claims":[{"text":"需要GPU","verification":"supported","evidence_ids":["e1"]},
{"text":"不需要GPU","verification":"supported","evidence_ids":["e2"]}],
"expected_verdict":"conflict"}
RuleVerifier 检测到两个 supported claims 语义互斥 → CONFLICT。
练习 4
Verifier 100% → 逻辑层正确。RAG 40% → 问题在 Verifier 之前的层(检索/ACL/生成/Auth)。排查:先看 HTTP 响应码(401/403→Auth,200 但空→检索),再看 retrieval_hits 指标、ACL 配置、LLM 模型版本。通用原则:分层评测 → 快速定位。
练习 5(最重要)
方案 1:同义词表
SYNONYMS = {"检索":["检索","获取","搜索"],"生成":["生成","创建","产生"],"增强":["增强","强化","提升"]}
方案 2:Embedding 相似度(需要调 API)。方案 3:正则 r"(检索|获取|搜索)"。
方案 4(推荐):黄金答案多套关键词
{"id":"rag-steps","expected_keywords_sets":[["检索","生成","增强"],["获取","创建","强化"],["retrieval","generation","augmentation"]]}
def kw_hit_any_set(ans, keyword_sets):
return any(all(k.lower() in ans.lower() for k in ks) for ks in keyword_sets)
完全确定性、不需额外依赖、新表述加一套关键词不改代码。
原则:评测的可重复性 > 精确性。宁用多套关键词覆盖 90%,也不要用 LLM 判分追求 100% 但结果不确定。
三句话带走第 10 课
- Mock 体系是测试基石:
mock_openai+mock_embedding+temp_vector_store→ 测试不花钱、不联网、完全隔离。每加一个新依赖,同步加一个 mock。 - 测试验正确性,评测验质量:pytest 用
assert判对错(确定);Eval 用 Golden dataset + 关键词打分判好坏(可重复)。分层评测 → 快速定位问题层。 - ACL 评测是安全红线:检查信息"必须不出现"而非"应该出现"。安全评测 pass_rate 必须 100%——任何泄漏都是事故。