プロンプトもテストする
プロンプトをファイルに入れ、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 課の zero-shot のプロンプトです。
把用户留言分成以下四类之一:缺陷、功能建议、使用问题、其他。
只输出类别名称。
classify_v2.txt は改良版です。第 2 課で zero-shot が間違えた 2 件をもとに、各カテゴリの定義を書き、混同しやすい境界を特に説明したうえで、第 2 課の 4 つの例を加えています。
把 httpx 项目收到的用户留言分成以下四类之一,只输出类别名称。
- 缺陷:httpx 库本身的行为不符合文档或者预期,比如报错、崩溃、结果不对。
- 功能建议:希望 httpx 增加目前没有的功能。
- 使用问题:问某个功能怎么用、某个行为是不是正常。哪怕看起来像在要新功能,只要 httpx 已经能做到,就算使用问题。拿不准是自己用错了还是库有问题的,也算使用问题。
- 其他:和 httpx 库本身无关的,比如文档网站、社区、招聘、感谢、和别的库比较。
例子:
(和第 2 课相同的 4 个例子)
テストケース
cases.jsonl には 1 行に 1 件のケースがあり、書き込みと正しいカテゴリが入っています。第 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 課で説明したように、同じ入力でも毎回結果が違うことがあります。1 回だけでは「安定して正解」と「たまたま正解」を区別できません。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……这正常吗」(response.elapsed が……これは正常ですか)は、v2 ではすべて正解しました。v2 の定義には「新機能を求めているように見えても、httpx ですでにできることなら使い方の質問とする」「ドキュメントのサイトやコミュニティ……はその他とする」と特に書いてあり、ちょうどこれらに対応しています。
壊れたもの。「同样的代码,requests 返回 200,httpx 返回 403」(同じコードで、requests は 200 を返すのに httpx は 403 を返す)は、v1 では 3 回中 2 回正解でしたが、v2 では 3 回とも不正解になり、すべて「不具合」に分類されました。これこそ総合点だけを見ていると見落とすものです。総合点は上がったのに、もともとおおむね正常だったケースが一つ悪くなりました。
新しい不安定さ。「httpx 支持 HTTP/3 吗?」(httpx は HTTP/3 に対応していますか)は、v2 では 3 回中 2 回が「機能提案」に分類されました。
ずっと不正解のもの。「301 跳转后 POST 变成了 GET」(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を実行し、あなたの結果が私のものとどう違うか見てください。さらに 2 回実行して、総合点は毎回同じでしょうか。- 異論の余地がある「requests は 200 を返すのに httpx は 403 を返す」のケースについて、あなたの判断を下し(ラベルを変える、書き込みを書き直す、削除する)、その理由を説明してください。
classify_v3.txtを書き、「httpx は HTTP/3 に対応していますか」の不安定さを直しつつ、他のケースを悪くしないようにしてみてください。results.jsonlを使って v2 と v3 を一件ずつ比べます。prompt_test.pyに、各版の合計費用を表示する機能を加えてください(モジュール 01 第 4 課のcost_usdを使う)。v2 のプロンプトはずっと長くなりましたが、いくら高くなったでしょうか。
確認テスト
1. 各テストケースを 1 回ではなく 3 回実行するのはなぜですか?
モデルの出力にはランダム性があり、同じ入力でも毎回結果が違うことがあります。1 回だけでは「安定して正解」と「たまたま正解」を区別できません。複数回実行すれば、正解したりしなかったりする不安定なケースを見つけられます。そうしたケースは、たいていプロンプトではっきり書かれていないあいまいな部分です。
2. 新しい版のプロンプトの総合点が古い版より高ければ、すぐに差し替えてよいですか?
一件ずつ見る必要があります。総合点が上がっても、もともと正常だったケースが悪くなっていることがあり、この課の「requests は 200 を返すのに httpx は 403 を返す」がその例です。悪くなったケースが重要な場面かどうか、モデルの問題かラベルの問題かを確認してから、差し替えるかどうかを決めます。
3. あるケースが、二つの版のプロンプトでどちらも全部不正解でした。どうすべきですか?
まず正解ラベルそのものが正しいか確認し、必要ならドキュメントやソースコードを調べて確かめます。ラベルに異論の余地があるなら、ラベルを修正するかそのケースを削除します。ラベルは確かに正しく、モデルに関連知識が欠けているのが原因なら、プロンプトを直してもたいてい直らないので、その誤りを受け入れるか、RAG などの方法でモデルに資料を補います。
質問と議論
このレッスンでつまずいたところは、ここで質問してください。他の人の質問に答えるのも歓迎です。
質問で 3 ポイント、回答で 6 ポイント。審査を通過すると公開されます。
議論を読み込んでいます…