跳到主要内容

数据截至 (上游 commit c149fcf36c2a)

Promptfoo — Provider 抽象:让任何东西都能当被测目标

30 秒导读: 01-config-to-matrix 把 YAML 展开成了「test × provider × prompt」的测试矩阵,02-execution-engine 讲了一格怎么跑完。这一章只回答一个问题:矩阵里那个「provider」到底是什么东西,凭什么它既可以是 openai:gpt-5、也可以是一个 Python 文件、一台跑着 Claude Agent SDK 的进程、一个 MCP 服务器、甚至一个浏览器?

答案短得出奇:一个对象只要有 id()callApi() 两个方法,它就是合法的被测目标。剩下的全部工程量,都花在**「怎么把配置里那行字符串变成这样一个对象」「怎么让最难缠的那类目标(任意 HTTP 服务)也能被一行 YAML 描述出来」**上。

本章只讲被测对象(target)这一侧。同一个 ApiProvider 接口在 promptfoo 里还兼任「裁判模型」和「攻击者模型」两个角色,那是 04-assertions-and-grading05-redteam 的内容,这里只在 §8 埋个伏笔。


1. 要解决的小问题:被测对象长得千奇百怪

评测框架的天然假设是「被测的是一个模型 API」。但真实要测的东西很少那么干净:

  • 团队自己包了一层 RAG 后端,只暴露一个 POST /chat,请求体字段名和任何厂商都不一样。
  • 要测的是一个 coding agent(Claude Agent SDK / Codex / OpenCode),它会跑很多轮、调工具、改文件。
  • 要测的是一个 MCP 服务器的某个 tool,输入是 JSON 参数不是自然语言。
  • 要测的是网页上那个聊天框,只能用浏览器点。
  • 业务逻辑在一段 Python 里,最省事的办法是直接跑那个函数。

这些东西唯一的共同点是:给它一段文本,它迟早会还你一段文本。promptfoo 的做法就是把这个共同点定死成接口,其余差异全部推到各自的实现里去。

一句话直觉:provider 是被测系统的「插头」标准。矩阵引擎只认插头形状,插头后面是模型、是脚本、还是一整台浏览器,它不关心。


2. 契约:一个必答题 + 一堆选答题

这节讲那个「插头形状」到底规定了什么。

2.1 最小契约只有两行

最内层的接口小到可以整段抄下来(src/contracts/prompts.ts:4-8MinimalApiProvider):

export interface MinimalApiProvider {
id: () => string;
callApi: (prompt: string, context?: any, options?: any) => Promise<any>;
label?: string;
}

它被放在 src/contracts/ 而不是 src/types/ 里,是为了让「写自定义 provider 的人」只依赖一个没有循环引用的最小声明。

对外的完整接口 ApiProvider 在此基础上加的全是可选项src/types/providers.ts:123-140):

成员必需干什么
id()这个 target 在结果表里的身份,也是缓存与筛选的键
callApi()唯一的执行入口
label人读的显示名;给了就优先当身份用
config构造时吃进来的配置,回显给 UI / 结果落库
delay每次调用后限速用的毫秒数
transformprovider 级输出后处理(在断言之前跑)
getSessionId()让目标自己报告会话 ID
cleanup()eval 结束时释放长期资源(子进程、浏览器、连接池)
callEmbeddingApi()当嵌入模型用
callClassificationApi()当分类器用
callModerationApi()当内容审核器用(ApiModerationProvidersrc/types/providers.ts:154-156
toJSON()落库/分享时的自定义序列化

后四个「call*Api」不是被测目标要实现的,它们是同一个接口被复用到别的角色上的产物:嵌入用于 similar 断言、审核用于 moderation 断言——都在 04-assertions-and-grading

关键判定函数是鸭子类型而非 instanceofisApiProvider 只检查 id 是函数、callApi 是函数(src/types/providers.ts:169-178)。所以一个字面量对象、一个 class 实例、一个从 .ts 文件 import 出来的东西,地位完全平等。

2.2 调用签名:三个参数,各管一摊

callApi(prompt: string, context?: CallApiContextParams, options?: CallApiOptionsParams)

三个参数的分工很清楚:

  • prompt —— 已经渲染完的那段文本,是「主输入」。
  • context —— 这一格的全部环境(src/types/providers.ts:82-113)。
  • options —— 只有两个字段:includeLogProbsabortSignalsrc/types/providers.ts:115-121)。请求级取消走这里。

context 里几个对本章重要的字段:

字段含义谁在用
vars这一行测试的变量表HTTP 模板、脚本参数
prompt未渲染的 prompt 对象(含 config让 provider 读 per-prompt 配置
originalProvider配置里那个真正的目标包装型 provider(见 §7.3)
test整条 test case需要看断言/元数据的 provider
traceparent / tracestateW3C 追踪上下文HTTP / agent provider 透传给下游
bustCache / debug绕过缓存、加详细元数据调试与 UI 的「测试连接」

originalProvider 由 evaluator 在组装上下文时塞进去(src/evaluator.ts:1045-1055buildCallApiContextsrc/evaluator.ts:1114-1124)。它存在的理由:当 test.provider 覆盖了本格的执行者时,被覆盖掉的那个「真目标」不能丢——包装型 provider 要靠它才能转发。

2.3 返回值:ProviderResponse 是一份「宽表」

返回结构定义在 src/contracts/providers.ts:38ProviderResponse)。它有意做得很宽,因为下游(断言、报告、红队策略)都从这里取料:

