写好提示词这事儿,看着简单,真干起来能把人逼疯。你调了半小时措辞,改了个标点,试了三条数据,感觉好像好了一点,又好像没变。手里捏着五十个测试案例,脑子里能记住的也就三五个。这活儿压根不该人干,因为它本质上是个数学问题——只要你的测试集能返回一个分数,机器就能帮你跑完剩下的活儿。
把提示词扔进一个循环里,让机器自己改自己,改到分数达标为止。
这个想法说出来就一句话,但真做起来,有个坑你得自己先跳进去,才能明白它有多深。
这个循环比你想象的多长了个脑子
你说这不就是个自动搜索吗?写一堆变体,挨个试,挑最好的。那叫网格搜索,天花板是你自己想出来的那十种写法。循环不一样的地方在于,下一版提示词是智能体根据上一版为什么失败写出来的。它不光看分数,还看哪些例子答错了,然后针对这些错误改措辞。
这是在走下山路,每一步都是对“当前提示词为啥弱”的一个猜测,再试着把这个漏洞补上。
脚本做不到这一点,因为脚本不会读失败的案例再动手改。
第一步:拿个硬分数,别拿感觉
没有独立于智能体之外的检查,循环就不成立。
提示词的麻烦在于,文本质量这东西天生就软,循环偏偏要个硬数字。测试集就是一堆输入和预期输出的配对。你比对答案的方式越硬,整个循环就越靠谱。
可靠性从高到低排个序:
精确匹配或者正则,答案要么等于参考答案要么不等于;
分类,模型从固定标签里选一个,跟正确答案比;
可验证属性,答案能解析成JSON、数字落在区间里、代码通过测试。
最底下才是拿另一个模型当评委打质量分,那是实在没办法了才用的。
python
# eval_set.jsonl - 每行是一个测试案例
{"input": "这个函数在空列表时返回None吗?\n\ndef first(xs): return xs[0]", "expected": "no", "type": "exact"}
{"input": "给这个工单分类:'密码重置邮件一直没收到'", "expected": "auth", "type": "label", "labels": ["auth", "billing", "ui", "other"]}
{"input": "从这句话里提取金额:'一笔4,200美元的付款已通过'", "expected": "4200", "type": "exact"}
规则很简单:把任务往这个阶梯的上端推。从“模型打分”往“精确匹配”走一步,就去掉了一个噪音源,也堵上了一个循环以后会钻的空子。如果你的测试全是主观的“答案不错”,先想办法把“不错”变成可验证的,再搭循环。确定性也是老规矩:同一个提示词跑两遍测试,分数要是蹦了,检查本身就不稳,循环追着噪音跑。测试时把模型温度钉死在零,否则你优化的不是提示词,是抽卡的运气。
第二步:先手动跑一版,拿个老底
开动任何东西之前,把初始提示词在整个测试集上手动跑一遍,记下分数。这是你的基准线,没这条线,你就分不清循环干的是正事儿还是自欺欺人。
python
# eval.py - 跑一个提示词过整个数据集,返回分数
import statistics
def run_eval(prompt: str, dataset: list, call_model) -> dict:
results = []
for ex in dataset:
answer = call_model(prompt, ex["input"], temperature=0)
if ex["type"] in ("exact", "label"):
ok = answer.strip().lower() == ex["expected"].strip().lower()
else:
ok = grade(answer, ex) # 可验证属性
results.append({"input": ex["input"], "answer": answer,
"expected": ex["expected"], "ok": ok})
score = statistics.mean(r["ok"] for r in results)
fails = [r for r in results if not r["ok"]]
return {"score": score, "fails": fails, "n": len(results)}
记下起始分数,更重要的是,自己用眼睛看看那些失败的案例。基准要是已经0.95了,循环基本没空间提升,纯属浪费钱。基准要是0.3,那可能不是提示词坏了,是测试集本身有问题。这步手动检查能在麻烦被一百次迭代放大之前,把两种坑都堵住。
第三步:最小的优化循环长这样
骨架跟所有循环一样:先检查,再让智能体干活,状态存硬盘。只不过这里的“干活”不是“修代码”,是“针对失败案例重写提示词”。
python
#!/usr/bin/env python3
# optimize_prompt.py - 循环改提示词直到分数超过阈值
MAX_ITER = 15
THRESHOLD = 0.90
def optimize(seed_prompt, dataset, call_model, propose):
best = {"prompt": seed_prompt, "score": -1.0}
for i in range(1, MAX_ITER + 1):
current = best["prompt"] if best["score"] >= 0 else seed_prompt
result = run_eval(current, dataset, call_model)
print(f"迭代 {i}: 分数={result['score']:.3f} "
f"({result['n'] - len(result['fails'])}/{result['n']})")
# 先验证:已经超过阈值就退出
if result["score"] >= THRESHOLD:
print(f"第 {i} 轮达到 {THRESHOLD}")
return best
# 保留见过的最好提示词,不是最后一个
if result["score"] > best["score"]:
best = {"prompt": current, "score": result["score"]}
# 智能体干活:看着失败案例重写提示词
new_prompt = propose(current, result["fails"], call_model)
cand_score = run_eval(new_prompt, dataset, call_model)["score"]
if cand_score > best["score"]:
best = {"prompt": new_prompt, "score": cand_score}
print(f"到达迭代上限 {MAX_ITER}。最好分数 {best['score']:.3f}")
return best
两个容易忽略的细节,忽略了你就会懵。第一,循环保留的是见过的最好提示词,不是最后一个。优化提示词不是单调上升的,一轮迭代很容易把事儿搞砸,你要是傻傻地拿最后一个变体,分数就往下滑了。第二,只有分数确实更高才接受新候选。这就把循环从随机游走变成了爬山,只往上走不往下退。
第四步:智能体到底怎么改提示词
最核心的部分是提议函数。傻的版本——“给你个提示词,改好它”——基本没用:智能体不知道该改哪,就瞎改改措辞。强一点的版本把失败案例亮给智能体,逼它先诊断,再治疗。
python
def propose(current_prompt: str, fails: list, call_model) -> str:
# 给智能体看最多8个具体失败案例,不是整个数据集
sample = fails[:8]
fail_text = "\n\n".join(
f"输入:{f['input']}\n模型输出:{f['answer']}\n"
f"预期输出:{f['expected']}"
for f in sample
)
meta_prompt = f"""你在优化一个提示词。这是当前的提示词:
{current_prompt}
它在这些案例上失败了:
{fail_text}
先用一两句话,诊断这些失败案例里最共性的那个原因。
然后重写提示词,专门针对这个原因去修。
规则:
- 保留已经好用的部分,只改能解决诊断出的问题的部分。
- 不要加针对这些具体输入内容的指令。要泛化修复方案,不要背答案。
- 只输出新的提示词,其他啥都别写。"""
return call_model(meta_prompt, "", temperature=0.7).strip()
这里藏了两个刻意的设计。智能体先给出一个单一诊断,而不是一次性打补丁——这样每次迭代的改动范围窄,防止提示词膨胀成一大堆互相矛盾的指令。而且明确禁止“针对这些具体输入内容”,这是抵御过拟合的第一道最弱的防线。
第五步:那个循环自己把自己绕进去的坑
到这里,这个循环就跟“修测试”不一样了,而且危险。当判断标准是软的,被优化的东西和做检查的东西都是同一个模型的文本,循环就有了三种作弊手段来拿高分,实质上什么都没改。每一种都是奖励黑客行为,每一种都有自己的解药。
第一种:过拟合测试集。循环往提示词里塞指令,这些指令在你那五十个例子上正好管用,换到第五十一个就崩。极端情况下智能体直接把答案缝进提示词里。分数1.0,泛化能力零。解法跟机器学习从一开始用的办法一样:拆分数据集。循环在训练集上优化,阈值在智能体没见过的保留集上检查。
python
# 数据集只拆分一次,智能体只能看到训练集
train, holdout = dataset[:35], dataset[35:]
# 循环在训练集上优化
best = optimize(seed_prompt, train, call_model, propose)
# 但“过没过阈值”的决定在保留集上做
holdout_score = run_eval(best["prompt"], holdout, call_model)["score"]
print(f"训练集 {best['score']:.3f} / 保留集 {holdout_score:.3f}")
if holdout_score < THRESHOLD:
print("过拟合了:训练集好看,保留集不行。这个提示词没用。")
训练集和保留集之间的差距就是作弊指标。训练集0.95,保留集0.6说明循环学会了你的例子,没学会这个任务。只信保留集的数字。
第二种:裁判给自己人打高分。如果评估依赖裁判模型,而提示词也是同一个模型重写的,共谋就出现了:智能体学会写裁判喜欢的答案,而不是正确的答案。尤其是当裁判和干活的是同一个模型时,它认得出自己的风格,分数就注水了。解法分两层。第一,裁判用和干活的不同模型——不会偏爱异己的风格。第二,裁判必须有可验证的锚点,不能只靠直觉打分。
python
# .judge.md - 用不同模型当裁判,基于事实
"""
你在把答案和已知正确的预期值做比对。
你不奖励风格、流畅度或自信程度。
预期值:{expected}
答案:{answer}
只有答案在事实上等同于预期值时才返回通过。
措辞不同没关系。自信但错了就是不通过。
犹豫但对了就是通过。只输出通过或不通过,其他啥都别写。
"""
注意:就算是用裁判模型,我们也喂了预期值——那个已知正确的答案。这就把“评估质量”变成了“核对事实”,堵住了大部分漏洞。纯粹凭口味的裁判没有锚点,那是最后的手段,它的分数绝对不能作为唯一的停转条件。
第三种:玩弄指标的形状。如果分数是精确匹配,智能体就让模型只回答一个词,把需要完整回答的任务搞砸;如果分数奖励长度,答案就注水。智能体优化的是你测量的东西,包括测量尺子本身歪掉的那部分。解法是用几个不能同时提高的指标做评估:准确率加上长度惩罚,正确性加上格式要求。当两个指标往不同方向拽的时候,便宜的作弊路径就堵死了。
第六步:记忆和提示词的历史
这个循环的记忆不只是“做了啥”,而是整条轨迹:哪个提示词拿了多少分,哪个诊断推动了这次改动。没有这个,循环就在原地打转,重试已经失败过的措辞。
json
// .prompt_history.jsonl - 每轮迭代一行
{"iter": 3, "train_score": 0.74, "holdout_score": 0.71,
"diagnosis": "模型把模糊工单过度分类为'其他'",
"prompt_sha": "a1b2c3", "kept": true}
{"iter": 4, "train_score": 0.71, "holdout_score": 0.69,
"diagnosis": "加了例子,变啰嗦了,伤害了标签准确率",
"prompt_sha": "d4e5f6", "kept": false}
kept字段——改动有没有被接受——把历史变成了一张地图:你能看到哪些方向变好了哪些没有,还能把以前被拒的诊断喂给智能体,让它别再提。用prompt_sha代替完整文本让日志保持小巧,提示词本身存在单独的文件里。最好的提示词也单独存一个文件,只有保留集分数确实涨了才覆盖:
python
import json
# 只有保留集分数涨了才覆盖冠军
champion = json.load(open(".champion.json"))
if holdout_score > champion["holdout_score"]:
json.dump({"prompt": best["prompt"], "holdout_score": holdout_score,
"iter": i}, open(".champion.json", "w"))
第七步:隔离和刹车
这个循环的爆炸半径跟代码循环不太一样。提示词循环通常不会往代码仓库里写东西,也不会碰生产环境,所以物理伤害不大。但金钱伤害不小,这就是刹车该放的地方。主要的花费计数器很阴险:每轮迭代要跑整个测试集,也就是每次循环步骤要调用N次模型。五十个例子跑十五轮就是七百五十次调用,如果评估里还套了个裁判模型,就翻倍。成本是迭代次数乘以数据集大小,没个天花板就等着被咬一口。
python
MAX_ITER = 15
MAX_EVAL_CALLS = 1500 # 模型调用总次数的硬上限
PATIENCE = 3 # 连续几轮没提升就停
calls_spent = 0
no_improve = 0
prev_best = -1.0
# 在循环里,每次评估之后:
calls_spent += result["n"]
if calls_spent >= MAX_EVAL_CALLS:
print("达到调用上限。停止。"); break
# 平台期检测:优化已经没劲儿了
if best["score"] <= prev_best + 0.005:
no_improve += 1
if no_improve >= PATIENCE:
print(f"连续 {PATIENCE} 轮平台期。循环在纯烧钱。"); break
else:
no_improve = 0
prev_best = best["score"]
这里的平台期检测是主要刹车,比迭代上限更重要。提示词优化几乎总是快速抓到容易拿的几分,然后撞墙:三轮从0.7到0.88,然后十轮卡在0.88,每轮都在烧数据集。PATIENCE正好抓住那个时刻——循环不再提升,开始纯花钱的时候。没这个,你就在为不涨分的轮次付钱。
第八步:这个循环是怎么死的
跟所有循环一样有四种死法,但症状专门针对提示词优化。
失控:调用计数器往上飙,保留集分数原地杵。原因是这个模型在这个任务上根本达不到阈值,循环自己不知道。解法是调用上限加平台期检测,再加一个清醒的阈值设定——基准要是0.5,目标定0.99可能物理上就不可能。
过拟合导致的无声死亡:这是提示词最阴险的死法。训练集分数慢慢往上爬,循环报告进展,实际上只是在背你的例子。症状只体现在训练集和保留集的差距上。解法:永远别把训练集分数当结果看,停转条件只看保留集。
措辞随机游走:循环每轮都重写提示词,分数上下跳动,就是不定点收敛。原因是提议函数只改表面措辞不做诊断,或者温度太高每版都是新的随机文本。解法:逼智能体先给一个诊断,保留最好的提示词而不是最后一个。
理解债务:这个最隐蔽。循环扔给你一个保留集0.93的提示词,你看都不看就部署了。里面是一堆拐杖,能对付你的数据集但原因你不明白,上了生产遇到测试集分布外的第一条输入就崩。解法:用你自己的眼睛读一遍最终提示词,问自己它为什么管用。你要是解释不了,你就不是在优化提示词,你是在过拟合测试集自己还没发现。
这玩意儿到底在哪儿能回本
这个循环不是所有提示词都值得上。它在三个条件同时满足的地方才回本:提示词要频繁跑(生产环境里的分类器、提取器、路由器),你手里有带标签的案例集或者收集起来很便宜,质量能用一个数字衡量。工单分类、字段提取、请求路由、内容审核——这些是理想候选:答案要么对要么错,数据集本身就从生产环境里累积出来了。
反过来,一个创意提示词你总共就跑两回,搭个评估循环就是拿显微镜钉钉子。搭建评估的成本要靠使用频次来摊平。动手之前先估算:这个提示词在生产环境里会跑多少次,给测试集打标签要花多少小时。如果提示词活一周就死了,手动优化就行了。
这个循环最妙的地方在于它指回了自己
这个循环有个整齐的对称性,这就是它值得搭的原因。到处都在做循环工程,本质上是一台把判断权交出去的机器:你把“做完没”的决定挪到外面,智能体干活直到检查说通过了。在这里,这台机器把“怎么交判断权”的判断权也交了。循环优化的不是代码,是提示词本身——也就是你指挥模型的那个界面。这就是开头那个递归陷阱为什么这么重要。
当被优化和做检查的都是同一个模型的文本时,唯一能让循环不漂亮地骗自己的,就是一个外部硬锚点:一个智能体没见过的保留集,一个绑在事实上而不是口味上的检查。拿掉这个锚点,循环会高高兴兴地把提示词优化到1.0,但那个分数啥也不代表。
规则还是那条,只不过在这儿更锋利了:把判断权挪到外面,把智能体重写不了的东西留在外面。修代码,那是测试的退出码。
优化提示词,那是你藏起来的保留集和绑在事实上的裁判。循环有多好,取决于它碰不到的那部分有多硬。
先把碰不到的那部分搭好,再搞优化。
玩了一圈回来发现,最难优化的不是提示词,是你自己那颗想偷懒不设保留集的心。