1015 分钟

测试 + 评测 + 部署 —— 从"能跑"到"能上线"

掌握 pytest 三层测试金字塔 + mock 体系、Golden Dataset 评测框架、Docker 打包与 CI/CD 流水线,打通上线前的三道关。

测试评测部署pytestDockerCI/CDPython
进度保存在本机浏览器;验收通过后再点更稳妥

第 10 课:测试 + 评测 + 部署 —— 从"能跑"到"能上线"

本节目标:掌握 pytest 三层测试金字塔 + mock 体系、Golden Dataset 评测框架、Docker 打包与 CI/CD 流水线,打通从"开发机能跑"到"上线不出事"的三道关。

学完本课后,推荐阅读 附10:LLM 评估体系 —— RAG 三角评估、RAGAS 四大指标、LLM-as-Judge 最佳实践、Bad Case 闭环。

前 9 课做完了一个功能完整、有权限、有监控的 RAG Agent。但"开发机上能跑"和"上线不出事"之间隔着三道关:测试、评测、部署。


1. 全局视角:三道关

Code
┌─────────── 第 1 关:单元/集成测试(pytest) ───────────────┐
│  "每个零件都对吗?拼起来对吗?"                             │
│  确定性 mock → 可重复 → CI 里自动跑                         │
└───────────────────────┬─────────────────────────────────────┘
                        │ 全绿 ┌───────────────────────┴─── 第 2 关:评测(Eval) ──────────┐
│  "端到端的质量达标吗?"                                      │
│  Golden dataset → 打分 → pass_rate ≥ 阈值                   │
│  不是"对不对"而是"好不好"                                    │
└───────────────────────┬─────────────────────────────────────┘
                        │ 通过阈值 ┌───────────────────────┴─── 第 3 关:部署(Deploy) ────────┐
│  "生产环境能安全跑起来吗?"                                  │
│  Docker 打包 → CI/CD 流水线 → 健康检查 → 灰度上线           │
└──────────────────────────────────────────────────────────────┘
关卡回答的问题失败代价
测试代码逻辑对不对Bug 上线 → 用户看到 500
评测RAG 回答质量好不好答案不准 → 用户不信 → 产品死
部署打包/发布流程可靠吗发版崩溃 → 回滚混乱 → 半夜被叫醒

2. pytest 测试架构

ini
[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 前跑

每章对应一个测试文件,和课程章节严格对齐:

Code
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 替身

python
@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_timeoutAPITimeoutError重试耗尽 → 502

mock_embedding:Embedding 替身

python
@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

python
@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 层:单节点测试(最细粒度、最快)

python
@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(中粒度)

python
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 集成(最粗粒度)

python
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
Code
        △  API 测试(少量)→ 验证 HTTP + 中间件
       ╱ ╲
      ╱   ╲
     ╱ 整图 ╲ → 验证节点串联 + 路由
    ╱  e2e   ╲
   ╱───────────╲
  ╱  单节点测试  ╲ → 验证函数输入/输出(最多最快)
 ╱─────────────────╲

5. 测试模式:Happy / Edge / Error

test_chat.py 为例——DoD 要求每章 ≥ 3 条:

类型例子验证什么
Happy正常问题 → 200 + answer核心流程正确
Edge空字符串/超长/全空白 → 422输入校验
ErrorLLM 超时/空响应 → 502错误处理

6. 评测框架(Eval)

测试 vs 评测

维度测试(pytest)评测(Eval)
目标代码正确性回答质量
输入mock 数据真实问题
判定assert True/False打分(pass_rate)
LLMmock,不花钱真实调用,花钱
运行频率每次 PR每次发版 / 每周

Golden Dataset

json
{"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 验证系统能正确拒绝无法回答的问题——很多团队只测"能回答"的,不测"应该拒绝"的。

打分函数

python
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 评测:检查"必须不出现"

python
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 精确匹配
ragHTTP → verified-qa关键词 + 引用 + 信任
aclHTTP + 多租户 token信息不泄漏

Verifier 是纯函数,直接调 service 更快更稳。RAG 必须走 HTTP——要验证整条链路(auth → RBAC → 检索 → ACL → 生成 → 验证)。


7. 部署:从代码到容器

Docker 多阶段构建

dockerfile
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 流水线

Code
代码推送 → pytest -m unit → 全绿 → 构建 Docker 镜像
→ 跑评测 --subset verifier → 推送镜像 → 灰度部署

环境变量管理

code
开发: .env 文件(gitignore)    测试: CI secrets    生产: K8s ConfigMap + Secret

OPENAI_API_KEY=sk-...    # 必须从 Secret 注入,禁止硬编码
JWT_SECRET=...            # 必须覆盖默认值
ENABLE_AUTH=true          # 生产必须开启

8. 5 条工程教训

1. 测试不花钱是底线

python
# 每次 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 题)

练习 1mock_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:

python
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

json
{"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:同义词表

python
SYNONYMS = {"检索":["检索","获取","搜索"],"生成":["生成","创建","产生"],"增强":["增强","强化","提升"]}

方案 2:Embedding 相似度(需要调 API)。方案 3:正则 r"(检索|获取|搜索)"

方案 4(推荐):黄金答案多套关键词

json
{"id":"rag-steps","expected_keywords_sets":[["检索","生成","增强"],["获取","创建","强化"],["retrieval","generation","augmentation"]]}
python
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 课

  1. Mock 体系是测试基石mock_openai + mock_embedding + temp_vector_store → 测试不花钱、不联网、完全隔离。每加一个新依赖,同步加一个 mock。
  2. 测试验正确性,评测验质量:pytest 用 assert 判对错(确定);Eval 用 Golden dataset + 关键词打分判好坏(可重复)。分层评测 → 快速定位问题层。
  3. ACL 评测是安全红线:检查信息"必须不出现"而非"应该出现"。安全评测 pass_rate 必须 100%——任何泄漏都是事故。