字段谁消费
output断言的主输入(04-assertions-and-grading
error标记该格失败
raw原始响应,写进结果库供人排查
tokenUsage / cost / latencyMs汇总统计与报告(06-results-storage-and-observability
cachedevaluator 据此跳过限速 sleepsrc/evaluator.ts:1182-1186
sessionId多轮红队策略据此保持会话(05-redteam
metadata万能挂载点:http.statustoolCallsterminalReason
promptprovider 可以改写显示用的 prompt(agent 类常用)
isRefusal目标明确拒答,红队据此判定

metadata 是 agent 类 provider 的主要输出面:Claude Agent SDK 往里放 toolCalls / numTurns / permissionDenialssrc/providers/claude-agent-sdk.ts:1841-1859),这些后来会变成「这个 agent 到底做了什么」的证据。

2.4 可选钩子在哪被消费

三个可选钩子容易被误解成「provider 自己负责」,其实全部由外层调用

钩子谁调用位置
delayevaluator 在每次非缓存调用后 sleepsrc/evaluator.ts:1182-1186;默认值在 src/evaluator.ts:1587PROMPTFOO_DELAY_MS 兜底
transformevaluator 在断言之前改写 outputsrc/evaluator.ts:1469-1475
cleanup()eval 跑完后逐个 provider 调src/node/doEval.ts:1227-1235
getSessionId()只在「测试目标连通性」流程里读src/node/testProvider.ts:135

值得诚实说明:截至本 commit,仓库内置的 provider 没有一个实现 getSessionId()(会话 ID 一律通过 ProviderResponse.sessionId 返回,见 src/redteam/util.ts:451-456getSessionId(response, context))。这个接口方法是留给自定义 provider 的扩展点。

除了每个 provider 各自的 cleanup(),还有一条全局兜底通道providerRegistry 是一个进程级的清理登记处(src/providers/providerRegistry.ts:14-71),谁登记了,谁就会在 SIGINT / SIGTERM / beforeExit 时被 shutdown()src/providers/providerRegistry.ts:53-56),evaluator 正常结束时也会显式 shutdownAll()src/evaluator.ts:4978)。Python worker 池和 Codex SDK 实例就是靠它避免留下僵尸子进程。


3. 从一行字符串到一个实例:解析装配流水线

这节讲加载期:YAML 里写的那点东西,怎么变成上面那个对象。

3.1 先看全景

怎么读这张图:从上到下是一次 loadApiProvider 的执行顺序,前两步会递归回到入口(cloud 引用和文件引用都只是「换一个 id 再来一次」)。

providers: 里的一项(字符串 / 对象 / 函数)

normalizeProviderRef ← 纯函数分类,不读文件不构造

┌──────────┴──────────┐
│ │
函数 → 直接包一层 字符串 + options
(createProviderFromFunction) │

loadApiProvider(path, ctx)

① 只渲染 env 模板,保留 {{vars}}

② 是 cloud 引用? ──是─→ 拉云端配置,递归
│否
③ 是 file://*.yaml? ─是─→ 读文件取 id,递归
│否
④ 遍历工厂表,第一个 test() 命中的 create()

ApiProvider 实例

入口函数是 loadApiProvidersrc/providers/index.ts:83-221)。它只接受字符串路径——所有「provider 可以写成对象/数组/函数」的花样,都在进来之前被 normalizeProviderRef 拍平了。

3.2 四种写法,一个分类器

配置里 provider 允许写成 5 种形状,normalizeProviderRefsrc/util/providerRef.ts:182-255)把它们分类成一个判别联合:

YAML/JS 写法kind后续动作
"openai:gpt-5"named直接进 loadApiProvider
"file://targets.yaml"file先读文件展开成多个配置
{ id: "http", config: {...} }options带 options 进 loadApiProvider
{ "openai:gpt-5": { config: {...} } }map键当 id、值当 options
(prompt) => ({ output })function包成 ApiProvider,不走注册表
其它unknown抛带索引的清晰错误

mapoptions 靠一个白名单区分:如果对象的第一个键属于 PROVIDER_OPTION_KEYSid/label/config/prompts/transform/delay/env/inputssrc/util/providerRef.ts:14-23),就是 options;否则那个键被当成 provider id,是 map

函数形态最省事:createProviderFromFunctionsrc/providers/index.ts:45-63)直接造一个 { id: () => label ?? id, callApi: fn },并只转发已定义的元数据键——注释里点明了原因:显式写 undefined 会把下游的 config ?? {} 默认值覆盖掉。

3.3 解析优先级链:三级,命中即停

loadApiProvider 内部按固定顺序做三次判定:

第一级 · 云端 provider 引用src/providers/index.ts:120-163)。如果 id 形如云端引用,就去 Promptfoo Cloud 拉那个 target 的配置,然后用拉回来的真实 id 递归调用自己。这里有两个细节值得看:一是显式禁止「云 provider 指向另一个云 provider」,避免无限跳转;二是配置合并有明确优先级——本地 options.config 盖过云端 config,env 则是 context.env < cloud.env < options.env 三层叠加。

第二级 · 文件形式的 provider 配置src/providers/index.ts:165-198)。file://xxx.yaml|yml|json 会被读成一份 ProviderOptions,取出其中的 id 再递归。这里有一条硬边界:如果文件里是数组(多个 provider),直接报错让你改用 loadApiProviders——单数入口不负责一变多。

