NPO单线提示词优化:用最少Token迭代出高稳定提示词

发布时间:2026/9/2 5:41:30
NPO单线提示词优化:用最少Token迭代出高稳定提示词 做大模型提示词优化时最卡预算的往往不是模型推理本身而是要把每一个候选提示词都放到评测集上重新跑一遍。GEPA 这类群体优化方法效果好但候选数量多、轮次多token 消耗成倍上涨。NPO 单线提示优化的思路则相反全程只维护一条候选提示线每一轮基于错误样例定向改写用更少的评测次数逼近同样的效果。这篇文章从实现角度拆解这条优化链路并给出一套可以直接跑起来的最小示例。文章默认读者已经能调用大模型接口了解提示词基础。优化目标会用一个非常具体的信息抽取任务来演示因为这类任务输出可解析、指标可计算最能看到优化前后的差异。工程上更复杂的对话、摘要、分类任务思路是通用的。1. 先理解两类提示优化方法的预算差异1.1 提示优化解决的并不是“模型不会”信息抽取任务看起来简单但实际跑一遍就会发现初始提示词容易漏字段、日期格式不统一、模型在 JSON 前后加解释性文字。比如只给模型一句“请从输入中抽取姓名、日期和城市输出 JSON”它可能输出带 Markdown 标记的代码块也可能把“5月6日”写成“5月6日”而不是“2024-05-06”。问题不是模型不具备抽取能力而是提示词没有把输出契约讲清楚。自动提示优化做的事情相当于在一个巨大的自然语言搜索空间里找到一段让模型稳定满足输出契约的指令。由于提示词是人类语言没办法用网格搜索遍历只能依赖另一条优化链路生成候选、评测、反馈、改写。1.2 群体式优化为什么贵GEPA 这类群体优化方法核心做法是同时维护多个候选提示词。每一轮里每个候选都会产生一个或多个变异版本然后所有候选和变异版本都被放到评测集上重新跑一遍选出得分最高的若干条进入下一轮。这种策略的优点是探索性强不容易被某一次评测噪声带偏。但成本非常直接总调用次数 ≈ 轮次 × 群体大小 × 评测样本数假设保持 8 个候选每轮每个候选再生成 8 个变异版本评测集 30 条样本跑 4 轮。仅评测调用就是(8 8) × 4 × 30 1920 次模型调用这还没算生成变异时调用优化模型的次数。如果任务本身需要长输出单次调用可能消耗几百甚至上千 token预算很容易失控。1.3 NPO 单线提示优化的核心假设NPO 单线提示优化使用另一种策略全程只维护一条最佳提示线。每一轮从当前最佳提示词出发收集失败样本让优化模型分析失败原因并生成一个改进版本然后对新版本做评测。如果分数提升或持平就接受新版本否则继续保留旧版本。单线优化的核心假设有两个好的提示词可以通过局部修改获得不一定需要多个候选之间交叉组合。从错误样本中反推改进方向比随机变异更高效。这意味着 NPO 不是在无方向地搜索而是把“错误分析”作为定位工具把“改写”作为移动方式。它牺牲了一部分探索广度换来了更少的评测次数。2. 最小实验环境用本地模型复现提示优化闭环2.1 任务定义和样例数据为了让优化效果可量化这里选一个 JSON 信息抽取任务。模型需要从一句话里抽取姓名、日期和城市并输出严格 JSON。初始提示词故意写得比较简单方便观察优化过程。SAMPLES [ { id: 1, input: 张三在2024年5月6日从北京出发。, expected: {name: 张三, date: 2024-05-06, city: 北京}, }, { id: 2, input: 李四于2024年6月1日抵达上海。, expected: {name: 李四, date: 2024-06-01, city: 上海}, }, { id: 3, input: 王五于2023年12月30日离开广州。, expected: {name: 王五, date: 2023-12-30, city: 广州}, }, ] INITIAL_PROMPT 请从输入中抽取姓名、日期和城市输出 JSON。这里的expected是标准答案。实际项目中样本数量会更多至少覆盖不同表达、不同实体和边界情况。示例只有 3 条目的是先把流程跑通。2.2 依赖和目录结构推荐使用 Python 3.10 以上版本并安装 OpenAI SDK。示例使用本地模型提供的 OpenAI 兼容接口避免外部服务的网络和鉴权问题。pip install openai如果使用 Ollama 启动本地模型可以先拉取模型并启动服务ollama pull qwen2.5:7b-instruct ollama serve项目结构可以这样组织prompt_opt/ ├── data.py # 样例数据 ├── model_client.py # 模型客户端封装 ├── evaluator.py # 评测函数 ├── optimizer.py # 单线优化主循环 └── main.py # 运行入口对于示例来说把所有函数放在一个文件里也能跑通。分文件的目的是让后续扩展时更容易替换评测逻辑或优化策略。2.3 模型客户端封装模型客户端封装要固定base_url、api_key和模型名。本地模型服务通常只需要一个占位 key。# model_client.py from openai import OpenAI client OpenAI( base_urlhttp://localhost:11434/v1, api_keyollama, ) MODEL qwen2.5:7b-instructdef chat(prompt: str, temperature: float 0.0) - str: resp client.chat.completions.create( modelMODEL, messages[{role: user, content: prompt}], temperaturetemperature, ) return resp.choices[0].message.content.strip()关键点有两个。第一评测时temperature要固定为 0 或接近 0否则同一个提示词重复评测会出现随机波动优化方向会被噪声带偏。第二生成新候选时可以使用稍高的temperature比如 0.5这样改写结果不会每次都一样。注意如果原始项目没有给出明确的模型版本落地前要先确认本地模型或在线模型的具体版本。不同模型对指令的遵循能力差异很大直接影响优化结果。3. 实现 NPO 单线优化主循环3.1 评测函数必须稳定、可复现评测函数决定优化方向。这里先解析模型输出然后与标准答案做严格比对。# evaluator.py import json def run_prompt(prompt: str, text: str, temperature: float 0.0) - str: full_prompt f{prompt}\n输入{text}\n输出 return chat(full_prompt, temperaturetemperature) def parse_output(text: str) - dict: start text.find({) end text.rfind(}) if start -1 or end -1: return {} try: return json.loads(text[start:end 1]) except json.JSONDecodeError: return {} def score_output(pred: dict, expected: dict) - float: if set(pred.keys()) ! set(expected.keys()): return 0.0 if pred.get(name) ! expected.get(name): return 0.0 if pred.get(date) ! expected.get(date): return 0.0 if pred.get(city) ! expected.get(city): return 0.0 return 1.0 def evaluate_prompt(prompt: str, samples: list) - float: total 0.0 for sample in samples: raw run_prompt(prompt, sample[input]) pred parse_output(raw) total score_output(pred, sample[expected]) return total / len(samples)这个评测逻辑对格式要求非常严格。字段缺失、日期不对、多输出解释都会判错。实际项目中如果任务本身允许多种合理输出就需要写一个更宽容的评测函数或者干脆用另一个 LLM 做裁判。3.2 从错误样本中生成新的提示线单线优化的关键不是随机换一个说法而是基于失败样本地改写。因此要先收集当前提示词跑错的样本把输入、期望输出、实际输出打包给优化模型。# optimizer.py import json REWRITE_SYSTEM 你是一个提示词优化器。你会收到当前提示词和一批失败样本。 请分析失败原因修改提示词使模型能输出符合要求的 JSON。 要求 1. 只输出修改后的提示词不要输出分析过程。 2. 不要改变任务目标。 3. 尽量保留原有成功部分的措辞。 .strip() def collect_errors(prompt: str, samples: list) - list: errors [] for sample in samples: raw run_prompt(prompt, sample[input]) pred parse_output(raw) if score_output(pred, sample[expected]) 1.0: errors.append({ input: sample[input], expected: sample[expected], raw: raw, pred: pred, }) return errors def propose_new_prompt(current_prompt: str, errors: list) - str: error_text for item in errors: error_text ( f输入{item[input]}\n f期望{json.dumps(item[expected], ensure_asciiFalse)}\n f实际输出{item[raw]}\n f解析后{json.dumps(item[pred], ensure_asciiFalse)}\n\n ) user_prompt f当前提示词\n{current_prompt}\n\n失败样本\n{error_text}\n\n请输出改进后的提示词 resp client.chat.completions.create( modelMODEL, messages[ {role: system, content: REWRITE_SYSTEM}, {role: user, content: user_prompt}, ], temperature0.5, ) return resp.choices[0].message.content.strip()这里的改进方向是错误驱动。如果失败原因是日期格式不对优化模型会倾向于在提示词里补充“日期必须是 YYYY-MM-DD 格式”。如果失败原因是 JSON 外面有解释文字优化模型会倾向于强调“只输出 JSON 对象”。3.3 主循环接受、拒绝、早停单线优化主循环要做三件事收集错误、生成候选、决定是否接受。为了控制预算还需要设置提前停止条件。def optimize_single_line( initial_prompt: str, samples: list, max_steps: int 5, target_score: float 1.0, patience: int 2, ): best_prompt initial_prompt best_score evaluate_prompt(best_prompt, samples) history [] no_improve 0 print(fstep0 best_score{best_score:.2f}) for step in range(1, max_steps 1): errors collect_errors(best_prompt, samples) if not errors: print(全部样本通过提前结束。) break candidate_prompt propose_new_prompt(best_prompt, errors) candidate_score evaluate_prompt(candidate_prompt, samples) if candidate_score best_score: best_prompt candidate_prompt best_score candidate_score no_improve 0 else: no_improve 1 history.append({ step: step, candidate_prompt: candidate_prompt, candidate_score: candidate_score, best_prompt: best_prompt, best_score: best_score, }) print(fstep{step} candidate_score{candidate_score:.2f} best_score{best_score:.2f}) if best_score target_score: break if no_improve patience: print(连续未提升提前停止。) break return best_prompt, best_score, history要注意的是evaluate_prompt和collect_errors都会走完整评测集所以每一轮至少会对所有样本跑两遍一遍收集错误一遍评估候选。这个成本已经比群体优化低很多但仍要控制轮数。3.4 示例输出与验证方式运行主循环后预期会看到类似下面的输出step0 best_score0.33 step1 candidate_score0.67 best_score0.67 step2 candidate_score1.00 best_score1.00实际结果取决于本地模型、初始提示词和评测集。如果一轮就达到满分说明评测集太简单。生产环境不要用这种小样本结果判断优化完成至少使用 50 到 100 条样本。4. 与 GEPA 式群体优化对比成本从哪来4.1 一个 GEPA 风格基线实现GEPA 式优化可以简化成下面的流程维护一个候选池每一轮对池中每个提示词生成变异版本然后全部评测保留最优的几个。def mutate_prompt(prompt: str, errors: list) - str: return propose_new_prompt(prompt, errors) def optimize_group( initial_prompt: str, samples: list, pool_size: int 4, steps: int 4, ): pool [initial_prompt] * pool_size best_prompt initial_prompt best_score evaluate_prompt(best_prompt, samples) for step in range(steps): new_pool [] for prompt in pool: errors collect_errors(prompt, samples) if errors: new_pool.append(mutate_prompt(prompt, errors)) else: new_pool.append(prompt) combined_pool pool new_pool scored [] for prompt in combined_pool: score evaluate_prompt(prompt, samples) scored.append((prompt, score)) scored.sort(keylambda x: x[1], reverseTrue) pool [prompt for prompt, _ in scored[:pool_size]] if scored[0][1] best_score: best_prompt scored[0][0] best_score scored[0][1] return best_prompt, best_score这个示例没有加入交叉操作但已经能看出群体优化的成本结构每个候选在每一轮都要收集错误并且变异后还要再评估一次。候选越多成本线性上升。4.2 两种策略的 token 消耗模型假设评测集有 30 条样本每轮每个候选只生成 1 个变异版本GEPA 的 pool_size 为 8跑 4 轮。那么模型调用次数大约是策略候选数轮次每轮调用总调用次数相对成本NPO 单线1 条最佳线4收集错误 30 评估候选 30约 2701 倍GEPA 群体8 个候选4每个候选 30 每个变异 30总计 480约 1950约 7 倍这只是去掉启动评测后的估算。真实场景中GEPA 为了更充分的探索往往会让每个候选生成多个变异成本还会继续上涨。4.3 预算分配建议单线优化适合把预算先花在“理解错误”上。评测集小一点、评测次数多一点比盲目扩大候选池更有效。典型做法是先用 10 到 20 条样本做粗筛快速判断候选提示词是否值得保留。对粗筛通过的候选再用全量评测集复评。每轮保留一条最佳线但可以记录被拒绝的候选方便人工分析。当单线优化连续多轮无法提升时再切换到群体优化。这种“先单线、后群体”的顺序在预算有限时通常比直接上群体搜索更稳。5. 常见问题排查5.1 分数为什么越优化越低这是最常遇到的问题。优化前 0.33优化一轮后还是 0.33甚至变成 0.0。现象可能原因检查方式处理建议候选分数低于当前最佳评测集太小随机波动大固定 temperature 重新评测同一 prompt 跑 3 次取平均先扩大评测集或做多次复评候选提示词被改写得太激进优化模型不理解原任务约束把当前 prompt 和候选 prompt 做 diff在改写指令中增加“小幅修改”要求输出格式频繁变化新提示词加入了两难约束查看失败样本的实际输出改用更明确的格式说明或增加后处理修复单线优化的回滚机制很重要。如果连续 2 到 3 轮没有提升不要继续硬跑回到历史记录里的最佳版本。5.2 输出格式频繁变化模型在优化过程中可能由于提示词措辞调整从“输出 JSON”变成“输出如下 JSON”或“这是一个有效的 JSON”。这些变化会导致解析失败。检查方式打印parse_output前后的原始文本看是不是 Markdown 代码块、空格、换行或中英文标点影响了解析。解决方案初始 prompt 就写明“只输出 JSON 对象不要代码块不要解释”。在改写指令中增加一句“不要改变输出格式约束”。后处理中使用更鲁棒的 JSON 提取函数例如定位第一个{和最后一个}。但要注意后处理修复只应该作为兜底不能代替提示词优化。如果评分函数对格式错误判 0优化器才会真正往“格式更稳”的方向走。5.3 优化结果过拟合验证集单线优化每一轮都盯着同一批错误样本很容易把提示词改成“对这几条样本有效对新样本无效”的形态。比如提示词变成了“当出现李四时日期要补成年份”这种规则明显不适合生产环境。现象可能原因检查方式处理建议评测集分数高真实场景效果差优化过程过拟合评测集把评测集拆成 develop 和 holdout只在 develop 上优化holdout 留作上线前验证提示词中出现具体实体名优化模型从失败样本中抄了答案审查最终 prompt在改写指令中禁止加入具体实体名和输入内容改动点太碎每轮都被同一条错误样本牵着走按错误类型聚类后再改写先汇总同类错误再一次性提出修改方案比较好的做法是维护一个开发集和一个留出集。优化时只用开发集每次优化结束后用留出集复评。如果留出集分数没有明显提升说明优化结果并不可靠。6. 实践建议与可复用清单6.1 发布提示词前的检查清单无论使用 NPO 单线优化还是群体优化上线前都建议对照这份清单检查评测集是否覆盖了不同表达、不同实体、不同边界情况。是否使用 holdout 集做过最终复评。是否有固定的temperature和随机种子配置。是否记录了每一轮优化后的 prompt 版本和分数。是否保留初始 prompt 作为回滚对象。是否对输出解析做了容错。是否有预算上限避免优化流程失控。是否已经审查最终 prompt确认没有过拟合具体样本。是否在对话类或生成类任务中安排了人工抽检。这份清单同样适用于 GEPA 等群体优化方案。自动优化只是辅助最终必须有人确认提示词的可读性和稳定性。6.2 单线优化适合什么场景NPO 单线提示优化适合以下情况评测集规模较大群体优化的评测成本难以承受。任务输出结构清晰错误类型集中例如 JSON 抽取、分类、格式转换。团队没有太多时间人工分析 prompt但希望用少量改动快速提升效果。提示词已经有一个稳定版本只需要做局部调整。如果任务非常开放比如开放式问答、创意写作评测标准本身就模糊单线优化的收益会下降。这时候应该先解决“如何评测”的问题再谈自动优化。6.3 扩展方向单线优化可以继续扩展成更实用的工程系统。将错误样本按类型聚类然后分别生成多个候选再用评测集挑选。引入粗筛和复评两级流程用少量样本快速淘汰明显更差的候选。把多轮优化记录写入日志方便回放 prompt 变化。结合 DSPy 等声明式提示优化框架用标准模块替代自己维护的流程。在离线评测通过后再接入线上流量做小范围 A/B 验证。预算有限时拼的不是谁的候选池更大而是谁的反馈链路更短。NPO 单线提示优化的本质就是把每一轮评测都用在真正能改进方向的地方。先把错误收集、评测、回滚这套基础设施搭好再决定要不要并行搜索会比一开始就堆候选更实用。