Windmill AI Agent Evals 实战指南:以数据集驱动的可复用 AI Agent 评测体系
【免费下载链接】windmillOpen-source developer platform to power your entire infra and turn scripts into webhooks, workflows and UIs. Fastest workflow engine (13x vs Airflow). Open-source alternative to Retool and Temporal.项目地址: https://gitcode.com/GitHub_Trending/wi/windmill
Windmill 的 AI agent evals(AI 智能体评测)是一套内建在平台内的评测机制:把"可复用 AI Agent"(ai_agent资源,见 docs/reusable-ai-agents.md)固定在一组精心挑选的cases(用例)上反复运行,用评分器(scorer)对每次回答打分,从而把"这个 agent 改得好不好"变成可对比、可追溯、可回放的数字。本文将以 docs/ai-agent-evals.md 为主线,结合仓库内数据库迁移、后端 API 实现与 CLI 评测框架源码,讲清三件事:一次评测运行在底层是怎么组织成单个 flow 的、评分与版本如何被永久记录、数据集与权限在存储层如何落地。读完你可以直接在 Windmill 中为已保存的 agent 建数据集、跑运行、解读评分表,并理解每个设计决策背后的工程理由。
三个词,没有第四个:case / run / iteration
评测体系围绕三个概念展开,文档用一句话概括:"三个词,没有第四个":
- case(用例):agent 应当能处理的一个输入,存放在一个dataset(数据集)中。它只有两样东西——发给 agent 的 message,以及它应当产出的答案(
expected)。 - run(运行):对整个数据集的一次执行,底层是一个 flow 任务(单个 flow job),回答每一个 case;UI 上称为 "Run N",正式名称是experiment(实验)。
- iteration(迭代):run 中回答每个 case 的那一次执行,即 flow 循环的一次迭代。
也就是说:dataset 里装着多个 case;对 dataset 跑一次 run 得到一个 experiment;experiment 中每个 case 对应一次 iteration。存储层与这三个词一一对应(详见后文"存储"一节):eval_dataset、eval_experiment、eval_experiment_case。
为什么不叫别的名字?因为"第四种东西"(比如单独存放 agent 的"轨迹")并不存在——轨迹、日志、子任务都是 job 自己的属性,评测系统不重复存储。这也是全篇反复出现的主题:评测系统只记录必要的最小事实,其余一切复用平台既有的 job 体系。
界面形态:两层深的对话框
Evals 的用户界面是一个"两层深的对话框":
- 第一层:runs 列表——该 agent 在所有被评测过的数据集上的运行历史,每行一个 run,每个评分器一个徽章(badge)。
- 第二层:结果表——打开某个 run 后,一张表:每行一个 case、每列一个 scorer、单元格是该 scorer 对该 case 的裁决(verdict);case 的详情(输入与期望输出)显示在表旁边。
编辑数据集则是覆盖在两者之上的一个drawer(抽屉面板)。它从 agent 已经存在的地方打开:flow 编辑器中 AI agent 步骤输入区顶部的 agent 卡片,以及/resources页面上ai_agent类型的那一行。
Evals 依附于已保存的 agent
Evals 属于一个已保存的 agent。数据集及其运行都挂在某个ai_agent资源上,因此:
- 它们比步骤活得更久:步骤被重命名、复制或删除都不影响已保存 agent 下的评测数据;
- 两次 run 之所以可比,是因为它们命名的是同一个对象(同一个 agent 路径)。
如果一个步骤的 agent 是**内联(inline)**写在 flow 里的,就没有任何东西可以挂载数据集和运行。此时界面上取而代之的是Save as reusable agent——这也是 evals 要求的唯一前置设置:先把内联 agent 保存为可复用 agent(保存为ai_agent资源,定义在 hub 的 windmill-integrations 中,通过标准缓存资源类型同步进各 workspace,见 docs/reusable-ai-agents.md)。
What runs:一次 run 就是一个 flow
一次 run 是一个 flow:一个对数据集 case 的循环,每次迭代先回答 case,再对该回答评分。关键实现细节(来自 backend/windmill-api/src/ai_evals/run.rs):
- flow 以
RawFlow形式推送(JobPayload::RawFlow),agent 步骤与ModuleTest.svelte测试 agent 步骤用的是同一个载体,因此 case 走的是ai_executor.rs的生产分支,而不是一条平行的测试分支。 - 为什么是一个 flow 而不是每个 case 一个 job?因为run 的生命周期比打开它的标签页长:一个 200 个 case 的数据集对着慢速 provider 运行,耗时久到没人盯着看,只有 worker 能注意到最后一个 case 完成了。所以评分必须是 flow 内的一个步骤,而不是客户端事后做的一件事;同时整个 run 是一样可以被 watch、cancel 或挂 schedule 的单一对象。
- 每个迭代通过节点 id 回读(
get_result_and_success_by_id_from_flow),取某个回答时无需遍历整个循环的状态。
循环本身是parallel的,带有界 parallelism和skip_failures: true(源码中"parallel": true、"parallelism": RUN_PARALLELISM、"skip_failures": true)。原因很直白:一个数据集是对同一个 provider 的一阵并发调用,一个 case 失败只是 run 里的一个格子,而不是整个 run 的终结。
静态迭代器:cases 住在 flow 的 value 里
cases 是循环的static iterator,所以它们存在于 flow 的 value 中,只被存储一次。如果把它们当作参数传入,则每个迭代的参数里都会复制一份完整数据集——这是刻意避免的浪费。
配置被一次性内联进步骤
无论选中 agent 的哪种状态——已部署的(deployed)、正在编辑的草稿、或过去的某个版本——其配置都会在 run 打开时固定一次,并内联进每个 case 都会运行的步骤。为什么不直接用链接(link)到资源?因为链接步骤会在每个 case 执行到它时才解析资源:如果 run 进行到一半有人部署了新版本,后面的 case 会用新配置执行,而每一行却仍标注着 run 开始时的版本号。一次 run 只度量一种配置。
代价是:run 不经过生产步骤走的"链接解析"分支。但草稿和过去版本只能用内联方式运行——资源引用永远解析到"当前已部署"的配置,而那恰恰不是草稿也不是旧版本。
配置的来源与可信度
- 已保存 agent 和过去版本的配置从不取自请求:两者都按路径从 workspace 读取(
resolve_subject从readable_agent_state/agent_version_config读取,见 backend/windmill-api/src/ai_evals/run.rs),携带 subject 的请求会被拒绝(validate_subject:agent或agent_version不允许带 draft,agent_draft必须带 draft)。 - 正在编辑的草稿是唯一一种必须由请求携带的配置——它只存在于编辑器里。run 会把它内联进 flow 并做哈希,因此可复现、可归因于"这个版本加上这些编辑"。服务器无法断言的只有一点:这些编辑确实派生自那个版本。
payload 步骤:让评分器看到 agent 看不到的东西
每次迭代由三部分组成:一个agent 步骤、一个payload 步骤、然后每评分器一个步骤。payload 步骤之所以存在,是因为 flow 看不到它需要的东西:agent 自己的结果带有回答和每条消息,但每个工具调用的参数、结果、状态、耗时和 schema 属于运行该调用的那个 job。payload 步骤通过GET /ai_evals/run_payload回读这些内容(唯一的参数是迭代自己的 job id),然后把评分器在任何其他地方都会收到的内容交给它们。
因此评分器度量的是 agent 的延迟,而不是自己的延迟:payload 报告的是agent 步骤的时长,绝不是整个迭代的时长。
几个边界行为
- 草稿中的 transforms 按原样执行,表达式也在内:一个读取
results.<step>.x或某个 case 未提供的flow_input的 transform,在这里解析为 nothing——与该步骤在自身 flow 之外运行的任何一次表现一致。 - 链接的 agent 并非完全自包含:宿主 flow 可以通过步骤的
tool_inputs覆盖其工具的输入。一次 run不重现这种接线——agent 使用自己编写的默认值运行——所以依赖某个 flow 覆盖的 agent 行为,在评测中是没有这些覆盖的。 - case 不携带对话:只有一个问题和一个应产出的答案,所以 run 从 agent 自身的记忆配置开始,不会有任何东西被回放进上下文。
Where results live:结果是 job,表格是例外
评测结果首先是 job:
- run 的日志、轨迹、工具调用子 job、权限和保留期,本来就都是
v2_job/v2_job_completed以及 flow 状态的agent_actions,没有任何东西被二次存储。
表格本身是唯一的例外:每个单元格的回答、其结果以及每个评分器的裁决,会在第一次可读时被复制进 run 自己的行。因为 job 有自己的保留期,而被记录的 run 被期望在产生它的 job 消失很久之后,仍然读起来像它当初的 run。
- 结果面板只展示回答,不展示 job 的其他内容;轨迹属于 run 页面,
job_id是通往那里的途径。 - 对已记录的行,回答从行中读取而不是从 job 里读,因为
job_id是整个迭代(agent 加上测量它的评分器),其结果是最后一个评分器的裁决,而不是回答。
为了让一个 job 之后可被重新找到,推送时就打上了标记:
runnable_path是 agent 自己的路径,于是既有的script_path_startjob 过滤器不用新增任何状态就能回答"这个 agent 的每一次运行"。- flow 的 args 中
_eval记录{subject: {kind, path, version}, dataset, experiment_id},每个迭代都继承它,所以从 runs 列表冷打开一个 job 也能自我解释。某个迭代运行的是哪个 case,记录在它自己的iter.value里——这也是单元格重新找到自己 job 的方式。额外的 flow inputs 是惰性的:agent 步骤只读取user_message/user_attachments。
Versioning:版本是"保存过多少次",而不是资源版本号
subject.version是 agent 的版本号:它被保存过多少次。它是按资源(per resource)计数的,而不是读resource_version.id——那是整张表的一条统一自增序列。一个保存了九次的 agent 在其名下可能读到 v4 … v24,缺口来自读者看不到的其他 workspace 的写入。
- id 是版本被寻址的方式(历史路由和 restore 用它);数字是版本被称呼的方式(run 用它命名和比较)。
- 数字存在行上而不是读时计数,因为删除版本的两种方式都删最旧的:monitor 裁剪超出
MAX_RESOURCE_VERSIONS的部分,以及把历史清空到只剩当前值。若对幸存者重新计数,两种情况都会重编号——一个记录为 v3 的 run 之后会指向一个不同的版本。
对agentrun 而言,版本命名的是 run 打开时读取的配置,也就是每个 case 执行的配置。固定一个更旧的版本是另一种 subject kind:agent_versionrun 说明要读取哪个版本;而agentrun 读取的是它启动那一刻已部署的任何配置。
一个版本捕获的是资源本身,而不是它的传递闭包。两个字节完全相同的版本可能行为不同,因为它们引用的$var:/$res:在底下被改了。因此:记录版本是归因的必要条件,但不是充分条件。
Experiments:一次运行产出一个实验
运行数据集产生一个 experiment:每个 case 都针对一个 subject 执行,每个 case 一行。实验记录它运行的确切 case 集合(按值)——数据集一直在变,一个说不出"哪些输入产生了这些结果"的结果集是不可复现的。
run 叫什么:Run N
一个 experiment 就是Run N:
run_number在 run 打开时按(dataset, agent path)分配,一次,永不复用。- 它是存储的而不是读时计数的,所以当历史被裁剪时,run 仍保留它被赋予的名字。
- 没有用户自定义标签。
- 编号按 agent 而非按 subject kind:已部署的 run 与在它之上的草稿编辑 run 共享同一条序列——"Run 7"只有一个含义,至于是两者中的哪一个跑了它,由 run 显示在数字旁边的内容说明。
run 是永久的
每个 run只写一次,之后只读:没有可写的 experiment,没有部分重跑,没有事后可编辑的单元格。如果一个 run 里某些格来自一个版本、另一些来自另一个版本,它就不值得比较;运行数据集是 run 出现的唯一方式。
具体规则(均有迁移与实现佐证,见 backend/migrations/20260812100659_ai_evals.up.sql):
- 一个 experiment 只装一个 subject,一个 agent 只保留一条历史。实验列表按打开面板所针对的 agent 过滤(跨两种 subject kind),所以两个 agent 共享的数据集,不会在打开其中一个时显示另一个的 run。
- 版本是逐单元格的(
eval_experiment_case.subject_version),而不是逐 experiment 的。subject 在 run 打开时解析一次,每个单元格都从它盖章,所以这一列今天是一致的;做成逐单元格,是为了将来某个 run 若逐单元格执行,可以如实说明,而不是悄悄平均两个版本。 - 编辑按哈希标记日期,而不是按版本,因为编辑不会移动任何版本能记录的东西。每个单元格携带
subject_draft_hash——它所运行配置的哈希(规范化后:键顺序无意义,而serde_json保留插入顺序)。 - agent 未保存的编辑是自己的 subject。它们的 run 挂在
agent_draftkind 下,所以由编辑产生的数字永远不会被悄悄当作已部署 agent 的来读。run 对话框只有在从编辑卡片打开时才提供它们(并在那里预选);从任何其他地方打开,agent 就是已部署的那个。
表格在打开时以及标签页重新获得焦点时,会做一次小的subject_state读取来问"agent 现在在哪个版本",而不是轮询;results 端点报告同一版本,但会在运行进行中收集 run,所以只在 run 进行中轮询它,一次一轮。在另一个标签页保存 agent 而当前标签页保持聚焦时,会在下一次聚焦或下一次 run 时被发现。
运行旧版本的 run 是历史并且如此说明(一个 agent 在 v24 旁显示Run 14 · v23);没有东西标记它,因为那会在任何东西部署的那一刻把每个过去的 run 都标记一遍。一个后来被部署的编辑 run 就是那个版本的 run,results 端点会识别并重新盖章(见下文"What a run says it ran")。每个 run 携带的哈希是记录的,而不是展示的。
在 flow 编辑器中,编辑一个链接的 agent 会把配置 fork 进步骤并清除链接;步骤是编辑的唯一副本,直到 Save changes、Cancel 或 Discard(见 docs/reusable-ai-agents.md)。Evals 从 agent 卡片的两种状态打开:从编辑卡片打开时运行"按下 Run 时步骤所持有的编辑";从链接卡片打开时运行已部署的 agent——与链接步骤在运行时做的读取一致。agent 自己的资源草稿(资源编辑器写的那个)从不被 evals 读取。卡片还标明 run 记录在哪个版本下(v24,以及对之上编辑的 unsaved-changes 徽章旁的v24),取自资源最新的历史条目——因为资源本身不携带版本号。
Scoring:评分不是第二幕
评分不是带自己按钮的第二个动作:Run 既产生回答又给它评分。run 的 flow 中每次迭代都以一个步骤对自己的回答评分,结果被读取时数字被收割进行。
因此,run 的单元格由运行时存在的评分器度量,之后永不重评。事后编辑或新增的评分器,在早于它的 run 中没有单元格:就地重评一个永久 run 会让它变得可编辑。唯一通过"当下"读取的东西是及格线——pass_if在分数被读取时应用,所以移动它会重读每个 run 而无需任何模型调用。
列本身是数据集当前的评分器,所以表格在它列出的 run 之间保持可比,而不是每个 run 长一列。移除一个评分器因此也会从已记录的 run 上拿掉它的列:它产生的行不删除,但没有任何东西渲染它们;加回该评分器会铸出一列新列,从下一个 run 起填充。移除会先询问,并说明这一点。
两样东西被刻意排除:
- 用编辑过的评分器重评已存答案需要一次自己的 run——它重用父 run 的回答、归因于产生它们的版本,所以永远不会读起来像 agent 又回答了一次。
- 以 (agent 配置, case, 评分器定义) 为键的结果缓存会断言 agent 是确定性的——而它不是。所以这必须是一个显式选择,并且要回答"当一个 run 的一半是上周算出来的,它意味着什么"。
分数是 0 到 1 之间的数字,可加一条线
每个评分器返回一个 0 到 1 之间的数字——两种模板都这么说,均值与通过率把它当分数读。实现层也强制这一点:backend/windmill-api/src/ai_evals/scoring.rs 中,返回范围外的值会被记录为错误而非数值("The scorer returned {}, outside the 0 to 1 range a score must be in"),pass_if阈值也受同一范围约束。
- 通过/失败不是第二种分数:一列携带可选的
pass_if,得分等于或高于它的 case 算通过。布尔评分器就是返回 0 或 1、线在 0.5 的那个。 - 带阈值的列在均值旁报告通过率并标记每个单元格;不带阈值的列是普通数字,不会被包装成裁决。
- 这条线刻意放在分数的定义哈希之外:它放在哪里是对分数的解释,而不是产生分数的一部分,所以移动它会在不重跑任何东西的情况下重读每个已记录的 run。它在添加列时设置,之后在数据集抽屉的Scorer settings中更改——那也是给列命名的地方。
- 列名是这个数据集对评分器自己的称呼,从添加时给出的摘要播种。它是一个副本而非链接:脚本或 judge agent 保留它自己的名字,所以在这里重命名一列不会重命名第二个数据集显示的任何东西。从 runnable 实时读取它要每列一次 fetch,而且会让读不到它指向内容的任何人看到空列。
评分器收到什么
agent 按行为被评判,所以最终回答只是证据中较小的一半。每个评分器——judge prompt 或脚本——都收到同一个EvalRun,由 run 已经存储的 job 构建(结构定义在 backend/windmill-api/src/ai_evals/payload.rs):
| 字段 | 来源 |
|---|---|
input、expected | 实验记录时的 case |
output | agent 步骤自己的结果 |
tool_calls | 每条携带agent_action的消息,按顺序,含运行该调用的 job 的参数、结果、错误与时长 |
tools | 被调用的工具,含所运行脚本版本的 schema |
metrics | steps、duration_ms,以及 provider 报告的任何usage |
两个重要的实现细节:
- 工具结果截断到 4 KiB(
MAX_TOOL_RESULT_BYTES = 4 * 1024,payload.rs),并标记truncated: true——大结果不会淹没 judge 的上下文,而读取截断结果的检查可以如实说明,而不是在缺失尾部时失败。 - schema 无法解析的工具携带
null,验证参数的评分器必须把这种情况当作"未检查"而不是失败。 - 没有成本字段:Windmill 不维护 provider 价格表——脚本模板改为把 rate 作为参数接收。
两种 kind,都是 runnable
| kind | 是什么 | 收到什么 |
|---|---|---|
agent | 用作 judge 的ai_agent资源 | 以消息形式渲染的 run |
script | 一个 workspace 脚本 | run,input、output、expected也单独拼出 |
让每个评分器都是 runnable 是列可比的原因:每个都有路径、版本和你可以打开的代码。没有第三种以配置形式存在数据集上的 kind——judge 的模型和评分 prompt 活在 agent 资源上,所以编辑 judge 就是编辑那个 agent,而列本身不是你能编辑的东西。编辑一列就是编辑它指向的 runnable,因此数据集抽屉就地打开它:脚本在脚本编辑器里,judge 在资源编辑器里。
添加评分器先选 kind 再开表单:judge 从你选的模型和一个以默认值开始的评分 prompt 创建在数据集旁边;脚本从模板创建并打开在编辑器里。两者都由"它们评什么"的摘要命名,摘要变成列头,加上数据集前缀变成路径。
reason值得返回:它是单元格悬停时显示的内容,连同逐断言的checks——所以一个看起来不对的数字可以被读出来,而不是从轨迹重新推导。
评分器可以返回裸数字、布尔值,或{score, reason, checks};judge 的回答出现在output下,有时是包着其中一种的字符串,常常是包着它的 markdown 代码围栏——这仍然会被读取。comment被当作reason读取,所以为另一个平台写的评分器保留其理由。任何不含数字的东西被留空而不是猜测——并意味着"跳过空值"——缺失的分数若被计为零,会被读成一次回归。
{score: null}是唯一的例外,它表示评分器读了 case 但无物可量:一个问"来源是否被引用"的列,对一个无可引之源的 case 没有裁决。单元格显示n/a,并被排除在列的均值与通过率之外——这不同于评分器失败(那是错误,列会把它报告为错误)。它是被显式写出的,而非仅仅缺席,因为什么都不返回的评分器就是坏掉的评分器。
What a run says it ran
一个 run 在运行已部署 agent 时是v15,在运行带未部署更改的该版本时是v15 + edits。
agent_draftrun 记录它是哪个版本的编辑,因为"草稿"若不说明它是哪个已部署状态的草稿,就不可归因。- 它也会自己停止是草稿:agent 按已部署形态做哈希,与草稿被哈希的形状相同,所以一个配置后来被保存的 run 会被识别为它变成的版本。编辑、运行、部署——你做的 run 读作
v16,而不是永远是v15的编辑。 - 这种识别是写出的,而非派生的(backend/windmill-api/src/ai_evals/results.rs 的
resolve_deployed_draft):当哈希匹配时,run 的 subject 被改写为那个版本的agent,一次,保留它所基于的哈希。每次读时派生会让答案过期:它只会意味着"这运行了现在已部署的东西",于是下一次部署会把一个已经读作v16的 run 送回到v15 + edits。写入走 unrestricted 池,与同一次读取中收割的分数一起;其中没有任何来自调用者的东西——哈希就是证明,一个从未被部署的配置的 run 只是保持为编辑。 - 由此推出:解析需要有人去看。run 在它的配置被部署后的第一次结果读取时盖章。一个配置被部署、随后又未等任何人打开表格就被替换的 run,会一直说
+ edits——当唯一的证据是一个不匹配任何已部署内容的哈希时,这就是诚实的回答。
复用评分器
添加表单列出这个 workspace 已经在用的评分器(最近编辑的数据集优先),从数据集自己的scorers中读出,而不存储在任何新地方。它被过滤两次,两次都按调用者能读什么:数据集通过user_db读取,所以只有携带该评分器的数据集可见时评分器才出现;然后 runnable 本身以同样方式检查,所以调用者打不开的脚本或 agent 永远不会被建议。
评分器是一列
评分器作为{id, name?, pass_if?, kind, path}存在数据集上,id分配一次、永不复用:写入时,只有当一个传入 id 命名数据集已持有的列时才保留,其他一切都被新铸——所以被移除的列不可能以旧 id 回来、继承记录在它名下的分数。这个 id 就是当评分器被重命名或定义被编辑时,让一列跨 experiment 仍是同一列的东西;delta 只在携带相同 id 的两个分数之间计算。两个指向同一脚本的评分器是两列。
分数以(experiment_id, ordinal, scorer_id)为键,而不是烘焙进 experiment——所以一个冻结的 experiment 可以增加分数,而不会以任何实质方式变得可变:被冻结的是"其中有哪些 run"。
每个分数还记录产生它的定义——kind、path,以及实际运行的脚本哈希或资源版本——所以单靠路径无法隐藏一次编辑。当同一列的两个分数携带不同定义时,delta 仍会显示,但带标记:隐藏数字会迫使为看见任何东西而发起模型调用,而不标记地显示它则会让一次换 judge 被读成一次换 agent。
The surface:打开、选择、运行、比较
- 打开 evals 会选中该 agent 上次工作的数据集,按 agent 记在
localStorage中,并且只有当它仍然存在且仍然可读时才恢复;不会替你打开任何 run。选择器把该 agent 自己的数据集列在最前,其他人的在下面,排序而非过滤——因为用第二个 agent 跑一个数据集,正是这个选择器存在的意义(一种比较)。 - 数据集按脚本的方式命名:一个"这些 case 是干什么的"摘要,路径由此而来,以 agent 为前缀以便与 agent 自己的东西排在一起。没有摘要时回退为
<agent>_dataset1,取下一个空闲数字。是路径的一个段而非 agent 下的一个文件夹,因为 Windmill 路径是<kind>/<owner>/<name>,而编辑它的选择器无法表达更深的层级。 - 数据集在覆盖表格的抽屉中编辑:摘要与路径、评分器、然后是网格中的 cases。管理评分器的每种方式都在那个抽屉里——添加、重命名、移动及格线、打开背后的 runnable、移除它;run 表格上方的列头只报告、不编辑,因为 run 是永久的。创建数据集是同一个抽屉,只是还没有 cases,从 runs 列表某一行的数据集名和 run 对话框都能到达。重命名移动数据集,其 cases 和 runs 通过外键跟随。抽屉编辑一份工作副本,按下Save时一次请求写入——重命名、摘要和 cases 一起——所以服务器拒绝的重命名会留下原来的 cases,而一个半完成的编辑绝不会是下一个 run 执行的东西。结果表中某一行的面板是只读的,显示 case按 run 执行它的样子,而非数据集现在的样子;删除 case 在抽屉里,并且先询问。
- 一个 case 是它的 message、它期望的东西,别无其他;message 是标识它的东西。
expected是评分器用来比较回答的:纯文本,或者当回答有结构时用 JSON。 - runs 列表每行是该 agent 的一个 run(按时间倒序,无论属于哪个数据集):run 的编号与执行它的内容(
v24、v24 + edits或固定的v18)、多少个 case、每个评分器一个徽章、数据集、时间。每个徽章是该列报告的头条——列有及格线时是通过率,没有时是均值——通过现在的阈值读取。从未给 run 打过分的一列读作—;还在进行的 run 转圈。徽章在服务端命名并解析:跨数据集的列表装不下每个数据集的评分器来查列名,所以名字和 kind 随数字同行,阈值按 (run, column) 逐个连接——对eval_score的一次分组查询,而不是读每个 run 的单元格。分数仍在 flow 中的 run 由列表本身从 flow 读出,每次调用有上限,且跳过已收集的 run——所以稳态是一个查询。 - Run问两个问题:agent 的哪种状态(
v24 (latest deployed)——按下 Run 时它保存的样子;过去版本——它当时的样子;v24 + edits (current)——按下 Run 时步骤的编辑,只在编辑卡片上提供并预选),以及哪个数据集,行上有编辑按钮,并且不必离开就能新建一个。固定版本复现的是配置,而不是它周围的世界:其中的$var:和$res:引用在运行时仍会解析。刚刚启动的 run 会立刻打开。 - 结果表的行是数据集的 cases,按数据集顺序,每行在选中的 experiment 中携带其结果(如果有)——所以从未运行过的数据集不是空表。experiment 跑过但数据集不再持有的 case,在末尾保留它的行:run 发生过,删除 case 不会撤销它。每列的均值在列头下方,选择基线时旁边有 delta。
- 选择基线会为每个单元格和每列均值增加按评分器的 delta,并统计发生回归的单元格。每个 delta 都说出它的评分器;数据集没有单一数字,因为把 judge 与精确匹配平均会凭空发明一个。行按 case id 连接,所以基线运行后添加的 case 没有 delta 而不是算作变化,而基线从未用它评过分的一列会报告这一点,而不是报告一个不存在的差异。
Storage:五张表,一次写入
数据集、cases 和 experiments 都是行(迁移在 backend/migrations/20260812100659_ai_evals.up.sql):
| 表 | 存放 |
|---|---|
eval_dataset | 一个数据集,按 workspace 路径寻址,以及作为其列的评分器 |
eval_case | 一个 case:它的输入和它被期望产生的回答 |
eval_experiment | 对一个数据集、针对一个 subject 的一次 run;只写一次,之后只读 |
eval_experiment_case | 它执行的 case 集合、每个 case 变成的 job,以及每个 case 运行的版本或草稿哈希 |
eval_score | 一个评分器对一次 run 的一个裁决,以及产生它的定义 |
一个 experiment按值记录它的 cases,而不是指向eval_case——因为数据集一直在变,而一个说不出"哪些输入产生了它"的结果集不可复现。出于同样原因,case_id是普通列而非外键:删除一个 case 不得改写使用它的 run 的历史。
删除数据集会通过外键级联带走它的 cases、experiments、它们记录的 case 集合和每个分数。那些 experiment 产生的 job 被原样留下——它们是 job,有它们自己的保留期。
一个 case 是文本:一条 message 和一个期望回答。附件是 S3 引用而非内联字节,所以 case 中没有什么该是大的;三个上限保证这一点——每个 case 256 KiB、每个数据集 16 MiB 和 1 000 个 case——全部在 API 层拒绝而非截断(常量MAX_CASE_BYTES = 256 * 1024、MAX_DATASET_BYTES = 16 * 1024 * 1024、MAX_CASES_PER_DATASET = 1_000、MAX_SCORERS_PER_DATASET = 20见 backend/windmill-api/src/ai_evals/mod.rs)。一个 run 会用每个评分器评每个 case,所以数据集至多容纳 20 个评分器,以同样方式拒绝。
Permissions:与其它路径寻址对象一致的权限模型
数据集的权限与任何其它路径寻址对象一样:eval_dataset上的**行级安全(RLS)**决定谁可见(其文件夹的读者、u/<self>、一个组、或extra_perms授权)以及谁可更改。操作者完全不能写。记录一个 experiment 算一次写,因为它持久化进数据集。
cases 是数据集的内容而非独立的对象:
eval_case携带派生自其数据集的读策略(see_parent_dataset)和检查数据集可写的写策略——eval_dataset_writable,一个函数持有与数据集自身写策略相同的析取,所以只读授权可以列出数据集的 cases 但不能编辑它们。- 因此数据集与其 cases 在一次
user_db事务中移动,由相同策略管辖,重命名也以同样方式对目标路径检查。 - experiment 表是例外:它们的行既由 launch(持有数据集写权限)写入,也由 harvest(只持有对它所复制行的 run 的读权限)写入,所以它们只携带读策略,并在 API 检查过正确访问后于unrestricted pool上写入。
为什么 experiment 在启动前就被记录
Launch 会预先确定run job 的 id,在一个事务里写入 experiment、其 case 集合和每个单元格一条 pending 分数,然后才排队 flow。如果先排队后记录,会留下一个窗口:flow 正在运行却没有任何 experiment 记账、没有任何东西会收集它、重试会静默重复。
在这个顺序下:
- 一个在 push 前死掉的 launch 留下一个命名了从未开始的 job 的 experiment——一个"没有跑的 run";
- 一次失败的 push 会删除它,因为一次失败的 push 就是整个 run。
数据集的外键保护与组装竞争的删除:事务失败,而那时没有任何东西被排队。它不覆盖落在本事务提交之后、flow 排队之前的删除——那会让 experiment 被级联删除,而 run 仍然启动。
单元格如何找到它的 job 和分数
flow 引擎铸造迭代 job id,所以一个 case 在拥有 id 之前就被记录了。三个缺口各自由三样东西填补,每样都在第一次可读时从 flow 复制出来:
- 哪个迭代跑了哪个 case:case 是循环迭代的对象,所以它按构造存在于迭代自己的参数里——
args -> 'iter' -> 'value' ->> 'case_id'匹配单元格,无论迭代以什么顺序完成。 - agent 回答了什么:agent 步骤的结果与结果状态,在该步骤一完成就被复制到单元格上——这远早于围绕它的迭代完成,因为评分器仍在读它。
- 评分器返回了什么:每个评分器步骤的结果从迭代的 flow 状态读入 launch 时为它写的 pending 行。
三者都只写一次(在它们第一次变得可读时),之后的每次读取都读行。一个在任何人读取前就被保留掉的 job,会让单元格如实说明,而不是看起来像一个仍在被回答的 case。
flow 本身无法写它们:它运行在对这些表一无所知的 worker上。所以两样东西调用收集器:
- 一个 run 的 flow 以一个调用
POST /ai_evals/experiments/collect的步骤结尾——这就是记录"没人看到它完成"的 run 的东西。 - 读取一个 run 也会收集它——这覆盖 flow 从未到达那个步骤的 run:一个中途取消的,或一个在没有任何东西服务
nativets标签时启动的。
那个步骤是簿记,所以它是continue_on_error:一个每个 case 都被回答并评分的 run,不会因为那次调用没落地而变成一个失败的 job。
配套:仓库里的 CLI 评测框架(ai_evals)
仓库还包含一个独立于平台内 evals 的 CLI 评测框架(ai_evals/README.md),它评测的是 Windmill 自身的 AI 生成模式(cli、flow、script、app、global),用于验证"当前 checkout 中的生产 prompts、工具与指引"。虽然这是开发者的内部基准工具,但它完整展示了本仓库对"case 驱动评测"的工程形态,与平台内 evals 形成互补:
- cases 是每个模式一个 YAML 文件(如 ai_evals/cases/flow.yaml),最小形态为
id+prompt+initial(起始状态 fixture)+expected(期望产物 fixture),可选validate(确定性校验规则)、toolExpect(必需工具调用断言)与judgeChecklist(LLM judge 清单)。例如flow-test0-sum-two-numbers的 prompt 是"创建一个接收a、b两个数字并返回其和的 flow"。 - 每次尝试走三条路径:真实生产路径 → 确定性校验 → LLM 评判。
- 支持
--skip-judge(只跑确定性)、--record(把通过率历史追加到ai_evals/history/<mode>.jsonl)、--models a,b,c(同一批 cases 顺序跑多个模型)等选项。 - 结果写入
ai_evals/results/,包含摘要 JSON 与生成的产物目录。
这套框架印证了平台内 evals 的设计思想:真实生产路径优先(而不是平行 mock)、确定性校验与 LLM 评判分层、case 集合可复现记录。
小结:设计要点的速查
- 一次 run 一个 flow:
parallel+ 有界parallelism+skip_failures,case 是静态迭代器,配置内联固定一次。 - 结果即 job:日志、轨迹、子任务复用
v2_job;表格行(回答、裁决)是唯一例外,第一次可读时收割一次。 - 版本是保存次数:按资源计数、存于行上;
agent/agent_version/agent_draft三种 subject kind 各司其职,草稿用哈希而非版本标记。 - run 永久、只写一次:
run_number分配后不复用,删除 case 不改写历史。 - 评分是第一步的一部分:分数限定 0–1,
pass_if及格线在定义哈希之外(可随时移动、零成本重读),{score: null}表示"无物可量"而非失败。 - 五张表、RLS 权限:数据集按路径寻址、级联删除,cases 随数据集事务移动,experiment 表只读、在 API 检查后于 unrestricted pool 写入,先记录后启动以避免无人记账的窗口。
这套设计让"agent 改得更好没有"成为一个可回答、可审计、可回放的问题——这正是把 AI agent 放进生产工作流之前,值得先建立的那道护栏。
【免费下载链接】windmillOpen-source developer platform to power your entire infra and turn scripts into webhooks, workflows and UIs. Fastest workflow engine (13x vs Airflow). Open-source alternative to Retool and Temporal.项目地址: https://gitcode.com/GitHub_Trending/wi/windmill
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考