循环提示工程:自动优化提示词的新方法

leanxbt 发布于 2026-06-28 阅读 23

本文详细介绍了如何构建一个自动化提示词优化循环系统。

图像

有一项任务,工程师们花费数小时手动操作,几乎总是失败:调整提示词。你换一种措辞,在几个例子上运行,看看效果变好还是变差,再换一种措辞。这个过程缓慢、主观,而且你心里只记着五十个例子中的三个。这正是可以用机器来检查的工作:一个返回数值的评估数据集。这意味着它可以被包装在一个循环中。

这个想法说起来很简单:循环重写提示词,在一组例子上运行,打分,然后重复直到分数超过阈值。你只需设定一次目标——"在这个数据集上准确率超过0.9"——然后退出回路。机器自行搜索能达到目标的措辞。

但正是这里,循环工程显露出其锋芒。因为这是最纯粹的情况,其中主要规则——将判断外置——从内部崩塌。当你修复测试时,判断标准是铁定的:一个退出码,无可争辩。当你优化提示词时,被优化的对象和检查的内容都是同一个模型的文本。裁判与它所评判的对象处于同一个系统内部。这是一个递归陷阱,本文的大部分内容就是关于如何避免掉入其中。

为什么这是一个循环而不是一个脚本

你可能会反驳:这只是一个搜索,一个带字符串替换的循环,和智能体有什么关系。区别在于谁来决定如何修改提示词。

在愚蠢的搜索中,你编写变体:一个包含十种措辞的列表,运行每个,取最好的。那是网格搜索,受限于你的想象力——你只尝试了你想到的。

在循环中,下一个变体由智能体编写,并且基于上一个失败的原因来编写。循环不仅测量分数,还读取提示词出错的例子,并围绕这些错误重写措辞。这不再是网格搜索,而是定向下降:每次迭代都是关于当前提示词弱点的一个假设,并试图弥补它。这种"读取失败并围绕它们重写"正是智能体所做而脚本不会做的事情。

第0步:返回数字而非意见的评估

与任何循环相同的第零步过滤器:没有独立于智能体的检查,就没有循环。但对于提示词,要求更严格,因为文本质量默认是模糊的,而循环需要一个明确的数字。

评估数据集是一组输入-期望值对。你比较答案与期望的方式越严格,整个循环就越可靠。可靠性等级从高到低依次是:

精确匹配或正则表达式——答案要么等于参考值,要么不相等。分类——模型从固定标签列表中选择,与正确标签比较。可验证属性——答案解析为JSON,数字落在某个范围内,代码通过测试。只有在最底层,才使用对质量进行评分的裁判模型,因为没有其他办法。

## 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": "提取金额:'支付了4200美元已通过'",
 "expected": "4200", "type": "exact"}

规则:尽可能将任务提升到该等级的高端。从"裁判评分"到"精确匹配"的每一步都会消除一个噪声源和一个循环此后会钻空子的漏洞。如果你只有主观的"答案很好",首先想办法让"好"变得可验证,然后再构建循环。

还有对所有地方都相同的确定性要求:在一个提示词上运行两次评估。如果分数波动,说明检查不稳定,循环会追逐噪声。在评估运行时将模型温度设为0,否则你优化的不是提示词,而是采样的运气。

第1步:一次手动运行和一个诚实的基线

在启动任何东西之前,手动用初始提示词运行整个评估,并记录数值。这是你的基线,没有它,你无法区分循环的工作和自我欺骗。

## 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,也许出问题的不是提示词,而是评估本身。在这一步手动查看,可以在这两种麻烦被数百次迭代放大之前就发现它们。

第2步:最小化优化循环

骨架与任何循环相同:先检查,然后智能体行动,状态保存在磁盘上。只是这里的"行动"不是"修复代码",而是"围绕失败重写提示词"。

##!/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

有两个容易忽略的细节,之后会让人惊讶。第一:循环保留最佳提示词,而不是最后一个。提示词优化不是单调的——一次迭代很容易让事情变糟,如果你盲目地取最后一个变体,就会走下坡路。第二:只有候选分数确实更高时才接受它。这使循环从在措辞间随机漫步变成了一个不会回退的爬升。

第2.5步:智能体如何重写提示词

