☰
基于Hadoop与Spark的大语言模型多维度性能评估系统实践
2026/11/4 9:10:46 网站建设 项目流程

1. 为什么要把大模型评测从单机搬到Hadoop与Spark上

做LLM选型和部署的人应该都有这种体会:模型压测这事,看着简单,真要做得可靠、可对比、可追溯,工作量完全不亚于部署一个在线服务本身。我最初遇到的场景是团队里同时评估Qwen、Llama系和DeepSeek几个开源模型,每个人拿一张A100跑vLLM,再用Python脚本开几十个线程发请求,统计完把结果贴在飞书表格里。第一轮测评还能应付,第二周问题就全暴露了:不同人提交的指标口径不一致,有人统计的是处理完成时间,有人统计的是流式首Token时间;压测数据没地方统一存,跑完就丢;最麻烦的是想分析"并发从32提高到64时P99延迟到底恶化到多少",所有人都得重新跑一遍。

这时再去考虑基于Hadoop与Spark构建大语言模型多维度性能评估系统,逻辑就很自然了。HDFS负责统一纳管海量原始评测日志,Spark负责把日志转成可量化的指标结论,可视化平台负责让管理者不用看Excel也能看懂趋势。这套系统解决的不是"能不能测",而是"测完的数据如何变成组织资产"——同一个模型在不同并发、不同提示词长度、不同推理引擎下的表现,全部沉淀下来,后续做容量规划、模型选型、成本核算都有据可依。

当然,也不是所有团队都需要上这套架构。如果你只需要单卡上简单验证一个模型能不能跑通,直接用text-generation-benchmark这类工具就够了。但如果你面临多模型对比、多引擎横向评估、定期回归验证、需要向管理层输出资源预算结论,那么Hadoop + Spark这套组合确实值得投入。整个系统从设计到落地,我用到的核心组件就是HDFS做数据底座、Spark做离线聚合计算、ECharts做前端可视化,整个技术栈都不小众,遇到问题能找到的参考资料也多。

2. 系统整体结构:HDFS管数据,Spark管计算,可视化管结论

这套系统的架构我拆成了五层,每一层边界划得很清楚。第一层是评测任务编排层,负责定义评测任务参数,比如模型服务地址、并发梯度、请求总条数、提示词数据集;第二层是负载注入与采集层,并发压测工具向模型服务发请求,同时在每个请求周期里采集TTFT、生成延迟、GPU显存占用和吞吐量等原始数据;第三层是数据落地层,原始日志经过轻量清洗写入HDFS,按评测批次和日期分区存储;第四层是计算层,Spark作业定期扫描新增评测批次,计算多维度聚合指标并写出结果表;第五层是可视化服务层,前端直接查Spark产出的结果表,渲染指标趋势图、对比图和资源热力图。

2.1 每一层为什么这么选

负载注入这个环节,我没有自己造轮子,直接用了基于Python的locust扩展。原因很简单:它天然支持分布式压测,从多台压力机同时对模型服务发起请求,能模拟出比单机脚本更接近生产环境的并发请求分布。采集到的原始记录是JSON格式,每行包含请求ID、批次ID、模型名、推理引擎、并发数、提示词长度、生成长度、TTFT、总延迟、是否成功、GPU显存峰值等字段。

数据落地选择HDFS而不是普通数据库,核心原因是原始日志量极大。一次完整的并发梯度评测,从并发1到128共七档,每档发5000条请求,一次就是35000条记录;如果做多模型多引擎交叉评测,原始日志动辄上百万行。这种规模的半结构化日志,存PostgreSQL也能扛,但后续做大规模聚合分析会越来越吃力。更关键的是,HDFS上可以直接被Spark原生的spark.read.parquet读取,省去了从数据库导数据到分析平台的环节,整条链路没有任何数据搬运成本。

2.2 批处理比流处理更适合这个场景

有同行问过我:评测数据是实时产生的,为什么不用Flink或者Spark Streaming?我当时的回答是:评测任务本质是批任务,每轮评测跑完才需要统计结论,没有毫秒级实时性诉求。用Spark批处理在凌晨定时跑一次聚合,结果表在早上上班前写好,可视化页面打开就有最新数据,完全够用。引入流处理会白白增加运维复杂度,你说对吧。但如果以后要做评测过程中的实时告警——比如某个并发档位下P99延迟突然超过阈值就自动熔断——那再叠加一套流任务是合理的演进方向。

下面用一张简表回顾各层的角色:

