☰
提示词回归测试:用Promptfoo把LLM输出变成可验证的工程实践
2026/10/7 11:59:23 网站建设 项目流程

1. 为什么提示词也需要回归测试

1.1 提示词工程不是玄学,是代码

先抛一个观点:提示词就是软件代码的一部分,只是它用自然语言编写,跑在别人家的模型上。Promptfoo 这个工具,做的事情和你的单元测试框架完全一样——把提示词、输入、期望结果固定下来,一键批量执行,然后告诉你这次改动到底是好是坏。

我见过太多团队把提示词当成"调一调试一试"的玄学。改一个形容词,感觉输出更好了,就直接上生产;结果换成另一批用户输入,输出直接崩盘,或者下一轮迭代时根本想不起来当时为了一句措辞纠结了两小时。这就是缺少本地回归测试的典型症状:没有基线,没有断言,没有历史记录,每一次修改都是一场赌博。

"本地回归测试"这四个字,放在提示词场景里其实一点都不绕:写一个配置文件,把你要测的提示词模板、测试用例、通过标准全部固定下来,然后跑一条命令,让 Promptfoo 调模型跑一遍,最后给你一份通过/失败的报告。跑通了,本地数据不出机器,报告页面也是本地起的 Web 服务。你不用依赖任何在线评测平台,也不用担心数据被第三方拿走去做什么。

1.2 回归测试到底在防什么

提示词场景下的"回归问题",和传统软件回归测试的本质一样:改了一个地方,结果其他原本正常的地方挂掉了。只不过传统代码的崩溃是报错、异常,而提示词的崩溃往往是"回答居然还能看,但已经不对了",特别有迷惑性。

我用下来,最常见的回归有三种:

  • 格式回归:原来严格输出 JSON,你加了一句"用友好语气回答",模型开始在 JSON 外面包一层 markdown 代码块,下游解析直接炸。
  • 行为漂移:强调"简洁"变成"最多50字"之后,模型的输出从摘要变成了电报体,把主语全丢了。
  • 隐性劣化:修复了 A 类问题的回答,结果 B 类问题的表现大幅下降,这种最凶险,因为肉眼很难一眼看出来。

回归测试要防的核心就是第三类。你用 Promptfoo 把几十个典型场景固化成用例,每次改提示词之后跑一遍,通过率一下降,立刻就能定位到是哪个用例变了。没有这层保护,你根本不知道改提示词的副作用有多大。

2. Promptfoo 的核心设计:把评测当成工程来做

2.1 一次评估的三个要素

Promptfoo 的模型很简洁,把一次提示词评估拆成三个部分,理解了这个你就能玩转所有配置。

第一个是 prompt 模板。它支持变量插值,你在模板里写{{变量名}},测试用例里给具体值。这其实就是把提示词从"一次性文本"变成了"可参数化的函数",这也是它能做回归测试的基础——同一个模板,换不同输入,跑不同断言。

第二个是 provider。就是你要测的模型,可以是 OpenAI 的某个模型、Anthropic 的 Claude、本地 Ollama,甚至一个自定义 HTTP 接口。Promptfoo 的 provider 层做得比较厚,API 变了、厂商不同都不影响你要不要写测试,你要测的逻辑反正就是"这个提示词放到这个模型里,对同一批输入,输出是否符合预期"。

第三个是 tests。每个测试用例包含两个东西:vars(喂给模板的变量值)和 assert(对输出的断言)。一个用例就是一次完整验证:给定什么输入、期望什么行为。你把这三样东西放进一个 YAML 文件,Promptfoo 就会自动化地把所有组合跑一遍,然后汇总结果。

2.2 本地是核心关键词

这个项目里,"本地"的价值被严重低估了。很多人用它第一时间想的是"能不能直接在网页上对比模型效果",但其实本地跑才是它的护城河。

首先,你的提示词和测试用例都是客户内部业务相关的内容,可能会有敏感信息。Promptfoo 默认是在本地发起推理请求,提示词内容虽然会发给对应模型厂商的 API,但整个配置、报告、缓存都在你机器上,不会额外传到一个评测平台上。比起把测试数据贴到第三方网页做对比,可控性高很多。

