跳到主要内容

数据截至 (上游 commit c149fcf36c2a)

Promptfoo — 架构与原理

30 秒导读: Promptfoo 是一个命令行工具 + Node 库,让你用一份 YAML 声明"要测哪些 prompt、 喂哪些输入、打给哪些模型、结果要满足什么条件",然后它把这三者展开成一张矩阵,逐格真调一次 模型、逐格打分,最后给你一张能比较、能存档、能分享的结果表。同一条流水线换个用例来源,就是 它的红队(自动攻击测试)功能。


1. 这是什么(零基础也能懂)

一句话定义: Promptfoo 是 LLM 应用的测试框架——相当于 LLM 时代的 jest/pytest, 只不过被测对象是"一段 prompt + 一个模型 + 一个应用端点"的组合,而断言允许"不确定"(可以让另一个 模型来当裁判)。

解决什么问题。 假设你在写一个客服机器人的 prompt。你改了一版措辞,想知道:

  • 新版比旧版好吗?
  • 换成另一家模型会不会更便宜且不变差?
  • 上次修好的那个 bad case,这次还稳吗?
  • 有人故意诱导它泄露系统提示词,它扛得住吗?

手工去 playground 里一条条试,不可复现、不可回归、不能进 CI。Promptfoo 把这件事变成一份可以进 Git 的配置文件。

给谁用: 写 prompt / 做 LLM 应用的工程师(评测与回归),以及做 AI 安全的人(红队扫描)。

它能做什么:

能力说明
评测(eval)同一批输入跑多个 prompt × 多个模型,横向对比
断言(assert)66 种内置检查,从 containsllm-rubric(让模型当裁判)
任意被测目标不只是模型 API,还能是你的 HTTP 服务、Python 脚本、浏览器、MCP server
红队(redteam)自动生成攻击用例并变形,扫出越狱、越权、数据泄露等风险
结果落地本地 SQLite 存历史、CLI 表格、本地 Web 报告、可选分享链接
CI 集成退出码 + 阈值,可以直接卡住 PR

用起来什么样。 一份最小配置(取自克隆里的示例 examples/simple-test/promptfooconfig.yaml):

prompts:
- file://prompts.txt
providers:
- openai:chat:gpt-5.4-mini
tests:
- description: Check for exact match
vars:
body: Yes
assert:
- type: equals
value: Yarr
- description: Use LLM to evaluate output
vars:
body: The quick brown fox jumps over the lazy dog
assert:
- type: llm-rubric
value: Is spoken like a pirate

然后两条命令:

promptfoo eval # 跑矩阵,终端里出结果表
promptfoo view # 起本地 web 服务,看详细报告

一句话直觉。 把它想成一张 Excel 表:行是测试用例(vars),列是"prompt × 模型"的组合, 每一格里 Promptfoo 帮你真调一次、把回答填进去、再顺手打个分。这张表就是整个项目的核心心智模型—— 后面所有机制,都是围绕"怎么把格子摊出来、怎么高效跑完、怎么给一格打分、格子存哪儿"展开的。

本文后面统一把矩阵里的一个组合叫 格(cell):一格 = 一个 test × 一个 prompt × 一个 provider。 一格恰好对应一次目标调用和一次打分。源码里它叫 RunEvalOptions, 01-config-to-matrix 里称之为"单元"——同一个东西的三个名字。


2. 顶层全景(它大概怎么转)

怎么读这张图: 从上到下是一次 promptfoo eval 的数据流,每一层的产物写在箭头旁边。

promptfooconfig.yaml
(prompts / tests / providers / defaultTest)


① 配置层:多份配置合并 → 展开成矩阵
│ 产物:一个扁平的「格」数组

② 执行引擎:按并发上限逐格跑
│ 一格 = 渲染 prompt → 调目标 → 拿输出

③ 断言层:给这一格打分
│ 确定性检查 / LLM 裁判 → 加权 → pass | fail

④ 结果层:SQLite + JSONL 双写

┌────────┼────────┐
▼ ▼ ▼
CLI 表格 Web 报告 分享链接

红队是支线,不是另一套系统——它只负责"生产格子的内容",生产完照样丢进上面这条流水线:

