别再手搓 prompt——把它当成一个由 metric 编译出来的参数,让搜索去调。
资深用户手调 prompt 的边际收益正在递减:你凭直觉改一版、跑几个例子、觉得「好像好点了」就上线——这既不可复现,也无法证明真的变好了。自动 Prompt 优化把这件事变成一个可度量、可搜索的优化问题:定义 metric,让程序去搜索 instruction 措辞和 few-shot 组合。这一期不讲 prompt engineering 是什么、CoT 为什么有效——那是 ai-ml-daily Day 3。这里只讲四个工程决策:什么时候值得自动优化、什么时候纯属浪费;DSPy 怎么把 prompt 当程序编译;APE / OPRO 这类指令搜索的真实收益与坑;few-shot 自动选样和 overfit 治理。核心反直觉:没有一个可信的 eval,自动优化只会让你更快、更自信地过拟合到错的目标上。
自动优化的本质是用 eval 分数做梯度的黑盒搜索:它只会朝你定义的 metric 爬坡。所以 metric 有多糙,优化就有多歪。触发自动优化要三条同时满足:(1) 有客观或半客观的 metric(准确率、F1、格式合规、LLM-judge 且已校准);(2) 有几十到几百条带 ground truth 的样本,且能切出独立的 train / val / test;(3) 这个 prompt 会反复高频调用,值得一次性投入优化成本。反过来,一次性任务、没法定义分数的开放创作、样本个位数——手调更快更省。还有个常被忽略的判据:任务是否稳定。需求每周变、schema 天天改的场景,优化产物很快过期,维护成本盖过收益。
DSPy(Khattab 等 2023,arXiv 2310.03714)的核心转变:你声明输入/输出的 signature 和模块结构,把「具体 prompt 长什么样」交给 optimizer 去搜。一个 Signature 定义字段(如 text -> label),一个 Module(如 ChainOfThought)定义调用方式,optimizer 则在 trainset 上联合优化两样东西:每个模块的 instruction 措辞,以及自动 bootstrap 出来的 few-shot 示例。MIPROv2(Opsahl-Ong 等 2024,arXiv 2406.11695)用贝叶斯优化搜索 instruction+demo 组合;它对多阶段 LM 程序做无模块级标签的信用分配——你只给最终 metric,它自己分摊到各模块。价值在于:换模型、改需求时,你重新 compile 一次,而不是重写所有 prompt。
import dspy
dspy.configure(lm=dspy.LM("anthropic/claude-haiku-4-5"))
class Classify(dspy.Signature):
"""判断工单的紧急程度。"""
ticket: str = dspy.InputField()
urgency: str = dspy.OutputField(desc="low|medium|high")
program = dspy.ChainOfThought(Classify)
# metric:只需返回一个可比较的分数
def metric(gold, pred, trace=None):
return gold.urgency == pred.urgency
from dspy.teleprompt import MIPROv2
opt = MIPROv2(metric=metric, auto="medium") # 搜索强度档位
compiled = opt.compile(program, trainset=train, valset=val)
compiled.save("classify.v3.json") # 产物入 registry(Day 35)
关键心智:你调的不是「prompt 文本」,是 metric、trainset、optimizer 档位三个旋钮。优化出来的 instruction 常常读起来平平无奇甚至有点怪,但在 val 上分更高——这正是把审美判断让给数据的意义。
两条经典路线。APE(Zhou 等 2022,arXiv 2211.01910)把「指令」当程序:让 LLM 生成一批候选 instruction,用目标模型的 eval 分数打分选最优——在 24 个 NLP 任务上追平甚至超过人手写。OPRO(Yang 等 2023,arXiv 2309.03409)更进一步:把「历史 (prompt, score) 对」按分数排序喂回给 LLM,让它基于轨迹提议更好的下一版,形成迭代爬坡。这类方法搜出来的 prompt 常出人意料——OPRO 在 GSM8K 上搜出的名句是「Take a deep breath and work on this problem step by step」,比人写的 CoF 引导更高分。这说明两件事:优化目标是分数不是可读性;且这种胜出强烈依赖被优化的那个模型,换模型往往要重搜。
不引第三方库也能手搓一个最小 OPRO 循环:
def opro_step(history, n=8):
# history: [(instr, score), ...] 已按 score 升序
shown = "\n".join(f"instr: {i}\nscore: {s:.2f}" for i,s in history[-10:])
meta = f"""下面是指令及其得分(越高越好)。
{shown}
写出 {n} 条新指令,目标是拿到更高分。只输出指令。"""
cands = propose(meta) # LLM 生成候选
return [(c, eval_on_valset(c)) for c in cands] # 逐条打分
# 反复迭代,把新 (instr,score) 并回 history,收敛后在 test 上定版
工程守则:候选一定要在 held-out val 上打分,最终版必须在从未参与搜索的 test 上验证——否则你搜到的是「刷分咒语」,不是真提升。想省事,Anthropic Console 的 prompt improver 是零代码入口,适合先拿到一个强基线再考虑上搜索。
一个被低估的事实:在很多任务上,自动 bootstrap 出的 few-shot 示例,收益大于 instruction 措辞的微调。DSPy 的 BootstrapFewShot 就是让 teacher 模型在 trainset 上跑、用 metric 筛出「答对的完整轨迹」当示例,再塞进 prompt。这比人手挑例子更系统。但示例是把双刃剑:选进来的样本会把它们的分布、格式、甚至偏见一起带进 prompt,在分布外输入上可能反噬。所以自动优化必须借用机器学习的纪律:train / val / test 三分,搜索只碰 train+val,报告只信 test;并把优化产物纳入 Day 35 的 prompt registry——版本化、可回滚、记录「用哪个模型、哪份数据、哪个 metric 编译的」。换模型或数据漂移时,靠 registry 触发重编译,而不是让线上悄悄退化。
# 数据切分是自动优化的安全带,不是可选项
train, val, test = split(data, 0.6, 0.2, 0.2)
from dspy.teleprompt import BootstrapFewShot
opt = BootstrapFewShot(metric=metric,
max_bootstrapped_demos=4, # teacher 生成的示例数
max_labeled_demos=4) # 直接取自 train 的示例数
compiled = opt.compile(program, trainset=train)
# 只信 test 上的分数——它从未参与搜索
print(evaluate(compiled, test))
# 记录 provenance:model + data 版本 + metric + 分数 → registry
治理清单:val 和 test 分数的差距就是过拟合温度计——差距大说明搜过头了,回退搜索强度或加数据。上线后监控线上分数 vs test 分数,出现 prompt 漂移(同一 prompt 因模型更新而效果变化)时,registry 里那份 provenance 让你能一键复现并重编译。
把四点串成一个能落地的周末改造:挑一个每天调用上千次的 prompt,走完「判断→编译→搜索→治理」全流程。
ChainOfThought,用 MIPROv2(auto="medium") 在 train+val 上编译,打印并存档最终 prompt。做完这套,你的 prompt 从「凭手感改」升级为「有 provenance、可复现、可回滚的编译产物」——换模型时重跑一次,而不是通宵重写。
compile 即可。类比编译器——你写高级语言,让编译器针对目标架构生成汇编,而不是手写汇编。省的不是「不出现 prompt」,是「不用手动为每个模型/需求组合去调 prompt」。