同一个接口每次返回都不一样?大模型输出的可靠断言怎么写
> 这篇用一个真实线上故障,完整还原 AI 测试开发工程师的处理过程:怎么定位、怎么写代码、最后沉淀成什么方法。
一、真实故障:断言天天红,后来有人把它关了
某电商公司的智能客服项目,测试工程师小周给"订单状态查询"这个 LLM 接口写了自动化用例,断言写得很传统:
```python
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% 的不确定性在源头掐掉:
```python
import jsonfrom 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)
格式、字段、枚举值这些"机器契约",必须硬断言,一个字符都不能让;语义措辞交给裁判模型判断:
```python
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%,语义一致性给一个可接受的阈值:```pythondef 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 都是这套思路的成熟实现,可以直接复用,不必从零造。## 五、面试追问,你答得上来吗1. LLM-as-Judge 自己也会出错、也会漂移,怎么办?——答:固定裁判模型与提示词版本、温度设 0、定期用人工标注集校准裁判的准确率(裁判的准确率本身也是一个被测指标)。2. 为什么不直接用余弦相似度做语义断言?——答:相似度对措辞敏感、对事实不敏感,"3 天送达"和"5 天送达"相似度很高但都是错的;语义判断交给更强的裁判模型更可靠,相似度只适合做粗筛。
3. 稳定性测试跑 10 次成本高、CI 慢,怎么权衡?——答:分层执行——格式断言每次 PR 跑;语义与稳定性测试每晚定时跑全量,发布前跑冒烟子集。---下一篇预告:《RAG 上线后为什么总"答非所问"?——黄金数据集与检索质量评测》 ","createTime":1786515718,"ext":{"closeTextLink":0,"comment_ban":0,"description":"","focusRead":0},"fa vNum":0,"html":"","isOriginal":0,"likeNum":0,
-
- 元宵节猜灯谜的祝福短信
- 角色扮演 |
-
- 关于柯南的沙雕网名有哪些
- 角色扮演 | 1
- 网名
-
- 最新中性名字男女通用网名有哪些
- 角色扮演 | 1
- 网名
-
- 关于蓝色说唱的网名有哪些
- 角色扮演 | 1
- 网名
-
- 我好喜欢你是什么梗?
- 角色扮演 |