红队配置(plugins + strategies)


插件生成攻击用例 ──► 策略把用例变形
(按风险类别造) (编码 / 越狱 / 多轮)


一批普通 TestCase


进入 ① ② ③ ④ 同一条流水线


风险评分 + 漏洞报告

部件一句话职责:

部件干什么主文件
CLI 入口注册 eval/view/redteam 等子命令src/main.ts
配置解析多份 YAML 合并、解析成一个 TestSuitesrc/util/config/load.ts
矩阵展开tests × providers × prompts 摊成扁平格数组src/evaluator.ts
执行引擎并发调度、超时、限流、断点续跑src/evaluator.ts + src/scheduler/
Provider 抽象把任何被测目标包成统一的 callApi()src/providers/index.tssrc/providers/registry.ts
断言与打分66 种断言 + LLM 裁判 + 加权聚合src/assertions/index.tssrc/matchers/
红队插件造攻击用例、策略做变形、风险评分src/redteam/
结果存储SQLite(drizzle ORM)+ JSONL 流式落盘src/models/eval.tssrc/database/tables.ts
观测内置 OTLP 接收器,把 trace 挂到格上src/tracing/

主线走一遍(高层,不进代码):

  1. 读配置。 resolveConfigs 把命令行参数 + 一份或多份 YAML 合成一个 TestSuite (src/util/config/load.ts:768)。多份配置的合并逻辑在 combineConfigs (src/util/config/load.ts:527)。

  2. 实例化目标。 配置里 openai:chat:gpt-5.4-mini 这样的字符串,被解析成一个真正的 provider 对象(loadApiProvidersloadApiProvider,src/providers/index.ts:83)。

  3. 摊平矩阵。 buildRunEvalOptions 用四层嵌套循环遍历 test × repeat × vars 组合 × provider × prompt,把每个组合 push 进一个扁平数组 runEvalOptions(src/evaluator.ts:2460,push 点在 :2552)。数组里每一项就是"一格"的 完整执行参数。每层循环具体落在哪个函数,见 01-config-to-matrix §10.6。

  4. 并发跑格。 Evaluator 类拿这个数组交给 async.forEachOfLimit,按并发上限跑 (src/evaluator.ts:3285 定义类,并发点在 :3853)。一格的内部流程由 runEval 驱动(src/evaluator.ts:1559)。

  5. 打分。 拿到模型输出后,runAssertions 跑完这一格上挂的所有断言,加权平均出一个分 (src/assertions/index.ts:752)。

  6. 落地。 每格结果写进 SQLite(src/models/eval.ts:751addResult),同时可流式写 JSONL;跑完再渲染 CLI 表格、可选生成分享链接(src/share.ts:685)。

整条链的驱动者是 doEval(src/node/doEval.ts:235)——它把 2→6 串起来,promptfoo eval 和 MCP 工具调用走的是同一个函数。


3. 阅读地图

六章按"由浅入深、跟着数据流走"排序。想快速理解全局,按 01 → 02 → 04 读;要接自家系统读 03; 做安全读 05;要做看板/CI 读 06。

章节讲什么什么时候读
01-config-to-matrix — 从 YAML 到测试矩阵配置怎么合并、vars 数组怎么变成组合、repeat/scenario 怎么放大矩阵想搞懂"我写的 YAML 到底会跑出多少次调用"
02-execution-engine — 执行引擎runEval 一格内部的十来个步骤、并发降级规则、限流、中断恢复跑得慢、被限流、想续跑
03-provider-abstraction — Provider 抽象ApiProvider 接口、两级注册表派发、HTTP/脚本/自定义 provider要测自己的服务而不是模型 API
04-assertions-and-grading — 断言与打分断言注册表、llm-rubric 等裁判类断言、权重/阈值/命名指标想知道"pass 到底怎么算出来的"
05-redteam — 红队插件 vs 策略的分工、synthesize 生成流程、迭代式攻击器、风险分做 AI 安全 / 漏洞扫描
06-results-storage-and-observability — 结果落地与观测表结构、分页查询、JSONL 流式写、分享、内置 OTLP 接收器要做历史对比、看板、CI 集成