其次,本地意味着可以进 Git。promptfooconfig.yaml就是一份代码,你可以像 review 代码一样 review 提示词的变更。谁改了什么、为什么改、当时测试通过率是多少,全部留在提交记录里。这一点对团队协作来说价值极大,因为提示词工程最缺的就是"可追溯性"。

最后,命令行的可编程性让它可以接进 CI。本地能跑的命令,就能在流水线里自动跑。这是后话,第四部分我会展开。

2.3 基础配置文件长什么样

Promptfoo 的入门配置文件是一个叫promptfooconfig.yaml的文件。我先给一个最小可运行版本,你可以照着建一个体验一下:

prompts: - "你是一个客服。请回答用户的问题:{{question}}" providers: - openai:gpt-4o tests: - vars: question: "我的订单已经付款三天了,为什么还显示未发货?" assert: - type: contains value: "订单"

这个配置做了三件事:定义了提示词模板,指定模型,再给了一个测试用例——如果模型输出里不包含"订单"两个字,就判定失败。跑npx promptfoo eval,控制台就会告诉你这个用例通过还是挂了。

跑完之后npx promptfoo view,它会起一个本地 Web 服务,打开一个报告页面,里面能看到每个用例的预期输出、实际输出、通过状态、耗时和费用估算。我第一次看这个报告页面时挺惊喜,因为干程序这么久,很少有一个工具能把"测试结果"呈现得这么直观。

3. 从零搭一套本地提示词回归测试:完整实操

3.1 安装与初始化

环境前提是你机器上有 Node.js 18 以上的运行环境,然后装一下 Promptfoo。官方推荐用 npx 直接跑,不污染全局:

npx promptfoo@latest init

init会在当前目录生成一个基础的promptfooconfig.yaml和测试目录骨架。我一般习惯全局安装,因为后面要频繁敲命令:

npm install -g promptfoo promptfoo --help

安装完先确认版本:

promptfoo --version

初始化出来的是官方模板,我觉得不太符合真实场景,通常删掉重写。真实项目里我会把提示词模板拆到单独文件,而不是全写在主配置里,后面第二部分说。

如果你用的是 OpenAI 系模型,本地跑之前要配好环境变量:

export OPENAI_API_KEY="sk-..."

注意别把 key 写进 YAML 配置文件。Promptfoo 支持从环境变量读取,配置文件里只写模型名,密钥放在.env文件里然后本地 source,这样才能保证密钥不进 Git。

3.2 第一个回归测试用例

配置文件的目录结构,我是这么组织的:

my-prompt-project/ ├── promptfooconfig.yaml ├── prompts/ │ ├── system.txt │ └── user.txt ├── tests/ │ └── cases.yaml └── .env

把模板拆开有几个好处:一是可以分别维护不同模块的提示词版本,二是prompts/目录下的文件可以直接 diff,改了一句话在 Git 里清清楚楚。配置文件改成引用文件的形式:

prompts: - file://prompts/system.txt providers: - openai:gpt-4o tests: - vars: question: "我的订单已经付款三天了,为什么还显示未发货?" assert: - type: icontains value: "订单"

system.txt文件里就放一行模板:

你是一个电商客服,回答用户问题时必须简洁、专业、有礼貌。用户的问题是:{{question}}

这里我故意把contains改成icontains,因为模型输出的大小写不可控,"订单"和"订单"虽然不会变,但有的时候你测的是英文关键词,就很容易踩大小写的坑。这是第一个经验:断言尽量选不区分大小写的版本,除非你严格要求精确格式。

跑一下评估:

promptfoo eval

第一次跑会输出一个类似这样的表格:

┌──────────────┬──────────────────┬─────────────────────────────┬─────────────┬────────────┬────────────┐ │ prompt name │ provider │ pass │ fail │ cost │ latency │ ├──────────────┼──────────────────┼─────────────────────────────┼─────────────┼────────────┼────────────┤ │ system.txt │ openai:gpt-4o │ 1 │ 0 │ $0.0001 │ 974 ms │ └──────────────┴──────────────────┴─────────────────────────────┴─────────────┴────────────┴────────────┘