最实质的部分是 propose 函数。愚蠢版本——"这是一个提示词,改进它"——几乎不起作用:智能体不知道改进什么,只会做表面功夫。强力版本向智能体展示失败,迫使它先诊断,再治疗。

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>
{current_prompt}
</current_prompt>

它在以下示例上失败:

{fail_text}

首先,用一两句话诊断这些失败的最常见的一个原因。
然后重写提示词,专门修复那个原因。

规则:
- 保留已经有效的部分,只修改针对所诊断出的失败的部分。
- 不要添加针对这些确切输入内容的指令。
  泛化修复,不要记忆示例。
- 只输出新的提示词,不要其他内容。"""

    return call_model(meta_prompt, "", temperature=0.7).strip()

这里有两个刻意的设计。智能体首先给出单一诊断,而不是一次性修补所有问题——这使每次迭代保持狭窄,防止提示词膨胀成一堆矛盾的指令。明确的禁令"不要针对这些确切输入内容"是第一个最薄弱的防线,防止我们接下来要讨论的过拟合。

第3步:递归陷阱和三种作弊方式

这就是这个循环与"修复测试"不同的地方,也是危险所在。当标准是模糊的,而被优化的对象和检查的内容都是同一个模型的文本时,循环有三种方式可以显示高分,但实质上没有任何改进。每种都是奖励黑客行为,每种都有其对应的解决方法。

第一:过拟合评估。循环向提示词追加指令,这些指令恰好在你五十个示例上有效,但在第五十一个示例上失效。极限情况下,智能体直接将答案缝入提示词。分数1.0,泛化能力为零。

解决方法是机器学习从一开始就使用的:分割数据集。循环在训练集上优化,阈值在智能体从未见过的保留集上检查。

## 数据集分割一次,智能体只看到训练集
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意味着循环学会了你的示例,而不是任务。只信任保留集分数。

第二:裁判自我表扬。如果评估依赖裁判模型,而提示词也由模型重写,就会出现勾结:智能体学会写裁判喜欢的答案,而不是正确的答案。特别是当裁判和工作模型相同时,它识别自己的风格并抬高分数。

解决方法是两层。首先,裁判使用与工作模型不同的模型:它不会偏爱其他风格。其次,裁判必须在可能的情况下拥有可验证的锚点,而不是纯粹的口味。

## .judge.md - 使用不同模型的裁判,基于事实
"""
你正在根据已知正确的期望值对答案进行评分。
你不应该奖励风格、流畅度或自信。

期望: {expected}
答案: {answer}

