模块 02 · 第 5 课

提示词也要测试

把提示词放进文件、准备 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 次全判"缺陷",未必是模型错了。

遇到这种用例,有三个选择:改标注;把留言改写得更明确;或者承认它就是模糊的,从测试集里删掉,或者允许两个答案都算对。不要为了让模型在一条有争议的用例上"答对"而不停地调提示词,那只是在拟合你自己的一个随意决定。

迭代的节奏

一个可行的节奏:

  1. 跑一遍测试,记下总分和每条用例的结果。
  2. 挑一类错误(不是一条),想清楚原因。先检查标注。
  3. 改提示词,只针对这一类错误。
  4. 再跑一遍,和上一次逐条对比:修好了几条,改坏了几条。
  5. 改坏的比修好的多,就退回去。

每次只改一处,是为了知道每个改动的效果。一次改五处,分数变了你也不知道是哪一处起的作用。

这个工具的边界

这是一个轻量的版本,适合分类、提取这种有标准答案的任务。它有几个明显的不足:

  • 30 条用例还是太少。90% 和 79% 的差距比较可信,但 90% 和 88% 就可能只是随机波动了。
  • 只能判断完全匹配。回答是一段文字(比如客服回复、摘要)时,没法用 == 判断对错。
  • 没有记录花费和耗时

06 模块会把它扩展成一个完整的评估系统:更大的评估集、用模型来给开放式回答打分、记录每次调用的日志和成本。

练习

  1. 运行 prompt_test.py,看看你的结果和我的有什么不同。多跑两次,总分每次一样吗?
  2. 针对"requests 返回 200,httpx 返回 403"这条有争议的用例,做出你的决定(改标注、改写留言、或者删掉),然后说明理由。
  3. 写一个 classify_v3.txt,尝试修好"httpx 支持 HTTP/3 吗"的不稳定问题,同时不让其他用例变差。用 results.jsonl 逐条比较 v2 和 v3。
  4. prompt_test.py 加一个功能:打印每个版本的总花费(用第 01 模块第 4 课的 cost_usd)。v2 的提示词长了很多,贵了多少?

自测

1. 为什么每条测试用例要跑 3 次,而不是 1 次?

模型的输出有随机性,同一个输入每次的结果可能不同。只跑一次,无法区分"稳定地答对"和"碰巧答对"。跑多次,可以发现时对时错的不稳定用例,这些往往是提示词里没说清楚的模糊地带。

2. 新版提示词的总分比旧版高,是不是就可以直接换上?

还要逐条看。总分高了,也可能有原本正常的用例变差了,本课的"requests 返回 200,httpx 返回 403"就是例子。要确认变差的用例是不是重要的场景,是模型的问题还是标注的问题,再决定要不要换。

3. 一条用例在两个版本的提示词里都全错。你应该怎么做?

先检查标注本身对不对,必要时去查文档或源码确认。标注有争议,就修正标注或者删掉这条用例。标注确实正确、错在模型缺少相关知识,改提示词往往修不好,可以接受这个错误,或者用 RAG 之类的方法给模型补充资料。