第三级 · 工厂表匹配src/providers/index.ts:200-209):

for (const factory of await getProviderFactories(renderedProviderPath)) {
if (factory.test(renderedProviderPath)) {
const ret = await factory.create(renderedProviderPath, providerOptions, context);
ret.transform = options.transform;
ret.delay = options.delay;
ret.inputs = options.inputs;
ret.label ||= renderEnvOnlyInObject(options.label || '', mergedEnv);
return ret;
}
}

注意最后四行:transform / delay / inputs / label工厂造完之后由加载器统一盖上去的,不需要每个 provider 实现类自己处理。这解释了为什么 §2.4 里那三个钩子对所有 provider 一视同仁。

三级都不中就抛一个带文档链接的错误(src/providers/index.ts:211-220)。

3.4 精华:加载期只渲染 env 模板,把 {{vars}} 留到调用期

这是本章最容易忽略、但设计最精巧的一处。

问题是这样的:provider 配置里可能同时出现两类模板:

providers:
- id: https://api.example.com/chat
config:
headers:
Authorization: 'Bearer {{ env.MY_API_KEY }}' # 构造时就得知道
body:
message: '{{ prompt }}'
userId: '{{ userId }}' # 每一行测试都不一样

env.MY_API_KEY 必须在构造 provider 时就变成真值,否则构造函数拿不到密钥;{{ userId }} 却必须原样活到 callApi(),因为它每一格的值都不同。一次性全渲染会把后者渲染成空串,从此再也救不回来。

解法是一个只认 env 的渲染器renderEnvOnlyInObjectsrc/util/render.ts:31-115)递归走遍配置对象,对每个字符串用正则抓出所有 {{ ... }},然后只有引用了 env.env[...] 的那些才交给 Nunjucks 渲染,其余原样返回src/util/render.ts:54-58)。

它还有两个细致的判断:

  • 变量不存在时保留模板而非渲染成空串src/util/render.ts:68-72)——除非模板里带了过滤器(如 | default(...)),那种情况交给 Nunjucks 自己处理。
  • 键名为 _conversation 的子树整个跳过不渲染src/util/render.ts:99-104):对话历史是运行时数据、且可能含不可信的模型输出,不能拿去当模板渲染。这是一处防注入的硬隔离。

调用点在 loadApiProvider 开头:config、id、甚至 providerPath 本身都过一遍(src/providers/index.ts:97-118)。于是形成了清晰的两段式时相

加载期 调用期
───────── ─────────
{{ env.KEY }} → 真实密钥 {{ prompt }} → 这一格的 prompt
{{ vars.x }} → 原样保留 ───────→ {{ vars.x }} → 这一行的变量值
{{ userId }} → 原样保留 ───────→ {{ userId }} → 这一行的变量值

3.5 三个批量/旁路入口

除了单个加载,src/providers/index.ts 还导出三个函数,各有明确分工:

函数位置干什么为什么需要它
loadApiProviderssrc/providers/index.ts:370-442把整个 providers: 列表并行实例化唯一能处理「一个文件里多个 provider」的入口
resolveProviderConfigssrc/providers/index.ts:300-345只展开 file:// 引用,不构造实例--filter-providers 能按文件里的真实 id 过滤,且过滤后再实例化,避免加载用不到的目标
getProviderIdssrc/providers/index.ts:463-491只取 id 列表,什么都不构造红队生成阶段只需要知道「打谁」,不需要真连上去(src/redteam/commands/generate.ts:128

resolveProviderConfigs 的价值在 src/util/config/load.ts:919-931 那段注释里说得最直白:先解析文件、再按 CLI 过滤、最后才实例化,既避免重复读盘,又避免为一个被过滤掉的目标去启动浏览器或连 MCP 服务器。


4. 注册表:78 条前缀规则 + 三个懒加载家族

这节讲第三级判定里那张「工厂表」到底长什么样。

先钉一个词:本章的**「家族」是专名**,只指 §4.2 那三个懒加载的 ProviderFamily(AWS / Google / redteam);注册表里那 78 条一律叫**「前缀规则」**,不叫家族。两者不是同一个东西,数量也差一个量级。

4.1 一条规则就是一对 test/create

注册表的元素类型只有两个字段(src/providers/registryTypes.ts:10-17):

export interface ProviderFactory {
test: (providerPath: string) => boolean;
create: (providerPath, providerOptions, context) => Promise<ApiProvider>;
}

providerMapsrc/providers/registry.ts:138-1719)就是这样一个数组,截至本 commit 有 78 条(74 个字面量 + 4 个由 createScriptBasedProviderFactory 生成的脚本工厂)。绝大多数 test 是朴素的前缀匹配:

test: (providerPath: string) => providerPath === 'a2a' || providerPath.startsWith('a2a:'),

—— src/providers/registry.ts:140,A2A provider 的匹配规则。

create 里再做二次拆分。以 anthropic: 为例:anthropic:claude-agent-sdk / anthropic:claude-code排在前面的一条规则先截走(src/providers/registry.ts:249-262),剩下的才落到通用的 anthropic: 规则去分 messages / completionsrc/providers/registry.ts:265)。顺序即优先级,这是这张表唯一的仲裁机制。

