先说明一个经常被忽略的事实:本地跑开源LLM服务的人,大部分时间都在调prompt、换模型权重、调采样参数,却很少有人认真对待“这个服务现在到底健不健康”这件事。我自己在本地环境维护过一个长期运行的LLM推理服务,一开始完全是黑盒,直到某天一个业务方反馈生成速度突然从每秒40个token掉到8个,我才意识到,显存可能早就被打满了,队列里堆着几十个请求,而日志文件里只有一行没有时间戳的报错。从那时候起,我开始认认真真做监控,最后落地成方案就是标题里的组合:用Prometheus + Grafana监控本地LLM指标。这篇文章会完整记录我搭建这套监控体系的过程,包括指标怎么设计、埋点怎么写、告警怎么配、坑在哪里,适合正在本地或内网环境部署LLM服务、想让可观测性不再裸奔的开发者参考。
这套方案解决的问题很具体:怎么知道GPU显存是否逼近上限、推理延迟是否在劣化、token吞吐有没有突然下降、服务有没有在默默报错、日志里能不能快速定位到异常请求。它不依赖任何外部平台,所有组件都跑在本地,数据也在本地,对私有化部署很友好。如果你是刚接触监控的新手,跟着文章的操作一步步来也能搭起来;如果你已经有一定经验,那可以重点看后面我列的那些容易踩的坑。
1. 监控方案的整体设计与思路拆解
1.1 本地LLM到底需要关注哪些指标
先把问题定义清楚。传统Web服务的监控重点是CPU、内存、QPS、请求错误率,但本地LLM服务还多了几个特有的维度:GPU显存占用、显存带宽利用率、推理时延、生成token的速率、KV Cache的占用变化、当前排队请求数。这些指标缺一个,出问题的时候就很难定位。
举个例子,显存占用飙升这个事,在LLM服务里比普通Web应用敏感得多。模型权重本身就吃掉一部分显存,每个请求进来又会动态分配KV Cache空间。如果某个时刻并发请求多了,显存可能会被瞬间打满,然后直接触发CUDA Out of Memory,进程崩溃重启。这种崩溃在日志里可能就是一行Traceback,并不好发现。但如果我提前在Grafana上建了一个显存使用率的面板,配合一个“超过90%持续5分钟”的告警,我就能在业务方感知之前知道危险要来了。
还有一个容易被遗漏的指标是生成token的速率,也就是吞吐。这个数能非常直观地反映推理服务的质量。模型从A模型换成B模型之后,吞吐有没有下降?加了某个量化方案之后,延迟是不是变高了?这些都需要用数字说话。日志可以告诉你一次请求用了多久,但它没法回答“整体趋势是在变好还是变坏”,而PromQL里一行rate函数就能画出趋势线。
所以,我的整体思路是三层指标:系统层(CPU、内存、磁盘、网络)、GPU层(显存使用、GPU计算利用率、温度)、推理服务层(请求数、延迟、token数、错误数、队列长度)。三层都接入Prometheus,然后统一在Grafana里看。日志作为辅助数据,单独接入一套日志采集链路,但和指标在同一个Grafana界面里串联查看。
1.2 Pull模型和三层采集架构
Prometheus采用的是Pull模型,也就是监控中心主动去各个被监控对象拉取指标,而不是被监控对象主动上报。这个设计对本地场景非常友好,因为我只需要在每个组件旁边开放一个HTTP接口,Prometheus按固定间隔去抓取就行。它天然避免了“上报端挂了导致数据断流”的问题,而且配置起来非常直观:告诉Prometheus往哪个IP的哪个端口去拉数据就可以。
我的采集架构是这样拆的:
- 系统层:运行一个node级别的exporter,暴露CPU、内存、磁盘IO、网络流量等基础指标。
- GPU层:运行一个GPU exporter,读取显卡驱动的指标并暴露出来,重点看显存、利用率。
- 推理服务层:在LLM服务进程内部直接暴露一个
/metrics接口,把请求数、延迟分布、token计数这些业务指标吐出来。这个接口可以和服务本身跑在同一个进程里,用独立端口启动,也可以走单独的exporter进程,取决于代码怎么组织。 - 日志层:应用所有业务日志以JSON格式持久化,然后由一个轻量级的日志采集器把它们转发到日志存储,日志存储本身也提供查询接口。
为什么采用分层而不是全部塞进一个exporter?因为每个层级的生命周期不同。系统层和GPU层基本是常驻的,跟业务进程没有耦合;推理服务层则必须和业务代码在一起,才能拿到内部的队列长度和延迟数据。拆开之后,如果LLM服务重建或升级,镜像里的Prometheus配置完全不用改,因为GPU层和系统层还在继续运行。
1.3 为什么是Prometheus + Grafana而不是其他监控组合
我在选型时也考虑过直接上完整的日志搜索平台,比如轻量的ELK组合,但最后还是回到了Prometheus + Grafana。原因很实在:这套组合在指标监控上的生态最成熟,而且组件少,启动成本低。
Prometheus本身是一个时序数据库,存储的就是“时间戳+标签+数值”,特别适合画趋势曲线和做阈值告警。它内置了PromQL查询语言,在做多维度聚合时极其方便。比如我想看所有模型里每种模型的p95延迟,一句histogram_quantile配合sum by (model)就能算出来。这种查询在日志系统里实现起来反而很别扭。
Grafana则解决了两件事:一是可视化,把Prometheus里的数据变成一张张面板,拖拽一下就能组合成指标大盘;二是告警,从数据源直接发告警到通知渠道。而且它不仅仅是Prometheus的专用前端,还能同时接日志数据源。这样我可以把“最近5分钟的错误日志”和“最近5分钟的延迟曲线”放在同一个大屏上,排查问题时不用来回切工具。
当然,这套方案也有短板。它对高基数数据的支持一般,如果你的标签里带着每个请求唯一的ID,Prometheus会爆炸;它也不适合存长周期的原始日志,日志的全文检索还是要交给专门的日志系统。但就监控“本地LLM指标”这件事,它已经足够强。
2. 核心细节解析与实操要点
2.1 指标类型和PromQL的基础玩法
理解Prometheus之前,先要过一遍它最基础的四种指标类型,因为后面所有埋点和查询都围绕这四种类型来。
Counter是计数器,只能增加,不能减少。比如总共处理了多少请求、生成了多少token。它适合统计总量,但在监控时通常不会直接看总数,而是用rate()函数算每秒变化率。例如表达式rate(llm_requests_total[5m])就是过去5分钟的每秒请求数。
Gauge是瞬时值,可以增加也可以减少。比如显存当前使用量、当前排队请求数、GPU利用率。这种直接查当前值就行。
Histogram是直方图,用于观测值的分布。它会把观测值放进预设的桶里,每个桶是一个Counter,然后在查询时通过直方图近似算法算出百分位数。LLM推理延迟这种“平均值往往很有欺骗性”的指标,最适合用Histogram来监控。比如histogram_quantile(0.95, sum(rate(llm_inference_seconds_bucket[5m])) by (le))的意思就是,过去5分钟内95%的请求延迟都低于某个秒数。
Summary和Histogram类似,但有本质区别:Summary计算分位数是在客户端完成的,客户端维护分位数的近似统计,然后直接把分位数结果暴露出来;Histogram则是在查询端计算的。客户端Summary的问题是多个实例聚合时无法做求和,而Histogram可以。所以我推荐延迟和token数这类需要聚合的指标一律用Histogram。
2.2 为本地推理服务埋点:一份可用的Python示例
我先展示一个不依赖具体模型框架的埋点示例,核心思路是把指标埋点打到推理服务的主流程里。假设我们的推理服务是Python写的,只需要安装prometheus_client这个库就可以。
from prometheus_client import start_http_server, Counter, Gauge, Histogram REQUEST_COUNT = Counter( "llm_requests_total", "Total requests received", ["model", "status"] ) TOKEN_COUNT = Counter( "llm_tokens_total", "Total tokens generated", ["model"] ) QUEUE_SIZE = Gauge( "llm_queue_size", "Current number of queued requests" ) INFERENCE_LATENCY = Histogram( "llm_inference_seconds", "Inference latency in seconds", buckets=(0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0) ) GPU_MEM_USED = Gauge( "gpu_mem_used_bytes", "GPU memory used in bytes" )然后在处理请求的代码里,把计数器放在执行前后的对应位置。重点是要记得让这些指标在一个独立的HTTP端口暴露出来,和业务端口分开,否则抓取接口的信号太密集时会抢业务流量。
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST from http.server import HTTPServer, BaseHTTPRequestHandler class MetricsHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path == "/metrics": data = generate_latest() self.send_response(200) self.send_header("Content-Type", CONTENT_TYPE_LATEST) self.end_headers() self.wfile.write(data) else: self.send_response(404) self.end_headers() def start_metrics_server(port=9100): server = HTTPServer(("127.0.0.1", port), MetricsHandler) server.serve_forever()这样做的好处是,即使业务进程挂了,只要这个指标服务还在运行,至少能告诉你进程已经不可用了。不过大多数场景更推荐把start_http_server(9100)直接丢进业务进程的主线程启动,因为如果进程都挂了指标接口也消失,Prometheus就拉不到,呈现出Target Down的状态,这也是一种信号。
下面再看埋点怎么插进推理流程:
def inference(prompt, model="default"): QUEUE_SIZE.inc() try: start = time.time() output = model.generate(prompt) latency = time.time() - start INFERENCE_LATENCY.observe(latency) REQUEST_COUNT.labels(model=model, status="success").inc() TOKEN_COUNT.labels(model=model).inc(len(output.split())) return output except Exception as e: REQUEST_COUNT.labels(model=model, status="error").inc() raise finally: QUEUE_SIZE.dec()这里的observe调用就是把延迟数值丢进直方图里,之后Prometheus那边才能查到分位数。注意labels的使用,模型和状态是合理的标签维度,千万不要把请求ID或者用户输入内容当标签,否则标签基数会高到让Prometheus内存爆掉。这个坑在后面我会专门展开。
2.3 日志的采集与指标联动
指标回答的是“是什么”和“有多严重”的问题,日志回答的是“具体是哪条请求”和“为什么”。标题里既然带了“日志”,就不能只把指标弄完就跑。我的日志方案是:应用打结构化JSON日志,每条日志带上一批关键字段。
{ "timestamp": "2025-01-15T20:31:22.123Z", "level": "info", "request_id": "a8f3c-1234", "model": "local-demo", "prompt_tokens": 32, "completion_tokens": 156, "latency_ms": 2345, "status": "success", "gpu_mem_used_mb": 6821 }这样一段日志本身就能用来做排查,比如某次请求耗时特别长,我就能在日志里看它分配了多少token。而且有一个非常实用的联动方式:当Prometheus告警触发“推理延迟P95超过5秒”时,我可以立刻切到日志查询,搜出那段时间所有延迟大于某阈值的请求ID,再结合请求上下文定位原因。
我这里用的是Grafana的日志数据源和Promtail采集器这套组合。Promtail会监听日志文件的增长,把每一条JSON日志转成一个可查询的文档。配置大概是这样:
scrape_configs: - job_name: llm-service-logs static_configs: - targets: ["localhost"] labels: job: llm-service-logs __path__: /var/log/llm-service.log在Grafana的Explore页面里,可以用LogQL查询指定时间段内的错误日志,比如只查status=error的记录,或者按模型标签分组统计错误数。从实际经验看,日志和指标在同一个大屏上展示,排查效率比单独看一边高一倍都不止。
2.4 Grafana面板与查询思路
Grafana面板设计的核心是:一层一层的往下钻,而不是把所有指标堆在一张图上。我的本地LLM大盘会分成五行:
第一行是请求概览,包含每秒请求数、每秒生成token数、当前排队请求数。这些直接反映服务整体压力。
第二行是延迟分析,包含平均延迟、P95延迟、P99延迟。延迟分布比单看平均值更能暴露尾部风险。
第三行是GPU资源,包含显存使用量、显存利用率、GPU计算利用率。这几块决定瓶颈到底在显存容量还是算力。
第四行是错误与日志,包含错误率曲线、错误日志数量、最近错误日志的简要表格。
第五行是系统资源,包含CPU、内存、磁盘IO。
每张面板对应的查询举一个例子。每秒请求数:
sum(rate(llm_requests_total[5m])) by (model)P95延迟:
histogram_quantile(0.95, sum(rate(llm_inference_seconds_bucket[5m])) by (le, model))显存使用率:
(gpu_mem_used_bytes / gpu_mem_total_bytes) * 100这里我建议把model作为变量,在Grafana顶部做一个下拉选择,这样一张面板能同时复用多模型场景。具体做法是新建一个Dashboard变量,Query直接用label_values(llm_requests_total, model),然后在面板查询里用$model引用变量就好。
3. 实操过程与核心环节实现
3.1 准备环境与组件清单
在开始动手前,先把组件清单和端口规划好。表格如下:
| 组件 | 作用 | 启动端口 | 数据来源 |
|---|---|---|---|
| Prometheus | 指标存储与告警计算 | 9090 | 主动抓取 |
| 系统Exporter | 采集CPU/内存/磁盘 | 9101 | 宿主机 |
| GPU Exporter | 采集显存/利用率 | 9835 | 显卡驱动 |
| LLM Service | 推理服务本身 | 8000 | 业务端口 |
| LLM Metrics | 推理业务指标接口 | 9100 | 服务内嵌 |
| 日志采集器 | 读取并转发日志 | 固定组件进程 | 日志文件 |
| Grafana | 可视化和告警UI | 3000 | 多种数据源 |
如果没有专用的GPU Exporter,也可以让GPU显存使用量从业务进程内部读取,然后通过自定义指标直接暴露。我两种都试过,建议有条件还是用专用exporter,因为它还会额外给出GPU利用率、温度、功耗等数据,对判断卡点很有用。
3.2 编写Prometheus采集配置并启动
Prometheus的配置文件一般叫prometheus.yml。第一次配置时,只需要让三组targets生效。
global: scrape_interval: 15s evaluation_interval: 15s scrape_timeout: 10s scrape_configs: - job_name: "local_llm_metrics" static_configs: - targets: ["127.0.0.1:9100"] - job_name: "node_exporter" static_configs: - targets: ["127.0.0.1:9101"] - job_name: "gpu_exporter" static_configs: - targets: ["127.0.0.1:9835"]scrape_interval是抓取频率。对于LLM这种服务,15秒足够。如果调成5秒会稍微灵敏,但会增加存储量。evaluation_interval是告警规则被评估的频率,也保持一致就行。scrape_timeout不能比抓取间隔还大,否则来回叠加会产生堆积。
保存配置后,用prometheus --config.file=prometheus.yml启动,等几秒后打开Prometheus自带的页面(端口9090),在Status -> Targets里能看到三组target的状态。理想状态下都是UP,HTTP抓取状态码是200。这里有一个很常见的坑:抓取的IP别配成localhost,因为Prometheus容器或服务如果跑在不同网络环境里,localhost指向的可能不是同一台机器。本地直接裸机运行的时候用127.0.0.1没问题,如果用了容器编排,目标地址就要写宿主机实际IP。
3.3 配置Grafana数据源和Dashboard
Grafana启动后,浏览器打开3000端口,默认登录后第一件事是添加数据源。数据源类型选Prometheus,URL填http://127.0.0.1:9090,点击“Save & Test”,看到成功提示就说明链路已经通了。
接着开始创建Dashboard。我建议不要一上来就尝试一次画二十张面板,从最关心的三张开始:每秒请求数、P95延迟、显存使用率。前两张能让你知道服务忙不忙、慢不慢,后一张能让你知道GPU有没有爆显存风险。
添加面板时,选择Prometheus数据源,把查询语句填进PromQL输入框。面板右侧可以设置图例和单位,比如延迟的单位选s(秒),显存单位选bytes。如果查询结果带有多条时间序列,比如按模型分开的,可以点击“Legend”输入{{model}}作为图例名称。
如果你想直接复用别人分享的Dashboard,也可以导入JSON模板,但导入后一定检查里面的数据源名是否和本地一致。很多模板里写的是${DS_PROMETHEUS}这种变量,导入时Grafana会提示你绑定。
最后别忘记给Dashboard配一个“快速筛选”变量。在Dashboard设置里新增一个叫model的变量,Query类型选Label values,标签选model,这样顶部就能切换不同模型,所有面板都会自动随模型刷新。实测下来,多模型共享一套大盘的体验比每模型一个大盘好很多。
3.4 设置告警:从指标异常到通知触达
监控最终要落到告警,否则不可能24小时盯着大屏。Grafana 8之后内置了告警功能,直接在Dashboard面板上就可以配置告警规则,也可以独立在Alerting页面维护。
我配置的第一个告警是显存高水位,规则如下:
gpu_mem_used_bytes / gpu_mem_total_bytes > 0.9触发条件可以设置持续5m,意思是连续5分钟超过90%才告警,避免瞬时抖动误报。严重级别我设为warning。第二个告警是排队长度,表达式:
sum(llm_queue_size) > 10持续2分钟就告警。第三个告警是错误率,表达式:
sum(rate(llm_requests_total{status="error"}[5m])) / sum(rate(llm_requests_total[5m])) > 0.05意思是5分钟内错误率超过5%。这个阈值对于LLM服务来说已经很高,正常应该在1%以下。
告警通知渠道我建议先用一个大路货:Webhook。很多办公通讯软件都支持Webhook机器人,配置好URL以后,Grafana会把告警消息POST过去。这样当我在工位上摸鱼时,手机也能收到推送。通知的模板里会包含告警名称、当前值、标签这些信息,已经足够定位到具体是哪个指标、哪台机器。
需要注意,告警规则里用到的指标如果带有model标签,且你又希望按不同模型分别触发,表达式里要保留标签,比如:
sum by (model) (llm_queue_size) > 10否则多模型混合求和后,一个模型排队多也看不出来。
4. 常见问题与排查技巧实录
4.1 数据拉不到或Target显示Down
这是新手最频繁遇到的问题。Prometheus的Targets页面显示DOWN,排查顺序是:
先确认本机端口有没有监听:curl http://127.0.0.1:9100/metrics。如果curl成功但Prometheus抓取失败,大概率是地址写错了,或者Prometheus运行在隔离网络里。如果curl失败,说明目标服务没起来或端口不对,去查那个服务自己的启动日志。
还有一个容易被忽略的点是scrape_timeout。如果某个metrics接口因为内部逻辑卡顿,导致抓取超时,Prometheus会把这次抓取标记为失败。解决办法是把接口本身做轻量化,不要在生成metrics时实时调用重型函数。
我在本地排查过一次非常诡异的问题:指标接口能被浏览器正常打开,但Prometheus一直显示DOWN。后来发现是这台机器上同时跑着两个进程,另一个旧进程抢占了同一个端口,浏览器看到的是旧进程的响应,而Prometheus抓取的响应格式不对。解决方式就是找到所有占用端口的进程,逐个核对命令。
4.2 指标为0或者是碎片
Targets显示UP,但Grafana里指标一直是0,或者曲线断断续续。这种情况通常有三个原因。
第一,指标确实还没产生数据。比如Counter类型的llm_requests_total,如果你的服务最近没有新增请求,rate算出来就是0,这是正常的。可以先用空查询llm_requests_total看原始值,如果有数值但rate是0,那是窗口内确实没有增量。
第二,代码里labels()使用不一致。比如埋点时用了labels(model="default"),查询时用的{model!=""},标签名对应不上就会查不到。我用promtool check metrics检查过配置,也手动跑到Prometheus的Graph页面翻看指标名称是否完全正确。
第三,时间窗口和重启导致的断崖。Counter是单调递增的,但一旦进程重启,Counter会重置为0。如果Prometheus的抓取间隔刚好错过了某些时间段,计算出来的rate可能偏低甚至为0。这不是配置错误,而是Counter的固有特性。为了让速率曲线更平滑,查询窗口可以拉长到[10m]甚至[30m]。如果服务频繁重启,那首先该解决的是稳定性问题,而不是只改查询窗口。
4.3 标签设计不当带来的坑
我在埋点示例里反复强调不要用请求ID做标签,这里具体展开说为什么。Prometheus本质上对高基数非常敏感,每个标签组合都会在内存和磁盘里占一条时间序列。请求ID这种每毫秒都不重复的标签,会让序列数量爆炸式增长。一旦涨到几十万条,Prometheus的查询和存储都会显著劣化,严重的会直接把进程内存撑爆。
正确的标签应该包含模型名、请求状态、错误类型这种维度,数量级通常是固定的几个到几十个。比如method、model、status、endpoint就够了。那些需要逐条查询的信息,应该放在日志里,而不是做进指标标签。
类似的坑还有把Prometheus label当作“注释”来用,比如往里面塞一条很长的错误信息。这会让标签值长度失控,浪费存储,而且查询时也难看。真需要看错误详情,去日志系统里按request_id查就好了。
4.4 显存和GPU指标采集的特殊问题
GPU指标采集的坑比较特殊。很多GPU Exporter在读取显存数据时依赖默认的显卡设备。如果你的机器有多个GPU,或者运行环境中GPU设备的索引不是从0开始,exporter可能报错或者只显示出一张卡。解决方法是查看exporter的错误日志,一般在启动时会有设备列表。然后在配置里指定需要监控的设备索引。
另外,不要以为gpu_mem_total_bytes是个常量就忽略它。不同显卡的显存大小不一样,如果监控的机器不止一台,用绝对字节数做比较其实没意义,要转成百分比。而百分比指标最好在PromQL里动态计算,不要直接在埋点端算好,这样可以保留原始字节数,方便后续做容量分析。
还有一个容易被忽略的点是显存使用和进程的绑定关系。同一个显卡上如果同时跑了多个推理进程,每个进程看到的显存使用量可能都是“整个卡的用量”,而不是自己占用的部分。这样的话,两个服务上报的gpu_mem_used_bytes其实是同一份数据,聚合时不注意就会重复计算。我习惯在PromQL里用max而不是sum来聚合GPU显存,因为物理设备的用量在同一张卡上只能取最大值。
4.5 日志时间与指标时间不同步
当我把日志和指标放在同一个Grafana大盘后,发现一个很别扭的问题:日志里面的时间戳和Prometheus抓取到的时间戳总是对不上。原因通常是应用日志里写的是本地时间,而Prometheus和Grafana默认按UTC处理。如果机器时区是东八区,日志时间显示是UTC+8的,Grafana查询面板用的是UTC,看起来就会差8个小时。
解决方式有两种。一是日志格式里强制使用UTC时间戳,比如上面JSON示例里的Z结尾,这是最推荐的做法,因为任何时区的人看这条日志都能快速转换。二是Grafana里把默认时区改成浏览器的本地时区,但如果你需要导出数据给别人,还是会撞上时区问题。
我在日志采集器配置里加了一个时间字段重命名,把日志里的本地时间字段转成采集时间,但最后还是回到源头,让应用统一打UTC。宁可日志里多一个local_timestamp辅助字段,也不能让主时间字段的语义含糊。
我个人在实际操作中还有一个体会:很多本地LLM服务刚开始监控时会发现,问题不是没有指标,而是指标太多。GPU利用率、显存、请求数、延迟全堆在一起时,你根本不知道该先看哪个。我的建议是先盯三样:显存使用率、P95延迟、错误率。这三样,一个代表资源,一个代表性能,一个代表质量。等这三张图稳定跑一段时间,你自然会对自己的服务产生直觉。之后再慢慢加队列长度、token吞吐、温度、功耗这些外围指标。
最后再分享一个小技巧:给告警规则都加一个for字段。一开始我图省事,把所有告警都设成for: 0m,结果显存只是抖了一下就触发通知,一天能收到二十条,半夜爬起来看往往又是正常的。后来统一改成5分钟窗口,噪音立刻降了下来。本地LLM服务的指标波动通常不是瞬时故障,持续5分钟以上的偏离才值得被叫醒。这个思路,跟指标怎么设计同等重要。