csbench serve 在结果目录之上启动一个小型本地 Web 服务器,并提供一个内嵌的单页查看器。它在一次运行正在进行时把它展示出来,帮你读懂已经跑完的那些运行,并且——只有在你明确要求时——让你编辑目标配置、发起运行。
只读是默认值,而这正是设计本身。 不加 --allow-write 时,所有写路由返回 405 并说出该加哪个 flag,于是「这里不让干」永远不会被读成「它坏了」。唯一的例外早于这个开关:给一次仍在进行的运行创建一个零字节的 cancel 标记,见下文“中止一次运行”。
已封存的记录永远不可写,开不开 flag 都一样。每条记录都带一个覆盖其测量值的 record_digest;一个编辑入口会让「这个结果可被复核」当场作废,而整个工具就立在这句承诺上。见下文“写能力”。
用法
(results: results, read-only) 或 read+write——因为这就是这个服务器全部的安全论证,翻回滚动条的人应该一眼看出跑的是哪一个。
该命令会打印实际绑定的地址(配合 --port 0 很有用),并持续运行直到按 Ctrl-C。
页头是五个段——基准、云平台、运行、观测、配置——外加语言切换器(EN | 中文;选择持久化到 localStorage,默认跟随浏览器语言)、工程师视图开关和主题开关(在手动切换前跟随 prefers-color-scheme,切换后持久化)。只有真的有运行在进行时,实时入口才会带一个计数徽标;不存在常驻的“0”。
实时观看一次运行
在一个终端里起一次运行,在另一个终端里csbench serve。这次运行会自己出现——查看器每隔几秒轮询一次 /api/progress,只要有运行在飞行中,看板上方就会出现一条实时条。
#/live——同时在飞行中的全部运行。如果恰好只有一个,页面直接转到那次运行。#/live/:run_id——单次运行,由一条到/api/progress/<run_id>/stream的 SSE 连接驱动。
- 页头——测评项、平台、状态、运行期间每秒跳动一次的计时器,以及 Stop run 按钮。
- 进度——用人话写的当前阶段(和记录页共用同一套阶段词表),以及当前 phase 的
12 / 99 query和进度条。剩余时间估算标注为 est.,并且在至少完成 3 个单位之前一律不显示(web/src/lib/eta.ts):一个自信但错误的“还剩 3 秒”比诚实的空白代价更高。 - 执行过程(实时)——到目前为止这次运行的瀑布图,数据来自
step事件而非 span。(它现在是独立的渲染器:已封存的轨迹页已改成“读结果”一节描述的那棵可缩放的树,不再画这张瀑布图。)实时 step 用名字指向父节点(套件知道自己把外层 phase 叫什么,却不知道 tracer 之后会给它铸什么 span id),所以嵌套是按名字解析的。 - 目前的数字——在浏览器里用已完成的 step 算出来的:完成了多少 step、step 耗时的中位数与 p99、失败的 step 数、目前最慢的 step。
- 日志——这次运行的
clousight_bench日志尾巴,可按级别过滤。自动滚动是“粘底”的:一旦你往上滚,它就不再把你拽回最新一行。
“初步”意味着它不是测量值
目前的数字里的每块数字都带一个初步标记,面板本身也直说这些数从哪来。这是一条契约,不是谦虚:- 测量值由
Evaluator离线地、在运行结束之后、基于已封存的证据算出——它是落进记录、并被record_digest覆盖的那个东西; - 实时数字是浏览器对“目前收到的事件”做的一个滞后算术,而这条流是被明确允许丢事件的。
sample 事件推导出来。实时页的截图就是一次未完成运行的截图,UI 的构造方式让读者无法把它误当成结论。
为什么被测量的窗口显示的是不确定进度条
一个 phase 可以声明:我知道自己有多大,但我没法在里面逐步上报——progress.phase("Throughput Test", total=396, unit="query", reports_progress=False)。
查看器于是画一条不确定进度条,并写上一句*“measured window 内不上报中间进度”*,而不是画一条读起来像卡死的 0% 进度条。
原因在套件里,不在 UI。在 TPC 官方模式下,Throughput 测试自己的墙钟就是那个指标(Throughput@Size,即 T_TT 项),在窗口内写进度文件等于把查看器的 I/O 写进了这个数。所以套件在窗口内什么都不上报,等窗口关闭后,再用它自己已经测好的区间把每条流、每条查询的 step 回放出来——同样的数、同样的布局,只是晚了几秒。套件作者需要遵守的规则见添加一个测评的“上报进度”一节。
中止一次运行
Stop 按钮是查看器唯一的写操作控件,并且刻意做成两步:先待命,再确认。按下后会向/api/progress/<run_id>/cancel 发一个 POST,它在这次运行的进度目录里创建一个零字节的 cancel 文件。HTTP 上再没有别的事发生。
运行按自己的节奏来取它——编排器在每个阶段边界检查一次,接好线的套件在自己的循环里轮询 should_cancel()(一次带缓存的 stat,最多每秒 4 次)。一旦命中,运行抛出 RunCancelled;它是 KeyboardInterrupt 的子类,因此中止走的是 Ctrl-C 早就走过的那条路:
- teardown 照常执行——已开通的资源不会被遗弃,也不会继续计费;
- 会持久化一条
interrupted记录,带上已经完成的那些阶段; - 实时页切换成交接卡片,指向那条记录。
运行结束之后
页面不会变白,也不会 404。进度目录会存活TERMINAL_GRACE_S(默认 120 秒),正是为了让订阅者仍能读到终态的 done 事件和它携带的已封存记录路径;页面随即切换成指向 #/record/<run_id> 的交接卡片。
如果有事件为守住流的上限而被丢弃,页面会明说,并说明丢的是哪几类——以及已封存的记录不受影响。
阅读结果
四个页面,按问题收敛的顺序排列:- 看板把每条记录先按域、再按测评项归组,展示每个测评项的平台数、运行数,以及最新一次运行的头条指标。域和套件按最近活跃时间排序,每张域卡片先展示 5 个测评项、其余折叠:一个积累了大量已退役测评项的结果目录,不该把你今天刚跑的东西挤出第一屏。没有同时给出域名和套件名的记录(schema 0.1 时代的运行会摘要成空字符串)不会被折进来——一张没有名字也没有值的卡片比缺席更糟——它们仍可在
#/runs看到。 - 套件对比以真正用来做选型决策的那张表开头:每个平台的最新一次运行,每个指标一列,按词表顺序(头条指标在前)而非字母序排列。只有某个平台的最新运行确实产出过的指标才会成列,这样 TPC-H 的
reference运行和official运行就不会拼出一张满是破折号的表。表后是最多四个指标的趋势图,历史是有界的(每个平台最近 30 次运行)。 - 记录自上而下地读:它发现了什么、机器本身有没有正常工作、它测到了什么、这些数能不能信。十一个被记录的生命周期阶段被收拢成一行健康摘要——“一切正常 · 11 个阶段全部通过”——点开展开成完整的四相位阶段条;一旦有阶段失败,这一行改为直接点名那个阶段。
- 轨迹页是一棵可缩放的树。拖动上方的总览条即可收窄时间窗口,每根条都按新窗口重画——是时间轴真的动了,而不是窗口外的 span 只被调暗。选中一行会在右侧浮出它的详情,且不把任何一行挤出视野;并发跑的子节点打包成若干泳道,可以点进去;每一行印两个时长:落在窗口内的那一段,以及这个 span 永不变化的总计。聚合与表格两个标签页在树的下方,统计的是窗口内的 span。每次运行都有轨迹——见下文“执行轨迹”。
#/runs 是从前那张所有运行的平铺表,带客户端过滤框。当你已经知道自己在找哪次运行时它确实好用,所以它仍然在——只是不再是大门。
写能力
用--allow-write 起来之后,控制台能做两件事。两件事围的篱笆是同一套:共用的 Host 守卫、必须携带的 X-Csbench-Write: 1 头(自定义头是跨域 <form> 够不着的,正好堵死简单表单 CSRF),以及在读取 body 的第一个字节之前、就从 Content-Length 上判掉的 64 KiB 上限。
目标(configs/*.yaml)可以新建、编辑、删除。从网络上来的名字永远是名字、不是路径:它必须匹配 ^[A-Za-z0-9._-]{1,64}$(并排除 . 与 ..——这两个完全符合该模式,却指的是目录),而且 resolve() 之后的路径必须仍在 configs 目录内——这一步必须在 resolve 之后做,因为符号链接能通过任何名字校验。YAML 先解析再落盘,所以一个加载不了的文件永远到不了磁盘。
key 看着像凭据的值(secret、token、password、key、credential)会以 *** 返回;一旦发生脱敏,文件原文就整份不发——原文是唯一没法安全脱敏的东西,所以这种目标在这里只读不可编辑。带着 *** 的 PUT 会被拒绝:那样的 body 是一次脱敏读取正在往回写,保存它会把真正的凭据覆盖成三个星号。
运行可以发起。请求体是一组固定字段——领域、基准、平台、目标名字、标量参数、重复、预热——并对着当下的插件注册表校验。没有「传一段命令行」的字段,也没有「传一个路径」的字段。repeat 有上限(20),因为它正是把一次点击变成一下午付费工作量的那个旋钮;并发第 5 个运行会被 429 拒掉,而不是排队。运行是一个分离的子进程(sys.executable -m clousight_bench run --run-id ...),所以回包能在运行开始之前就叫出它的名字,浏览器也就能立刻打开它的实时视图。
表单刻意不强制的一件事:某个基准是否「属于」某个平台。注册表里根本没有这层对应关系,任何规则都会是这段代码自己编的。它转而展示每个基准实际在哪跑出过记录,并在平台会花钱、或适配器还是骨架时给出警告,把选择权留给读者。
控制台把自己的记事放在 results/.launches/ 下——它被要求启动了什么,放在结果旁边而不是结果里面。这也正是一个目标能说出「有多少次运行用过我」的原因:封存记录里并不保存它拿到的是哪个配置文件。
说人话
查看器通过一层统一的去黑话层web/src/lib/glossary.ts 渲染内部词汇,遵守两条规则。
永远两层,不是一层。 人话标签在前,原始 key 以等宽字体保留在下方——Typical query time 之下是 tpc-h.geomean_latency_ms,Seal evidence 之下是 SEAL。没有任何东西因此变得不可审计,工程师也依然能一眼对上字段名。任何开关都不会改变这一点。
绝不编造。 一个不认识的测量 key 仍然会被渲染:它会拿到一个整理过的标签,格式则从它声明的 unit 推断,或者谨慎地从后缀推断。但它拿不到方向。除非有人写过 spec,否则 betterIs 保持 "none"——因为把一个上升的数说成“更好”而它其实是回退,比什么都不说更糟——所以未知指标在对比表里永远不会被加粗为领先者。
同一层还解释了两个最容易让人误会的东西:invalid(我们根本没测到任何东西——这不是被测系统的失败)与 failed(我们测了,然后它坏了)的区别;以及 PUBLISH: skipped——它是一条持久的声明,说明没有任何发布器碰过这条已封存的记录,而不是一个错误。
工程师视图是页头里一个持久化的开关。关掉时,记录页展示的是结论。打开后,它增加一块面板,完整展示记录的 fingerprints、identity、environment 和 extensions——正是这些字段让一次运行可复现、可比较,而旧版查看器压根没有渲染过它们。这个开关不会隐藏的东西是:每个标签下面的原始 key,两种状态下都在。
磁盘上的进度平面
实时进度是与已封存记录刻意分开的第二个平面(src/clousight_bench/core/progress.py):
- 可丢弃,且在记录之外。 这里的任何东西都不被
record_digest覆盖,任何东西都无法改变结论,且一旦已封存记录存在,整个目录就会被清掉。任何时刻删掉results/.progress/,除了实时视图什么都不会丢。 - 刻意加点前缀。 一个带
status字段的state.json,距离被一句随手写的rglob("*.json")当成结果记录只有一步之遥。 - 尽力而为。 每次写入都被兜住:IO 失败只在 debug 级别打一条日志,把该次运行剩下的进度平面关掉,测评继续跑。
- 有界。 超过上限后写入方选择丢弃而不是继续增长,而且只丢可丢的类别——
log和sample。stage和step永远不丢:它们就是这次运行的形状。丢弃计数会写进state.json并在 UI 上明示。 - 会被回收。 运行启动时和查看器启动时各扫一次陈旧目录,这样一台在测评中途断电的机器不会永远显示一行幽灵般的“running”。一个仍然声称在运行、但进程已经消失的快照会被报成
abandoned,而不是永远挂着。 - 有版本。
state.json带"schema": "progress/1";不认识该 schema 的读者应当忽略该目录,而不是猜。
API
其余一律返回带 JSON body 的 404——这个应用是 hash 路由,没有会把打错的地址变成 HTTP 200 的 SPA 回退。
SSE 流在快照变化时发
state 帧,用 events 帧携带流事件,每 15 秒发一条 : heartbeat 注释以免空闲代理掐断连接,最后发一帧 done。它每 400 毫秒轮询这两个文件,而不是监视它们——这个平面刻意就是没有通知通道的普通文件。每条流在其整个生命周期里独占一个服务器线程,因此有硬性上限:同时 8 条流(第 9 条拿到 503),以及每条流 6 小时的生命上限。每个事件都带 seq,浏览器在重连后用它作为续传令牌。
开发与构建
查看器是位于web/ 下的 React + TypeScript + Vite + Tailwind 应用。其生产构建产物已提交在 src/clousight_bench/resources/viewer/dist/ 并随 wheel 一起发布,因此安装本包永远不需要 Node。
web/ 下的任何内容后,需在同一变更中重新构建并提交生成的 dist/——测试校验的是已提交的构建产物,而不仅是源码。CI 的 viewer-dist job 会跑 npm test、重新构建,并在已提交的 dist/ 与重建结果不是逐字节一致时失败。
范围与安全
- 默认绑定
127.0.0.1。没有任何鉴权——请勿暴露到公网接口。 Host头不是绑定主机或 localhost 别名的请求一律 403(DNS rebinding 守卫)。该守卫位于方法分发之上,因此POST不可能落到某个措辞不同的副本后面。- 写路由默认关闭,不加
--allow-write时返回 405 并说出该加哪个 flag。读目标不受这个开关限制:一个在只读服务器上什么都列不出来的目标页,就成了一个关于 flag 的页面。已封存的记录在两种模式下都没有写路由。 - 中止端点早于这个开关,被单独围了起来:共用的 Host 守卫、必须携带的
X-Csbench-Progress: 1头(自定义头是跨域<form>够不着的,这正好堵死简单表单 CSRF 这一形态)、拒绝任何请求体,以及 run_id 必须先是一个纯[A-Za-z0-9._-]+token 才能去指代磁盘上的任何东西。它能做的极限,就是给一次仍在运行的运行创建一个零字节标记。 - 缓存策略:文档和所有 JSON API 响应一律
no-store——它们全都是活状态(SSE 流发的是no-cache);/assets/*一律public, max-age=31536000, immutable——这些文件名由 Vite 构建按内容哈希生成。没有前半条,升级csbench后刷新会拿到缓存的index.html,而它引用的 asset 哈希在新 wheel 里并不存在:一个白屏,控制台里还什么都不说。 - 构建后的应用是离线优先的:一切(React、ECharts、样式、两套语言词典)都打包进已提交的
dist/,只使用系统字体栈,无任何外部请求——构建期守卫加测试共同强制所有 dist 文件不含绝对 URL。 - 它在严格 CSP(
default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self')下提供服务,无任何内联内容(测试强制index.html不含内联脚本、样式或事件处理器);服务器提供的字符串始终以纯文本渲染,绝不作为标记解析(dangerouslySetInnerHTML被测试禁用)。 - 静态文件服务在任何路径拼接之前就做包含性检查;
..、路径分隔符、冒号和未知扩展名一律返回 404。 - artifact 与 trace 的读取都会先 resolve,并且必须留在结果目录内部。
/api/meta只公开结果目录的 basename,绝不公开完整路径——进度交接里的记录路径用相对路径,也是同一个理由。
执行轨迹
一次运行的轨迹是一棵树:根是csbench.run,下面是生命周期阶段,再往下——对于会上报的测评——是该测评自己的 span,嵌在产生它们的那个阶段里,一直细到单条查询。
这需要两件事,而且从外面都看不出来。
阶段需要一条真实的时间线。 emit_run_trace 以前把阶段按时长首尾相接摆出来,所以只有”花了多久”是真的,“什么时候发生”是编的。现在 run.stage_spans 记录每个计时阶段相对运行开始的真实 [start_ms, end_ms]。两个后果:套件自带时间戳的 span 能落到该落的位置;阶段之间的空隙变得可见,而一串时长是显示不出空隙的——第一次带真实标记的运行就暴露了 PREFLIGHT 之前有五秒钟,此前没人看得见。这个字段是可选的增量字段,没有它的旧记录仍会通过旧的重建方式渲染。
两半必须接起来。 它们本来就是同一条 trace——套件在 DriverContext 上拿到的就是这次运行的 trace_id——但套件的根 span 没有父节点,又待在自己的文件里,于是查看器只能二选一。现在 emit_run_trace 会把套件的轨迹重新发射为 csbench.stage.EXECUTE 的子节点。套件自己的 artifact 保持逐字节不变:它是被测系统未经改动的陈述,其 sha256 钉在记录里;所以运行轨迹成为合并后的视图,而 artifact 仍是第一手来源。时间戳落在阶段窗口之外的 span 保留其原值——把它裁进去只会藏起最值得看见的那处不一致。
读者拿到的是什么
/api/record/<run_id>/trajectory 会在 source 字段里说明返回的是三者中的哪一个,判断依据是 span 本身而不是版本号:
full——results/traces/<trace_id>.jsonl,已合并测评自己的 span。完整链路。这是常态。artifact——记录里kind=trajectory的边车文件,用于合并机制出现之前写下的记录。细节在,阶段不在。lifecycle——不含任何测评 span 的运行轨迹,因为这次测评什么都没上报。每次运行至少有这一层。
哪些测评会上报
一个测评要让自己的 span 出现在EXECUTE 里,就要写一个 kind=trajectory 的 artifact。内置的这些在真实路径上都会写:
mock 路径不封存任何 span。 内置 fixture 里是预置的固定耗时,一张由编造数字画成的瀑布图比没有瀑布图更糟,所以
mode: mock 的运行显示为 lifecycle 并如实标注。
如何写一个,参见上报进度。两者受同一条规则约束:span 可以记录自一段被测量区域已经取过的标记——上表里的每条查询 span 就是 latency_ms 所依据的那个区间本身——但绝不能在”自身墙钟就是测量值”的窗口里写任何东西。
MMLU 与 GSM8K 是这条规则前半句唯一有案可查的例外。它们的实时条目步骤取自条目循环自己的标记(覆盖整次调用),而封存的 latency_ms 是 chat_once 内部另取的一次读数——从发出 HTTP 请求之前到解析完响应之后。两者在每次运行中都会相差「调用前后的准备开销」,在开启 params.retry 时还会再相差失败尝试与退避睡眠——所以实时数字总是两者中较大的那个。这是有意为之:实时样本回答”这道题跑了多久”,封存值回答”端点有多快”。
span 形态
主要的 span 形态是 v3 OTel 原生 span(core/sut_span.py):span_id / trace_id / parent_span_id / name / start_unix_nano / end_unix_nano / status / attributes,其中 status 为 OTel 的 OK / ERROR / UNSET,且 attributes 必须至少包含一个语义约定命名空间的键作为 kind 判别键——gen_ai.*(模型与工具调用)、db.*(查询)或 csbench.*(阶段、流,以及运行自身的生命周期)。旧版 v2 span(parent_id、t_start / t_end、attrs、字面 kind)仍被查看器接受并归一化。
查看器按 kind 上色,并把运行自身的生命周期当作框架而非一个数据系列:它取的是不抢眼的坐标轴色,好让框架不与挂在里面的工作争视线。记录如何引用 artifact 参见结果记录 schema(src/clousight_bench/resources/schemas/result-record-0.4.schema.json)。