@leanxbt: https://x.com/leanxbt/status/2070852461494202609

X AI KOLs Timeline 工具

摘要

一篇详细介绍Loop Prompt Engineering的文章,这是一种通过基于数据集评估迭代重写提示来自动化提示优化的方法,重点强调避免递归陷阱。

https://t.co/mrIJXQy3Lc
查看原文
查看缓存全文

缓存时间: 2026/06/28 06:01

循环提示工程:一种优化提示的新方法

有一项任务,工程师们手动花费数小时却几乎总是失败:调优提示。你调整措辞,在几个例子上运行,看看效果变好还是变差,再调整一次。这很慢,主观性强,而且你脑子里只记着五十个例子中的三个。而这正是适合用机器检查的工作:一个能返回数值的评估数据集。这意味着它可以被封装在一个循环中。这个想法说起来很简单:循环本身重写提示,在一组例子上运行它,打分,然后重复直到分数超过阈值。你设定一次目标——“在这个数据集上准确率高于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": "提取金额:'付款4,200美元已完成'", "expected": "4200", "type": "exact"}

规则:尽可能将任务提升到该等级的高处。从“由评判者评分“到“精确匹配“的每一步都移除一个噪声源和一个循环之后会钻的空子。如果你只有主观的“答案很好“,首先弄清楚如何让“好“变得可验证,然后才构建循环。

与任何地方相同的确定性要求:在一个提示上运行两次评估。如果分数跳跃,说明检查不稳定,循环就会追逐噪声。在评估运行时将模型温度固定为零,否则你优化的不是提示,而是采样的运气。

第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"iter {i}: score={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}
它在以下示例上失败了:
{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,别无其他。
"""

注意:即使是对评判模型,我们也输入了expected——已知正确的答案。这变“评估质量“为“对照事实检查“,并堵住了大部分漏洞。纯粹基于品味的无锚评判者是最后的手段,其分数绝不能被单独用作停止条件。

第三种:操纵度量的形状。 如果分数是精确匹配,智能体可以让模型用单个词回答,这会破坏所有需要完整回答的情况;如果分数奖励长度,答案会膨胀。智能体正好优化你测量的东西,包括度量本身的扭曲。解法是使用多个不能同时提高的度量:准确率加上长度惩罚,正确性加上格式。当有两个度量朝不同方向拉动时,廉价的作弊路径就被关闭了。

第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的提示,你未经阅读就部署了。里面是一堆工作良好的拐杖,但

相似文章