这几年做大模型落地项目,我最大的感受是:真正决定线上效果的不是你选了哪个模型,而是你往模型里塞了什么、以及怎么塞的。Model-Optimizer最初只是我电脑里一个叫optimize_prompt.py的脚本,后来慢慢长成了一个自动优化提示词和调用参数的本地工具。这篇文章就讲讲我为什么写它、它的核心机制是什么、以及在几十个真实分类任务上把准确率从 65% 拉到 93% 的整个过程。
如果你正在做大模型 API 集成、需要反复调 prompt 和 temperature、或者觉得“凭感觉试错”太低效,这篇文章应该能给你一套可以直接抄走的思路。不涉及复杂的分布式训练,也不是什么论文级别的算法,就聊聊一个普通开发者如何把模型调用打磨得更稳。
1. 为什么我会写一个叫 Model-Optimizer 的工具
1.1 痛从哪里来:不是在“调模型”,而是在“调调用”
先说说我遇到的真实场景。当时我在做一个商品评论的自动分类系统,需求是把用户评论分成“质量投诉”“物流问题”“退货申请”“其他咨询”四类。我一开始想得很简单:调一个现成的大模型 API,把评论丢进去,让它返回分类结果就行。
结果第一天就被打脸了。
同一个模型,同样的温度参数,只因为提示词里“请对以下评论进行分类”和“请判断以下评论属于哪个类别”这种微小的措辞差异,准确率就能差出十几个百分点。更离谱的是,当我在提示词里加上几个示例之后,准确率从 71% 跳到了 82%,而我只改了提示词,模型权重、推理配置全都一模一样。
这时候我才反应过来:在大模型应用开发里,“调模型”这件事早就被偷换成了“调提示词”和“调调用参数”。你无法直接干预模型内部参数,能动的只有输入内容、温度值、最大输出长度、停止符这些外围变量。而偏偏这些变量对结果的影响大得惊人。
那会儿我的工作流是这样的:改一段提示词,跑一遍测试集,肉眼扫一遍输出,记下感觉,再改下一版。测了十几个版本之后,我已经完全分不清“上一版更好的感觉”到底是真实差异还是我的错觉。
1.2 我想要的不是“元提示词”,而是一个可复用的调试闭环
我知道市面上一堆现成的工具能做的事情比我多:有的能追踪 prompt 版本,有的能可视化 token 消耗,还有的能做大模型链路追踪。但对我来说,它们都缺一个关键环节——自动评价一个提示词好不好。多数工具只记录“模型回答了啥”,而判断“回答得好不好”这件事,还是得靠人工肉眼去看。
一个人盯着一百条测试输出发脾气,这不是工程化的思路。
我想要的闭环其实特别朴素:给出一个初始提示词和一批带标注的测试数据,工具自动生成若干个提示词变体,挨个跑测试集,按一套统一的评分规则打分量化,最后告诉我哪个变体最好、好在哪。这样我就不用自己瞎试,而是让数据说话。
Model-Optimizer就是奔着这个目标去的。它的定位不是修改模型参数,而是在模型调用层构建评测、优化、排序的自动化流程,把所有“凭感觉”的判断尽量变成可量化的分数。
2. Model-Optimizer 的整体设计:把提示词优化拆成可迭代的四步
这个工具的设计思路其实就四个环节:定义评测数据、生成候选提示词、批量跑评测、汇总排序。单看每一步都不复杂,但组合起来就是一个能自转的优化闭环。
2.1 数据先行:先把“好结果”定义清楚
我犯过最大的错误,就是没定义清楚“好”就开始优化。
第一版工具我直接让它把几十条测试评论和提示词变体都跑一遍,然后输出原始结果给我看。结果我看得头晕:有的变体分类正确但废话太多,有的分类错了但格式极其规范,有的连 token 消耗都翻了一倍。我没法判断哪个变体更“好”,因为“好”的标准压根没立起来。
所以后来我在工具里强制要求先配置一个评测文件,里面包含三类信息:
- 输入样例:一组有代表性的测试输入,必须覆盖各种边界情况
- 期望输出:每个输入对应的标准答案
- 评分规则:如何判断模型输出是否得分
比如商品分类这个场景,我的评测文件长这样:
{ "test_cases": [ { "input": "收到的手机屏幕有一条明显的划痕,外包装完好,怀疑是翻新机", "expected": "质量投诉", "id": "case_001" }, { "input": "快递已经5天没动了,物流信息一直停在仓库", "expected": "物流问题", "id": "case_002" } ], "scoring": { "type": "exact_match", "case_sensitive": false } }这一点必须强调:没有评测数据就谈不上优化。你连好坏都分不清,自动优化就是在随机游走。
2.2 候选生成:让模型自己产出提示词变体
人工改写提示词效率太低,我的做法是让模型当“提示词改造师”。给它一段基础提示词,让它按不同策略生成变体:
- 策略一:改写措辞,保持语义不变,换掉句式
- 策略二:补充边界条件,告诉模型哪些情况容易混淆
- 策略三:加入示例,在提示词里嵌入 few-shot 样例
- 策略四:调整输出约束,要求 JSON 输出、限定 token 长度、要求先分析后判断
这一步的作用不在于生成“最好”的提示词,而在于制造多样性。你说不准哪种写法能击中这个模型的“最佳发挥区”,那就多试几种,让评测环节去淘汰。
工具会为每个策略生成 3 到 5 条变体,加起来一轮迭代大概有 15 到 20 条候选提示词。生成时我用的是较低的 temperature,避免变体之间差异过大,同时限制每条变体的长度,防止模型大幅跑偏。
2.3 批次评估:让评测跑起来,并且可复现
评测环节最讲究“可复现”。有些坑我踩过之后才知道多重要:
- 评测时必须固定 temperature,我一般设成 0 或接近 0
- 并发请求要控制节奏,避免限流导致部分请求失败
- 每条测试用例和每个候选提示词的结果都要落盘,存成结构化日志
跑批的代码大致长这样:
import asyncio from model_optimizer import Evaluator, PromptGenerator async def run_evaluation(base_prompt: str, test_cases: list): candidates = PromptGenerator().generate_variants(base_prompt, strategies=4) evaluator = Evaluator(scoring_rule="exact_match", temperature=0.0) results = [] for prompt in candidates: score = await evaluator.evaluate(prompt, test_cases) results.append({ "prompt": prompt, "score": score, "samples": evaluator.detailed_outputs }) print(f"候选提示词得分: {score:.2%}") results.sort(key=lambda x: x["score"], reverse=True) return results[0]所有中间结果都会存到本地runs/目录下,文件名带上时间戳。这样即使某个变体效果异常好,我也可以回头查看它到底在哪些用例上得分、哪些用例上失分,而不是只看一个总分。
2.4 结果汇总与排序:不只看单一指标
我把评分拆成了四个维度,最终用一个加权总分来排序候选提示词:
| 维度 | 权重 | 计算方式 |
|---|---|---|
| 任务准确率 | 0.6 | 测试集中分类正确的比例 |
| 输出合规率 | 0.2 | 输出是否符合预设格式(JSON/枚举值) |
| 平均响应耗时 | 0.1 | 单条请求的平均耗时,越小越好 |
| token 消耗 | 0.1 | 平均每条请求消耗的 token 数,越小越好 |
为什么要看四个维度?因为只盯准确率会翻车。有一次某个变体准确率极高,但它要求模型“先输出 200 字分析再给结论”,直接把 token 成本翻了四倍。如果不看成本维度,这个方案就会被误选为最优,上线后账单会让你重新做人。
3. 评测脚本是整个优化器的灵魂:怎么判断“模型答对了”
工具跑得再多,如果评分规则本身是错的,结果全白搭。这一章我详细讲讲评测脚本的设计,这是Model-Optimizer的核心命门。
3.1 为什么不能用“看起来像”来评测
最开始我用的是宽松包含匹配,也就是“模型输出里包含期望关键词就算对”。比如期望输出是“质量投诉”,模型回答“该评论属于质量投诉”,那就算命中。
听起来挺合理,实际用起来漏洞百出:
- 模型输出“这个不是质量投诉,是物流问题”,关键词“物流问题”被算对
- 模型输出“可能是质量投诉也可能是物流问题”,俩关键词都在,被重复判对
- 模型输出“质量投诉都需要看情况,如果外包装有损伤则是物流问题”,居然也被算对
这些误判导致评测分数虚高,我一度以为某个提示词效果很好,一上真实数据就露馅。
后来我改成两个硬性校验叠加:格式校验加枚举值精确匹配。分类任务的输出必须是指定的枚举值之一,不能输出额外解释。如果格式不对,哪怕内容是对的也直接判错。
3.2 LLM 作为评分器的注意事项
有些任务没法用精确匹配,比如抽取客户留言的“核心诉求”,答案没有唯一标准,这时候就得让另一个模型当裁判。
我试了让一个大模型给输出打分,过程很顺利,但后来发现几个细节必须处理:
- 裁判模型的 temperature 必须设为 0,否则同样的输出可能得 8 分也可能得 10 分
- 评分 prompt 里必须给出参考标准和评分锚点,不然裁判模型会当老好人,什么都给高分
- 不要让待评测的模型给自己打分,自评偏差在真实测试里非常明显
裁判模型的评分 prompt 我一般写成这样:
你是一名严格的评测员。请根据以下标准对候选输出评分: - 5分:完全符合要求,信息完整,无多余内容 - 3分:基本符合要求,但存在轻微偏差 - 0分:核心内容错误或答非所问 参考期望答案:{expected} 候选输出:{candidate} 只输出一个数字分数,不要解释。3.3 实际评测脚本的骨架参考
def score_classification(candidate: str, expected: str) -> int: # 严格格式校验:输出必须恰好等于枚举值 if candidate.strip().lower() == expected.strip().lower(): return 1 # 允许带序号或引号,但内容必须与期望一致 cleaned = candidate.strip().strip("0123456789.、\"' ") return 1 if cleaned.lower() == expected.lower() else 0 def score_extraction(candidate: str, expected: str) -> float: # 针对非精确匹配场景,使用LLM裁判 from model_optimizer import LlmJudge judge = LlmJudge(temperature=0, rubric="RUBRIC_CLASSIFICATION") score = judge.score(candidate=candidate, expected=expected) return score / 5.0 # 归一化到0-14. 一个真实优化案例:商品评论分类从 65% 到 93%
光讲设计可能不够直观,我拿一个真实跑过的任务完整演示一遍。这是上周帮一个电商团队做的评论自动分类优化,测试集 200 条,四条类别,初始准确率 65%。
4.1 初始提示词长什么样
当时业务方给的初始提示词特别简单:
请对以下用户评论进行分类,类别包括:质量投诉、物流问题、退货申请、其他咨询。 评论:{input} 分类:用这个提示词跑 200 条测试数据,准确率 65%,平均单条耗时 320ms,每条消耗 token 大约 380 个。我把它作为基线和后续所有迭代对照的起点。
4.2 第一轮迭代:结构化约束让准确率跳到 78%
第一轮生成的候选提示词里有好几条都在做同一件事——要求模型先输出一个类别代码再输出类别名称。我挑了一条变化最明显的看一下:
请对以下用户评论进行分类。你只能输出以下四个词之一:质量投诉、物流问题、退货申请、其他咨询。禁止输出解释或其他内容。 用户评论:{input} 分类:这一条的准确率是 78%,比基线涨了 13 个百分点。涨在哪?我看了详细的逐条日志,发现原先模型经常在输出分类后附带一句“因为……”,而当它开始解释的时候,分类错误的概率会明显升高。加了“禁止输出解释”之后,模型被迫在输出前自己完成推理,而不是边写边想,分类稳定性反而上去了。
4.3 第二轮迭代:给出类别边界说明,涨到 86%
第一轮虽然涨了不少,但我发现有两个类别特别容易混淆:“质量投诉”和“退货申请”。模型经常把“收到商品破损想退货”判成“质量投诉”,从业务角度这俩确实沾边,但从分类目标看需要明确边界。
第二轮我手动在候选基础上加了一条类别边界描述,让模型知道这两个类别之间的分水岭是什么:
请对以下用户评论进行分类。注意:“质量投诉”指用户对商品质量本身不满,不涉及退货动作;“退货申请”指用户明确提出退货诉求,即使原因是质量问题。 类别:质量投诉、物流问题、退货申请、其他咨询。 用户评论:{input} 分类:准确率 86%。模型的混淆明显减少,但我发现一个新问题:不少评论里用户既抱怨质量又想退货,这类数据模型开始犹豫,返回“质量投诉和退货申请”,格式直接违规,被算作错误。
4.4 第三轮迭代:细节纠偏后冲到 93%
于是我加了分支处理逻辑和示例,也就是大家常说的 one-shot:
请对以下用户评论进行分类。你只能输出以下四词之一:质量投诉、物流问题、退货申请、其他咨询。 判定优先级: 1. 如果用户明确表达退货/退款诉求,即使有质量抱怨,也分类为“退货申请”。 2. 如果用户只抱怨质量但没有退货诉求,分类为“质量投诉”。 3. 运输延迟、快递丢件、物流信息不动,分类为“物流问题”。 4. 不涉及以上三类的,分类为“其他咨询”。 示例: 评论:这手机用三天就死机了,想去退货。 分类:退货申请 评论:包装盒被压扁了,但手机没问题。 分类:物流问题 用户评论:{input} 分类:这轮跑完准确率 93%,平均单条耗时 280ms,token 消耗略涨到 420。虽然 token 成本变高了,但准确率提升更关键,业务方接受了这个方案。
4.5 三轮迭代背后,我发现的三条规律
- 模型本身不笨,笨的是你没把边界说清楚。很多分类错误源于类别间边界模糊,而写进 prompt 里是最便宜的纠偏手段。
- 明确输出格式的收益,往往比增加描述更大。某些场景下“只输出一个词”这种硬约束带来的提升,甚至超过了类别描述的提升。
- 示例不是越多越好,关键是示例要覆盖易混边界。我试过放 5 个示例,效果反而不如 1 个精准的边界示例,因为模型被过多样式带偏了。
5. 实际使用中的坑与对策:这些细节不入坑根本想不到
自动优化提示词这件事听起来很爽,但你实际跑上几天就会碰到一堆诡异问题。我把最坑的几个问题和对策写下来。
5.1 评测噪声:同一提示词换个时机跑,分数居然不一样
这是最坑的一个坑。
某个候选提示词第一次跑准确率 88%,我记下来,继续优化。下一轮迭代我把它作为基线再跑一次,结果只有 82%。同一个提示词,同一批测试数据,分数差了 6 个百分点。
原因有三个:
- 模型推理不是确定性的,尤其是输出长度较长的任务,即便 temperature 设成 0,某些推理后端也会因为采样算法和批处理的不同产生差异
- 并发请求会影响响应长短,导致个别请求截断,截断后的输出会被评测规则判错
- 后端偶尔有随机超时,部分请求降级成兜底输出,污染了评测结果
对策是我后来一直在用的:每个候选提示词至少跑三遍,取中位数作为最终得分。多跑三遍的时间是可以接受的,总比信了一个噪声分数强。
5.2 过拟合评测集:优化过头反而变傻了
跑过七八轮之后,我发现一个现象:选出来的“最优提示词”在测试集上准确率 95%,但业务方拿真实线上数据一测,只有 74%。
原因很经典——过拟合。测试集只有 200 条,自动优化会不断放大在这个测试集上表现好的写法,包括巧合的措辞、恰好覆盖了测试集特点的示例,而不是普适性的分类能力。
我的解决办法是把测试集拆成两份:一份 160 条做优化筛选,另一份 40 条做最终验证。整个优化过程中模型只看筛选集的结果,最后拿着 40 条验证集跑一遍,防止分数虚高。
5.3 候选生成不能变成“套娃”生产器
用模型生成提示词变体有一个隐蔽的麻烦:变体会继承生成者的表达习惯。我试过一个策略,让模型“换一种语气重写下面的提示词”,结果生成的 10 条变体有 7 条都是用“请你”开头,几乎没什么多样性。
后来我调整了生成策略,不再让模型自由发挥,而是给每条变体一个强制约束模板:
| 变体类型 | 强制要求 |
|---|---|
| 结构化 | 必须使用编号列表,禁止自然段落 |
| 边界描述型 | 必须包含一个“注意”段落,指明易混类别 |
| 示例型 | 必须嵌入至少一个示例,且示例必须带期望输出 |
| 精简型 | 总字数不超过原提示词的 60% |
有了强制模板,变体之间的差异明显变大,评测的筛选价值也跟着上来了。
5.4 大模型后端一升级,之前的所有结论都可能作废
这个坑是我最无语的:某家大模型厂商升级了底层版本,我没收到任何通知,第二天工具自动跑出来的评测结果全线漂移,之前优化出来的提示词准确率直接跌了 10 个百分点。
从那以后我养成了一个习惯:每次跑优化前都先跑一遍基线提示词,用它的准确率作为“当前模型状态校准值”。如果校准值和历史基线相差超过 2 个百分点,我就知道模型环境有变化,先别急着优化提示词,而是先评估新模型是否值得整体切换。
6. Model-Optimizer 还能怎么扩展:自动化与链路集成
工具的核心是自动评测加迭代寻优,这个框架一旦跑通,能扩展的地方其实不少。
6.1 接入日常回归测试,防止提示词“悄悄退化”
我现在每周五都会跑一次全量回归:把线上正在用的提示词、全量标注测试集、历史最优候选提示词一起拉出来跑一遍,自动生成一份对比报告。如果本周的得分低于上周超过 2 个百分点,就触发告警。
这个机制已经帮我抓到了两次问题:一次是上游数据源格式微调导致输入文本多了一段 HTML 标签,另一次是模型厂商侧更新了默认行为。如果没有回归测试,线上效果什么时候变差的可能根本没察觉。
6.2 自动筛选长度阈值和调用参数组合
除了提示词,工具目前还支持对temperature、max_tokens、top_p做网格搜索。优化循环的核心逻辑完全一样,只是候选生成器从“改写提示词”换成了“枚举参数组合”。
比如我会固定提示词不变,只搜索 temperature 在 0 到 1 之间的最优值。对分类任务来说,最优温度往往是 0,但对文本扩写任务,最优温度可能落在 0.7 左右。让工具自动跑一遍,能省下不少人工直觉判断的时间。
6.3 多模型横向对比选型
接到一个新任务时,我常常不确定用哪个模型性价比最高。这时候我会把同一组提示词、同一个评测集,同时丢给不同的模型 API 跑一遍,对比准确率和成本。
表格对比是最直观的表达方式:
| 模型 | 准确率 | 单条约耗时(ms) | 单条约消耗token | 单条成本(约) |
|---|---|---|---|---|
| 模型A | 93% | 280 | 420 | 0.008元 |
| 模型B | 88% | 190 | 360 | 0.004元 |
| 模型C | 95% | 450 | 510 | 0.02元 |
这种对比跑上一次就能知道,如果对 1% 的准确率不敏感,模型 B 可能是性价比最优的答案。比凭印象拍脑袋选型靠谱得多。
6.4 做成一个本地命令行工具
我最终把Model-Optimizer固化成了一个本地命令行工具,用法很简单:
model-optimizer run --config configs/comment_classifier.yaml \ --eval-data data/test_cases.json \ --runs 3配置文件里写明模型 API、初始提示词、候选生成策略、评分规则和权重,命令一敲就自动跑完整个优化流程,结果输出成 Markdown 报告。整个过程不依赖任何在线平台,数据只存在本地,对数据安全敏感的场景也很实用。
工具本身不复杂,但这个“定义数据、生成变体、批量评测、排序收敛”的思路,我觉得值得更多在大模型应用层干活的人试一试。它不要求你懂炼丹,只需要你把“好”的定义写清楚,剩下的交给迭代去寻找答案。对我这种天天和提示词搏斗的开发者来说,这算是把最重复的那部分劳动彻底从手上拿掉了。