层级承担任务关键组件
评测编排定义批次、并发梯度、数据集自研调度脚本
负载注入分布式并发压测locust 扩展
数据落地海量原始日志统一存储HDFS + Parquet
离线计算多维度指标聚合Spark DataFrame API
可视化指标展示与对比分析ECharts / Superset

3. 多维度指标体系:别只盯着吞吐量一个数字

很多评测工具默认给你三个指标:总延迟、吞吐量、成功率。这套系统我做的最重要的设计,是把指标体系拆成了四个维度来收集和存储,这样"多维度性能评估"才真正落在实处。

3.1 能力维度与性能维度的区分

能力维度衡量"答得好不好",包括在标准评测集上的准确率、ROUGE/BLEU分数、pass@k等。这个维度其实不依赖Hadoop和Spark也能算,但多模型多版本频繁迭代时,能力评测结果需要和对应版本的性能数据做联动分析,所以我把能力评测结果也拍成parquet写入HDFS,统一纳入数据治理。

性能维度才是Spark大显身手的部分。细分下来包含:首Token延迟(TTFT,用户感知到的"第一个字出来有多快")、端到端请求延迟、Token生成吞吐量、并发承载上限、失败率与超时率。其中TTFT和P99延迟是用户体感最强的两个指标,前者决定对话时"打字机"的状态,后者决定系统在高峰期的稳定性。

3.2 资源与成本维度不可省

资源和成本维度是很多自建评测脚本完全忽略的。GPU显存峰值、平均利用率、单次评测的服务器能耗折算、按Token数计算推理成本——这些数据单独看意义不大,但放到Spark里和性能指标做关联分析就很有说服力。比如"模型A比模型B吞吐量高20%,但显存占用高了35%,单位Token成本反而贵了8%",这种结论只有数据关联后才能自然浮现。

维度划分清楚了,表结构也就顺势定下来了。原始请求日志表按行存每一条请求的明细;批次汇总表按批次存聚合后的指标;资源快照表存压测过程中周期性采样的GPU信息。三张表都以批次ID为主键或分区键,Spark作业做跨表JOIN也有明确的关联线索。

4. 评测数据在HDFS上的预处理:格式、分区与合并策略

数据存储这块看着基础,实操中却是最容易返工的地方。我第一版直接用Spark把JSON文件读进来,不做任何预处理就落HDFS,结果跑了三次就出问题:凌晨批量写入时小文件激增,NameNode内存吃紧,后续Spark读取时任务数据倾斜严重。后来踩坑总结下来,三件事必须前置。

4.1 用Parquet替代纯JSON文件

HDFS上存储的原始数据统一转化为Parquet格式,压缩选用Snappy。理由很直白:Parquet是列式存储,Spark对它做谓词下推效果最好,查询时只读取需要的列能省掉大量IO。我做过实测,同样一批评测日志,JSON格式在HDFS上占用约8.6GB,转成Parquet+Snappy之后只有2.1GB,列裁剪后Spark作业读取的数据量再降一个数量级。数据写入之前会先做一层轻量清洗:丢掉字段缺失的记录、统一时间戳格式、把模型名和引擎名映射成枚举编码。清洗规则很简单,没必要上复杂的ETL框架。

4.2 分区粒度设计关系Run效率

分区策略直接决定Spark作业后续跑得快不快。我按batch_date/dt和batch_id两级分区组织数据。两级的考虑是:日常查询绝大多数时候按日期过滤最近数据,而按批次过滤能一次性把一个评测任务的所有明细取出来,不需要额外的索引维护。如果你把分区粒度过细,比如再叠加model_name层,HDFS会产生大量小目录,Spark调度开销飙升;过粗则单分区文件过大,并发读取仍会碰到热点。我这套两级分区方案在百万级请求记录下实测,单批次Spark聚合作业能在3到5分钟内完成,属于可接受的范围。

4.3 合并小文件是必须做的日常维护

小文件问题是HDFS上的经典病。初期每天压测结束后,几十个executor并发写parquet文件,每个分区下可能产生上百个小文件。解决方案是两道工序:写入前用repartition(col("batch_date"))或者coalesce(n)控制分区数,在分区内尽量写出大文件;写入后额外起一个轻量合并作业,定期把小于128MB的parquet碎片合并。合并作业没必要天天跑,批次数据量小时可以攒三天一跑。做完这套之后,Spark作业执行计划的扫描文件数从几千降到几十,效率提升非常明显。

注意:如果你的评测数据量在百万行以内,这套处理看上去确实有些重;但考虑到系统要长期沉淀多轮评测数据,提前建立数据治理规范,后面扩容时才不会被迫推翻重建。

5. Spark分布式聚合逻辑:从原始请求日志到性能结论