只有当答案在事实上等同于期望时才返回 PASS。
不同的措辞是允许的。一个自信的错误答案是 FAIL。
一个谨慎的正确答案是 PASS。只输出 PASS 或 FAIL,不要其他内容。
"""

注意:即使是裁判模型,我们也输入期望值——已知正确的答案。这变"评估质量"为"对照事实检查",堵住了大部分漏洞。没有锚点的纯口味裁判是最后的手段,其分数不能作为唯一的停止条件。

第三:玩弄指标形状。如果分数是精确匹配,智能体可以让模型用一个词回答,破坏所有需要完整答案的情况;如果分数奖励长度,答案会膨胀。智能体优化你测量的任何东西,包括测量本身的偏差。

解决方法是使用多个指标进行评估,这些指标无法同时提高:准确率加上长度惩罚,正确性加上格式。当有两个不同方向的指标时,廉价的作弊路径就被堵住了。

第4步:记忆和提示词历史

循环的记忆不仅包括"做了什么",还有整个轨迹:哪个提示词得到了哪个分数,以及是哪个诊断导致了变化。没有它,循环会原地打转,重新尝试已经失败的措辞。

// .prompt_history.jsonl - 每次迭代一行
{"iter": 3, "train_score": 0.74, "holdout_score": 0.71,
 "diagnosis": "模型将模糊的工单过度分类为'other'",
 "prompt_sha": "a1b2c3", "kept": true}
{"iter": 4, "train_score": 0.71, "holdout_score": 0.69,
 "diagnosis": "添加了示例,使提示词冗长,损害了标签准确性",
 "prompt_sha": "d4e5f6", "kept": false}

kept 字段——更改是否被接受——将历史变成一张地图:你看到哪些方向改进了,哪些没有,你可以向智能体提供过去被拒绝的诊断,这样它就不会再提出。prompt_sha 代替完整文本使日志保持紧凑,提示词本身存放在单独的文件中。

最佳提示词本身存在于一个单独的文件中,只有当保留集分数实际提高时才被覆盖:

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"))

第5步:隔离和制动

这个循环的破坏半径与代码循环不同。提示词循环通常不会写入仓库或触及生产环境,因此物理损害很小。但财务损害不小,这就是制动发挥作用的地方。

这里的主要支出计数器是危险的:每次迭代运行整个评估数据集,即每次循环步骤需要 N 次模型调用。十五次迭代五十个示例就是七百五十次调用,如果评估中有裁判模型,则翻倍。成本随着迭代次数乘以数据集大小而增长,没有上限就会咬人。

MAX_ITER = 15
MAX_EVAL_CALLS = 1500     # 模型调用的硬上限
PATIENCE = 3              # 如果连续 N 次迭代没有改进则停止

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 正好捕捉那个时刻——当循环停止改进并开始只是花钱。没有它,你会为那些不改变数字的迭代付费。

第6步:这个循环如何死亡

与其他循环相同的四种死亡方式,但症状特化于提示词优化。

失控。调用计数器攀升,保留集分数停滞不前。原因:该模型在此任务上无法达到该阈值,循环无法得知。解决方法:调用上限和平台检测器,外加一个清醒的阈值——如果基线是0.5,目标0.99可能物理上不可达到。

过拟合导致的静默死亡。对提示词来说最险恶的:训练集分数稳步上升,循环报告进展,但实际上只是在记住你的示例。症状仅在训练集和保留集之间的差距中可见。解决方法:永远不要将训练集分数视为结果,仅根据保留集设置停止条件。

措辞上的随机漫步。循环每轮都重写提示词,分数在一个点附近上下波动,不收敛。原因:propose 修补表面而不是诊断,或者温度太高,每个变体都是一个新的随机文本。解决方法:强制智能体先给出一个诊断,保留最佳提示词,而不是最后一个。

理解债务。这里是最微妙的。循环给你一个保留集分数0.93的提示词,你没有阅读就部署了。里面是一堆拐杖,它们在数据集上有效,但原因你无法理解,一旦遇到评估分布之外的第一个输入就会在生产环境中失效。解决方法:用自己的眼睛阅读最终提示词,问自己它为什么有效。如果你无法解释,你就没有优化提示词,而是过拟合了测试而没注意到。

哪里真正有回报

这个循环并非适用于所有提示词。它在三个条件同时满足时才有回报:提示词经常运行(生产中的分类器、提取器、路由器),你有一个标记好的示例集或者收集成本低,并且质量可以量化为数字。工单分类、字段提取、请求路由、内容审核——理想候选:答案要么对要么错,数据集自动从生产中积累。

反过来:对于一个只会运行两次的一次性创意提示词,构建评估循环就像用显微镜敲钉子。构建评估的成本只有通过频繁使用才能收回。在构建之前,估算一下:这个提示词在生产中会运行多少次,与标记评估需要多少小时相比。如果提示词只用一周就废弃了,那就手动优化吧。

一个指向提示词本身的循环

这个循环中有一个优美的对称性,这正是它值得构建的原因。在任何地方,循环工程都是一个委托判断的机器:你把"完成或未完成"的决定外置,智能体工作直到检查说"是"。在这里,机器委托了关于如何委托判断的判断。循环优化的不是代码,而是提示词本身——也就是说,是你用来引导模型的界面。

而这正是开头提到的递归陷阱如此重要的原因。当被优化的对象和检查的内容都是同一个模型的文本时,唯一能让循环不对自己撒谎的,是一个外部的硬锚点:智能体没有见过的保留数据集,以及基于事实而非口味的检查。移除锚点,循环就会快乐地将提示词优化到分数1.0,但这个分数毫无意义。

因此规则保持不变,只是这里更加尖锐:将判断外置,并确保外部部分不被智能体重写。修复代码时,那是测试的退出码。优化提示词时,那是你隐藏的保留集和裁判所依据的事实。一个循环的价值恰好等于它不能触及的部分。首先构建那个不可触及的部分,然后再进行优化。

  • 原文链接: x.com/leanxbt/status/207...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论