4.2 懒加载家族:canHandle 是闸门,不是分发器

三个体量大的 provider 群(AWS、Google、redteam)不放进 providerMap,而是登记成 ProviderFamilysrc/providers/registry.ts:1743-1765):

getProviderFactories(path)

├─ 没有 family 的 canHandle 命中
│ └─→ 直接返回共享的 providerMap(不复制数组)

└─ 有命中
└─→ await 那些 family 的 factories()(动态 import)
└─→ 返回 [...家族工厂, ...providerMap]

四个设计点各有原因:

其一,canHandle 与内部 test 故意重复。 类型注释写得很明确(src/providers/registryTypes.ts:19-27):canHandle加载闸门,要足够便宜(一个字符串前缀检查);真正的分发仍然由每个工厂自己的 test 完成,那个可以写得任意细。

其二,无命中时直接返回共享数组。 src/providers/registry.ts:1772-1777 明确说明:常见情况下不要为了拼接而复制 ~78 个元素,返回类型标成 readonly 防止调用方改它。

其三,家族工厂必须排在 providerMap 前面。 这是修过的一个真实 bug,注释在 src/providers/registry.ts:1806-1816providerMap 里有一条与前缀无关的后缀规则 isJavascriptFile(providerPath)src/providers/registry.ts:1440),任何以 .js/.ts/.mjs… 结尾的 provider 路径都会被它当成「自定义 JS 模块」劫走。如果某个家族的 provider id 末段恰好长这样,就会被误加载。把家族前置即可,且因为家族前缀两两不相交、也和所有具体前缀不相交,前置只改变它与那条 catch-all 的相对次序,其余顺序中性。

其四,动态 import 失败要带上下文。 家族加载被包了一层 try/catch,把裸的 ERR_MODULE_NOT_FOUND 重写成「加载 provider family for '<你写的 id>' 失败」(src/providers/registry.ts:1789-1804),否则用户看到的是一个和自己配置毫无关系的内部文件名。

4.3 自定义模块这条「万能后门」

isJavascriptFile 那条规则(src/providers/registry.ts:1439-1459)是整个体系的开放边界:任何 .js/.ts 文件被 importModule 加载后 new 出来,就是一个 provider。它保留原始路径当 id(不用解析后的绝对路径),这样结果表里显示的还是你写的那行。

注册表 = 一张「前缀 → 构造器」的路由表,加一条「给我一个 JS 文件我就 new 它」的兜底。


5. HTTP provider:把任意 HTTP 服务变成被测目标

这是整个 provider 体系里工程量最大的一支——src/providers/http.ts 有 3266 行。理由很简单:大多数人要测的不是模型 API,是自家包了一层的服务,而「自家的服务」长什么样是不可预知的。

5.1 思路:把「一次 HTTP 调用」的每一环都做成可配置的插槽

prompt + vars


① 认证物料入 vars(token / signature / sessionId)


② transformRequest:prompt → 任意形状


③ 请求装配(两条路:结构化 config / 原始 HTTP 报文)


④ fetchWithCache 发出去


⑤ validateStatus:这个状态码算不算失败


⑥ sessionParser + transformResponse → ProviderResponse

配置 schema 用 Zod 定义在 src/providers/http.ts:892-955HttpProviderConfigSchema),构造函数第一行就 parse 它(src/providers/http.ts:1903)——配置错误在加载期就炸,不会等到跑到一半。

5.2 两条互斥的请求装配路

触发条件特点入口
结构化配了 url + body/multipart按字段拼,JSON 由框架负责callApiInternalsrc/providers/http.ts:2581
原始报文配了 request:直接粘一段 POST /x HTTP/1.1\n...callApiWithRawRequestsrc/providers/http.ts:2928

分流就一个 if(src/providers/http.ts:2682-2684)。原始报文路是给「我从 Burp/DevTools 里复制了一段请求」的场景准备的:它会先把所有字符串变量做 JSON 转义再代入(escapeJsonVariablessrc/providers/http.ts:74),否则 prompt 里的引号和控制字符会撑破报文里的 JSON 字符串。它还会主动删掉用户手写的 content-length,交给 fetch 重算(src/providers/http.ts:2958)。

5.3 body 模板:先渲染,再「猜」它是不是 JSON

结构化路的核心难点是:body 模板渲染完之后,那个坑位里应该放字符串还是对象

processJsonBodysrc/providers/http.ts:1204-1267)的策略是:先整体渲染变量,然后递归遍历每个字符串值,如果它 trim 后以 {[ 开头就尝试 JSON.parse,成功就换成对象/数组。这让「先把 tools 序列化成字符串塞进模板、再自动还原成数组」成为可能——callApiInternal 正是这么预序列化 tools / tool_choice 的(src/providers/http.ts:2611-2622),好处是用户写 {{ tools }} 不必再套 | dump 过滤器。

上一层的 determineRequestBodysrc/providers/http.ts:1580-1616)再按 content-type 分岔:

  • JSON 且 transformRequest 返回了对象 → 对象合并Object.assign({}, configBody, parsedPrompt)),即 transform 的结果覆盖模板。
  • JSON 且返回字符串 → 走 processJsonBody,prompt 作为一个变量代入模板。
  • 非 JSON → processTextBody,纯文本渲染。

5.4 四个可插拔的钩子

四个钩子接受同一套三选一的写法:JS 函数 / file:// 路径(可带 :functionName / 一段内联 JS 表达式字符串