Spark在这套系统里承担的计算任务,本质上可以总结成一句话:把散落在HDFS上的海量请求明细,按评测批次、模型名、并发档位、推理引擎等维度聚合出可对比的指标。下面给出我最常用的几个核心计算场景的写法。

5.1 按批次与并发档位聚合延迟分布

分布式环境下计算百分位数,不用自己造轮子,直接用Spark SQL自带的percentile_approx函数。下面是批次维度聚合的关键代码:

from pyspark.sql import functions as F raw = spark.read.parquet("hdfs://namenode/warehouse/llm_eval/raw") summary = ( raw .filter(F.col("batch_id") == BATCH_ID) .groupBy("model_name", "engine", "concurrency") .agg( F.count("*").alias("total_requests"), F.avg("ttft").alias("avg_ttft"), F.expr("percentile_approx(ttft, 0.5)").alias("ttft_p50"), F.expr("percentile_approx(ttft, 0.95)").alias("ttft_p95"), F.expr("percentile_approx(total_latency, 0.99)").alias("latency_p99"), F.sum("output_tokens").alias("total_output_tokens"), F.sum("output_tokens").alias("total_output_tokens"), F.mean("gpu_mem_peak").alias("avg_gpu_mem_peak") ) )

这里必须提一个容易被坑的点:percentile_approx默认相对误差是0.05,对P99这种尾部指标来说误差会被放大,可能直接影响"是否达到SLO"的结论。实际项目中我建议显式传入第三参,比如percentile_approx(ttft, 0.99, 0.001),把误差控制到千分之一。代价是计算量略有增加,但对评测数据量级来说完全可以承受。

5.2 吞吐量的两种口径尽量都保留

对于吞吐量指标,有两种常见口径,它们的含义完全不同。第一种是成功请求的Token生成速率,用sum(output_tokens) / sum(total_latency)计算;第二种是系统层面的有效吞吐,考虑并发请求和失败请求,用sum(output_tokens) / max(end_time) - min(start_time)计算。建议两个字段都算出来写入结果表,因为容量规划场景看系统有效吞吐,而模型性能对比场景看Token生成速率。我见过不少评测报告只给一个"吞吐量"数字,导致后端做资源规划时一次又一次返工,根源就是口径没对齐。

5.3 横向对比分析的Spark读取口径

多模型横向对比是另一个高频场景。当年想给管理层出一张"三个模型分别在并发32、64、128下的P99延迟对比"的报告,其实代码逻辑非常简单:把同一批次内的多模型指标表做一次透视。

compare = ( summary .groupBy("concurrency") .pivot("model_name") .agg(F.round(F.first("latency_p99"), 2).alias("latency_p99")) .orderBy("concurrency") ) compare.write.mode("overwrite").parquet("hdfs://namenode/warehouse/llm_eval/report")

这类作业的数据量不大,重点是让可视化服务能快速查到一张已透视好的宽表,前端拿到的数据已经不需要二次处理。Spark的计算优势不在于这单次查询有多复杂,而是面对一个月几百个评测批次积累下来的几百GB明细,任何时候想追溯任何一批历史数据的结论,都能在分钟级完成,这是单机Pandas脚本完全做不到的。

6. 可视化平台:不止画几张折线图那么简单

可视化这一层最容易做成"把表格换个样子",但真正有价值的可视化是帮助人快速回答问题。这套系统的可视化平台有四个固定页面,对应四类核心角色。

6.1 性能总览大屏

性能总览大屏服务运维和平台管理员,核心是一张气泡图。横轴是吞吐量,纵轴是P99延迟,气泡大小代表GPU显存峰值,颜色代表不同模型。一眼能看出哪个模型是"高吞吐低延迟"的优等生,哪个模型虽然吞吐不错但代价是延迟波动大。气泡图背后就是前面Spark产出的summary表,前端每次加载时按日期过滤,基本在几百毫秒内出图,完全不依赖预计算缓存。

6.2 并发压测趋势图

并发压测趋势图服务做容量规划的同学,展示的是随着并发数从1增长到128,TTFT的P50/P95/P99三条曲线如何变化。这类图能直接反映系统拐点:并发到64之前延迟平缓,超过64后P99直线上升,那这个系统承载并发容量就可以定在64以下。为了画这组图,Spark作业里专门计算了各并发档位下的延迟分位数,可视化层只是普通折线图。

6.3 热点字段相关热力图

