模型返回字段类型改变、额外字段或markdown包装都会静默破坏下游代码,建议为模型输出写契约测试——固定prompt、解析原始响应、验证schema,在CI中运行。
有一天,模型突然不再返回你预期的结构。你没有改代码,没有改 Prompt,但解析器开始在生产环境抛 KeyError。
你可以再打一个补丁。大部分团队都是这么做的。更好的做法是,为那个没有版本号的 API 写一个契约测试:模型本身。
你已经在为 REST 端点写契约测试。模型同样值得被一视同仁,尤其是当你把它跑在免费层、和其他常规服务共用的时候。
大语言模型返回的是文本。你的应用期望的是 JSON。这两个事实之间存在一份脆弱的契约,包含三条未写明的条款:key 必须匹配、类型必须匹配、payload 必须完整传输。
每条条款都有自己的破裂方式。模型开始把 amount 作为字符串返回。它多返回一个你从未要求过的字段。它用 markdown 围栏包裹 JSON,因为之前的 Prompt 告诉它要健谈。
这些都不会让 API 调用失败。但它们都会让你的下游代码崩溃。
模型输出的契约测试是一个小脚本,做三件事:
向模型发送一个固定 Prompt。
解析原始文本响应。
根据 schema 验证结果。
在 CI 里跑它。定时跑它。把失败当作 API 契约破裂那样对待:大张旗鼓。
你可以针对任何可访问的端点跑这个测试。我用 MonkeyCode 的免费模型访问作为这种测试套件的廉价目标。披露:本文是 MonkeyCode 产品推广的一部分。同样的测试适用于任何 OpenAI 兼容的端点。
从你的应用实际需要的最小 schema 开始。不是理想输出。是契约。
对于一个收据提取器,大概长这样:
{
"type": "object",
"required": ["vendor", "amount", "date", "items"],
"properties": {
"vendor": {"type": "string"},
"amount": {"type": "number"},
"date": {"type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$"},
"items": {"type": "array", "items": {"type": "string"}}
},
"additionalProperties": false
}
注意 additionalProperties: false。这是大多数人会忘记的条款。它能捕获模型开始凭空发明字段的那一天。
下面是可复用的 pytest 脚手架。它把三个关注点分离:调用模型、清理输出、验证结构。
import json
import os
import urllib.request
import jsonschema
import pytest
SCHEMA = {
'type': 'object',
'required': ['vendor', 'amount', 'date', 'items'],
'properties': {
'vendor': {'type': 'string'},
'amount': {'type': 'number'},
'date': {'type': 'string', 'pattern': '^\\d{4}-\\d{2}-\\d{2}$'},
'items': {'type': 'array', 'items': {'type': 'string'}},
},
'additionalProperties': False,
}
def call_model(prompt):
body = json.dumps({
'messages': [{'role': 'user', 'content': prompt}],
'temperature': 0,
}).encode()
req = urllib.request.Request(
os.environ['MODEL_URL'],
data=body,
headers={
'Authorization': f"Bearer {os.environ['MODEL_TOKEN']}",
'Content-Type': 'application/json',
},
)
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read())
return data['choices'][0]['message']['content']
def parse_model_output(raw):
raw = raw.strip()
if raw.startswith('```'):
raw = raw.strip('`')
if raw.startswith('json'):
raw = raw[4:]
return json.loads(raw)
@pytest.mark.parametrize('fixture', ['receipt-1.txt', 'receipt-2.txt', 'receipt-3.txt'])
def test_model_output_matches_contract(fixture):
with open(fixture) as f:
prompt = f.read()
raw = call_model(prompt)
parsed = parse_model_output(raw)
jsonschema.validate(parsed, SCHEMA)
assert isinstance(parsed['amount'], (int, float))
这三个 fixture 是你的基准收据。它们应该是无聊的、现实的例子,而不是边界情况。无聊的 fixture 能捕捉漂移;边界情况测试的是聪明。
注意 parse_model_output 函数。它存在是因为模型会用散文、markdown 围栏或两者兼之来包裹 JSON。
不要让清理函数无限膨胀。如果你发现自己要加第四条规则,停下来,去改 Prompt。清理函数应该处理已知噪声,而不是吸收未知的模型行为。
在 CI 里跑一次的契约测试是有用的。每个小时跑一次的契约测试是另一个工具。
把测试套件放到定时器上。我在 MonkeyCode 的免费服务器选项上跑它,这样我想要一个长期观察者而又不用守着笔记本。模型访问的免费层把成本压到接近零,服务器处理调度。
目标是建立一段响应历史。当模型改变结构时,失败是一个数据点,而不是一个谜。
下面是我在模型输出验证失败时使用的决策表:
| 失败类型 | 可能原因 | 处理方式 |
|---|---|---|
additionalProperties 错误 |
模型新增了一个 key | 把这个 key 加到 schema 或收紧 Prompt;永远不要让解析器静默忽略它 |
amount 是字符串 |
类型漂移 | 先修 Prompt,只作为临时防护才加转换层 |
| 日期格式不匹配 | 格式漂移 | 用示例约束 Prompt,然后重新跑测试套件 |
| 出现 markdown 围栏 | Prompt 泄漏或模型习惯 | 在清理函数中处理,然后记录频率以防止它变成常态 |
Schema 验证结构。它不验证含义或安全性。
模型可以返回 {'amount': 12.5, 'vendor': 'Not A Vendor', 'date': '2026-08-29', 'items': []} 并通过所有断言。你的业务逻辑仍然需要自己的检查。
这个测试也无法检测 fixture 本身的 Prompt 注入尝试。把它当作稳定性网,而不是安全边界。
如果你的模型输出是展示给用户的一句话,跳过 schema。字符串本身已经是一份契约。
如果你只在脚本里调用一次模型,跳过完整测试套件。一次 try 块里的 json.loads 就够了。
但如果你有多个解析器、多个 Prompt 模板、或者一个把模型输出转成数据库行的 cron 任务,写契约测试。