资讯动态

Windmill AI Agent Evals 实战指南:以数据集驱动的可复用 AI Agent 评测体系

发布时间:2026/9/14 4:23:04 来源:尧图企业网站定制
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/windmillWindmill 的 AI agent evalsAI 智能体评测是一套内建在平台内的评测机制把可复用 AI Agentai_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回答每一个 caseUI 上称为 Run N正式名称是experiment实验。iteration迭代run 中回答每个 case 的那一次执行即 flow 循环的一次迭代。也就是说dataset 里装着多个 case对 dataset 跑一次 run 得到一个 experimentexperiment 中每个 case 对应一次 iteration。存储层与这三个词一一对应详见后文存储一节eval_dataset、eval_experiment、eval_experiment_case。为什么不叫别的名字因为第四种东西比如单独存放 agent 的轨迹并不存在——轨迹、日志、子任务都是 job 自己的属性评测系统不重复存储。这也是全篇反复出现的主题评测系统只记录必要的最小事实其余一切复用平台既有的 job 体系。界面形态两层深的对话框Evals 的用户界面是一个两层深的对话框第一层runs 列表——该 agent 在所有被评测过的数据集上的运行历史每行一个 run每个评分器一个徽章badge。第二层结果表——打开某个 run 后一张表每行一个 case、每列一个 scorer、单元格是该 scorer 对该 case 的裁决verdictcase 的详情输入与期望输出显示在表旁边。编辑数据集则是覆盖在两者之上的一个drawer抽屉面板。它从 agent 已经存在的地方打开flow 编辑器中 AI agent 步骤输入区顶部的 agent 卡片以及/resources页面上ai_agent类型的那一行。Evals 依附于已保存的 agentEvals 属于一个已保存的 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.rsflow 以RawFlow形式推送JobPayload::RawFlowagent 步骤与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_subjectagent或agent_version不允许带 draftagent_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表格是例外评测结果首先是 jobrun 的日志、轨迹、工具调用子 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 kindagent_versionrun 说明要读取哪个版本而agentrun 读取的是它启动那一刻已部署的任何配置。一个版本捕获的是资源本身而不是它的传递闭包。两个字节完全相同的版本可能行为不同因为它们引用的$var:/$res:在底下被改了。因此记录版本是归因的必要条件但不是充分条件。Experiments一次运行产出一个实验运行数据集产生一个 experiment每个 case 都针对一个 subject 执行每个 case 一行。实验记录它运行的确切 case 集合按值——数据集一直在变一个说不出哪些输入产生了这些结果的结果集是不可复现的。run 叫什么Run N一个 experiment 就是Run Nrun_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 就是那个版本的 runresults 端点会识别并重新盖章见下文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 inpass_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实验记录时的 caseoutputagent 步骤自己的结果tool_calls每条携带agent_action的消息按顺序含运行该调用的 job 的参数、结果、错误与时长tools被调用的工具含所运行脚本版本的 schemametricssteps、duration_ms以及 provider 报告的任何usage两个重要的实现细节工具结果截断到 4 KiBMAX_TOOL_RESULT_BYTES 4 * 1024payload.rs并标记truncated: true——大结果不会淹没 judge 的上下文而读取截断结果的检查可以如实说明而不是在缺失尾部时失败。schema 无法解析的工具携带null验证参数的评分器必须把这种情况当作未检查而不是失败。没有成本字段Windmill 不维护 provider 价格表——脚本模板改为把 rate 作为参数接收。两种 kind都是 runnablekind是什么收到什么agent用作 judge 的ai_agent资源以消息形式渲染的 runscript一个 workspace 脚本runinput、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 复制出来哪个迭代跑了哪个 casecase 是循环迭代的对象所以它按构造存在于迭代自己的参数里——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最小形态为idpromptinitial起始状态 fixtureexpected期望产物 fixture可选validate确定性校验规则、toolExpect必需工具调用断言与judgeChecklistLLM 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 一个 flowparallel 有界parallelismskip_failurescase 是静态迭代器配置内联固定一次。结果即 job日志、轨迹、子任务复用v2_job表格行回答、裁决是唯一例外第一次可读时收割一次。版本是保存次数按资源计数、存于行上agent/agent_version/agent_draft三种 subject kind 各司其职草稿用哈希而非版本标记。run 永久、只写一次run_number分配后不复用删除 case 不改写历史。评分是第一步的一部分分数限定 0–1pass_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),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价