热力图服务模型调优组。把多个模型在"不同提示词长度区间、不同生成长度区间"下成功率和延迟的表现用热力图呈现,能迅速暴露出问题:模型A在短提示词下表现优秀,但提示词超过2000 token后成功率明显下降,TTFT膨胀得厉害。这些问题隐藏在平均数之下,只在细粒度聚合时才看得见。所以Spark作业里我保留了prompt_len_bucket和output_len_bucket字段作为聚合维度,就是为这类分析留的口子。

6.4 评测报告自动生成

最后一个页面是评测报告生成器。可视化不用我做复杂设计,选择合适技术栈就行——平台整体用的ECharts,后端用FastAPI提供查询接口,数据源直连HDFS上的Parquet结果表。评测报告页面提供一键导出功能,前端把图表转成图片,后端把汇总指标拼成Markdown文档,自动上传到团队的文档系统。当初设计这个功能时,我的想法很简单:评测结论不能只留在系统里,要能随报告一起流转出去。

7. 实测踩坑记录与参数调优

这个系统运行了两个月,期间踩过的坑比预想的多,这里挑最有代表性的几个记录下来。整体来看,系统本身复杂度不高,真正让人头疼的点全在工程化细节。

7.1 客户端压测和Spark作业抢资源导致的OOM踩坑

第一次全量评测跑批的时候,压测服务和Spark作业同时跑在同一批GPU机器上,结果两边一起崩。原因现在看很清晰:vLLM服务占满显存,Spark executors又申请内存做聚合,物理机内存被打穿。解决方案是硬性的资源隔离:压测任务在专门的GPU资源池执行,Spark作业则在CPU资源池执行,两个池子上层用YARN队列做限制。如果你没有条件拆资源池,那就错峰执行,把Spark聚合全部排在压测完成之后,用工作流编排来控制依赖顺序,不要再让两个任务并发抢资源。

7.2 executor内存与shuffle调参经验

Spark作业的参数优化,我花了最多时间的是spark.sql.shuffle.partitions和executor内存配置。早期我沿用默认的200个shuffle分区,结果中等规模数据量下,每个分区的任务只有几十MB数据,大量空任务在空转。后来根据评测数据量动态计算分区数,公式很简单:目标每个shuffle分区处理约200MB数据,用计算输入大小总量除以200MB估算分区数。executor内存方面,我的经验是不要无脑申请64GB大内存,单executor 8GB到16GB、配4个CPU核,在这个数据量级下性价比最高,容器化调度也更灵活。

7.3 schema推断是最隐蔽的负面因素

还有一个隐蔽的坑是Spark读取原始日志时的schema推断。日志里有ttft和total_latency这两个时间字段,有时候采集端上报时因为四舍五入把值上报成了整数字符串,有时候上报成小数,Spark推断出的schema就不一致,导致聚合时Null值突然增多。后来统一在落地阶段预设schema,显式声明字段类型为DoubleType,并加一层校验,凡是无法转换成Double的字段直接丢弃并记录异常计数。这个细节看起来不起眼,但能避免深夜被"性能指标全部为Null"的告警叫醒。

7.4 评测批次元数据管理

说一个容易被忽略的设计:每个评测批次必须有唯一的batch_id,并且批次详情表里写明模型版本、评测集版本、压测参数、引擎参数、涉及节点列表。这样一旦某次评测数据出现异常,可以快速定位到"是不是那天某台机器的GPU降频了"或者"某个引擎版本带了会导致延迟异常的配置"。批次元数据是不可或缺的审计信息,千万别省。

8. 这套系统后续还能扩展什么

系统跑稳之后,我一直在想它的演进方向,这里给出两个明确可行的扩展思路。一个是把评测集从静态变成动态——当前评测集大多是预先整理好的固定问题集,后续可以接入线上真实流量采样,把脱敏后的实际用户请求回放到评测管道里,做持续性能回归,保证每次模型升级或引擎优化上线前都能自动跑一遍历史回归集。另一个是在Spark作业里增加基于诊断规则的自动化解读——比如模型升级后同一复现集下P99延迟劣化超过20%,平台自动生成告警快照并推送给相关人,省去每轮都要人工看报告判断是否需要干预。

从工程交付的角度看,这个基于Hadoop与Spark的大语言模型多维度性能评估系统,最大价值不在于用了多新的技术,而在于把大模型评测这件"本来是临时脚本的工作"变成了一个可迭代、可追溯、有产出物沉淀的平台化能力。如果你也在做类似的事情,我的建议是先把指标体系定清楚,再谈存储和计算,最后做可视化——顺序反了的话,大概率会在数据口径上反复返工。整个系统复制和改造都不难,真正需要花时间的是把你自己业务的评测口径想明白。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询