本节摘要:用单预测器问答任务走通第一次编译:编译前的基线分数、编译命令、编译后的分数变化,以及用历史检查工具直接观察"编译器到底写了什么"。本节的目标不是分数本身,而是让你亲手建立"程序与产物分离"的手感。
任务选最小的:不带检索的封闭式问答,一个 Predict 预测器,五十条训练数据。程序短到三十行以内,但编译前后的一切机制都在里面。
import dspy class SimpleQA(dspy.Signature): """回答问题,答案简短准确。""" question = dspy.InputField() answer = dspy.OutputField(desc="事实型短答案") class SimpleQAProgram(dspy.Module): def __init__(self): super().__init__() self.prog = dspy.Predict(SimpleQA) def forward(self, question): return self.prog(question=question) lm = dspy.LM("openai/gpt-4o-mini", temperature=0.0, max_tokens=256) dspy.configure(lm=lm)
先测编译前基线。这一步不可省:没有基线,编译收益就是一句空话。
def exact_match(pred, example, trace=None): return example.answer.strip().lower() in pred.answer.strip().lower() devset = load_devset() # 假设已按 4.1 切好:训练 50 条、验证 30 条 baseline = dspy.Evaluate(devset=devset, metric=exact_match, display_table=5) print("编译前:", baseline(SimpleQAProgram())) # 典型输出:Average Metric: 21.0 / 30 (70.0%)
然后是编译本体。第一次编译建议就用 BootstrapFewShot,参数给得保守些:
from dspy.teleprompt import BootstrapFewShot teleprompter = BootstrapFewShot( metric=exact_match, max_bootstrapped_demos=4, # 每个预测器最多自举 4 条示范 max_labeled_demos=2, # 另混 2 条人工标注示范 max_rounds=1, # 自举只跑一轮,首编控制预算 ) compiled = teleprompter.compile(SimpleQAProgram(), trainset=load_trainset()) print("编译后:", baseline(compiled)) # 典型输出:Average Metric: 25.5 / 30 (85.0%)
同一段代码,同一份数据,编译只改变了每个预测器携带的提示词。这就是"程序与产物分离"最直接的体感:源代码没变,变的是编译器塞进程序内部的东西。
dspy.inspect_history(n=1) 会打印最近一次模型调用的完整提示词。编译后的提示词大致长这样(节选意译):
回答问题,给出简短的事实型答案。 --- 遵循以下示例作答: 问题:珠穆朗玛峰位于哪个山脉? 答案:喜马拉雅山脉 问题:《红楼梦》的作者是谁? 答案:曹雪芹 (……共六条示范,其中四条来自程序自跑且通过指标验收的轨迹) --- 问题:苏伊士运河连接哪两个海域? 答案:
三个观察点。第一,示范的"口味":自举示范全部来自程序自己的输出分布,天然贴近程序的行为方式,这比人工精选的"标准范例"更对模型的胃口——这是自举优于手工攒示例的机理。第二,指令的改写痕迹:简单编译里指令基本保留你的 docstring,MIPROv2 之后会看到明显改写,这是搜索空间扩大的直观证据。第三,失败的示范不会出现:没通过指标验收的轨迹被丢弃,所以你在产物里看到的每条示范,都是指标盖过章的。
必须提前打预防针:第一次编译完全可能不涨分甚至掉分。常见原因有三种。其一,基线本来就高:任务简单、模型够强时,零样本已经是高分,示范的边际收益趋近于零,此时编译的价值在稳定性而非均值。其二,训练集与验证集分布错位:训练集偏简单、验证集偏难,自举出的示范帮不上验证集的忙。其三,指标与任务脱节:编译朝指标方向优化得很卖力,但那个方向不是你要的。排查顺序从三到一:先怀疑指标,再怀疑数据,最后才承认"任务太简单"。这个顺序背后的逻辑是 3.5 节讲过的放大器原理——先排除"指挥棒错了",再怀疑"士兵不努力"。
分数之外还要看稳定性:同一配置连跑三次(编译本身带随机性,建议重新编译三次取均值),如果三次分数方差很大,说明自举过程运气成分过重——解法是加大 max_bootstrapped_demos 或换用带候选挑选的 BootstrapFewShotWithRandomSearch,用冗余对冲运气。
编译产物是可以序列化的资产,反复调试时不必每次重新编译:
compiled.save("simpleqa_compiled.json") # 保存:指令与示范落盘 restored = SimpleQAProgram() restored.load("simpleqa_compiled.json") # 复载:恢复到编译后状态
落盘文件里是结构化的指令与示范条目,可以进代码仓库做版本管理——手工时代"那条 47 行提示词谁也不敢动"的窘境,到这里才真正翻篇:产物是文件,文件有版本,版本可回滚。