如果表格出现了FAIL,后面还会列出具体的输出内容,一眼就能看到模型到底回答了什么,为什么没匹配上。这一条跑通了,你的本地回归测试就正式立起来了。

3.3 断言类型:别只会用 contain

断言是 Promptfoo 的灵魂,因为 LLM 的输出天然有随机性,你不能像传统测试那样要求assert(a === b)。Promptfoo 内置了几十种断言类型,我从实战角度挑了几个高频的:

断言类型作用适用场景
equals输出和指定值完全一致极少用,除非是强制格式要求
contains输出包含子串验证关键信息是否出现
icontains忽略大小写包含英文关键词匹配首选
matches正则匹配验证邮箱、订单号、日期格式
is-json输出是合法 JSON下游要解析 JSON 时必用
contains-json输出中包含 JSON 对象模型把 JSON 包在代码块里时也能用
javascript自定义 JS 函数复杂逻辑,比如解析 JSON 后逐字段校验
similar余弦相似度阈值语义模糊场景,要求"意思接近"而不是字面一致
llm-rubric用另一个 LLM 打分需要判断语气、风格、回答质量时

我建议的原则是:能用硬断言就不用软断言。像"必须包含订单号""必须是合法 JSON"这种硬规矩,用contains、is-json、matches搞定;像"回答是否礼貌""立场是否中立"这种主观判断,才用llm-rubric。如果整个测试用例全用语义相似度,你实际上是把评判权完全交给了模型,测试的稳定性会很差。

给一个自定义断言的例子,这是检查模型输出 JSON 里订单状态字段的:

assert: - type: is-json - type: javascript value: "const data = JSON.parse(output); return data.status === 'PENDING' && data.eta.length > 0;"

output是内置变量,代表模型完整输出。这个断言的意图很清楚:输出必须是合法 JSON,而且解析之后的status字段要是PENDING,eta不能为空。这在传统单元测试里也就是几行代码,但放到提示词上下文,它就是一条非常硬核的回归防线。

3.4 批量跑分与报告解读

单条用例只是开始,回归测试的价值在于规模。我一般会把核心场景全部沉淀成用例,少则二十条,多则上百条。多用例配置一次跑完,Promptfoo 会逐个模型、逐条用例执行,最后汇总通过率。

真实项目里的配置会长这样:

prompts: - file://prompts/system.txt providers: - openai:gpt-4o - openai:gpt-4o-mini tests: - vars: question: "我的订单已经付款三天了,为什么还显示未发货?" assert: - type: icontains value: "订单" - type: is-json - vars: question: "你们家的退货政策是什么?" assert: - type: icontains value: "退货" - vars: question: "女朋友生气了,买什么礼物能哄她开心?" assert: - type: llm-rubric value: "回答需要避免推荐贵重物品,优先推荐心意类礼物,语气要温暖"

这时一次promptfoo eval会跑 2 个模型 × 3 个用例,共 6 次调用。如果模型多了、用例多了,promptfooconfig.yaml里的 tests 也可以单独放到tests/cases.yaml文件里,通过下面方式引用:

tests: - file://tests/cases.yaml

跑完后,我建议先忽略那个花花绿绿的 web 报告,直接看命令行表格的pass/fail比例。如果所有用例都绿,再看一眼 cost 和 latency,因为模型输出的质量、成本和速度往往是三个互相冲突的目标。Promptfoo 的报告页面里还有一个很实用的功能——失败用例可以直接点开看输入、输出、断言详情,排查问题省事很多。

4. 进阶:多模型对比、提示词 A/B 与 CI 集成

4.1 多模型对比:同一个提示词,不同模型的差距

Promptfoo 天然支持在 providers 里写多个模型,于是你就有了一个非常顺手的模型评测工程:

providers: - openai:gpt-4o - openai:gpt-4o-mini - anthropic:claude-3-5-sonnet - ollama:llama3

跑一轮以后,你会看到一个矩阵表格:每一行是模型,每一列是测试用例,通过率一目了然。