4. 巧妙之处(可借鉴的技术)

这一节是"精华带走清单"。每条先说妙在哪,再给源码位置。

① 先摊平,再执行——于是续跑变成了一次数组过滤。

矩阵展开不是嵌套循环里边算边跑,而是先把所有格子 push 成一个扁平数组再交给调度器 (buildRunEvalOptions,src/evaluator.ts:2460)。好处在续跑上体现得最明显:--resume 的实现 只是从数组里把已完成的 testIdx:promptIdx 删掉,执行逻辑一行都不用改 (filterCompletedResumeSteps,src/evaluator.ts:2904)。

repeat 用缓存命名空间实现,而不是"关掉缓存"。

同一格要重复跑 5 次取分布时,天真做法是绕过缓存;Promptfoo 的做法是给第 N 次重复套一个 repeat:N 的缓存命名空间(getRepeatCacheNamespace,src/evaluator.ts:433; withCacheNamespace,src/cache.ts:249)。结果:重复之间互不撞车,但每一次重复自己仍然可缓存, 中断后重跑照样省钱。

③ Provider 派发是两级的:便宜的闸门 + 精确的谓词。

ProviderFamily.canHandle 只做一次廉价的字符串前缀判断,用来决定"要不要把这一族 provider 的模块 import 进来";真正的路由交给每个 ProviderFactory.test(src/providers/registryTypes.ts:10-31)。 两者刻意重复,是为了让冷启动只加载真正用到的那一族——注册表 providerMap 本身就有 78 条 provider 规则,另外三个体量最大的 provider 家族(AWS / Google / redteam)干脆不进这张表、 只登记成懒加载的 ProviderFamily,否则全量加载会显著拖慢 CLI (getProviderFactories,src/providers/registry.ts:1767)。

④ 断言注册表就是一张对象字面量,加一种断言 = 加一个 key。

ASSERTION_HANDLERSRecord<BaseAssertionTypes, (params) => GradingResult> 的字面量表, 66 个断言类型一一映射到 handler(src/assertions/index.ts:229-319),分发处只有一行查表 (src/assertions/index.ts:654)。所有 handler 共享同一个 AssertionParams 入参形状, 所以"确定性检查"和"LLM 裁判"在调用侧完全同构。

顺带一个很实用的小设计:weight: 0 的断言被强制置为 pass: true (src/assertions/index.ts:670)——于是同一套断言语法既能当判定,也能当纯指标采集

⑤ 有状态特性会自动把并发压到 1,而不是留给用户踩坑。

用了会话变量 _conversationstoreOutputAs 寄存器、或浏览器 persistSession 时,格与格之间就有 了顺序依赖。adjustConcurrencyForSerialFeatures 检测到这些特性会直接把并发降为 1 并打日志说明原因 (src/evaluator.ts:2934)。把"隐式的正确性前提"变成显式的、会说话的降级。

⑥ 红队复用主流水线,靠的是把攻击建模成两个极小的接口。

  • 插件(plugin) = 产 TestCase[]:PluginFactory 只有 key / validate / action 三个字段(src/redteam/plugins/index.ts:94-98)。
  • 策略(strategy) = TestCase[] → TestCase[] 的变形函数:Strategy 接口只要求一个 action (src/redteam/strategies/types.ts:94-105)。

Base64 编码、多语言翻译、Crescendo 多轮升级……34 个策略全部塞进这一个签名里 (Strategies,src/redteam/strategies/index.ts:42)。因为产物是普通 TestCase,红队用例进的是和 普通评测完全相同的执行/断言/存储链路——没有第二套引擎要维护。

⑦ 分层边界是配置化并进 CI 的,不是靠口头约定。

architecture/layers.jsontierOrder + 每层 allowedDependencies 声明了模块依赖方向,还限制了 循环依赖团的最大规模(maxStronglyConnectedComponentSize: 6),由 npm run architecture:check (scripts/checkArchitectureBoundaries.ts)强制。对一个动辄出现 4.9k 行 evaluator.ts 这种量级核心文件的项目,这是防腐烂的关键。


5. 代码地图(导航索引)

