提示词也要测试
把提示词放进文件、准备 30 条测试用例、每条跑 3 次,用数据比较两个版本的提示词。还会看到测试结果本身也要检查:有时错的不是模型,是标注。
- 约 40 分钟
- 难度:入门
- 实测:2026-09-14 deepseek-flash
改提示词最常见的方式是这样的:发现一个回答不好,改一句提示词,再试一次那个问题,好了,收工。
问题在于,你只检查了那一个问题。改动可能修好了它,却把另外三个原本正常的问题改坏了,而你要等到用户投诉才知道。这和改代码不跑测试是一回事。
这一课搭一个很小的测试工具:提示词放在文件里,测试用例放在文件里,一条命令跑完所有用例,告诉你通过率、哪些错了、哪些时对时错。
把提示词从代码里拿出来
第一步,把提示词存成单独的文本文件,而不是写死在 Python 代码里:
code/02-prompting/
prompts/
classify_v1.txt 第一版提示词
classify_v2.txt 第二版提示词
cases.jsonl 测试用例
prompt_test.py 测试脚本
这样做的好处:两个版本可以并排比较;可以用 git 看每次改了什么;不会写代码的同事也能改提示词。
classify_v1.txt 就是第 2 课的零样本提示:
把用户留言分成以下四类之一:缺陷、功能建议、使用问题、其他。
只输出类别名称。
classify_v2.txt 是改进版。根据第 2 课零样本错的那两条,给每个类别写了定义,特别说明了容易混淆的边界,再加上第 2 课的 4 个例子:
把 httpx 项目收到的用户留言分成以下四类之一,只输出类别名称。
- 缺陷:httpx 库本身的行为不符合文档或者预期,比如报错、崩溃、结果不对。
- 功能建议:希望 httpx 增加目前没有的功能。
- 使用问题:问某个功能怎么用、某个行为是不是正常。哪怕看起来像在要新功能,只要 httpx 已经能做到,就算使用问题。拿不准是自己用错了还是库有问题的,也算使用问题。
- 其他:和 httpx 库本身无关的,比如文档网站、社区、招聘、感谢、和别的库比较。
例子:
(和第 2 课相同的 4 个例子)
测试用例
cases.jsonl 每行一条用例,包括留言和正确类别。第 2 课的 20 条之外,我又加了 10 条更难分的,都是分类时我自己也要想一想的:
{"text": "httpx 支持 HTTP/3 吗?", "label": "使用问题"}
{"text": "文档里 Limits 那一节的示例代码跑不通,max_keepalive 这个参数名好像不对", "label": "其他"}
{"text": "response.elapsed 在流式请求里读出来一直是 0,这正常吗", "label": "使用问题"}
{"text": "同样的代码,requests 返回 200,httpx 返回 403", "label": "使用问题"}
{"text": "follow_redirects=True 时,301 跳转后 POST 变成了 GET", "label": "使用问题"}
……
好的测试用例有几个来源:真实用户的输入(最重要);你修过的每一个错误,修完之后把它加进来,防止以后又坏;你能想到的边界情况。
测试脚本
import json
import os
import sys
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from openai import OpenAI
client = OpenAI(
api_key=os.environ["LLM_API_KEY"],
base_url=os.environ.get("LLM_BASE_URL", "https://api.deepseek.com"),
)
MODEL = os.environ.get("LLM_MODEL", "deepseek-flash")
RUNS = 3
HERE = Path(__file__).parent
cases = [json.loads(line) for line in (HERE / "prompts/cases.jsonl").read_text().splitlines() if line.strip()]
def classify(system, text):
response = client.chat.completions.create(
model=MODEL,
messages=[{"role": "system", "content": system}, {"role": "user", "content": f"留言:{text}\n类别:"}],
extra_body={"thinking": {"type": "disabled"}},
)
return response.choices[0].message.content.strip()
records = []
for prompt_path in sys.argv[1:]:
system = (HERE / prompt_path).read_text()
jobs = [case for case in cases for _ in range(RUNS)]
with ThreadPoolExecutor(10) as pool:
outputs = list(pool.map(lambda c: classify(system, c["text"]), jobs))
passed = sum(out == case["label"] for out, case in zip(outputs, jobs))
print(f"{prompt_path}:{passed}/{len(jobs)} 通过({passed / len(jobs):.0%})")
for i, case in enumerate(cases):
answers = outputs[i * RUNS:(i + 1) * RUNS]
right = sum(a == case["label"] for a in answers)
records.append({"prompt": prompt_path, "text": case["text"], "label": case["label"], "outputs": answers})
if right == 0:
print(f" 全错 {case['text']} 标注={case['label']} 模型={answers}")
elif right < RUNS:
print(f" 不稳 {case['text']} 标注={case['label']} 模型={answers}")
with open(HERE / "results.jsonl", "w") as f:
for r in records:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
有两个设计值得解释。
每条用例跑 3 次。这次我没有把温度设成 0,用的是默认温度,和线上真实使用时一样。第 01 模块第 3 课讲过,同一个输入每次的结果可能不同。只跑一次,你没法区分"稳定地对"和"碰巧对了"。跑 3 次,就能把用例分成三种:全对、全错、时对时错。
结果存下来。每次运行的原始输出都写进 results.jsonl。以后改了提示词,可以把新旧结果逐条对比,看到底哪些用例变好了、哪些变坏了。
运行:
python prompt_test.py prompts/classify_v1.txt prompts/classify_v2.txt
结果
prompts/classify_v1.txt:71/90 通过(79%)
全错 怎么给单个请求设置不同的超时时间? 标注=使用问题 模型=['功能建议', '功能建议', '功能建议']
全错 你们的文档网站打不开了 标注=其他 模型=['缺陷', '缺陷', '缺陷']
全错 文档里 Limits 那一节的示例代码跑不通,max_keepalive 这个参数名好像不对 标注=其他 模型=['缺陷', '缺陷', '缺陷']
全错 response.elapsed 在流式请求里读出来一直是 0,这正常吗 标注=使用问题 模型=['缺陷', '缺陷', '缺陷']
不稳 同样的代码,requests 返回 200,httpx 返回 403 标注=使用问题 模型=['使用问题', '使用问题', '其他']
全错 follow_redirects=True 时,301 跳转后 POST 变成了 GET 标注=使用问题 模型=['缺陷', '缺陷', '缺陷']
全错 能不能出一个视频教程 标注=其他 模型=['功能建议', '功能建议', '功能建议']
prompts/classify_v2.txt:81/90 通过(90%)
不稳 httpx 支持 HTTP/3 吗? 标注=使用问题 模型=['功能建议', '功能建议', '使用问题']
不稳 文档里 Limits 那一节的示例代码跑不通,max_keepalive 这个参数名好像不对 标注=其他 模型=['其他', '缺陷', '其他']
全错 同样的代码,requests 返回 200,httpx 返回 403 标注=使用问题 模型=['缺陷', '缺陷', '缺陷']
全错 follow_redirects=True 时,301 跳转后 POST 变成了 GET 标注=使用问题 模型=['缺陷', '缺陷', '缺陷']
总分从 79% 升到 90%。但只看总分会漏掉很多东西,下面逐条看。
读结果:修好了什么,改坏了什么
修好的。v1 全错的"怎么给单个请求设置不同的超时时间"、"你们的文档网站打不开了"、"能不能出一个视频教程"、"response.elapsed……这正常吗",在 v2 里都对了。v2 的定义里专门写了"哪怕看起来像在要新功能,只要 httpx 已经能做到,就算使用问题",以及"文档网站、社区……算其他",正好对应这几条。
改坏的。"同样的代码,requests 返回 200,httpx 返回 403"在 v1 里 3 次对了 2 次,到了 v2 变成 3 次全错,都被分成了"缺陷"。这就是只看总分会漏掉的东西:总分涨了,但有一条原本基本正常的用例变差了。
新的不稳定。"httpx 支持 HTTP/3 吗?"在 v2 里 3 次有 2 次被分成了"功能建议"。
一直错的。"301 跳转后 POST 变成了 GET"两个版本都全错,模型坚持认为这是缺陷。
先怀疑标注,再怀疑模型
面对错误的用例,第一件事不是改提示词,而是检查标注本身对不对。
"301 跳转后 POST 变成了 GET",我标的是"使用问题",理由是这是 httpx 的正常行为。可我得确认这一点。翻 httpx 的源码 httpx/_client.py,_redirect_method 里写着:
# If a POST is responded to with a 301, turn it into a GET.
# This bizarre behaviour is explained in 'requests' issue 1704.
if response.status_code == codes.MOVED_PERMANENTLY and method == "POST":
method = "GET"
这是有意为之的设计,沿用了浏览器和 requests 的做法,所以标注是对的,是模型不知道这个细节。这种错误改提示词很难修好,因为问题出在模型的知识上。可以接受它,或者把这类关于"某个行为是否正常"的问题交给能查文档的系统来判断(那是 04 模块的 RAG)。
"requests 返回 200,httpx 返回 403"就不一样了。我当初标"使用问题",是因为这通常是请求头的差异(比如默认的 User-Agent 不同)导致的,用户调整一下用法就好。但仔细想想,从留言本身根本看不出原因,把它当成"库的行为不符合预期"也说得通。这条用例的标注本身就有争议。模型 3 次全判"缺陷",未必是模型错了。
遇到这种用例,有三个选择:改标注;把留言改写得更明确;或者承认它就是模糊的,从测试集里删掉,或者允许两个答案都算对。不要为了让模型在一条有争议的用例上"答对"而不停地调提示词,那只是在拟合你自己的一个随意决定。
迭代的节奏
一个可行的节奏:
- 跑一遍测试,记下总分和每条用例的结果。
- 挑一类错误(不是一条),想清楚原因。先检查标注。
- 改提示词,只针对这一类错误。
- 再跑一遍,和上一次逐条对比:修好了几条,改坏了几条。
- 改坏的比修好的多,就退回去。
每次只改一处,是为了知道每个改动的效果。一次改五处,分数变了你也不知道是哪一处起的作用。
这个工具的边界
这是一个轻量的版本,适合分类、提取这种有标准答案的任务。它有几个明显的不足:
- 30 条用例还是太少。90% 和 79% 的差距比较可信,但 90% 和 88% 就可能只是随机波动了。
- 只能判断完全匹配。回答是一段文字(比如客服回复、摘要)时,没法用
==判断对错。 - 没有记录花费和耗时。
06 模块会把它扩展成一个完整的评估系统:更大的评估集、用模型来给开放式回答打分、记录每次调用的日志和成本。
练习
- 运行
prompt_test.py,看看你的结果和我的有什么不同。多跑两次,总分每次一样吗? - 针对"requests 返回 200,httpx 返回 403"这条有争议的用例,做出你的决定(改标注、改写留言、或者删掉),然后说明理由。
- 写一个
classify_v3.txt,尝试修好"httpx 支持 HTTP/3 吗"的不稳定问题,同时不让其他用例变差。用results.jsonl逐条比较 v2 和 v3。 - 给
prompt_test.py加一个功能:打印每个版本的总花费(用第 01 模块第 4 课的cost_usd)。v2 的提示词长了很多,贵了多少?
自测
1. 为什么每条测试用例要跑 3 次,而不是 1 次?
模型的输出有随机性,同一个输入每次的结果可能不同。只跑一次,无法区分"稳定地答对"和"碰巧答对"。跑多次,可以发现时对时错的不稳定用例,这些往往是提示词里没说清楚的模糊地带。
2. 新版提示词的总分比旧版高,是不是就可以直接换上?
还要逐条看。总分高了,也可能有原本正常的用例变差了,本课的"requests 返回 200,httpx 返回 403"就是例子。要确认变差的用例是不是重要的场景,是模型的问题还是标注的问题,再决定要不要换。
3. 一条用例在两个版本的提示词里都全错。你应该怎么做?
先检查标注本身对不对,必要时去查文档或源码确认。标注有争议,就修正标注或者删掉这条用例。标注确实正确、错在模型缺少相关知识,改提示词往往修不好,可以接受这个错误,或者用 RAG 之类的方法给模型补充资料。