钩子作用工厂
transformRequestprompt → 请求体的任意形状createTransformRequestsrc/providers/httpTransforms.ts:81
transformResponse响应 → ProviderResponsecreateTransformResponsesrc/providers/httpTransforms.ts:18
sessionParser从响应头/体里抠出会话 IDcreateSessionParsersrc/providers/http.ts:1155
validateStatus状态码 → 是否算成功createValidateStatussrc/providers/http.ts:1619

四个实现细节值得留意:

其一,内联字符串要分辨「表达式」还是「函数」。 两个 transform 工厂都用同一个正则判断(src/providers/httpTransforms.ts:48:112):如果以 (...)=>function(...) 开头就当函数调用,否则当表达式包一层 returntransformRequest 还额外检测有没有 return 关键字,有就当函数体用(src/providers/httpTransforms.ts:127-147)——这样 json.choices[0].message.content 和一整段多行逻辑都能写。

其二,file:// 必须提前加载。 这两个工厂被设计成能在浏览器里跑(Web UI 要让你在跑 eval 之前先试 transform),所以它们不能 import ../esm。文件引用由 loadTransformModule 在构造函数里预加载好再传进来,工厂里遇到未加载的 file:// 直接抛「这是 HTTP provider 的实现 bug」(src/providers/httpTransforms.ts:38-42)。

其三,返回值自动归一。 transform 返回什么都行:返回一个 ProviderResponse 就原样用,返回别的就包成 { output: value }normalizeResponseTransformResultsrc/providers/transformResult.ts:12-14)。

其四,validateStatus 默认全放行。 不配置时返回 () => truesrc/providers/http.ts:1622-1624),也就是说 HTTP 500 默认不算失败,会带着错误体进入断言。要让 4xx/5xx 直接失败得显式写 validateStatus: 'status < 400'

5.5 会话保持:两种模式与一个小巧思

多轮红队要求「同一段对话打到同一个会话上」。HTTP provider 提供两条路:

  • 被动解析 —— 配 sessionParser,从每次响应里抠出会话 ID 放进 ProviderResponse.sessionIdsrc/providers/http.ts:2901-2908),下一轮由策略把它塞回 vars.sessionId
  • 主动获取 —— 配 session: 块(SessionEndpointConfigSchemasrc/providers/http.ts:876-890),在主请求之前先打一个「开会话」的端点。

主动模式里有一处很聪明的判断(resolveSessionIdsrc/providers/http.ts:2344-2366):provider 维护一个 fetchedSessions 集合,记录哪些会话 ID 是它自己从端点取回来的

vars.sessionId 存在?

├─ 是,且在 fetchedSessions 里 → 复用(Hydra/Crescendo 这类共享会话的多轮策略)

└─ 否(不存在,或是策略自己生成的 UUID)→ 去端点开一个新会话

这一步把「策略想续用旧会话」和「策略想要一个干净的新会话」区分开了,而区分依据不是配置,是这个 ID 是不是我给出去的

5.6 签名认证:三种证书形态收敛成一个 {{signature}} 变量

金融/企业内网的网关常要求请求带数字签名。generateSignaturesrc/providers/http.ts:340-634)支持三类证书来源:

type私钥从哪来备注
pemprivateKey / privateKeyPath / base64 的 certificateContent最常用
jksJava keystore 文件或 base64 内容需另装 jks-js;口令可走 PROMPTFOO_JKS_PASSWORD
pfxPFX/P12,或 certPath + keyPath 一对

没写 type 时会按提供了哪些字段反推src/providers/http.ts:348-361),这是为旧配置留的兼容路径。

签名不是每次都重算:refreshSignatureIfNeededsrc/providers/http.ts:2284-2326)缓存上一次的签名与时间戳,只在过期(含一个刷新缓冲窗口)时重算。算完把 signaturesignatureTimestamp 写进 vars,于是用户在 headers 模板里写 {{ signature }} 就行——三种证书形态的差异,对模板作者是不可见的

同一个「注入到 vars」的手法也用在 OAuth(vars.token)和文件型认证(vars.token + vars.expiration)上,且都会在覆盖用户已有同名变量时打 warning(src/providers/http.ts:2624-2667)。

5.7 边界与坑

  • validateStatus 默认放行(上面已说),最容易踩。
  • 构造函数强制要求 body / multipart / method: GET 三者有其一src/providers/http.ts:1937-1943invariant),否则报错——防止你写了 URL 却忘了请求体。
  • responseParsertransformResponse 的旧名,schema 里标了 deprecated(src/providers/http.ts:935-936),两者取其一。
  • 日志里的 URL、请求头、body 都过 sanitizeObject / sanitizeUrl 脱敏后才打(如 src/providers/http.ts:2771-2779)。

6. 脚本类目标:Python / Go / Ruby / 任意可执行文件

「我的逻辑在一段代码里」是另一大类目标。四个脚本 provider 共用同一个工厂生成器 createScriptBasedProviderFactorysrc/providers/scriptBasedProvider.ts:13-54):

createScriptBasedProviderFactory('exec', null, ScriptCompletionProvider),
createScriptBasedProviderFactory('golang', 'go', GolangProvider),
createScriptBasedProviderFactory('python', 'py', PythonProvider),
createScriptBasedProviderFactory('ruby', 'rb', RubyProvider),