这种对比在实际决策中非常实用。比如成本敏感的场景,你想用gpt-4o-mini替换gpt-4o,直接跑一次对比,如果 mini 在你的用例集上通过率只差一两个点,但成本低到十分之一,这个替换就是有数据支撑的。否则你只能靠感觉拍板,后面出了问题再互相甩锅。

有了 Promptfoo 之后,"换模型"这件事从拍脑袋变成了走测试流程。

4.2 提示词 A/B:同一批用例,两个版本的提示词

回归测试不只是防止变差,还可以主动帮你做提示词迭代决策。把两个版本的提示词模板同时放在 prompts 数组里,跑同一批测试用例,通过率高、成本低、延迟短的那个就是当前最优版本。

prompts: - file://prompts/system_v1.txt - file://prompts/system_v2.txt providers: - openai:gpt-4o tests: - file://tests/cases.yaml

跑完以后,Promptfoo 的报告会按 prompt 名称分组展示。你就可以明确地回答:"新版提示词在 20 个用例里通过了 18 个,旧版只过了 14 个,而且新版平均输出短了 30%,改吧。"

这个方法我强烈建议养成习惯。每次你想调整提示词,不要直接改生产模板,而是新建一个system_v2.txt,跑一轮对比再合并。这样你的 Git 历史里不仅有"改了什么",还有"为什么改"——因为测试结果就是理由。

4.3 接进 CI/CD:让回归测试自动跑

本地跑多了,自然就想让它在每次提交代码时自动跑。Promptfoo 在 CI 里工作得很好,原因是它的一条命令就能完成评估并返回退出码:只要有任何一项断言失败,命令就会以非零状态退出;全部通过则退出码为 0。这个特性让它在 GitLab CI、GitHub Actions、Jenkins 里都能直接作为一个 job 使用。

这里给一个 GitHub Actions 的最小配置片段,你可以参考着改:

name: prompt-eval on: pull_request: paths: - 'prompts/**' - 'promptfooconfig.yaml' jobs: eval: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm install -g promptfoo - run: promptfoo eval env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

我特意在paths里限定了触发条件——只有提示词文件或配置文件发生变化时才跑,避免每次 PR 都白跑一遍。还有一个细节:你可以在 CI 里故意先跑一个不带--cache的完整评估,保证不会因本地缓存而蒙混过关。

接入 CI 以后,提示词的改动就有了"红灯绿灯"机制。这带来的心理变化很有趣:以前改提示词靠感觉,现在看到红灯就想赶紧查是哪条用例挂了;以前新人入职乱改提示词,现在直接说"你去跑一下 CI 看能不能过"。

5. 常见问题与避坑清单

5.1 非确定性输出:为什么同一个用例有时过有时挂

这是所有人刚接触提示词测试时都会崩溃的问题。同一份代码、同一条用例,跑两次结果可能不同,因为 LLM 本身有采样随机性。你解决不了随机性,但可以降低随机性对测试结果的影响。

第一,设 temperature 为 0。Promptfoo 支持在 provider 后面带参数:

providers: - id: openai:gpt-4o config: temperature: 0

这能把抖动降下来,但不可能完全消除。第二,尽量不依赖equals这类绝对断言,改用contains、icontains、is-json、llm-rubric,这些对轻微措辞变化有容忍度。第三,为关键用例增加重复采样,比如promptfoo eval --repeat 3,每条用例跑三次,只有两次以上失败才判定失败。这种方式能明显减少 flaky 测试,代价是成本乘以三。

我个人还有个习惯:把"稳定性"本身作为一个测试维度来跑,用同一组用例连着跑多次,统计哪条用例挂得最多。挂得最多的那条,往往不是模型问题,而是你的提示词在那个场景上本身就模棱两可。

5.2 断言的误报与漏报:软硬断言怎么配

断言设计得不好,回归测试要么形同虚设,要么三天两头报红灯。过于宽松,比如每个用例都只加一个contains,很容易出现漏报——模型输出完全跑偏但碰巧包含一个关键词。过于严格,比如equals,必然天天误报,因为模型不可能每次输出一模一样。

我的经验是三层组合。第一层查格式:is-json、matches,保证下游能解析。第二层查关键内容:icontains检查必要的信息点是否出现。第三层查质量:llm-rubric检查语气、逻辑、立场是否符合预期。这三层里,第一层是硬门槛,第二层是核心,第三层要少用、精用,因为每个 rubric 判断都会额外消耗一次模型调用,用例多了成本很高。

还有一个特别容易踩的坑:llm-rubric默认用的裁判模型有时候和你测的主模型是同一个,这会造成"自己给自己打分"的偏袒。Promptfoo 支持单独指定 rubric 的 provider,建议用不同厂商或至少不同型号的模型来当裁判,客观性会好一些。

5.3 成本和速率:跑一次到底要花多少钱

这是我要专门说的一件事:提示词回归测试不是免费的,一不小心账单就盖不住了。

一次评估的调用次数可以按这个公式估算:

调用次数 ≈ prompt 数量 × provider 数量 × 用例数量 × 重复次数

举个例子:你测 2 个提示词版本的 A/B,配 3 个模型,写了 30 条用例,每条跑 3 次(--repeat 3)——总共就是 2 × 3 × 30 × 3 = 540 次模型调用。如果主模型是 gpt-4o,这 540 次调用大约要消耗几十万 token,一次 CI 跑下来折合几美元到十几美元。按每天提交几次 PR 来算,一个月下来是一笔不小的开支。

控制成本的办法有几个:

  • 本地开发时用小模型先跑通,CI 里再用大模型。
  • 核心用例数量控制在 20-50 条,别把什么都放进去。
  • Promptfoo 自带缓存,只要输入完全一致,重复跑会命中缓存不额外调用;只有当你确实要重测时才用--no-cache。
  • 在 provider config 里设置max_tokens,限制输出长度。

我把这一条放在常见问题里,是因为大多数人第一次玩 Promptfoo 的时候都忽略了成本,直到收到账单才心疼。做提示词工程也一样,不是效果越高越好,要在质量、成本、延迟三者之间做取舍。

5.4 报错排查:几个高频问题速查

现象最常见原因解决办法
Provider API key missing环境变量没配确认OPENAI_API_KEY已 export 或写入.env并已加载
YAML parse errorYAML 缩进错误不要用 Tab,统一两个空格;用promptfoo eval --verbose看详细堆栈
Model not found模型名写错到 Promptfoo 文档里查对应 provider 的准确模型 ID
Request timed out网络问题或模型负载高在 provider config 里加大timeout
Local model connection refusedOllama 没启动先ollama serve再跑;确认模型已ollama pull
CI 里明明失败了但 job 显示成功没有让退出码生效确认没有在 eval 命令后面接 `

排查报错我有个通用习惯:先加--verbose跑一遍,Promptfoo 会把每个请求的完整过程和报错原因打出来,百分之八十的问题一眼就能定位。

最后分享一点实际操作体会

从第一次用 Promptfoo 到现在,我最深的感受是:给提示词加回归测试,最大的受益者不是团队,而是"几天后的自己"。提示词的很多决策细节,当时觉得刻骨铭心,两周后就忘得一干二净。把测试用例写成配置文件放进 Git,等于给每个决策都留了证据。

还有一个值得养成的习惯:每次改提示词之前,先跑一次当前版本的基线,把通过率记下来,再改,改完再跑一次对比。别看这个动作简单,它能逼你想清楚"我到底期望这次改动带来什么变化",而不是漫无目的地调参。跑的次数多了,你会发现很多所谓的"优化"其实并没有让用例通过率提高——反而是你把第三层的 rubric 调松了。这时候 Promptfoo 会毫不留情地把真相摆在你面前。

对于刚开始接触的人,我建议不要一上来就追求几十上百条用例。先挑 5 到 10 个日常最常踩的高频场景,配上格式断言加一个关键内容断言,跑通流程。等你觉得"CI 里亮了红灯会心里咯噔一下"的时候,再逐步扩量。提示词回归测试这件事,最大的门槛不是工具本身,而是你要愿意把提示词当成正经代码来维护。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询