Day 19 / 共 68 天 · 阶段3 提示词工程

Prompt 版本管理 & A/B 测试

昨天(Day 18)你学了「上下文工程」——怎么把有限的窗口塞得刚刚好。今天更进一步:怎么把你写的 Prompt 当"正经代码"一样管起来,改了能回退、能对比新旧哪个更好。这也为后面所有阶段(RAG、Agent、评测)打底——因为 Prompt 会被你改无数次。

📍 你在 68 天里的位置(阶段3 · 提示词工程 D16-20)
D16 Prompt 基础 D17 进阶 Prompt D18 上下文工程 D19 版本管理 & A/B D20 注入防护
💡 今天的世界观:把 Prompt 当"餐厅菜谱"来管 今天所有比喻都围绕一家餐厅的后厨:① 你写的 Prompt 就是一张菜谱(写死在墙上很难改);② 改菜谱要留存旧版本,万一新版被客人骂了能立刻换回来;③ 想知道"红烧版"和"清蒸版"哪个更受欢迎,就端给同一批客人各尝一次、记录评分——这就是 A/B 测试。记住"菜谱要存档、改版要试吃",今天就通了。
L01

为什么 Prompt 也要"版本管理"

🤔 痛点你把 Prompt 直接写在 Python 代码里,改了几个字感觉更好了。第二天客户说"最近回答变差了",你却想不起来昨天改了哪句、也回不到之前的版本。Prompt 一改,效果可能天差地别,但你两眼一抹黑。
💡 本质一句话Prompt 版本管理 = 给每一版菜谱编号、存档,让"改了什么、什么时候改的、想回退随时回退"变得可追溯。

为什么这事重要到要单独讲一天?因为 Prompt 有三个"要命"的特点:

  • 它对结果影响巨大:同一个模型,Prompt 差一句,成功率可能从 60% 掉到 30%。
  • 它很脆:你以为"顺手优化"的一句话,可能悄悄破坏了另一类问题的表现。
  • 它会被改无数次:一个真实项目里,一条核心 Prompt 迭代几十版很正常。

所以专业做法是:把 Prompt 当代码一样,纳入版本控制。改动有记录、能 diff(看两版差在哪)、能回退。这正是面试时"你怎么迭代 Prompt 的?"这类问题想听到的答案。

👶 一句大白话就像餐厅老板不会把菜谱用记号笔直接涂改——他会把每版菜谱拍照存档,编号 v1、v2、v3。新版翻车了,立刻贴回 v2。
L02

第一步:把 Prompt 从代码里"抽"出来

🤔 痛点Prompt 一长串塞在 Python 字符串里,又臭又长,改一个标点都要动代码、重新发版,非技术的同事也没法帮你看。
💡 本质把菜谱从墙上抠下来,写到一张单独的卡片文件里。代码负责"读卡片",卡片负责"写内容",两者分开。

最简单可行的做法:给每一版 Prompt 存一个文件,文件名带版本号。比如建一个 prompts/ 文件夹:

prompts/
├── summarize_v1.md      # 第 1 版:最初的总结提示词
├── summarize_v2.md      # 第 2 版:加了"分点输出"
└── summarize_v3.md      # 第 3 版:又加了"控制在 100 字内"

代码里不再写死 Prompt,而是读文件

# 读取指定版本的提示词文件,返回里面的文字
def load_prompt(name: str) -> str:
    # open 打开文件;encoding="utf-8" 保证中文不乱码
    with open(f"prompts/{name}.md", encoding="utf-8") as f:
        return f.read()          # read() 读出全部内容为字符串

# 想用哪一版,改这一行就行,代码逻辑一个字不用动
system_prompt = load_prompt("summarize_v2")
print(system_prompt)             # 打印出来确认读对了
好处:① 换版本只改文件名;② 每一版都留着,随时回退;③ 用 git(Day 06 学过)管这个文件夹,天然就有了"谁在什么时候改了哪句"的完整历史。
📝 例子:一次真实的"回退" 周一你把线上 Prompt 从 summarize_v2 切到 summarize_v3,结果用户投诉"总结太短、丢信息"。你不用手忙脚乱回忆改了啥——直接把代码那行改回 load_prompt("summarize_v2"),一分钟恢复。旧菜谱一直在,随时贴回去。
L03

