如何设计单元测试自动校验返回字段类型与范围?
解读
在大模型应用落地过程中,服务接口返回的 JSON 结构往往包含生成文本、置信度、token 用量、安全标签等字段。一旦字段类型或取值范围漂移,下游业务就会直接报错。单元测试必须在 CI 阶段无人工介入地拦截这类问题,否则等线上监控报警时损失已发生。国内面试场景下,面试官想确认两点:
- 你能否把“大模型不可控的输出”转化为“可断言的契约”;
- 你能否用最小运行成本在本地流水线里跑通,而不是把大模型当黑盒全量推理。
知识点
- Schema 驱动断言:使用 pydantic、marshmallow 或 jsonschema 预定义字段类型、必选/可选、取值范围、正则格式。
- 伪造(Mock)大模型返回:用 pytest-mock、respx、httpx.MockTransport 把 200 ms 的百亿参数调用换成 5 ms 的本地桩,CI 零 GPU 成本。
- 参数化与组合爆炸:pytest.mark.parametrize 一次性覆盖“正常值、边界值、异常值”三元组,确保笛卡尔积无遗漏。
- 动态范围校准:对置信度、token 长度等“统计型字段”,采用3σ 规则或历史样本分位自动生成上下界,测试代码随模型迭代自更新,避免人肉改数字。
- 流水线集成:在 GitLab-CI 或 GitHub Actions 里加一段 job: unit_test_mock,阻断合并于 PR 阶段,线上故障率可量化下降。
- 合规留痕:国内监管要求日志留档 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 预算。
拓展思考
- 大模型输出是概率系统,单测只能保证“格式正确”,不能保证“事实正确”。因此还需分层测试:
- 单元层:schema 断言,毫秒级;
- 集成层:用金丝雀样本集(golden set)做语义相似度≥θ断言,分钟级;
- 线上层:可观测性接入 Prometheus + Grafana,置信度掉标或安全标签异常即回滚。
- 范围阈值随模型版本漂移,可每周把线上最近 7 天真实回包抽样 10k 条,离线跑统计脚本自动生成新的上下界,MR 机器人自动提 PR,实现阈值自更新。
- 国内信创环境可能禁用部分 Python 库,需要提前准备基于 jsonschema 的纯标准库降级方案,避免上线前才发现依赖被安全扫描拦截。