—— src/providers/registry.ts:150-153

工厂做的事只有两件:匹配(python:xxx.py 前缀 file://xxx.py 后缀,src/providers/scriptBasedProvider.ts:19-33)、把路径解析成绝对路径再 newexec 没有扩展名所以只认前缀。

PythonProvidersrc/providers/pythonCompletion.ts:176-433)是这类里做得最重的一个,四个点值得学:

一、懒初始化 + 单飞(single-flight)。 initialize() 用一个 initializationPromise 字段保证并发调用只初始化一次;失败时把 promise 置空好让后续重试(src/providers/pythonCompletion.ts:205-256)。

二、进程池而非每次起进程。 初始化时建 PythonWorkerPoolsrc/providers/pythonCompletion.ts:238-247),之后每次 callApi 只是往池里投递(src/providers/pythonCompletion.ts:383)。这是脚本 provider 能跟上并发评测的关键。

三、缓存键含脚本内容哈希。 缓存键把 脚本路径 : 函数名 : apiType : 文件 sha256 : prompt : options : vars 全串进去(src/providers/pythonCompletion.ts:328-334)。加进文件哈希意味着你一改脚本,旧缓存自动失效——不用手动 --no-cache

四、注册全局清理。 初始化成功后 providerRegistry.register(this)src/providers/pythonCompletion.ts:246),shutdown() 里关池并注销(src/providers/pythonCompletion.ts:425-432)。这就是 §2.4 那条兜底通道的典型用法。

Python 脚本同时可以导出 call_api / call_embedding_api / call_classification_api 三个函数(src/providers/pythonCompletion.ts:404-423),对应 ApiProvider 的三个方法——一个脚本文件能同时当被测目标和嵌入模型用


7. Agent 类目标(本 shelf 的关注点)

这一节是 ai-agent-reference 读者最该看的部分:promptfoo 怎么把「一个会自己跑很多步的 agent」塞进「给一段文本、要一段文本」的接口里。

7.1 一览

provider id 前缀实现文件被测的是什么有状态吗
anthropic:claude-agent-sdk / anthropic:claude-codesrc/providers/claude-agent-sdk.ts本机跑的 Claude Agent SDK 会话是(session / resume / fork)
openai:codex-sdk / openai:codexsrc/providers/openai/codex-sdk.tsCodex SDK 线程是(thread 复用 + 串行队列)
opencode / opencode:*src/providers/opencode-sdk.tsOpenCode 服务端会话是(LRU session 映射)
mcp / mcp:<server>src/providers/mcp/index.tsMCP 服务器的某个 tool否(每次一次 tool call)
a2a / a2a:<url>src/providers/a2a/index.ts遵循 A2A 协议的远端 agent是(contextId)
browsersrc/providers/browser.ts网页上的聊天界面是(持久 Page)
ws: / wss: / websocketsrc/providers/websocket.ts长连接式接口否(每次开关一次)
sequencesrc/providers/sequence.ts包装:把一格拆成多次调用借宿主
promptfoo:simulated-usersrc/providers/simulatedUser.ts包装:让 LLM 扮用户和目标对话借宿主

7.2 三种「把多步压成一次 callApi」的做法

做法 A · 跑完整个 agent,把终局报告成一次响应。 Claude Agent SDK provider 消费 SDK 的消息流,直到拿到 result 消息,再把它压成 ProviderResponseoutputstructured_output ?? resultmetadata 里挂 toolCalls / numTurns / durationMs / permissionDenials / terminalReasonsrc/providers/claude-agent-sdk.ts:1830-1860)。「agent 中间干了什么」不进 output(那是给断言的),而是进 metadata(那是给报告和高级断言的)。

它还有几处 agent 特有的安全与可复现设计:

  • 默认工具白名单收紧:不给 working_dir 时默认不允许任何工具;给了才放开只读文件系统工具集,custom_allowed_tools / append_allowed_tools 两种覆盖方式(src/providers/claude-agent-sdk.ts:1258-1277)。
  • 危险模式要双重确认permission_mode: 'bypassPermissions' 必须同时写 allow_dangerously_skip_permissions: true,否则直接抛错(src/providers/claude-agent-sdk.ts:1239-1245)。
  • 缓存键剔除运行时对象abortController / canUseTool / cwd / mcpServers / title 等被显式排除在缓存键之外(src/providers/claude-agent-sdk.ts:1315-1328),因为它们要么是回调(哈希无意义)、要么是不影响输出的会话元数据。工具列表则去重 + 排序再入键,保证同一份配置得到同一个键(src/providers/claude-agent-sdk.ts:1260-1277)。
  • 异常终止不污染输出契约terminal_reasonaborted_ 开头时把 OTel span 标成 ERROR,但不动 output/error——已产出的内容对断言仍然有用(src/providers/claude-agent-sdk.ts:1808-1819abortedTerminalReason)。

做法 B · 一次 callApi = 一次工具调用。 MCP provider 反过来:它把 prompt 当成 JSON 解析,从里面找 tool 名和参数(容忍 tool/toolName/function/name 等多种字段名),调用后把结果转成响应(src/providers/mcp/index.ts:72-150)。这样「测 MCP 服务器」就变成了「用一堆 JSON payload 打它的 tool 面」。它的初始化也是构造函数里就发起、callApi 里 await 的模式,并显式把 promise 的 rejection 标为已观察,避免 unhandled rejection(src/providers/mcp/index.ts:39-41)。