模板 + 变量占位:一张菜谱套无数道菜

🤔 痛点同一个 Prompt,只有中间"要总结的文章"不一样。难道每来一篇文章就复制一份 Prompt?
💡 本质菜谱里写"主料 ___ 克"留个空,真正做菜时再填今天的主料。Prompt 里也留占位符 {article},运行时再把真实内容填进去。

文件里这样写({...} 是占位符,先空着):

# prompts/summarize_v2.md 文件的内容大概长这样:
# 你是一名编辑,请把下面的文章总结成 3 个要点,每点一句话。
# 文章:
# {article}

代码里用字符串的 .format() 把真实文章填进去:

template = load_prompt("summarize_v2")     # 读出带 {article} 的模板
real_article = "今天 A 公司发布了新款手机,售价 3999 元……"

# .format 会把模板里的 {article} 替换成 real_article 的内容
prompt = template.format(article=real_article)
print(prompt)   # 现在 {article} 已经被真实文章填满了

👶 小白:那我把 Prompt 拼成一个大字符串直接用不行吗?为什么非要"模板+占位符"?

👨‍🏫 老师:能跑,但会乱。分开之后,"固定的话术"(版本要管的部分)和"每次变化的数据"(文章内容)彻底分家。你调 Prompt 时只动模板文件,不碰业务数据;而且这也是明天(Day 20)"指令与数据分离"防注入的地基。一举两得。

L04

A/B 测试是什么:让两版菜谱同台竞争

🤔 痛点你把 v2 改成 v3,"感觉"更好了。但"感觉"靠得住吗?也许只是你随手试的那两个例子刚好变好,其它情况反而变差了。
💡 本质A/B 测试 = 让 A 版和 B 版菜谱,端给同一批客人各尝一遍,用同一把尺子记录评分,谁分高用谁。把"我觉得"变成"数据说"。

A/B 测试的三个要素,缺一不可:

  • 同一批题目:拿完全一样的一组输入分别喂给 A、B(不能 A 用简单题、B 用难题)。
  • 同一把尺子:用同一个评判标准给两版打分(下一讲讲怎么打)。
  • 只变一处:A 和 B 最好只差你要验证的那一点,否则分出高低也不知道是哪句话的功劳。
A/B 测试:同一批题目,两版 Prompt 各跑一遍 同一批测试题 (比如 20 道) A 版 Prompt (v2) 得分:14 / 20 B 版 Prompt (v3) 得分:17 / 20 → B 更好,采用 v3
图注:不是靠"感觉",而是让两版在同一批题上比分数——这就是 A/B 测试的骨架。
L05

动手把 A/B 跑起来(小样本对比)

💡 本质不用什么高级平台,一个循环就能跑 A/B:准备几道题 → 两版各答一遍 → 打分 → 比总分。就是端菜、试吃、记账。

下面这段代码演示"骨架"(模型调用先用假函数占位,你把 ask_llm 换成 Day 11 学的真实调用即可):

# ---- 1. 准备一个小测试集:每道题 = 输入 + 我们期望里应出现的关键词 ----
test_set = [
    {"article": "A 公司发布新手机,售价 3999 元", "must": ["手机", "3999"]},
    {"article": "B 队 3:0 战胜 C 队夺冠",          "must": ["夺冠", "3:0"]},
]

# ---- 2. 读两个版本的模板 ----
prompt_A = load_prompt("summarize_v2")
prompt_B = load_prompt("summarize_v3")

# ---- 3. 一个简单的"打分尺子":期望关键词命中越多分越高 ----
def score(answer: str, must: list) -> int:
    # sum(...) 对列表里每个 True 计 1 分;kw in answer 判断关键词是否出现
    return sum(kw in answer for kw in must)

# ---- 4. 让某一版 Prompt 跑完整个测试集,返回总分 ----
def run(prompt_template) -> int:
    total = 0
    for case in test_set:
        prompt = prompt_template.format(article=case["article"])
        answer = ask_llm(prompt)          # 调模型(Day11 的真实调用换到这里)
        total += score(answer, case["must"])
    return total

