AI 测试开发面试高频题:"大模型的输出是不确定的,你怎么做自动化测试?"
这篇用一个真实线上故障,完整还原 AI 测试开发工程师的处理过程:怎么定位、怎么写代码、最后沉淀成什么方法。
一、真实故障:断言天天红,后来有人把它关了
某电商公司的智能客服项目,测试工程师小周给"订单状态查询"这个 LLM 接口写了自动化用例,断言写得很传统:
assert response.text == "您的订单 TEST-1001 已发货,预计3天内送达"
上线第一周,这条用例在 CI 里天天红。同样的输入,模型今天回"已发货,预计3天送达",明天回"您好,包裹已在路上啦~",后天直接换成 markdown 列表。小周先是把断言放宽成 assert "发货" in response.text,折腾几轮后烦了,直接把用例禁用,备注写了句:"LLM 输出随机,断言没意义"。
三周后,半夜告警:订单查询功能整体不可用。排查发现,模型输出悄悄变成了"带 markdown 代码块的 JSON"——内容其实没错,但下游服务直接 json.loads(),遇到 ``` 包裹就抛异常。致命的是,这条用例早就被禁用了,发布前没有任何拦截,故障持续了 40 分钟。
复盘时团队才意识到:不是大模型不能测,而是用精确匹配的旧思路去测,一定会经历"天天误报 → 麻痹放弃 → 故障裸奔"这个死亡循环。
二、先破除一个误区:temperature=0 也不保证逐字相同
面试里很多人第一反应是:"把 temperature 设成 0,输出不就固定了吗?"
不够。temperature=0 只是让采样退化为取最大概率 token,但服务端的 continuous batching、浮点累加顺序、并行解码等实现细节,仍然可能让同一输入产生细微的措辞差异。这在 OpenAI 官方论坛和 GitHub issue 里是被反复讨论过的已知现象。
所以正确的目标不是"逐字相等",而是把断言分层:机器能稳定的部分用硬断言锁死,语言天然漂移的部分用软断言兜住。
三、核心代码:四层断言策略
第 1 层:请求侧先约束输出
能结构化的绝不自由发挥。temperature=0 + 结构化输出,把 80% 的不确定性在源头掐掉:
import json
from openai import OpenAI
client = OpenAI()
def ask_order_status(order_id: str) -> str:
resp = client.chat.completions.create(
model="gpt-4o-mini",
temperature=0,
response_format={
"type": "json_object"}, # 强制 JSON 输出
messages=[
{
"role": "system", "content": (
"你是客服助手,只返回 JSON:"
'{"status": "shipped|pending|refunding", "summary": "不超过30字"}'
)},
{
"role": "user", "content": f"查询订单 {order_id} 的状态"},
],
)
return resp.choices[0].message.content
第 2、3 层:格式硬断言 + 语义软断言(LLM-as-Judge)
格式、字段、枚举值这些"机器契约",必须硬断言,一个字符都不能让;语义措辞交给裁判模型判断:
JUDGE_PROMPT = """你是严格的测试评审员。
参考答案:{expected}
模型实际输出:{actual}
判断实际输出与参考答案语义是否一致,且不含事实错误。
只返回 JSON:{
{"pass": true/false, "reason": "..."}}"""
def assert_llm_output(actual: str, expected: str):
# —— 第2层:格式硬断言(复现故障的关键防线)——
data = json.loads(actual) # 不是合法 JSON 直接失败
assert {
"status", "summary"} <= set(data), "缺少必需字段"
assert data["status"] in {
"shipped", "pending", "refunding"}, "枚举值非法"
# —— 第3层:语义软断言(LLM-as-Judge)——
verdict = client.chat.completions.create(
model="gpt-4o", temperature=0,
messages=[{
"role": "user",
"content": JUDGE_PROMPT.format(expected=expected, actual=actual)}],
)
result = json.loads(verdict.choices[0].message.content)
assert result["pass"], f"语义断言失败:{result['reason']}"
注意裁判模型要选比被测模型更强的型号,并且裁判的提示词、温度都要固定——裁判本身也要"可复现"。
第 4 层:稳定性测试,用"一致性率"替代单次判定
单次通过不算通过。同样的输入跑 N 次,格式一致性必须是 100%,语义一致性给一个可接受的阈值:
def test_output_stability(n=10):
results = []
for _ in range(n):
raw = ask_order_status("TEST-1001")
json.loads(raw) # 任何一次格式失败都算不通过
results.append(json.loads(raw))
semantic_ok = sum(r["status"] == "shipped" for r in results)
rate = semantic_ok / n
assert rate >= 0.9, f"语义一致性率 {rate:.0%},低于阈值 90%"
这条用例就是小周团队复盘后补上的:它不管模型怎么措辞,只要 10 次里有 1 次格式不对,CI 立刻红——故障里那种"格式漂移"从此不可能静默上线。
四、沉淀成方法:AI 测试开发的断言四层模型
| 层级 | 断言对象 | 方式 | 容忍度 |
|---|---|---|---|
| L1 请求侧约束 | 输出格式 | temperature=0、response_format、严格 schema | 从源头减少漂移 |
| L2 格式硬断言 | JSON 结构、字段、枚举 | 代码断言 | 零容忍 |
| L3 语义软断言 | 内容正确性 | LLM-as-Judge / G-Eval | 阈值制(如 ≥0.8) |
| L4 稳定性断言 | 一致性率 | 多次采样统计 | 格式 100%,语义 ≥90% |
配套两条工程纪律:一是禁止禁用失败用例,LLM 用例红了要么修断言要么修模型,不许关掉;二是裁判提示词纳入版本管理,裁判变更也要走回归。开源社区的 DeepEval(G-Eval 指标)、OpenAI Evals 都是这套思路的成熟实现,可以直接复用,不必从零造。
五、面试追问,你答得上来吗
- LLM-as-Judge 自己也会出错、也会漂移,怎么办?——答:固定裁判模型与提示词版本、温度设 0、定期用人工标注集校准裁判的准确率(裁判的准确率本身也是一个被测指标)。
- 为什么不直接用余弦相似度做语义断言?——答:相似度对措辞敏感、对事实不敏感,"3 天送达"和"5 天送达"相似度很高但都是错的;语义判断交给更强的裁判模型更可靠,相似度只适合做粗筛。
- 稳定性测试跑 10 次成本高、CI 慢,怎么权衡?——答:分层执行——格式断言每次 PR 跑;语义与稳定性测试每晚定时跑全量,发布前跑冒烟子集。
下一篇预告:《RAG 上线后为什么总"答非所问"?——黄金数据集与检索质量评测》