做法 C · 把状态挂在 provider 实例上。 浏览器 provider 靠 persistedPage 字段跨多次 callApi 复用同一个 Playwright 页面(src/providers/browser.ts:127-155),注释点明了它成立的前提:Hydra 这类多轮策略会在整段对话里复用同一个 provider 实例。OpenCode provider 用一个 cacheKey → session 的 Map 加 LRU 淘汰(src/providers/opencode-sdk.ts:755-757),Codex provider 则额外为每个 thread 维护一条串行执行队列 threadRunQueuessrc/providers/openai/codex-sdk.ts:687)——同一个线程上的多次调用不能并发。

这三类都必须实现 cleanup():OpenCode 删会话 + 关服务(src/providers/opencode-sdk.ts:810-829)、Codex 销毁实例并从注册表注销(src/providers/openai/codex-sdk.ts:750-770)、MCP 关客户端(src/providers/mcp/index.ts:153-161)。

7.3 包装型 provider:originalProvider 的用法

sequencepromptfoo:simulated-user 不连接任何外部系统,它们把自己插在测试矩阵和真目标之间

SequenceProvider 是最小的示范(整个文件只有 80 行):

invariant(context?.originalProvider, 'Expected originalProvider to be set');
// ...
const response = await context.originalProvider.callApi(renderedInput, context, options);

—— src/providers/sequence.ts:45,60。它按配置里的 inputs 列表依次给真目标发消息,把回复用分隔符拼起来,token 用量累加(src/providers/sequence.ts:52-74)。

SimulatedUsersrc/providers/simulatedUser.ts:58-418)是同一模式的高级版:它每一轮先让一个托管的「模拟用户」模型生成下一句用户话术,再发给 context.originalProvider,最多 maxTurns 轮(src/providers/simulatedUser.ts:275-314)。于是「多轮对话评测」不需要 evaluator 支持多轮——它被完整地封装在一个 provider 里。

这就是 originalProvider 存在的全部理由:让 provider 可以组合,而不只是并列。


8. 官方托管 provider:为断言章与红队章埋的伏笔

有两个文件里的 provider 不是「被测目标」,而是 promptfoo 自己托管的远程能力

文件干什么属于哪章
src/providers/promptfoo.tsPromptfooHarmfulCompletionProvider:42调用未对齐模型生成有害测试用例05-redteam
src/providers/promptfoo.tsPromptfooChatCompletionProvider:175task 分发:crescendo/goat/iterative/judge04-assertions-and-grading05-redteam
src/providers/promptfoo.tsPromptfooSimulatedUserProvider:284生成模拟用户的下一句话本章 §7.3 用到
src/providers/promptfooModel.tsPromptfooModelProvider:59promptfoo:model:<name>,打服务端 /api/v1/task通用兜底模型

关键点:它们实现的是同一个 ApiProvider 接口PromptfooChatCompletionProvidertask 字段枚举里能直接看到攻击策略与裁判的名字(src/providers/promptfoo.ts:171-184),也能看到「远程生成被禁用时给出可读报错」的设计(src/providers/promptfoo.ts:214-219)。

也就是说,promptfoo 的攻击者和裁判复用了本章这套 provider 抽象——这正是为什么本章的接口值得先读懂:


9. 巧妙之处(可带走的技术)

  1. 两段式模板渲染,把「构造期需要」和「调用期需要」的变量分开。 只渲染引用了 env 的模板、其余原样保留(src/util/render.ts:54-58)。任何「配置对象既要立刻用、又要留坑给运行时」的系统都能抄。

  2. 未定义变量保留模板而不是渲染成空串src/util/render.ts:68-72)。空串是不可逆的信息损失,保留模板给了下游第二次机会。

  3. _conversation 子树不参与模板渲染src/util/render.ts:99-104)。把「可能含模型输出的运行时数据」明确排除在模板引擎之外,是一条干净的注入防线。

  4. 鸭子类型判定接口isApiProvidersrc/types/providers.ts:169-178)。使得「函数」「字面量对象」「class 实例」「外部 JS 文件」四种来源完全等价,扩展成本近乎为零。

  5. canHandle / test 分层:便宜的闸门 + 精确的分发src/providers/registryTypes.ts:19-27)。既拿到懒加载的启动收益,又不牺牲分发精度。

  6. 只解析、不实例化的旁路入口resolveProviderConfigs / getProviderIds)。让「过滤」先于「构造」发生,避免为被过滤掉的目标启动浏览器或连服务器(src/util/config/load.ts:919-931)。

  7. 脚本缓存键含文件内容哈希src/providers/pythonCompletion.ts:328-334)。改代码即失效,省掉一整类「为什么我改了脚本结果没变」的困惑。

  8. fetchedSessions 区分「我给出去的会话」与「别人给我的 UUID」src/providers/http.ts:2344-2366)。用来源而非配置来决定复用还是新建,让同一份 HTTP 配置同时服务共享会话和独立会话两类策略。

  9. agent 的「过程」进 metadata、「结果」进 outputsrc/providers/claude-agent-sdk.ts:1841-1859)。断言面保持简单,观测面保持丰富。

  10. 家族工厂前置以躲开后缀 catch-allsrc/providers/registry.ts:1806-1816)。一条注释完整记录了 bug 成因、修法、以及「为什么这个改动是顺序中性的」——值得当代码注释的范本读。


