Guide · 使用教程
DSPy Python 教程:提示词优化的数据划分、评分指标与预算控制
使用固定版本、合成客服消息和本地模拟模型,验证数据隔离、严格评分、编译与保存,并说明 GEPA 预算及真实优化边界。
在正式运行提示词优化前,先验证训练样本没有混入验收集、评分器能拒绝错误输出,并且保存后的程序能重新加载。本教程用 DSPy 的真实接口和本地模拟模型完成这些检查,再说明如何为 GEPA 设计数据和预算。
验证使用 Python 3.11 与 dspy==3.4.1,样本均为合成客服消息。DummyLM 是框架里的测试辅助类,答案由脚本预设,不能测量模型能力或优化效果;测试期间阻断网络连接,没有调用付费模型,也没有运行真实 GEPA 搜索。
1. 固定版本与运行范围
在一个新目录创建环境:
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install 'dspy==3.4.1'
下面直接使用 dspy 包,不安装历史名称的替代包。当前官方文档与项目入口见 DSPy和仓库。完整依赖、模型和 adapter 也应随实验固定,升级后重新验收。
2. 建立数据隔离和严格评分
保存为 workflow.py。每个样本保留唯一 id 和来源 source,供划分审计使用;模型输入只含 text,参考标签不会放进待预测输入。
验证器检查来源跨集合、重复 id,以及经 Unicode NFKC、大小写与空格规范化后的精确重复。这个指纹只用于去重,不能发现所有语义近重复,也不是敏感数据匿名化方法。
评分函数只接受合法且完全匹配的标签,返回布尔值。这样可以避免把“合法 JSON”或“非空字符串”误当正确,也避免在接受真值的接口里把低但非零的分数无意视为通过。不同优化器对 metric 返回值有不同要求,应按当前接口核对。
import hashlib
import os
import unicodedata
os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")
import dspy
from dspy.utils import DummyLM
dspy.configure_cache(enable_disk_cache=False, enable_memory_cache=False)
LABELS = {"billing", "technical", "other"}
SPLITS = {
"train": [
{"id": "tr1", "source": "case-a", "text": "合成消息:想修改账单抬头", "label": "billing"},
{"id": "tr2", "source": "case-b", "text": "合成消息:客户端启动失败", "label": "technical"},
],
"validation": [
{"id": "va1", "source": "case-c", "text": "合成消息:咨询发票状态", "label": "billing"},
{"id": "va2", "source": "case-d", "text": "合成消息:附件无法打开", "label": "technical"},
],
"test": [
{"id": "te1", "source": "case-e", "text": "合成消息:核对上期扣款", "label": "billing"},
{"id": "te2", "source": "case-f", "text": "合成消息:询问营业时间", "label": "other"},
],
}
def fingerprint(text):
normalized = " ".join(unicodedata.normalize("NFKC", text).casefold().split())
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
def validate_splits(splits):
if set(splits) != {"train", "validation", "test"}:
raise ValueError("Require train, validation and test")
ids, source_owner, content_owner = set(), {}, {}
for split, rows in splits.items():
if not rows:
raise ValueError("Empty split")
for row in rows:
if any(not isinstance(row.get(k), str) or not row[k].strip()
for k in ("id", "source", "text", "label")):
raise ValueError("Invalid dataset row")
if row["label"] not in LABELS or row["id"] in ids:
raise ValueError("Invalid label or duplicate id")
ids.add(row["id"])
for key, owner in [(row["source"], source_owner), (fingerprint(row["text"]), content_owner)]:
if key in owner and owner[key] != split:
raise ValueError("Cross-split leakage")
owner[key] = split
class Route(dspy.Signature):
"""Return exactly one label: billing, technical, or other."""
text: str = dspy.InputField()
label: str = dspy.OutputField()
def examples(rows):
# Source/id are for split auditing; only text is a model input.
return [dspy.Example(text=r["text"], label=r["label"]).with_inputs("text") for r in rows]
def exact_label(gold, pred, trace=None):
if gold.label not in LABELS:
raise ValueError("Invalid reference label")
actual = getattr(pred, "label", None)
return isinstance(actual, str) and actual in LABELS and actual == gold.label
def audit(program, rows):
correct, errors = 0, 0
for example in examples(rows):
try:
prediction = program(**example.inputs())
correct += int(exact_label(example, prediction))
except Exception:
errors += 1
return {"total": len(rows), "correct": correct, "errors": errors,
"accuracy_all": correct / len(rows) if rows else None,
"measurement_ok": errors == 0}
def compile_mock():
validate_splits(SPLITS)
# Scripted responses exercise interfaces, not actual model learning.
scripted = {r["text"]: {"label": r["label"]} for r in SPLITS["train"]}
with dspy.context(lm=DummyLM(scripted)):
return dspy.BootstrapFewShot(
metric=exact_label, max_bootstrapped_demos=1,
max_labeled_demos=0, max_rounds=1, max_errors=1,
).compile(dspy.Predict(Route), trainset=examples(SPLITS["train"]))
def gepa_metric(gold, pred, trace=None, pred_name=None, pred_trace=None):
passed = exact_label(gold, pred)
return dspy.Prediction(score=float(passed), feedback=(
"Correct allowed label." if passed else "Return the correct single allowed label."
))
def build_gepa(reflection_lm):
# Construction only. Calling compile with a real LM can incur charges.
return dspy.GEPA(
metric=gepa_metric, auto=None, max_metric_calls=40,
reflection_lm=reflection_lm, num_threads=1, track_stats=True,
)
compile_mock() 使用 BootstrapFewShot 收集通过评分的训练轨迹,只允许一个 bootstrapped demo,不自动补充带标签的原始示例。它调用了真实编译接口,但模拟答案已预设,所以不能据此报告“模型提高了多少”。BootstrapFewShot 参考
build_gepa() 仅构造优化器:显式提供反思模型,选择 max_metric_calls=40,没有发起搜索。其五参数 metric 接口与普通三参数评分器不同,这也是本地测试要检查的兼容点。
3. 用正反例验证工作流
保存为 test_workflow.py。测试包含:模型输入不含参考标签、来源泄漏、规范化重复、重复 id、错误或非法标签、错误参考答案、异常分母、原生编译与 JSON 回读,以及 GEPA 预算参数互斥。
数据隔离检查失败时,应修正划分,重新建立基线;不能只删除报错断言。参考标签本身非法时属于数据或测量问题,需要与模型答错分开记录。
import copy
import json
import tempfile
import unittest
from importlib.metadata import version
from pathlib import Path
from unittest.mock import patch
import workflow as w
class WorkflowTests(unittest.TestCase):
def test_valid_splits_and_model_inputs(self):
w.validate_splits(w.SPLITS)
inputs = dict(w.examples(w.SPLITS["train"])[0].inputs())
self.assertEqual(set(inputs), {"text"})
def test_source_leakage_rejected(self):
splits = copy.deepcopy(w.SPLITS)
splits["test"][0]["source"] = splits["train"][0]["source"]
with self.assertRaises(ValueError):
w.validate_splits(splits)
def test_normalized_content_leakage_rejected(self):
splits = copy.deepcopy(w.SPLITS)
splits["train"][0]["text"] = "SYNTHETIC BILLING"
splits["validation"][0]["text"] = "synthetic billing"
with self.assertRaises(ValueError):
w.validate_splits(splits)
def test_duplicate_id_rejected(self):
splits = copy.deepcopy(w.SPLITS)
splits["test"][0]["id"] = splits["train"][0]["id"]
with self.assertRaises(ValueError):
w.validate_splits(splits)
def test_metric_rejects_wrong_or_invalid_output(self):
gold = w.examples(w.SPLITS["train"])[0]
self.assertTrue(w.exact_label(gold, w.dspy.Prediction(label="billing")))
for value in ("technical", "billing because...", "", None):
self.assertFalse(w.exact_label(gold, w.dspy.Prediction(label=value)))
def test_bad_reference_is_measurement_error(self):
gold = w.dspy.Example(text="synthetic", label="not-a-label")
with self.assertRaises(ValueError):
w.exact_label(gold, w.dspy.Prediction(label="other"))
def test_errors_keep_the_full_denominator(self):
def broken(**kwargs):
raise RuntimeError("Synthetic failure")
result = w.audit(broken, w.SPLITS["validation"])
self.assertEqual(result, {"total": 2, "correct": 0, "errors": 2,
"accuracy_all": 0.0, "measurement_ok": False})
def test_native_bootstrap_and_save_load(self):
compiled = w.compile_mock()
self.assertEqual(len(compiled.demos), 1)
train_texts = {r["text"] for r in w.SPLITS["train"]}
self.assertTrue(all(demo.text in train_texts for demo in compiled.demos))
with tempfile.TemporaryDirectory(prefix="dspy-workflow-") as directory:
path = str(Path(directory) / "program.json")
compiled.save(path)
restored = w.dspy.Predict(w.Route)
restored.load(path)
self.assertEqual([dict(x) for x in compiled.demos], [dict(x) for x in restored.demos])
row = w.SPLITS["validation"][0]
with w.dspy.context(lm=w.DummyLM({row["text"]: {"label": "billing"}})):
self.assertEqual(restored(text=row["text"]).label, "billing")
def test_native_gepa_budget_constructor(self):
optimizer = w.build_gepa(w.DummyLM([{"label": "other"}]))
self.assertEqual(optimizer.max_metric_calls, 40)
with self.assertRaises(AssertionError):
w.dspy.GEPA(metric=w.gepa_metric, auto="light", max_metric_calls=40,
reflection_lm=w.DummyLM([{"label": "other"}]))
if __name__ == "__main__":
suite = unittest.defaultTestLoader.loadTestsFromTestCase(WorkflowTests)
with patch("socket.socket.connect", side_effect=AssertionError("Network disabled for fixtures")):
result = unittest.TextTestRunner(verbosity=2).run(suite)
report = {"date": "2026-10-19", "python": "3.11", "dspy": version("dspy"),
"tests_run": result.testsRun, "failures": len(result.failures), "errors": len(result.errors),
"backend": "DSPy DummyLM scripted fixtures", "network_connections_blocked_during_tests": True,
"gepa_compile_executed": False, "paid_model_calls": 0,
"scope": "Split guards, metric contracts, native BootstrapFewShot compile, JSON save/load and GEPA constructor only; no real optimization benchmark"}
Path(__file__).with_name("snippet-checks.json").write_text(json.dumps(report, ensure_ascii=False, indent=2))
raise SystemExit(0 if result.wasSuccessful() else 1)
执行:
export DSPY_CACHEDIR="$PWD/.dspy-demo-cache"
export LITELLM_LOCAL_MODEL_COST_MAP=True
python test_workflow.py
这九组测试在固定版本下通过。网络阻断发生在测试执行期间,包安装不在这个范围;模拟调用没有发送模型请求。测试验证的是数据与接口约束,不证明真实任务可泛化,也没有执行 TextGrad、AdalFlow 或真实 GEPA 的效果比较。
4. 验证集评估和保存示例
可以用下面的小脚本检查报告形状。两个验证样本的模拟输出被刻意设置为一对一错;得到的 0.5 是脚本设定的结果,不是模型测量或优化收益。
import json
import workflow as w
compiled = w.compile_mock()
rows = w.SPLITS['validation']
scripted = {
rows[0]['text']: {'label': 'billing'},
rows[1]['text']: {'label': 'other'},
}
with w.dspy.context(lm=w.DummyLM(scripted)):
report = w.audit(compiled, rows)
assert report == {
'total': 2, 'correct': 1, 'errors': 0,
'accuracy_all': 0.5, 'measurement_ok': True,
}
compiled.save('program.json')
print(json.dumps(report, ensure_ascii=False))
程序保存的是指令、示例等状态,可能包含训练内容,应先检查再共享。JSON 状态加载需要匹配的程序结构和版本;不要加载来历不明的可执行序列化文件。保存与加载
5. 接入真实 GEPA 时怎样划分数据
先替换合成数据并重新运行验证器,再为实际模型和评价设计实验。下面函数表达接入方式,本次没有调用它;如果 program 或 reflection_lm 使用外部供应商,执行 compile 可能产生付费请求和数据传输。
def optimize_with_gepa(program, reflection_lm, splits):
import workflow as w
w.validate_splits(splits)
optimizer = w.build_gepa(reflection_lm)
return optimizer.compile(
program,
trainset=w.examples(splits['train']),
valset=w.examples(splits['validation']),
)
GEPA 用训练样本推动更新,用验证样本追踪并选择候选;不传 valset 时会复用 trainset。最终 test 应在程序与指标冻结后单独运行,不传进上面的搜索函数。按来源分组后还要抽查近重复和覆盖;没有适用于所有任务的固定划分比例。官方数据角色说明
本例每组只有两条合成消息,只用于检查接口,完全不足以评价真实分类器。真实数据还需要类别代表性、来源变化、易混淆问题、多意图样本和独立标注复核。
6. 优化预算怎样控制
GEPA 的 auto、max_full_evals 和 max_metric_calls 只能选一种预算形态。本例选第三种;构造时不要同时传 auto='light'。当前接口的具体行为已在固定版本中核对。GEPA 参考
这些参数限制优化工作量,不能直接换算成完整美元硬上限:
- 一个被评估程序可能运行多个预测器和工具。
- 反思模型会读取运行信息并提出候选,形成独立调用与 token 用量。
- 如果 metric 自己调用 judge,还会增加另一组请求。
- 重试、并发、缓存和长上下文都会改变实际费用。
因此先进行小规模、可中止的实验,分别记录学生、教师或反思、judge 与工具的实际用量。费用可按各模型输入 token、输出 token 及当期单价分别计算,再加外部工具等费用。不要把 40 metric calls 写成“最多 40 次所有 API 请求”。
应用侧还需要供应商配额、并发、输出长度与费用监控;达到停止条件时处理在途请求。仅有预算提醒不一定会阻止继续计费。track_stats=True 可保留优化统计,具体结果和日志内容也需检查。GEPA 运行机制
7. 怎样判断值得发布优化产物
固定基线模型、任务定义、数据和指标版本。保留集上除了平均分,还要报告关键错误、无法评分样本、成本与耗时。遇到执行或评分异常,本例保留 errors 并将 measurement_ok 置为 false,不把分母偷偷缩成成功样本。
不要根据一轮测试分数反复修改提示后,仍把同一测试集称为独立验收;不要把原始标签、完整敏感文本或未经批准的轨迹发给优化及反思模型。配置外部模型时,本地 Python 和关闭磁盘缓存都不等于数据不出本机。
最后保存程序、数据版本、指标版本、模型配置和费用记录;使用可信产物做加载回归,再按业务条件发布。更多工具选择见 5 款提示词自动优化工具,框架差异见 DSPy vs TextGrad与 DSPy vs AdalFlow。
常见问题
- 教程会调用付费模型吗?
- 不会。本地运行使用 DSPy DummyLM 预设输出,测试期间阻断网络;没有运行真实 GEPA 搜索或付费模型请求。
- 九组测试通过是否证明模型优化有效?
- 不能。它们验证数据隔离、评分、原生编译、JSON 回读和预算配置。模拟答案已预设,不代表模型学习或泛化。
- 最终 test 为什么不传入 compile?
- trainset 与 valset 会参与更新或候选选择。最终测试应在程序和指标冻结后单独运行,避免验收信息泄漏。
- metric calls 等于所有模型调用吗?
- 不等于。多模块执行、反思、judge 和工具会产生不同请求与 token,用量预算不能直接当美元硬上限。
- 指纹检查能发现所有近重复吗?
- 不能。它只检查经规范化的精确重复,还要按来源分组并人工或用适当方法检查语义近重复。
- 为什么异常不从分母删除?
- 只统计成功样本会掩盖无法处理或测量失败的情况。本例保留完整样本数、errors,并以 measurement_ok 标明测量是否正常。