print("A 版总分:", run(prompt_A))
print("B 版总分:", run(prompt_B))          # 分高的那版胜出
📝 例子:输出长这样 跑完打印:A 版总分: 3B 版总分: 4。满分是 2 题 × 2 个关键词 = 4 分。说明 B 版在这批题上把关键信息保留得更全 → 你更有底气把线上切到 B。这就是"用数据说话",而不是"我觉得 B 好像好点"。
👶 一句大白话run() 就是"让这版菜谱把今天全部客人的菜都做一遍,记下总评分"。两版都做一遍,账本一比就知道该留谁。
L06

怎么判断"更好"?以及记好实验账

🤔 痛点"更好"太模糊了。总结任务的"好"和写代码任务的"好"根本不是一回事,用关键词命中够吗?

没有万能尺子,但常用的"尺子"就这么几把,按任务挑:

尺子怎么量适合的任务
格式合规率输出能不能被程序解析(如是否是合法 JSON)结构化输出、function 参数
关键词/要点命中该出现的信息出现了几个(本课 L05 用的)总结、抽取
人工抽查打分人按 1-5 分打,或"A 好/B 好/平"写作、对话质量
模型当裁判让另一个模型按标准打分(阶段9 D50 细讲)大批量、难量化的主观任务

更关键的一步:把每次实验记成账。别跑完就忘,攒成一张表,你会越改越有方向:

import csv, datetime
# 把一次 A/B 结果追加进 experiments.csv("a" = append 追加不覆盖)
def log_result(version, total, note):
    with open("experiments.csv", "a", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        # 记:时间、版本号、得分、这版改了啥
        w.writerow([datetime.date.today(), version, total, note])

log_result("summarize_v3", 4, "加了'控制100字内'")   # 一行账

👶 小白:才 2 道测试题,B 赢了就能上线吗?会不会是运气?

👨‍🏫 老师:好问题!2 道太少,容易被偶然带偏——就像只请 2 位客人试吃说明不了什么。原则是题目越多、越接近真实场景,结论越可信。入门先攒 10-20 道覆盖常见情况的题;分数差得很小(比如 14 vs 15)时别急着换,可能只是噪声。这套"攒测试集"的思路,正是阶段9「评测」要放大的核心,今天先埋下种子。

L07

今日小结 + 动手 10 分钟

🧠 今天你应该能回答

  • 为什么 Prompt 要版本管理?(影响大、很脆、改得频繁,得能追溯和回退)
  • 怎么把 Prompt 抽成文件、用 git 管起来?读文件而不是写死在代码里。
  • 模板 + 占位符 {变量} 解决什么?固定话术与变化数据分家。
  • A/B 测试三要素是什么?同一批题、同一把尺子、只变一处。
  • "更好"能用哪些尺子量?为什么测试题不能太少?

✋ 动手(约 10 分钟)

把你 Day 16-17 写过的任意一个 Prompt,做成"可对比两版"的迷你实验:

# 1. 建目录,存两版提示词(v1 原版,v2 你改一句话)
mkdir prompts
echo "把下面文章总结成3点:{article}" > prompts/sum_v1.md
echo "把下面文章总结成3个要点,每点不超过15字:{article}" > prompts/sum_v2.md

# 2. 用 git 把它管起来(Day06 学过),以后每次改都 commit 一下
git init && git add prompts && git commit -m "prompt v1 v2"

然后照 L05 的代码,准备 3-5 道你熟悉的测试题,两版各跑一遍、打分、比总分,把结果 log_result 记进 csv。体会一次"用数据决定用哪版"的感觉。

明天预告 · Day 20:提示词注入 & 防护。今天我们把"固定指令"和"变化数据"分开了——明天你会发现,如果这个"数据"是用户或外部网页传来的,它可能藏着一句"忽略以上所有指令"来劫持你的模型。怎么识别和防住这种攻击,就是明天的主题。
← Day 18 上下文工程 Day 20 · 提示词注入与防护 →