模組 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 之類的方法給模型補充資料。

提問與討論

這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。

提問 +3 點,回答別人 +6 點。內容經審核後公開。

正在載入討論…