自托管轻量级LLM API基准测试平台:统一评测延迟、吞吐与质量
1. 为什么我要自己搭一个 LLM API 基准测试平台做 LLM 应用开发这两年最头疼的事情不是写 prompt也不是调 RAG 链路而是怎么客观地知道自己手里的模型到底行不行。你肯定也遇到过这种场景供应商发来一份跑分表MMLU 多少、HumanEval 多少、C-Eval 多少看着都挺漂亮但真接到自己的业务里响应速度、并发稳定性、长上下文表现、JSON 输出合规率全都跟跑分对不上。更麻烦的是你手上有本地部署的模型、有云端 API、有不同量化版本想横向比一比结果发现每家的测试口径都不一样根本没法放在一张表里看。Uni LLM Bench 就是在这个背景下我自己动手做的一个东西。一句话概括它是一个可以自己托管、轻量化部署的 LLM API 基准测试平台把不同来源的模型接口统一成一套测试流程跑出可对比的延迟、吞吐、质量和稳定性数据。它不追求做成一个庞大的评测框架而是聚焦在我作为一个开发者想快速知道 A 模型和 B 模型在我的场景下谁更合适这件事上。这篇文章适合三类人看第一类是自己部署了本地模型比如用 llama.cpp、vLLM、Ollama 跑起来的想知道量化版本和原始版本差距到底有多大第二类是同时接了好几家云端 API想用统一标准做选型对比的第三类是想给自己团队搭一套内部评测流水线但不想引入太重依赖的。我会把整个平台的设计思路、核心模块、实操部署过程、参数计算逻辑以及我踩过的坑全部摊开讲一遍。你照着做基本能在一台普通开发机上把整套东西跑起来。先说清楚它的定位边界Uni LLM Bench 不是要替代 lm-evaluation-harness 那种学术级评测工具也不是要做成 SaaS 化的评测服务。它更像是一把随身携带的尺子你走到哪、换什么模型都能用同一把尺子量一下。这个定位决定了它在架构上必须轻、必须自托管友好、必须对异构 API 有足够强的适配能力。2. 整体架构设计与技术选型思路2.1 为什么是自托管 轻量化这条路线市面上的评测方案大致分两类。一类是学术评测框架功能全但依赖重跑一次要装一堆 Python 包配置项多到劝退另一类是各家厂商自带的 playground方便但封闭你没法控制测试集也没法拿到原始数据做二次分析。这两类都解决不了我前面说的统一口径横向对比的问题。自托管的核心价值在于数据主权和口径可控。你的测试集、你的评分逻辑、你的并发策略全都捏在自己手里。轻量化的核心价值在于部署成本低。我给自己定的硬指标是单机 Docker 一条命令起服务内存占用控制在 1GB 以内不含被测模型本身不依赖外部数据库测试结果落本地文件即可。这个约束逼着我在技术选型上做减法。具体来说后端我用的是 FastAPI原因是它对异步请求的支持天然友好而基准测试本质上就是大量并发 IO 操作。前端没有上 React 那套重家伙直接用轻量的静态页面加少量原生 JS通过接口拿数据渲染。存储层用 SQLite单文件、零配置测试记录、模型配置、评分结果全放里面需要迁移的时候拷贝一个文件就行。任务调度没有引入 Celery 或 Redis而是用 Python 的 asyncio 队列加一个简单的任务状态机够用且不增加运维负担。提示轻量化不等于功能残缺。判断标准是去掉任何一个组件核心测试流程是否还能跑通。如果去掉某个中间件就跑不了那它就不该出现在轻量方案里。2.2 核心模块拆解与数据流整个平台我拆成了五个核心模块数据流是单向的这样排查问题的时候链路清晰。第一个是模型适配层。这是整个平台最关键的抽象。不管你是 OpenAI 兼容接口、还是自定义的 HTTP 接口、还是本地进程调用都要被归一化成统一的chat_completion和completion两个方法。适配层负责处理鉴权头、请求体格式转换、流式与非流式的差异。第二个是测试集管理模块。测试用例以 JSONL 格式存放每行一条包含输入、期望输出可选、评分方式、标签。标签很重要它让你能按代码生成摘要结构化抽取等维度分组看结果。第三个是执行引擎。它负责按配置的并发数、重试策略、超时时间把请求打出去记录每条请求的首 token 延迟TTFT、总延迟、token 数、是否成功。第四个是评分模块。支持精确匹配、正则匹配、JSON 结构校验、以及可选的 LLM-as-judge 打分。评分逻辑和测试执行解耦这样你可以先跑完收集原始输出再离线换评分策略重算。第五个是结果聚合与展示。把原始记录聚合成 P50/P90/P99 延迟、吞吐tokens/s、成功率、质量分前端按模型和测试集维度对比展示。数据流是这样的配置模型 → 选择测试集 → 执行引擎并发打请求 → 原始结果落库 → 评分模块打分 → 聚合展示。每一步的中间产物都持久化任何一步失败都能从断点续跑这一点在跑大规模测试集的时候特别重要。2.3 关键参数的计算逻辑很多人做基准测试只看一个平均延迟这是不够的。我在平台里强制记录并计算这几个指标每个都有明确的用途。TTFTTime To First Token从发出请求到收到第一个 token 的时间。对流式输出场景这个指标直接决定用户感知的卡不卡。计算方式是first_token_timestamp - request_send_timestamp。TPOTTime Per Output Token(total_latency - ttft) / (output_tokens - 1)。它反映的是模型持续生成的速度跟 TTFT 分开看才能定位瓶颈——是首包慢还是生成慢。吞吐量分两种口径。单请求吞吐是output_tokens / total_latency系统吞吐是总输出 token 数 / 测试总耗时。并发测试时这两个数会差很多系统吞吐才是评估服务承载能力的指标。成功率成功请求数 / 总请求数。这里要注意HTTP 200 不代表成功还要校验返回体结构是否符合预期。我把返回了但格式错误单独归为格式失败跟网络失败区分开。P90/P99 延迟把所有请求延迟排序后取分位值。平均值会被少数极快或极慢的请求带偏分位值才能反映真实体验。我一般看 P90因为 P99 在样本量不够大的时候波动很大。这些指标的计算全部在聚合阶段完成原始记录里只存时间戳和 token 数保证可重算。3. 核心细节解析与实操要点3.1 模型适配层怎么写才能兼容各种接口适配层是整个平台的地基写得好后面省心写得差后面全是补丁。我的做法是定义一个抽象基类所有适配器继承它只实现三个方法build_request、parse_response、health_check。from abc import ABC, abstractmethod class BaseAdapter(ABC): abstractmethod def build_request(self, prompt: str, **kwargs) - dict: 构造发往目标服务的请求体和 headers pass abstractmethod def parse_response(self, raw: dict) - dict: 从原始响应里抽出 text、input_tokens、output_tokens pass abstractmethod async def health_check(self) - bool: 测试前先探活避免对着挂掉的服务狂打请求 pass对于 OpenAI 兼容接口build_request基本是直通只需要把 base_url 和 api_key 注入。对于自定义接口就要做字段映射比如有的服务返回的是data.content而不是choices[0].message.contentparse_response里做兼容。对于本地进程调用比如直接调 llama.cpp 的 server其实也是 HTTP只是 base_url 指向 localhost。这里有个容易忽略的点token 计数。不同服务的 token 计数口径不一样有的返回 usage 字段有的不返回。我的处理策略是优先用服务返回的 usage没有的话用 tiktoken 本地估算并在结果里标记这个 token 数是实测还是估算。做对比的时候估算值只能同口径比不能跟实测值混着看。注意适配层一定要做超时和重试的兜底。我见过太多测试跑了一半因为某个请求卡死导致整批结果作废的情况。超时建议按测试集的最长期望输出动态设置重试只对网络类错误重试格式错误不要重试否则会掩盖真实问题。3.2 测试集设计别让测试集本身成为偏差来源测试集的质量直接决定评测结论的可信度。我踩过的最大坑是早期测试集里全是短问题结果所有模型跑出来延迟都很低完全区分不出差异。后来我把测试集按输入长度和输出长度做了分层。我的测试集组织方式是每个业务场景一个文件比如code_gen.jsonl、summarize.jsonl、json_extract.jsonl。每行结构如下{id: code_001, input: 写一个快速排序, expected: null, scoring: llm_judge, tags: [code, short_output], max_tokens: 512}scoring字段决定用哪种评分方式tags用于分组统计max_tokens控制单条用例的输出上限。这个max_tokens很关键如果不设有的模型会一直生成到上下文上限把延迟数据拉得很难看而且不公平。测试集规模上我的经验是每个维度至少 30 条最好 50 条以上。少于 30 条的时候P90 分位值基本没有统计意义。如果只是想快速冒烟测试10 条也行但结论只能当参考不能当决策依据。还有一个细节测试集里要故意放几条边界用例比如超长输入、需要拒答的请求、格式要求极严格的 JSON 输出。这些用例最能暴露模型的真实短板也是跑分表里永远不会体现的部分。3.3 并发策略与限流处理并发数是基准测试里最容易设错的参数。设太低测不出服务的真实承载能力设太高又会触发目标服务的限流导致大量失败请求数据失真。我的做法是阶梯式加压。先从小并发比如 1、2、4开始跑观察成功率和延迟变化找到延迟开始明显上升的拐点那个并发数就是这套服务的舒适区。然后再往上加压看它在过载时的表现——是优雅降级还是直接雪崩。具体实现上用 asyncio 的 Semaphore 控制并发上限import asyncio async def run_batch(cases, adapter, concurrency4): sem asyncio.Semaphore(concurrency) async def worker(case): async with sem: return await execute_one(case, adapter) return await asyncio.gather(*[worker(c) for c in cases])限流处理上我加了一个简单的自适应逻辑如果连续 N 个请求返回 429限流就自动降低并发并等待一段时间再继续。这个逻辑不复杂但能救命尤其是在测云端 API 的时候。提示测云端 API 一定要先确认对方的速率限制政策并且把测试时间安排在非高峰时段。我有一次在对方高峰期跑测试结果 P99 延迟比平时高了三倍差点误判模型性能。4. 完整实操过程与核心环节实现4.1 环境准备与部署整套平台的部署我尽量做到极简。基础环境只需要 Python 3.10 和 Docker可选。如果你不想用 Docker直接 pip 装依赖也能跑。依赖清单我控制在个位数fastapi、uvicorn、httpx、pydantic、tiktoken、aiosqlite。没有 ORM没有消息队列没有前端构建工具链。这个依赖规模意味着你在任何一台能跑 Python 的机器上都能部署。用 Docker 的话Dockerfile 大概长这样FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD [uvicorn, main:app, --host, 0.0.0.0, --port, 8000]启动命令就一行docker run -p 8000:8000 -v ./data:/app/data uni-llm-bench。data目录挂载出来里面放 SQLite 文件和测试集这样容器重建数据也不丢。内存占用实测下来空载大概 80MB跑测试的时候主要开销在并发请求的缓冲区100 并发以内基本不会超过 500MB。这个数字对一台 2C4G 的小机器来说完全没压力。4.2 配置一个模型并跑通第一条测试配置模型是通过一个 YAML 文件或者前端表单完成的。我以配置一个本地 Ollama 服务为例走一遍完整流程。第一步确认目标服务可达。Ollama 默认在http://localhost:11434先手动 curl 一下确认能返回。第二步写模型配置name: qwen2.5-7b-ollama adapter: openai_compatible base_url: http://localhost:11434/v1 api_key: ollama model_id: qwen2.5:7b default_params: temperature: 0 max_tokens: 512 timeout: 120这里temperature设成 0 是为了保证可复现性基准测试最忌讳随机性。timeout设 120 秒是因为本地小模型跑长输出确实慢设太短会误杀。第三步选一个测试集比如smoke_test.jsonl10 条并发设 1先跑一遍冒烟。这一步的目的是验证适配层没问题、评分逻辑没问题而不是看性能。第四步冒烟通过后换正式测试集并发从 4 开始阶梯加压。每跑完一轮结果自动落库前端刷新就能看到对比。4.3 结果解读怎么从一堆数字里看出门道跑完测试你会得到一张表包含每个模型的 TTFT、TPOT、吞吐、成功率、质量分。怎么读这张表比怎么跑测试更重要。我的解读顺序是这样的。先看成功率成功率低于 95% 的模型直接排除性能再好也不能用因为生产环境里失败请求是要用户买单的。再看质量分质量分是硬门槛不达标的一票否决。然后才在达标模型里比性能这时候 TTFT 和 TPOT 分开看如果是交互式场景聊天TTFT 权重高如果是批处理场景离线生成吞吐权重高。举个我实际遇到的例子。两个模型 A 和 BA 的 TTFT 是 200ms、TPOT 是 30msB 的 TTFT 是 800ms、TPOT 是 15ms。单看平均延迟B 可能还更快但在聊天场景里A 的体验明显更好因为用户等首字的时间短。这就是为什么必须把 TTFT 单独拎出来看。质量分这块我用的是精确匹配 LLM-as-judge混合策略。能精确匹配的比如 JSON 抽取、分类就用精确匹配不能的用 judge 模型打分。judge 模型要固定不能这次用 A 下次用 B否则分数不可比。4.4 参数计算实例一次真实的并发测试我拿本地一个 7B 量化模型做过一次完整的阶梯加压测试把数据摊开给你看你就明白怎么找拐点了。并发数成功率TTFT P50TTFT P90TPOT P50系统吞吐(tokens/s)1100%180ms210ms28ms342100%195ms240ms30ms624100%230ms310ms35ms105898%380ms620ms52ms1481685%720ms1500ms95ms1603260%1400ms3200ms180ms155从这张表能清楚看到并发 4 是这套服务的舒适区吞吐还在线性增长延迟增长可控。到并发 8P90 延迟开始明显抬头成功率也掉了。到并发 16成功率跌破 90%系统吞吐基本不再增长说明已经过载。并发 32 的时候吞吐甚至下降了这是典型的雪崩前兆。这个拐点数据才是你真正需要的东西。它告诉你这套部署在保证体验的前提下最多能扛多少并发。这个数字比任何跑分都实在。5. 常见问题与排查技巧实录5.1 测试结果波动大怎么办这是被问得最多的问题。同一套配置跑两次结果差 20%到底信哪个先排查外部因素。目标服务是不是在跑别的任务机器负载是不是有波动网络是不是不稳定这些都会影响结果。我的做法是测试前先让服务空转一分钟测试期间不要在同一台机器上跑其他重活。再排查测试本身。并发是不是设太高导致限流测试集是不是太小导致分位值不稳如果是把并发降下来把测试集加大重跑。如果外部因素都排除了还是波动大那可能是模型本身的问题。有些量化模型在不同输入下的性能差异就是很大这时候你要接受这个事实并在报告里如实标注波动范围而不是取一个好看的数字。提示我一般会跑三轮取中位数而不是跑一轮。三轮的成本可以接受但结论的可靠性提升明显。5.2 常见问题速查表现象可能原因排查方向解决方式大量请求超时并发过高或服务过载看超时请求的时间分布降低并发加长超时成功率突然掉到 0服务挂了或鉴权失效手动 curl 目标接口检查服务状态和密钥token 数明显偏少适配层解析字段错误打印原始响应对比修正 parse_response质量分异常低评分逻辑或 judge 模型问题抽样人工核对调整评分 prompt结果无法复现temperature 非 0 或测试集变了检查配置和测试集版本固定参数测试集加版本号内存持续增长结果没落盘全堆内存里看进程内存曲线分批落库及时释放5.3 几个只有踩过才知道的坑第一个坑别用生产环境的 API key 跑大规模测试。我有一次图省事用了生产 key结果测试流量把当月配额跑掉了一大半差点影响线上业务。测试一定要用独立的 key 和独立的配额。第二个坑流式和非流式要分开测。有些服务流式和非流式的性能差异巨大混在一起测出来的数据没有意义。我的平台里这两种模式是分开配置、分开统计的。第三个坑judge 模型本身也会犯错。用 LLM 打分的时候一定要抽样人工核对确认 judge 的准确率在可接受范围内。我一般会抽 10% 的样本人工看如果 judge 和人工的一致率低于 85%就要重新调 judge 的 prompt。第四个坑测试集要版本化。你今天改了测试集昨天的结果就没法跟今天比了。我在测试集文件里加了版本号字段每次改动都升版本结果表里记录用的是哪个版本这样历史数据永远可比。第五个坑别忽略冷启动。有些模型第一次请求特别慢要加载权重如果你把第一次请求也算进统计数据会被严重污染。我的做法是测试前先发一条预热请求不计入统计。6. 我对这套平台后续扩展的一些想法这套东西我用了大半年从最初只能测 OpenAI 兼容接口到现在能覆盖本地部署、云端 API、不同量化版本基本满足了我日常选型和回归测试的需求。它最大的价值不是某个具体功能而是把评测这件事从一次性的、不可复现的操作变成了一个可以随时跑、随时比、随时回溯的常规流程。如果你也想搭一套我的建议是先从最小可用版本开始一个适配器、一个测试集、一个评分方式跑通再说。不要一上来就想着支持所有接口、所有评分策略那样大概率会烂尾。等最小版本跑顺了再按需扩展适配器和评分模块每一步都有实际需求驱动才不会做成一个没人用的花架子。最后分享一个我最近在试的扩展方向把基准测试接到 CI 流程里每次模型配置变更或者 prompt 模板调整自动跑一轮回归测试质量分或延迟退化超过阈值就报警。这样能把很多问题挡在上线之前比事后救火划算得多。这个方向我还在打磨等跑稳了再单独写一篇。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →