Promptfoo 源码级解析:LLM 评估框架的核心设计与 CI/CD 集成实践
为什么需要专门的 LLM 测试框架
传统软件测试有断言、有 mock、有覆盖率,但到了 LLM 应用这里,这套体系全失效了。LLM 的输出是自然语言,非确定性、非结构化;同一个 prompt 在不同模型、不同温度参数下能给出完全不同的回答。你没法写 assert response == "Hello",也没法用 diff 工具比较两个对话。
Promptfoo 正是在这个断层里冒出来的工具——GitHub 23K+ Stars,已被 OpenAI 收购但仍保持 MIT 开源。它的核心理念很简单:把 LLM 测试变成声明式配置,通过预定义的评测指标(正确性、相关性、安全性等)对模型输出做自动评分,再嵌入 CI/CD 管道让每次 prompt 变更都可追溯。
本文从源码架构和配置设计两个层面,拆解 promptfoo 到底怎么工作的,以及如何把它接入你的测试流水线。
声明式测试配置:YAML 即测试用例
Promptfoo 最核心的设计决策是用 YAML 描述测试场景。不需要写 Python 或 TypeScript 代码,一个 promptfooconfig.yaml 就定义了全部测试逻辑。
# promptfooconfig.yaml
prompts:
- "Translate this to French: {{input}}"
- "Act as a French translator: {{input}}"
- |
System: You are a professional translator.
User: {{input}}
providers:
- id: openai:gpt-4o
config:
temperature: 0.3
- id: anthropic:claude-sonnet-4
config:
temperature: 0.5
- id: openai:gpt-4o-mini
tests:
- vars:
input: "Hello, how are you?"
assert:
- type: contains-any
value: ["Bonjour", "Salut"]
- type: llm-rubric
value: "The response should be a polite French greeting"
- type: cost
threshold: 0.01
- vars:
input: "Where is the Eiffel Tower?"
assert:
- type: llm-rubric
value: "Response should mention Paris and be factual"
- type: latency
threshold: 3000
三段 prompts × 三个 providers × 两个测试用例 = 9 个组合,promptfoo 自动遍历所有排列并评分。这比手写脚本调 API 然后肉眼检查结果要系统得多。
内置断言类型:从字符串匹配到 LLM 裁判
Promptfoo 提供了 30+ 种断言类型,按能力分层:
| 断言类型 | 作用 | 适用场景 |
|---|---|---|
contains / contains-any |
关键词匹配 | 基础验证,如必须包含 "error" |
javascript |
自定义 JS 表达式 | 复杂逻辑断言 |
llm-rubric |
用 LLM 评 LLM | 语义正确性、语气、风格 |
cost |
token 成本阈值 | 控制推理成本 |
latency |
响应延迟 | 性能回归检测 |
model-graded-closedqa |
闭卷问答评分 | 事实性验证 |
factuality |
事实一致性 | 防幻觉检测 |
其中最有价值的是 llm-rubric——用一个 LLM(默认 GPT-4o)作为裁判来评估另一个 LLM 的输出质量。其原理是自动构造一个评测 prompt,让裁判模型给出 1-5 分的评分和理由。
# 用 LLM 裁判评估回答质量
assert:
- type: llm-rubric
value: |
Score the response on a scale of 1-5:
- 5: Perfect, comprehensive, and well-structured
- 3: Adequate but missing details
- 1: Incorrect or irrelevant
Provide a brief justification.
threshold: 4 # 低于 4 分即失败
源码层面的评估引擎调度
这是 promptfoo 评估流程的核心代码路径。从 promptfoo eval 命令到最终报告输出,经历以下调度链:
CLI (eval.ts)
→ EvalConfigLoader (load config + resolve providers)
→ PromptRunner (iterate prompt × provider matrix)
→ TestRunner (execute tests + collect outputs)
→ AssertionEngine (run all assert types)
→ LLMRubricEvaluator (call judge model)
→ CostTracker (calculate token usage)
→ LatencyTimer (measure response time)
→ ResultAggregator (merge + rank)
→ ReportGenerator (HTML / JSON / CSV)
关键设计亮点在 AssertionEngine——它用责任链模式(Chain of Responsibility)串联不同类型的断言器,每个断言器只关心自己的匹配逻辑:
// 简化的断言引擎调度逻辑
interface Assertion {
type: string;
value: string | number;
threshold?: number;
}
class AssertionEngine {
private handlers: Map<string, AssertionHandler>;
async evaluate(output: string, assertions: Assertion[], vars: Record<string, string>) {
const results = [];
for (const assert of assertions) {
const handler = this.handlers.get(assert.type);
if (!handler) throw new Error(`Unknown assertion type: ${assert.type}`);
const start = performance.now();
const pass = await handler.evaluate(output, assert, vars);
results.push({
type: assert.type,
pass,
latency: performance.now() - start,
});
}
return results;
}
}
每个断言类型的 handler 独立实现,互不干扰。llm-rubric handler 内部会构造一条评测 prompt 发给裁判模型,解析返回的评分 JSON:
class LLMRubricHandler implements AssertionHandler {
async evaluate(output: string, assert: Assertion, vars: Record<string, string>) {
const rubricPrompt = `
You are evaluating an AI assistant's response.
Rubric: ${assert.value}
Response to evaluate: """${output}"""
Respond with JSON: { "score": <number 1-5>, "reason": "<brief justification>" }
`;
const judgeResponse = await this.judgeModel.call(rubricPrompt);
const { score } = JSON.parse(judgeResponse);
return score >= (assert.threshold ?? 4);
}
}
CI/CD 集成:不让 prompt 变更"裸奔"上线
Promptfoo 最实用的场景是嵌入 CI/CD 流水线。每次 PR 修改 prompt 或更换模型时,自动运行评估,对比基线,阻止退化。
GitHub Actions 配置
# .github/workflows/prompt-eval.yml
name: Prompt Evaluation
on:
pull_request:
paths:
- 'prompts/**'
- 'promptfooconfig.yaml'
jobs:
evaluate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install promptfoo
run: npm install -g promptfoo
- name: Run evaluations
run: promptfoo eval
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Compare with baseline
run: promptfoo auto-update --threshold 0.9
- name: Comment PR with results
uses: promptfoo/actions@v1
if: github.event_name == 'pull_request'
with:
promptfoo_results: 'promptfoo-output.json'
这里 auto-update 做的事情是:将本次评估结果与上一次基线对比,如果有指标下降超过阈值(10%),则标记为失败。防止 PR 合入后 prompt 质量反而变差。
在 Node.js 测试套件中调用 API
除了 CLI 模式,promptfoo 也提供编程接口:
import promptfoo from 'promptfoo';
const results = await promptfoo.evaluate({
prompts: ['Summarize: {{text}} in one sentence'],
providers: ['openai:gpt-4o'],
tests: [
{
vars: { text: 'AI safety is important because...' },
assert: [
{ type: 'llm-rubric', value: 'The summary should be under 20 words and capture the main point', threshold: 4 },
{ type: 'latency', threshold: 2000 },
],
},
],
});
console.log(results.stats);
// { passRate: 0.85, totalTokens: 1523, avgLatencyMs: 1240 }
适合在 Jest / Vitest / Mocha 测试中混入 AI 评测。
红队测试(Red Teaming):自动发现 prompt 注入漏洞
Promptfoo 的 red team 模块是另一个亮点。它不是简单用一个 prompt 列表去测,而是基于模型的攻击向量生成——让攻击者模型自动生成对抗性 prompt:
promptfoo redteam init
promptfoo redteam run
这会生成数百条测试用例,覆盖: - Prompt 注入:尝试绕过系统指令 - 越狱攻击:DAN (Do Anything Now) 类 prompt - 角色扮演越权:冒充管理员 - 信息泄露:诱导输出 API key、数据库密码 - 拒绝服务:超长输入导致 OOM - 偏见与有害内容:生成歧视性、暴力内容
配置粒度很细:
# redteam.config.yaml
redteam:
plugins:
- id: prompt-injection
config:
strategies: ["direct", "indirect", "encoded"]
- id: jailbreak
config:
severity: "high"
- id: pii
config:
detection-types: ["email", "phone", "ssn", "api-key"]
- id: harmful
categories: ["hate-speech", "self-harm", "violence"]
target:
id: openai:gpt-4o
config:
systemPrompt: "You are a helpful customer support agent..."
输出是 HTML 报告,会列出每个攻击向量、目标响应和风险等级。这个功能在生产环境上线前做一次,能省很多安全审计的沟通成本。
RAG 评估:不只是测 LLM
RAG 应用的评测比纯 LLM 调用更复杂——牵涉检索质量和生成质量两个维度。Promptfoo 用 rag 类型的测试覆盖这个场景:
prompts:
- "Answer based on the context: {{context}}\n\nQuestion: {{input}}"
providers:
- id: openai:gpt-4o
tests:
- vars:
input: "What is the refund policy?"
context: file://docs/refund-policy.md
assert:
- type: rag
config:
# 检索质量
retrieval:
- type: precision
threshold: 0.8 # 检索结果相关度
- type: recall
threshold: 0.7 # 是否召回全部关键文档
# 生成质量
generation:
- type: faithfulness
threshold: 0.9 # 回答忠实于上下文
- type: answer-relevance
threshold: 0.8 # 回答与问题的相关性
这里 faithfulness 评测特别有意思——它不检查回答是否"正确"(因为没有 ground truth),而是检查回答中每个陈述是否都能在提供的 context 中找到依据。本质上是在做引用溯源检查,对抗 RAG 最常见的幻觉问题。
实际踩坑记录
过去半年在项目里深度使用了 promptfoo,几个值得注意的点:
1. LLM 裁判的一致性偏差
用 llm-rubric 时,裁判模型偶尔会"偏袒"同个家族的模型——GPT-4 给 GPT-4o 打分偏高,Claude 给 Claude 打分偏高。解法:裁判模型用第三方(如 DeepSeek V3),或对同家族做归一化校准。
2. 测试成本控制
全量跑几十个模型 × 几百条测试用例,token 消耗不小。建议分策略: - 每次 PR 跑最小集(1-2 个模型 × 核心断言) - 每天定时跑全量集 - 用 cost 断言设 token 上限,超出即失败
3. 随机性带来的假阴性
温度 > 0 时,同样的测试跑两次可能得到不同结果。建议: - 评测环境设 temperature: 0 - 对有随机性的测试跑 3 次取多数票 - 通过 --repeat 3 参数控制
4. 上下文窗口限制
RAG 评测中 context 很长时容易超 token 限制。promptfoo 默认不会自动截断,需要手动在 provider config 里设 max_tokens 和 max_payload_size:
providers:
- id: openai:gpt-4o
config:
max_tokens: 4096
max_payload_size: 32000
对比其他 LLM 测试工具
| 工具 | 定位 | 配置方式 | CI/CD | 红队测试 | 开源 |
|---|---|---|---|---|---|
| Promptfoo | 全栈 LLM eval | YAML | ✅ | ✅ | MIT |
| LangSmith | LangChain 生态 | Python SDK | ✅ | ❌ | 部分 |
| DeepEval | Python 原生 | Pytest | ✅ | ❌ | Apache 2.0 |
| RAGAS | RAG 专项 | Python | ❌ | ❌ | Apache 2.0 |
| Guardrails | 输出校验 | XML/RAIL | ❌ | ❌ | MIT |
Promptfoo 的差异化优势是 YAML 声明式 + 红队测试 + CI/CD 原生集成,且语言无关——不绑定 Python 生态,Node.js、Go、Rust 项目都能用。
进阶方向
- 自定义断言插件:通过
promptfoo plugins接口写自定义评估器 - 缓存策略:对确定性测试启用响应缓存,减少重复 API 调用
- 漂移检测:持续跟踪同一 prompt 在模型更新后的输出变化
- 多模态评测:promptfoo v1.x 已开始支持图像输入评测
Promptfoo 的核心价值不是替你做测试,而是把 LLM 测试从手工对话变成可编程、可复现、可量化的工程流程。对于正在把 LLM 推向生产的团队,这应该是工具箱里的第一件工具。
更多推荐

所有评论(0)