做实验这事儿最烦的不是跑不起来而是跑起来之后你根本说不清楚上次那个结果到底是哪来的。过去大半年我被评测任务折磨得够呛于是动手写了一个叫 openrig 的小项目。它的定位很直接一个开源的实验基架把基准测试、模型对比、指标采集这些杂活统一成一份份声明式任务跑完自动给你结构化结果。目前我主要拿它跑 LLM 推理评测和 Agent 轨迹分析但它的设计并不绑定任何模型或框架。如果你经常要对比不同的推理后端、想跑一组可复现的实验、或者想把自己散落在脚本里的评测流程收拢成流水线这篇文章的完整思路值得你参考。1. 为什么会有 openrig我的评测流程彻底乱套了rig 这个词在英文口语里本身就有一层为一桩事把设备搭齐的意思。openrig 不加更多解释就是把实验设备搭成开放的、可以反复拆装的台架。说人话这是一个自制的、开源的实验基架工具。我先讲一下我是被什么逼到写它的。过去半年我几乎每周都要做一类事情拿同一个数据集去对比不同推理服务的实现。比如在同一台机器上llama.cpp 和 vLLM 各跑一遍再比如改一个采样参数想看看回答正确率有没有变化。听起来很简单对不对实操起来很快会变成灾难每个推理服务有各自的启动方式有些要起 REST API有些直接命令行跑。指标收集基本靠人肉用 time 命令写 curl 循环或者盯着 nvidia-smi 看显存。跑完的结果散落在终端、日志、截图里。想写对比结论的时候数据根本对不齐只能重新跑。换了一台机器后之前的数据几乎没法复现因为没人记录环境差异。我也试着用现成的评测工具发现不是不行但总有点别扭。有的工具背靠大平台要求先准备好一套完整的数据集账号体系有的工具把评测和某个推理服务绑死换一个框架就要换一套配置写法还有的工具太重装完要拉十几个容器镜像。我只是想跑个实验不想先运维一个集群。于是我把需求收敛成四点第一一份任务配置能描述环境 命令 要采的指标 输出第二有一个可插拔的 runner本地跑、容器跑、远程跑都能接第三结果必须结构化直接落地成 JSON / Markdown方便汇总第四不绑定任何特定的模型或评测集。这就是 openrig 的雏形。它不尝试替代某个评测框架而是做评测框架下面那一层通用的执行和采集工具。1.1 先说清楚openrig 不是什么很多人第一次听到实验基架会把它想象得很重所以我想先划清边界。openrig 不是实验管理平台没有 Web UI没有任务队列也没有用户权限体系。它类似一个非常薄的执行加采集层把一份 YAML 变成一次可重复运行的实验把一次实验的 stdout 变成一份可机读的报告。它也不负责画图不负责对比。这些事留给其他工具openrig 只保证数据以干净、可解析的格式落盘。这背后是一个我吃过亏的选择小工具一旦想做大平台很快会因为什么都管不好而失败。我只需要一个能被我完全看懂、能在半小时内改动的工具。所以我宁可让 openrig 看起来功能单薄也不愿把它变成黑盒。2. openrig 的设计核心一份 YAML 就是一个可复现的实验2.1 任务声明长什么样openrig 里最核心的概念叫 task一份 task 就是一个 YAML 文件。设计目标很简单让一个没看过文档的人读到这个文件就知道这组实验在干嘛。我沿用了一个在 CI 领域很常见的思路——把我要跑什么东西和怎么跑写清楚剩下的交给工具。一个最小任务长这样name: qwen2-7b-gguf-cpu description: 用 llama.cpp 的 CPU 推理跑一遍本地测试集 env: MODEL_PATH: /data/models/qwen2-7b-q4_k_m.gguf THREADS: 8 command: | ./llama-bench -m $MODEL_PATH -t $THREADS -p 你好请介绍一下杭州 \ --repeat 3 -o json metrics: - name: tokens_per_second parser: jsonpath jsonpath: $.results[0].tps - name: memory_mb parser: grep pattern: mem_usage unit: MB timeout: 300 resource: gpu_mem_limit_mb: 0 concurrency: 1这里有几个字段值得展开说。env 放环境变量。一开始我也纠结过要不要在 command 里直接写全后来发现单列出来的好处是同一条命令可以喂给不同环境换一个 MODEL_PATH 就是跑另一个模型。command 用 YAML 的竖线语法保留多行脚本执行时注入 env 变量然后交给 runner。metrics 是 openrig 和普通脚本最大的区别。普通脚本跑完只告诉你成功或失败openrig 会在命令执行完之后根据声明好的采集规则去解析 stdout 或结果文件把指标摘出来变成结构化数据。目前内置的 parser 有三种jsonpath、grep、regexp后面我会讲怎么扩展。2.2 Runner 注册机制为什么每种执行方式都是一块插件openrig 启动后并不关心任务要跑在哪里。它只对着一个 registry 说这个任务你帮我去执行。registry 里注册着不同类型 runner每个 runner 只实现三件事准备环境、执行命令、采集产物。class BaseRunner: name base def prepare(self, task, context): raise NotImplementedError def execute(self, task, context): raise NotImplementedError def collect(self, task, context): raise NotImplementedErroropenrig 内置了 local 和 docker 两种 runner。local 适合快速验证和跑本机脚本docker 适合需要依赖隔离、或者要在固定 GPU 环境里跑的评测。日常使用中我九成任务都用 docker runner因为推理框架的依赖冲突实在太多llama.cpp 和 vLLM 的 Python 环境几乎不可能共存于同一个解释器里。这种解耦带来的好处是想新增执行方式比如 SSH 远程跑、Kubernetes Job、或者 Windows 节点任务只需要写一个新类并注册进来任务配置完全不动。任务和执行的分离是这个项目能活过三个月的关键。2.3 一次执行的完整链路openrig 的命令行入口很轻只有一条openrig run task.yaml但这条命令背后会做七件事加载配置并做 schema 校验必填字段缺失、timeout 类型错误都会在这里暴露。根据 task 里的 runner 字段找到对应 runner 类。调用 prepare准备环境。docker runner 在这一阶段拉镜像local runner 创建临时目录。调用 execute并带上超时控制。不会让卡死的命令无限期占用资源。调用 collect解析指标落盘。生成两个文件原始 stdout 日志和结果 JSON。前者用于排查后者用于汇总。这套链路的好处是每个环节都能单独测试。哪怕任务在 execute 阶段炸了也还有带时间戳的日志可以看不会出现跑了一个小时除了屏幕滚动啥也没留下的情况。3. 动手实现半个下午从零搭出 openrig 的可用骨架3.1 工程结构与依赖选择openrig 用 Python 实现核心依赖只有三个PyYAML 用来解析任务配置jinja2 用来对 command 做模板渲染click 用来处理命令行参数。整个项目结构很扁平openrig/ cli.py # 命令行入口 schema.py # 任务配置校验 runner/ __init__.py # registry 注册入口 base.py local.py docker.py parser/ __init__.py # 指标解析器注册 jsonpath.py grep.py regexp.py report.py # 生成 JSON 和 Markdown 报告选 Python 的原因很朴素所有做算法评测的人基本都会 Python。这个工具如果要让大家愿意改、愿意加 runner就必须用最通用的语言。它也刚好是我写原型最快的语言从有想法到跑通第一个任务大概只花了两小时。3.2 配置加载与校验我最想聊的是 schema 校验这是整个项目里投资回报率最高的二十行代码。没加校验之前我经常写错字段名比如把 timeout 写成 time_outopenrig 就静默使用默认超时半夜任务挂了自己还不知道。加了校验以后任何错误字段都会在运行前直接报错。宁可严格也不要静默错误。REQUIRED_FIELDS [name, command] OPTIONAL_FIELDS { description: str, env: dict, metrics: list, timeout: int, resource: dict, runner: str, } def validate(task: dict) - list[str]: errors [] for field in REQUIRED_FIELDS: if field not in task: errors.append(fmissing required field: {field}) for key, value in task.items(): if key not in REQUIRED_FIELDS and key not in OPTIONAL_FIELDS: errors.append(funknown field: {key}) # 类型检查、timeout 范围检查等省略 return errors这段代码本身平平无奇但放在最前面后面所有环节都省心。我见过很多人写小工具时觉得校验浪费时间直接开始写执行逻辑结果 bug 全堆到运行时排查成本高了三个量级。3.3 runner 的实现local 这个最小的闭环先看最简单的 local runner。理解了它docker runner 就是在上面加一层容器封装。class LocalRunner(BaseRunner): name local def prepare(self, task, context): context[workdir] tempfile.mkdtemp(prefixopenrig-) return context def execute(self, task, context): env os.environ.copy() env.update(task.get(env, {})) timeout task.get(timeout, 120) result subprocess.run( task[command], shellTrue, capture_outputTrue, textTrue, envenv, timeouttimeout, cwdcontext[workdir], ) context[returncode] result.returncode context[stdout] result.stdout context[stderr] result.stderr return context def collect(self, task, context): report { task: task[name], returncode: context.get(returncode), metrics: {}, } for metric in task.get(metrics, []): parser get_parser(metric[parser]) value parser.parse(metric, context.get(stdout, )) report[metrics][metric[name]] value return report有个细节我刻意用 subprocess.run 而不是 Popen因为 run 本身支持 timeout 参数。这样不用自己写线程监视超时标准库已经帮你做完了。另一个细节是 execute 阶段把 stdout 和 stderr 都留着collect 阶段再做解析而不是执行完就丢弃。这样哪怕指标解析规则写错了也可以拿旧日志手测不必重新跑任务。3.4 CLI 入口与第一个可运行版本CLI 用 click 写逻辑很直白import click click.command() click.argument(task_file) def run(task_file): task load_yaml(task_file) errors validate(task) if errors: click.echo(配置校验失败:) for error in errors: click.echo(f - {error}) raise SystemExit(1) runner get_runner(task.get(runner, local)) context {started_at: datetime.utcnow().isoformat()} runner.prepare(task, context) runner.execute(task, context) report runner.collect(task, context) save_report(report, output_dirruns)保存下来的报告大致是这个形状{ task: llama-cpp-cpu, returncode: 0, metrics: { tokens_per_second: 18.2, memory_mb: 2531 }, started_at: 2025-01-12T15:04:05Z }到这里openrig 的最小闭环就成立了一份 YAML跑一条命令得到一份 JSON 报告。后面所有功能——docker runner、模板渲染、对比报告——都是在这个闭环上叠起来的。4. 用它做一次真实对比llama.cpp 与 vLLM谁更适合这台机器4.1 评测目标与准备光讲架构太虚我用 openrig 跑一次真实对比。场景是我手头有一台 24GB 显存显卡、32GB 内存的机器需要决定后续本地推理用 llama.cpp 还是 vLLM。测试集是 20 条混合 prompt覆盖中英文问答、代码生成、简单逻辑推理。目标不是做一个论文级的严格基准而是拿到足够指导决策的相对结论。评测量了三个维度tokens per second生成吞吐首 token 延迟影响交互体感显存占用决定能不能在这张卡上跑以及是否留有余量写任务配置时我把 20 条 prompt 放在同一个测试集文件里两个任务都读这个文件。这里有个实践技巧prompt 集要单独作为数据文件管理不要在命令里手写。prompt 一多手写就会让两个方案用的测试输入不一致对比结果失去意义。4.2 两个任务配置第一个是 llama.cpp 的 CPU 推理任务name: llama-cpp-cpu runner: local command: | ./llama-bench -m /models/qwen2-7b-q4_k_m.gguf \ -t 8 -p $(cat /data/prompts.txt) --repeat 3 -o json metrics: - name: tps parser: jsonpath jsonpath: $.results[0].tps - name: peak_memory_mb parser: regexp pattern: mem_usage.*?([0-9]) MB timeout: 900第二个是 vLLM 的容器化评测任务name: vllm-gpu runner: docker env: HF_HOME: /data/hf MODEL: /models/qwen2-7b command: | python /app/bench.py --model $MODEL --prompts-file /data/prompts.txt \ --output /runs/vllm-result.json metrics: - name: tps parser: jsonpath jsonpath: $.tps - name: gpu_mem_gb parser: jsonpath jsonpath: $.gpu_mem_gb resource: gpu_mem_limit_mb: 20480 timeout: 1200两个完全不同的执行环境在配置层却是同一套语言。docker runner 决定 vLLM 任务跑在容器里llama.cpp 任务直接在宿主机跑。你不用学两套工具只要改一个字段。4.3 结果解读与复盘跑完之后openrig 在 runs/ 目录下给出两份 JSON。我写了个小脚本合并成对比表格顺手导出的 Markdown 长这样方案tokens/s首token延迟(ms)峰值显存/内存结论llama.cpp CPU12.3612内存 3.9GB能跑但交互偏慢vLLM GPU41.8138显存 21.4GB吞吐优势明显结论很清楚这台机器上 vLLM 虽然显存占用接近上限但吞吐和延迟都明显胜过 CPU 方案。如果要跑交互式应用vLLM 更合适如果只是后台批处理、不太在意首 token 延迟llama.cpp 也够用。这个对比如果不用 openrig手动做需要同时盯两个终端窗口不断记录时间戳和 nvidia-smi 输出最后大概率还是用 Excel 对齐。而用 openrig只需要把两组任务配置放进同一个目录按顺序跑完然后自动拿到结构化结果。这也是我坚持指标声明式采集的原因——人记数据一定会漏程序采数据不会。5. 让 openrig 真正可用的几个关键坑5.1 并发资源控制显存是会打架的第一个坑是并发执行。openrig 最初实现得很天真多个任务同时跑恨不得一分钟内跑完全部对比。直到某次我同时启动 vLLM GPU 任务和另一个 GPU 任务显存直接打满容器 OOM两个任务一起失败。解决方式是在 resource 字段里加入 concurrency 全局锁和 gpu_mem_limit_mb 显存限制。openrig 启动时会先统计当前所有运行中任务的资源占用新任务申请的资源会导致超限就排队等待。这很像信号量只不过信号量保护整数这里保护的是显卡这种不可简单分割的稀缺资源。反思我最初的设计期望所有任务极限并发是贪心。评测任务本质上不应该追求并发上限评测数据对并发并不敏感但对资源隔离极度敏感。一个卡死或 OOM 的任务会污染旁边跑得好好的任务结果。如果你的实验要跑 GPU默认并发调到 1 或 2除非你对显存有十足的把握。5.2 超时与用例级容错让失败也留下产出第二个坑是任务卡死。有一次评测一个长上下文模型最大序列长度配得太大单条请求跑了 20 分钟还没返回。任务配置里 timeout 是 300 秒那为什么没被掐断查了半天发现subprocess.run 设置了 timeout但它直接抛出了 TimeoutExpired而我的 openrig 没有捕获这个异常任务进程挂掉之后报告文件根本没生成。修复很简单execute 阶段捕获 TimeoutExpired把结果标记为 timeout 而不是抛异常同时保留已捕获的 stdout 和日志。这种失败也要有产出的思路非常重要。评测任务跑挂是常态但挂掉之后至少留下三样东西退出码、跑到一半的输出、失败原因。有了这些你才能判断是模型问题、配置问题还是机器问题。更微妙的问题是单条用例失败会拖垮整个任务。典型场景是 vLLM 处理某条 prompt 时超时后端抛异常整个评测脚本终止20 条 prompt 只跑到第 7 条就白跑。最佳实践是在评测脚本内部做用例级容错每条 prompt 单独 try/except失败的用例记录原因但继续。openrig 能做的是把这条经验写进文档评测脚本的错误处理应当让每个用例独立失败而不影响整体不要让一条脏数据毁掉整次实验。5.3 可复现性环境指纹必须落地第三个坑也是我开头吐槽过的问题——换台机器结果对不上。后来我给 openrig 加了一个功能每次任务执行完成后自动记录一份 environment.json 环境指纹内容包括 CPU 型号、GPU 型号、Python 版本、操作系统、当前任务配置哈希、代码仓库 commit。这些信息用户不用自己填openrig 在执行时自动采集。有了环境指纹一周后翻 runs/ 目录每份结果旁边都带着当时在哪台机器上跑出来的证据。这个设计成本很低却解决了我最大的复现焦虑前后不到一百行代码。5.4 接入 CI让每个 PR 都自动跑一小撮回归测试最后说 CI 集成。openrig 本身不提供服务端只提供命令行所以接入 CI 是水到渠成的事在 GitHub Actions 或者其他 CI 里写一个 step安装 openrig 后运行openrig run sanity.yaml。sanity.yaml 放一组很小的冒烟测试比如 5 条 prompt 和一个小模型只验证吞吐没有大退化。这一步对项目维护帮助极大。以前我改推理服务的代码总是担心改坏性能现在每次 PR 都自动跑一遍并生成报告如果 tps 下降超过 5%CI 直接标红。评测不再是月底的一次性活动而是成为工程流水线的一部分。6. 从 LLM 评测到通用实验基架openrig 还能扩展去哪6.1 Agent 评测追踪每一步轨迹openrig 目前的指标模型是执行完命令后采集最终数值。但做 Agent 评测时需要的不只是最终得分还有整个思考-行动轨迹。一个 Agent 即使最后答错了只要执行路径合理也说明框架能力不错反过来答案对了但中间调用乱来也不值得信任。要在 openrig 里表达这件事可以给 metrics 增加一个 trajectory parser解析评测脚本输出的 JSONL 轨迹文件把每一步工具调用、观察结果、时间戳都收进报告。这样实验平台和推理过程分析就接起来了报告会更有层次。6.2 批处理趋势图给实验结果配上时间轴目前 openrig 每次落一份 JSON 报告。对比多个时间点的实验时我通常把 runs/ 目录里的多份 JSON 喂给一个小脚本生成趋势图。后续计划内置一个 plot 命令专门聚合指定目录下相同 task 名的结果画出吞吐或显存随配置变化的折线图。做之前我有过顾虑会不会与专门的实验管理平台相比太单薄后来想明白了我的需求就是轻量、白盒、好改。用它批量跑完后把数据导出到任何工具里做可视化都行。openrig 只要保证导出格式稳定不承担重平台的职能。6.3 任何跑命令取指标的场景都是 openrig 的菜最后分享一个扩展思路把它当通用实验基架别局限在 LLM 推理。我现在已经开始用它跑图像生成模型的生成速度测试命令是 python 脚本生成 N 张图采集每张耗时和显存也在用它跑本地检索系统的召回率对比。本质上任何通过命令执行一项探测任务然后从输出或日志里提取数值的场景openrig 的配置模型都适用。如果你也想在自己的项目里用这个思路核心就一条把环境、命令、指标、资源四件事拆开描述交给一个共同的执行器处理。如果一开始就针对某个具体框架写死后面会越改越痛苦。我个人在使用中的体会是小工具的价值不在于功能多而在于约定简单、边界清楚。openrig 也就是几条 YAML 加两三个 runner正因为没有背上完整平台的包袱它才能在各种奇怪的实验里活得很好。最后分享一个小技巧写任务配置时给每个任务加一个 description 字段哪怕只有一句话。三个月后重新翻 runs 目录时这一句话会帮你省掉大量回忆时间。