10. 边界与局限

  • callApi 的输入永远是一个字符串。 多模态、工具定义、消息数组都得先编码进这个字符串或塞进 config/vars,再由各 provider 自己解码。MCP provider 把 prompt 当 JSON 解析(src/providers/mcp/index.ts:81-90)就是这条约束的直接后果。
  • 注册表按顺序线性匹配前缀,没有 trie、没有冲突检测。新增前缀若与已有前缀重叠,行为由数组位置决定,只能靠测试守住。
  • 内联 transform 用 new Function 执行src/providers/httpTransforms.ts:50src/providers/http.ts:1180),没有沙箱。配置文件等同于可执行代码——这是设计取舍,不是疏漏,但意味着不能加载不可信的配置。
  • validateStatus 默认全放行,HTTP 错误默认变成「一段奇怪的输出」而非「失败」。
  • 有状态 agent provider 依赖调用方复用实例。浏览器 provider 的会话保持在注释里写明了这个前提(src/providers/browser.ts:95-99);若调用方每轮新建实例,状态就断了。
  • getSessionId() 接口在仓库内无人实现,只在测试连通性的路径上被读(src/node/testProvider.ts:135)。它是给外部 provider 的扩展点。
  • 云端 provider 引用需要联网且禁止二级跳转(src/providers/index.ts:124-128)。

11. 与其它章的关系

想知道什么去哪章
这些 provider 是怎么被排进矩阵、providerPromptMap 怎么当闸门01-config-to-matrix
一格里 callApi 前后发生了什么、并发/超时/缓存怎么套在外面02-execution-engine
拿到 ProviderResponse 之后怎么判它对不对、裁判 provider 怎么选04-assertions-and-grading
攻击器怎么伪装成 provider、真目标怎么降级成 originalProvider05-redteam
eval_results.provider 那一列存的是什么、怎么脱敏06-results-storage-and-observability

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

主题文件路径关键符号
最小契约src/contracts/prompts.tsMinimalApiProvider
完整接口与判定src/types/providers.tsApiProviderCallApiContextParamsCallApiOptionsParamsisApiProviderisProviderOptions
响应结构src/contracts/providers.tsProviderResponse
加载入口(三级优先链)src/providers/index.tsloadApiProviderloadApiProvidersresolveProvider
只解析不构造src/providers/index.tsresolveProviderConfigsgetProviderIds
函数形态包装src/providers/index.tscreateProviderFromFunction
引用形状分类器src/util/providerRef.tsnormalizeProviderRefisProviderConfigFileReferencereadProviderConfigFile
两段式模板渲染src/util/render.tsrenderEnvOnlyInObjectrenderVarsInObject
工厂/家族类型src/providers/registryTypes.tsProviderFactoryProviderFamily
前缀路由表src/providers/registry.tsproviderMapproviderFamiliesgetProviderFactories
进程级清理登记src/providers/providerRegistry.tsProviderRegistryproviderRegistryshutdownAll
HTTP 目标src/providers/http.tsHttpProviderHttpProviderConfigSchemacallApiInternalcallApiWithRawRequest
HTTP 请求体处理src/providers/http.tsprocessJsonBodyprocessTextBodydetermineRequestBodyescapeJsonVariables
HTTP 钩子工厂src/providers/httpTransforms.tscreateTransformResponsecreateTransformRequest
HTTP 会话与状态码src/providers/http.tscreateSessionParsercreateValidateStatusresolveSessionIdfetchSessionFromEndpoint
HTTP 签名认证src/providers/http.tsgenerateSignaturerefreshSignatureIfNeededneedsSignatureRefresh
脚本目标工厂src/providers/scriptBasedProvider.tscreateScriptBasedProviderFactory
Python 目标src/providers/pythonCompletion.tsPythonProviderinitializeexecutePythonScriptshutdown
其它脚本目标src/providers/golangCompletion.tsrubyCompletion.tsscriptCompletion.tsGolangProviderRubyProviderScriptCompletionProvider
Claude agent 目标src/providers/claude-agent-sdk.tsClaudeCodeSDKProvider
Codex agent 目标src/providers/openai/codex-sdk.tsOpenAICodexSDKProvider
OpenCode 目标src/providers/opencode-sdk.tsOpenCodeSDKProvider
MCP 目标src/providers/mcp/index.tsMCPProvider
A2A 目标src/providers/a2a/index.tsA2AProvider
浏览器 / WebSocket 目标src/providers/browser.tssrc/providers/websocket.tsBrowserProviderWebSocketProvider
包装型 providersrc/providers/sequence.tssrc/providers/simulatedUser.tsSequenceProviderSimulatedUser
官方托管远程 providersrc/providers/promptfoo.tssrc/providers/promptfooModel.tsPromptfooChatCompletionProviderPromptfooHarmfulCompletionProviderPromptfooSimulatedUserProviderPromptfooModelProvider
钩子消费点src/evaluator.tssrc/node/doEval.tsprovider.delay sleep、provider.transformprovider.cleanup?.()

本章讲完了「被测对象是什么」。 接下来:04-assertions-and-grading 讲拿到 ProviderResponse 之后怎么判它对不对;05-redteam 讲怎么用同一套接口反复攻击这个目标;回看这些 provider 是怎么被排进矩阵、怎么被并发调度的,见 01-config-to-matrix02-execution-engine