用输入+契约+快照三部分构建 Prompt 测试,输出形状校验、内容约束、行为三大属性,适合 CI 集成。
上个月我对一条 system prompt 做了个小改动,结果悄悄导致了一个测试用例失效。模型听起来还是正常的——只是不再对边界情况返回合法的 JSON,直到下游解析器报错我才注意到。如果你交付的任何产品依赖 LLM 输出,都会有这个问题:prompt 是代码,但几乎没人像测代码那样对它们做回归测试。
本文介绍一个轻量的、可复现的 prompt 回归测试框架,完全可以跑在免费额度内——用免费托管的模型作为被测对象,用免费的服务器选项来定时运行测试脚本,不用依赖笔记本一直开着。有趣的其实不是某个具体的提供商,而是这套工作流。以下所有内容都只需要普通的 Python 和一个 HTTP API。
核心思路:把 prompt 当成被测函数来对待
一个 prompt 测试由三部分组成:
输入(Input)——你发送的用户消息或上下文。
契约(Contract)——对输出的可机器校验的断言(合法的 JSON、包含必填 key、匹配某个正则、长度限制、拒绝行为)。
快照(Snapshot)——完整的原始响应,存储下来以便后续对不同模型或 prompt 版本做行为 diff。
注意这里没有的东西:"答案感觉好不好"。感觉检查过不了 CI。先从能机械校验的契约开始,只有在机械检查真的无法覆盖的场景才加人工审查。
以下是一个可运行的骨架代码(经过验证的模式,可将端点适配到你的提供商):
# prompt_eval.py — minimal prompt regression harness
import json, re, sys, time
import urllib.request
import yaml # pip install pyyaml
API_URL = "https://your-provider.example/v1/chat/completions"
API_KEY = "sk-..." # read from env in real usage
MODEL = "your-model-name"
def call_model(system, user):
body = json.dumps({
"model": MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0,
}).encode()
req = urllib.request.Request(
API_URL, data=body,
headers={"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
data = json.loads(r.read())
return data["choices"][0]["message"]["content"]
def check(case, output):
failures = []
for rule in case.get("assert", []):
if rule["type"] == "valid_json":
try:
parsed = json.loads(output)
except json.JSONDecodeError:
failures.append("output is not valid JSON")
continue
for key in rule.get("required_keys", []):
if key not in parsed:
failures.append(f"missing key: {key}")
elif rule["type"] == "regex":
if not re.search(rule["pattern"], output):
failures.append(f"regex not matched: {rule['pattern']}")
elif rule["type"] == "max_length":
if len(output) > rule["chars"]:
failures.append(f"too long: {len(output)} chars")
elif rule["type"] == "must_refuse":
# crude but effective: refusal should NOT contain the forbidden payload
if rule["forbidden_substring"].lower() in output.lower():
failures.append("expected refusal, got payload")
return failures
def main(suite_path):
suite = yaml.safe_load(open(suite_path))
results = []
for case in suite["cases"]:
out = call_model(suite["system_prompt"], case["input"])
fails = check(case, out)
results.append({"id": case["id"], "ok": not fails,
"failures": fails, "snapshot": out})
time.sleep(1) # be polite to free tiers
passed = sum(1 for r in results if r["ok"])
print(f"{passed}/{len(results)} passed")
with open("last_run.json", "w") as f:
json.dump(results, f, indent=2)
sys.exit(0 if passed == len(results) else 1)
if __name__ == "__main__":
main(sys.argv[1])
下面这个测试套件捕获了那个 JSON 回归问题:
# suite.yaml
system_prompt: |
You extract invoice data. Respond with JSON only:
{"vendor": string, "total": number, "currency": string}
cases:
- id: happy_path
input: "Invoice from Acme Corp, total $1,250.00 USD"
assert:
- type: valid_json
required_keys: [vendor, total, currency]
- id: no_currency_stated
input: "Invoice from Beta Ltd, amount due 300"
assert:
- type: valid_json
required_keys: [vendor, total, currency]
- id: garbage_input
input: "asdf qwer zxcv"
assert:
- type: valid_json
- type: max_length
chars: 400
no_currency_stated 这个用例才是关键。一个让模型变得更"健谈"的 prompt 修改("我找不到货币,所以……")通过人工肉眼的检验,但在这个测试套件里两秒就失败了。退出码 1 意味着你的 CI 变红。这就是整套方法的核心 trick。
免费额度的适用场景(和不适用场景)
在笔记本上跑没问题,但真正产生价值是在无人值守运行的时候——每天夜间运行,或者在仓库里每次 prompt 文件变更时触发。这是两个你不需要付费就能获得的东西的临界点:一个可以调用而不需要计费账户的模型端点,以及一个运行测试脚本的地方。
声明:本文是 MonkeyCode 产品推广的一部分。在我的 setup 中,MonkeyCode 的免费模型访问用作被测端点,其免费服务器选项托管测试脚本并按 cron 定时执行。我刻意把提供商当作可替换的配置(API_URL、MODEL),而不是在框架里写入任何提供商特定的东西——你也应该这样做,因为免费额度会变,但你的测试不应该关心这些。
一个务实的决策表,帮你决定什么该在哪里跑:
| 场景 | 推荐方案 |
|---|---|
| 快速原型验证 | 本地笔记本 |
| 定时无人值守运行 | 免费服务器 + cron |
| 大量测试用例 | 考虑付费 tier |
契约测试捕获的是结构性回归,而非质量回归。模型可能返回合法的 JSON 但提取质量更差。对于最高风险的场景,搭配一小套黄金答案对比(精确匹配或 embedding 相似度)来使用。
Temperature 0 不等于确定性。提供商会在你不知情的情况下切换底层模型。这也正是为什么要保留快照——当一次运行变红但 prompt 没有改动时,last_run.json 的 diff 能告诉你是不是提供商悄悄上线了新版本。
免费额度有 rate limit,也可能消失。上述框架在调用之间 sleep 以及把提供商放在配置后面,正是出于这个原因。如果你的测试套件增长到免费额度承受不住的程度,这是个好问题——意味着测试已经产生了足够的价值。
如果你的"测试"实际上是对主观写作质量的判断,或者你只有不到约 5 个有意义的用例,那就别用这个框架——框架的开销会超过收益。手动跑一下 prompt 肉眼看一看就够了。
Prompt 是我们交付的代码中测试覆盖率最低的一种。一个 YAML 文件、约 60 行 Python 代码,加上机械化的断言,就能获得 fancy eval 框架 80% 的价值,而且整个方案完全可以在免费模型访问 + 免费定时运行器的额度内放下。如果你想在不绑定计费账户的情况下尝试这种工作流形态,MonkeyCode 的免费模型和免费服务器选项是其中一种搭建方式——但上面的框架可以对接任何 OpenAI 兼容端点,这才是值得保留的部分。
你交付过的最尴尬的 prompt 回归是什么?很好奇大家发现有哪些断言值得编码进来。