如何设计单元测试自动校验返回字段类型与范围?

解读

在大模型应用落地过程中,服务接口返回的 JSON 结构往往包含生成文本、置信度、token 用量、安全标签等字段。一旦字段类型或取值范围漂移,下游业务就会直接报错。单元测试必须在 CI 阶段无人工介入地拦截这类问题,否则等线上监控报警时损失已发生。国内面试场景下,面试官想确认两点:

  1. 你能否把“大模型不可控的输出”转化为“可断言的契约”;
  2. 你能否用最小运行成本本地流水线里跑通,而不是把大模型当黑盒全量推理。

知识点

  1. Schema 驱动断言:使用 pydantic、marshmallow 或 jsonschema 预定义字段类型、必选/可选、取值范围、正则格式。
  2. 伪造(Mock)大模型返回:用 pytest-mock、respx、httpx.MockTransport 把 200 ms 的百亿参数调用换成 5 ms 的本地桩,CI 零 GPU 成本
  3. 参数化与组合爆炸:pytest.mark.parametrize 一次性覆盖“正常值、边界值、异常值”三元组,确保笛卡尔积无遗漏
  4. 动态范围校准:对置信度、token 长度等“统计型字段”,采用3σ 规则历史样本分位自动生成上下界,测试代码随模型迭代自更新,避免人肉改数字。
  5. 流水线集成:在 GitLab-CI 或 GitHub Actions 里加一段 job: unit_test_mock阻断合并于 PR 阶段,线上故障率可量化下降
  6. 合规留痕:国内监管要求日志留档 6 个月,因此单元测试报告(junit.xml)与覆盖率文件要上传至内部 MinIO 或 OSS 桶,方便审计。

答案

下面给出可直接落地的 Python 示例,兼顾类型、范围、性能与可维护性,全部单测在 CPU 容器 10 秒内跑完。

# schema.py
from pydantic import BaseModel, Field, confloat, conint
from typing import Literal

class Usage(BaseModel):
    prompt_tokens: conint(ge=0, le=8192)      # 国内 8k 上下文是主流上限
    completion_tokens: conint(ge=0, le=8192)
    total_tokens: conint(ge=0, le=16384)

class Safety(BaseModel):
    level: Literal[0,1,2,3,4]                 # 网信办四级审核映射
    hit_words: list[str] = Field(max_items=10)

class ChatResponse(BaseModel):
    id: str = Field(regex=r"^chatcmpl-[0-9a-z]{12}$")
    answer: str = Field(min_length=1, max_length=4096)
    confidence: confloat(ge=0.0, le=1.0)
    usage: Usage
    safety: Safety
# test_chat.py
import pytest
from schema import ChatResponse

# 1. 正常样本
good = {
    "id": "chatcmpl-1234567890ab",
    "answer": "你好,世界",
    "confidence": 0.87,
    "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
    "safety": {"level": 0, "hit_words": []}
}

# 2. 参数化异常样本
bad_cases = [
    ({**good, "confidence": 1.2}, "confidence gt 1"),
    ({**good, "usage": {**good["usage"], "total_tokens": 20000}}, "token overflow"),
    ({**good, "safety": {**good["safety"], "level": 5}}, "illegal safety level"),
]

@pytest.mark.parametrize("payload", [good])
def test_type_range_success(payload):
    # 如果校验失败,pydantic 会抛 ValidationError,测试自动失败
    ChatResponse(**payload)

@pytest.mark.parametrize("payload,msg", bad_cases)
def test_type_range_fail(payload, msg):
    with pytest.raises(Exception):
        ChatResponse(**payload)
# mock_llm.py
import json
from httpx import Response
from pytest_httpx import httpx_mock

def test_service_layer(httpx_mock):
    # 把真实大模型调用替换为本地桩
    httpx_mock.add_response(
        url="http://llm-internal.company.com/v1/chat",
        json=good,
        status_code=200
    )
    from service import chat
    raw = chat("你好")          # 内部用 httpx.post
    ChatResponse(**raw)         # 再走一遍 schema 校验

执行命令
pytest -q --junitxml=report.xml --cov=service --cov-report=term-missing
在 .gitlab-ci.yml 中只要加

unit_test_mock:
  stage: test
  image: python:3.10-slim
  script:
    - pip install -r requirements-test.txt
    - pytest
  coverage: '/TOTAL.*\s(\d+%)$/'
  artifacts:
    reports:
      junit: report.xml
      coverage_report:
        file: coverage.xml
        format: cobertura

至此,字段类型与范围校验完全自动化PR 合入前必须全绿无需消耗 GPU 预算

拓展思考

  1. 大模型输出是概率系统,单测只能保证“格式正确”,不能保证“事实正确”。因此还需分层测试
    • 单元层:schema 断言,毫秒级;
    • 集成层:用金丝雀样本集(golden set)语义相似度≥θ断言,分钟级;
    • 线上层:可观测性接入 Prometheus + Grafana,置信度掉标或安全标签异常即回滚
  2. 范围阈值随模型版本漂移,可每周把线上最近 7 天真实回包抽样 10k 条,离线跑统计脚本自动生成新的上下界,MR 机器人自动提 PR实现阈值自更新
  3. 国内信创环境可能禁用部分 Python 库,需要提前准备基于 jsonschema 的纯标准库降级方案避免上线前才发现依赖被安全扫描拦截