想读源码时从这张表跳。符号名比行号抗漂移,grep 符号名通常仍能定位。

主题文件路径符号名关键位置
CLI 命令注册src/main.tsevalCommandredteamBaseCommandsrc/main.ts:125:93
一次 eval 的总驱动src/node/doEval.tsdoEvalsrc/node/doEval.ts:235
配置合并与解析src/util/config/load.tsresolveConfigscombineConfigsreadConfig:735:494:325
矩阵展开(格数组)src/evaluator.tsbuildRunEvalOptionsappendRunEvalOptionsForTestCasesrc/evaluator.ts:2460:2365
vars 笛卡尔积src/evaluator.tsgenerateVarCombinationssrc/evaluator.ts:1897
一格的生命周期src/evaluator.tsrunEvalrunEvalInternalsrc/evaluator.ts:1559:1410
执行引擎主体src/evaluator.tsEvaluatorevaluatesrc/evaluator.ts:3285:4871
并发与超时src/evaluator.tsrunConcurrentEvalStepsprocessEvalStepWithTimeoutsrc/evaluator.ts:4009:3486
并发降级规则src/evaluator.tsadjustConcurrencyForSerialFeaturessrc/evaluator.ts:2934
断点续跑src/evaluator.tsfilterCompletedResumeStepsbuildExistingPromptsMapsrc/evaluator.ts:2904:2027
限流与自适应并发src/scheduler/RateLimitRegistryProviderGroupedCallQueuesrc/scheduler/rateLimitRegistry.ts:22src/scheduler/providerCallQueue.ts:14
Provider 加载src/providers/index.tsloadApiProviderloadApiProviderssrc/providers/index.ts:83:370
Provider 注册表src/providers/registry.tsproviderMapgetProviderFactoriessrc/providers/registry.ts:138:1677
Provider 接口约定src/providers/registryTypes.tsProviderFactoryProviderFamilysrc/providers/registryTypes.ts:10:28
断言分发src/assertions/index.tsASSERTION_HANDLERSrunAssertionsrc/assertions/index.ts:229:406
断言批量执行src/assertions/index.tsrunAssertionsrunCompareAssertionsrc/assertions/index.ts:752:842
加权聚合与阈值src/assertions/assertionsResult.tsAssertionsResulttestResult:96(累加)、:149(归一化)、:154(阈值)
LLM 裁判src/matchers/llmGrading.tssrc/matchers/comparison.tsmatchesLlmRubricmatchesSelectBestselectMaxScorellmGrading.ts:168comparison.ts:16comparison.ts:86
红队用例生成src/redteam/index.tssynthesizeapplyStrategiessrc/redteam/index.ts:963:593
红队插件注册src/redteam/plugins/index.tsPluginsPluginFactorysrc/redteam/plugins/index.ts:741:86
红队插件基类src/redteam/plugins/base.tsRedteamPluginBasegenerateTestssrc/redteam/plugins/base.ts:41:106
红队策略注册src/redteam/strategies/index.tsStrategiesStrategysrc/redteam/strategies/index.ts:42types.ts:3
风险评分src/redteam/riskScoring.tscalculatePluginRiskScorecalculateSystemRiskScore:207:320
结果模型src/models/eval.tsEvalsaveaddResultgetTablePage:304:662:744:1224
数据库表结构src/database/tables.tsevalsTableevalResultsTabletracesTable:58:79:439
数据库连接src/database/index.tsgetDbgetDbPathsrc/database/index.ts:315:36
缓存src/cache.tsfetchWithCachewithCacheNamespacesrc/cache.ts:806:249
分享src/share.tscreateShareableUrlisSharingEnabledsrc/share.ts:685:53
追踪(OTLP)src/tracing/OTLPReceiverstartOtlpReceiverIfNeededotlpReceiver.ts:275evaluatorTracing.ts:140
Web 服务端src/server/src/server/routes/eval.ts 等路由
架构分层约束architecture/layers.jsontierOrderallowedDependenciesarchitecture/layers.json:9:27

所有引用 as-of sourceCommit: 6ea8783c0802ed2e001e3d253a461e64602c2968,行号相对克隆根 aiRef/repos/promptfoo/