在现实里看到4 hours and 37 minutes of serving nothing这种日志,第一反应多半不是惊讶,而是想立刻去查服务进程还活着没有。
这个标题其实代表了一类很常见的本地部署问题:服务进程在跑,端口能访问,GPU/CPU 也可能有占用,但任务发进去之后就像进了黑洞,几分钟、几十分钟甚至几个小时都没有有效输出。如果这是线上服务,等于白白烧了几个小时算力,还找不到任何明确的报错。
这篇文章来拆解这类问题。不只是讲“现象是什么”,而是给出一套可以照着走的排查流程:先确认服务是否真的可用,再逐层检查资源占用、日志、推理链路、队列和 API 超时,最后给出预防空跑的工程化建议。无论你是在跑大模型推理服务、ComfyUI 工作流、TTS 任务,还是 OCR 批量解析,这套方法都适用。
先给结论:“服务空跑”通常不是单一原因,而是从请求进入、资源分配、模型推理到结果返回的某个环节出现了静默阻塞。排查顺序比排查工具更重要,下面按顺序展开。
1. 核心问题速览:空跑问题边界与排查维度
先看清楚“serving nothing”可能出现在哪些层面,避免一上来就陷入某个细节。通常可以从下面几个维度判断问题边界:
| 排查维度 | 典型现象 | 常见 Root Cause |
|---|---|---|
| 进程层 | 进程未退出,CPU/内存占用正常 | 死锁、队列阻塞、等待外部资源 |
| 资源层 | GPU 利用率低但显存占用高 | 显存碎片、静态分配、批次未提交 |
| 日志层 | 无 ERROR,也无进度输出 | 日志缓冲、错误被吞、输出级别过高 |
| 网络层 | 客户端请求已发送,服务端无响应 | 连接池耗尽、Keep-Alive 卡死、代理超时 |
| 模型层 | 模型加载后推理无输出 | 数据预处理卡住、推理后端崩溃未退出 |
| 业务层 | 批量任务队列长期不消费 | 并发数设置过大、单任务超时无兜底 |
这张表的含义是:排查时不要只看有没有报错,而是要对整个请求生命周期做分段观测。很多空跑问题恰恰是“没报错但没结果”,比显式崩溃更难定位。
2. 适用场景:谁最需要这套排查方法
这篇文章的读者不需要特定项目背景,但以下场景最容易遇到“空跑”:
- 本地部署 LLM / Stable Diffusion / TTS / OCR 等模型服务,通过 WebUI 或 API 对外提供推理能力。
- 使用 ComfyUI 或其他工作流引擎跑批量任务,队列积压却看不到任务进度。
- 编写定时脚本调用推理接口,返回超时后脚本不退出,任务堆积。
- 把推理服务封装成容器或 systemd 服务,设置了高并发请求,但某些请求导致服务整体不可用。
- 在批量场景中,上游任务一批一批进入,下游偶尔发生长时间无输出,最终通过日志才发现出现了
4 hours and 37 minutes of serving nothing这类空转记录。
如果只是偶尔一次,重启服务可能就能恢复。但如果是批量任务、定时服务、长连接 API,这种问题会反复出现,必须从机制上解决。
3. 第一轮检查:先确认服务是不是“假活”
很多情况下服务看着在运行,但实际已经无法接收或处理新请求。这一步的目标是快速分清“服务崩了但进程没退出”和“服务还能正常接收请求”的差别。
3.1 健康检查接口与行为探测
如果服务提供了/health或/ready之类的健康检查接口,先调用它:
# 假设服务监听在 127.0.0.1:8000 curl -v --max-time 10 http://127.0.0.1:8000/health观察几个关键信息:
- 是否有 HTTP 状态码返回。
- 是否几秒钟内就返回,还是一直阻塞到超时。
- 返回内容是
ok还是包含模型加载状态、队列长度、GPU 状态。
如果健康检查接口本身长时间不响应,说明服务主循环已经阻塞,问题很可能在线程调度、锁竞争或事件循环卡死。如果健康检查正常,但推理接口无输出,说明问题在具体推理链路,而不是服务整体。
3.2 验证端口监听和连接状态
# 查看 8000 端口是否在监听,以及当前 accept 队列 ss -lntp | grep 8000 # 查看已建立的连接数量和等待队列 ss -antp | grep 8000 | head -n 50 # 查看进程是否存在 ps aux | grep -E "python|uvicorn|comfyui" | grep -v grep重点看 ESTABLISHED 连接数量是否持续增长但 FIN_WAIT / CLOSE_WAIT 堆积。如果是,通常是客户端发起请求后没有正确读取响应,或者服务端处理线程没有释放连接,最终把连接池耗尽,新请求全部排队。
3.3 最小请求探测法
调用一个最简单、保证能出结果的接口,而不是直接提交重任务:
# 常用推理服务的通用探测请求,具体字段需要按实际项目调整 curl --max-time 30 http://127.0.0.1:8000/api/generate \ -H "Content-Type: application/json" \ -d '{"prompt": "hi", "max_tokens": 8}' # 记录返回时间 time curl --max-time 30 http://127.0.0.1:8000/api/generate \ -H "Content-Type: application/json" \ -d '{"prompt": "hi", "max_tokens": 8}'最小探测请求的意义在于:它能区分“所有请求都卡住”和“只有特定请求卡住”。如果最小请求也卡住,说明推理后端有问题;如果最小请求很快,说明是特定任务的输入、参数或队列策略引发阻塞。
4. 第二轮检查:资源层到底发生了什么
服务进程看着是正常的,资源占用却可能出卖它。这里说的资源不只是 GPU,还包括 CPU、内存、磁盘 I/O。
4.1 GPU 状态观测
先看 GPU 整体资源情况:
nvidia-smi再看具体进程占用:
nvidia-smi --query-compute-apps=pid,used_memory,process_name --format=csv然后持续观测变化:
watch -n 2 nvidia-smi需要重点关注三种状态:
| 状态 | 说明 |
|---|---|
| GPU-Util 长期为 0,显存占用很高 | 模型加载后没有推理请求,或批次没有提交,或者显存分配后未释放 |
| GPU-Util 在 0 和 100 之间频繁跳动 | 推理在跑,但可能卡在 CPU 预处理、后处理或数据加载上 |
| GPU 进程消失但显存仍占用 | 进程退出未释放显存,或存在僵尸进程 |
如果任务是图片生成、视频生成类,还要看 GPU 工作频率和功耗。如果频率一直很低,可能是推理任务根本没有进入 kernel 执行阶段。
4.2 CPU / 内存 / 磁盘 I/O
top -H -p $(pgrep -f 你的服务进程名 | head -n 1) # 查看是否存在大量 D 状态进程(不可中断睡眠) ps -eo pid,stat,wchan:30,cmd | grep -E "D|服务进程名"如果某个进程长期处于 D 状态,说明它正在等待磁盘 I/O。这类问题在批量文档解析、大量视频抽帧、模型反复加载的场景特别常见:输入输出目录放在网络磁盘上,网络波动导致 read/write 挂起,任务队列全部堆住。
4.3 显存不足的“静默表现”
显存不足未必直接抛CUDA out of memory。在有些推理框架中,申请显存失败会进入重试循环,或者退回 CPU 推理,或者干脆暂停任务等待显存释放。此时日志可能只是“等待资源”,但没有任何有效输出。从材料和技术常识来看,更稳妥的判断是:显存问题不能只看有没有报错,要看任务是不是在等显存。
可以在任务运行期间采样显存变化曲线,观察是否存在持续申请但从未释放的显存碎片增长。
5. 第三轮检查:日志与错误码,重点找“被吞掉”的异常
空跑问题最棘手的地方是日志里常常什么都没有。但“没有日志”本身也是一种信息。下面几类情况很典型:
5.1 日志级别和输出缓冲
如果服务使用 Python 的print或logging,并且被重定向到文件,输出可能被缓冲,导致进程实际已经打印了大量日志,但文件里什么都没有。排查时先确认输出模式。
建议在启动命令中强制禁用缓冲:
# Python 服务通用做法,具体脚本名按实际项目替换 python -u app.py > server.log 2>&1 # 如果使用 uvicorn,注意 access log 和 error log 是否分开 uvicorn app:app --host 0.0.0.0 --port 8000 --log-level debug >> server.log 2>&1如果日志已经堆积很久,重启前先看文件大小,判断是否还在写入:
ls -lh server.log tail -n 200 server.log5.2 依赖库的静默失败
很多 infer 库底层是 C++ / CUDA 实现,Python 侧可能只捕获到通用异常,甚至不捕获。常见现象是:
- 模型文件损坏,加载阶段返回 null,但服务没退出。
- CUDNN / TensorRT 初始化失败,进入重试循环。
- 图像解码失败,某个 batch 的数据预处理直接返回空,但后续流程还在等待。
排查时可以把日志级别调到 debug,并且给关键调用加上显式超时。不要假设底层库会用raise上报错误。
5.3 资源句柄泄漏与文件锁
如果服务长时间运行,很容易出现文件句柄耗尽:
# 查看进程文件句柄数量 ls /proc/<pid>/fd | wc -l # 查看限制 cat /proc/<pid>/limits | grep "open files"句柄耗尽后,新任务无法打开模型文件、输出文件或日志文件,表现就是任务“无输出”。这类问题不容易从业务日志中看到,但系统日志里通常会有Too many open files。
5.4 CUDA 错误与上下文损坏
CUDA 上下文损坏后,后续所有调用都可能失败,但 Python 进程不退出。此时可以尝试在代码里定期检查 CUDA 状态,或者用cuda-memcheck做诊断。不过最直接的验证方式还是重启服务后重新提交小任务,看是否恢复。如果重启后正常,说明问题大概率发生在运行过程中的某个 CUDA 操作或显存状态上。
6. 第四轮检查:推理链路分段定位
假设服务本身没有假活,日志也正常,这时需要把“请求到输出”的全链路拆开,逐段确认卡在哪里。这里给出一种通用分段思路。
6.1 请求接收与参数校验
服务端是否已经接收到请求?请求体是否完整?
# 在服务端入口处增加调试输出,属于通用示例,路径需按项目调整 @app.post("/api/generate") async def generate(request: Request): body = await request.json() logger.debug("request received: keys=%s", list(body.keys())) # 如果这里能打出日志,说明请求已经到服务端如果这里的日志没有输出,问题在网关、负载均衡或网络层。如果有输出,说明进入业务处理逻辑。
6.2 队列与并发控制
大多数推理服务会先把请求放入队列,由后台 worker 消费。空跑常见原因:
- 队列满了,新请求阻塞等待。
- worker 数量为 0 或全部卡死。
- 某个任务执行时间过长,没有超时控制,把唯一 worker 占死。
查看队列长度:
# 通用思路:如果是 Redis 队列 redis-cli LLEN task_queue # 如果是 Python multiprocessing 队列,需要从代码侧写监控 # 如果是 pg 或 mysql 表驱动任务,直接查表如果队列一直在增长但没有 worker 消费,重点检查 worker 是否启动、是否因为异常退出未重启、是否有全局锁阻塞。
6.3 数据预处理与输入文件读取
很多空跑发生在“读输入”阶段。例如:
- 批量图片中的某一张损坏,解码卡住。
- 视频抽帧时某帧序列异常。
- 输入文本编码不是 UTF-8,解析卡住。
- 输入文件来自对象存储或网络磁盘,下载超时。
排查方法:先输入最小、最简单、已知完整的样本,确认流程能跑通。然后再逐步替换为真实样本,直到定位到导致卡住的具体输入。
6.4 模型推理后端
如果在预处理之后,日志停留在“正在推理”但没有结束时间,重点关注推理后端状态。
# 查看进程内线程数,确认是否存在多个阻塞线程 top -H -p <pid> pstree -p <pid> | wc -l # 如果是 Java 服务,可以用 jstack jstack <pid> > thread_dump.txt # 如果是 Python 服务,可以用 py-spy 做采样 py-spy dump --pid <pid>py-spy dump对 Python 进程很有用,可以快速看到当前线程栈卡在哪个函数。如果线程栈显示卡在 CUDA runtime 调用,问题大概率在底层推理;如果卡在文件读写,问题在 I/O;如果卡在queue.get(),问题在生产者-消费者链路。
6.5 后处理与响应返回
推理本身可能已经完成,但后处理阶段卡住。例如:
- 生成结果需要组装成 JSON 或 Markdown,但某个字段类型不匹配导致死循环。
- 输出目录无法写入,进程在重试。
- 流式响应没有 flush,客户端等不到任何数据。
在返回响应的代码处加上时间戳日志,对比请求进入时间,就可以判断是否卡在后处理。
7. 批量任务场景:为什么队列会空跑数小时
回到标题4 hours and 37 minutes of serving nothing,批量任务最容易出现这种长时间静默。如果单看某一次任务,可能几秒钟就完成,但放到批量场景里,几个不小心的设计会放大成几小时的空转。
7.1 并发数远超实际处理能力
任务提交方并发拉满,推理服务或下游模型只能串行处理,请求全部堆积在队列里。服务看起来“在工作”,但某些请求已经超时,对调用方来说就是无输出。排查方法:
- 查看任务队列堆积数量。
- 查看每个任务从入队到完成的时间分布。
- 查看是否有很多超时重试,导致下游积压更严重。
7.2 失败任务没有跳过机制
一个批次里如果有 10% 的任务因输入损坏或依赖问题失败,而代码没有捕获异常,整个批次可能停住。有些框架表现得像“还在跑”,实际上已经卡在某个失败的子任务上。
通用做法是:给每个子任务单独捕获异常,并记录失败原因,而不是让整个批次中断。
import concurrent.futures from typing import Callable def run_batch_with_fault_tolerance( items: list, worker: Callable, max_workers: int = 2, timeout_per_item: int = 60, ): results = {} with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = { executor.submit(worker, item): idx for idx, item in enumerate(items) } for future in concurrent.futures.as_completed(future_map): idx = future_map[future] try: results[idx] = future.result(timeout=timeout_per_item) except Exception as exc: results[idx] = {"error": str(exc), "idx": idx} return results7.3 没有任务级超时
这是批量空跑最重要的原因。一个任务如果没有硬超时,下游卡住时上游会一直等。单个任务卡住不可怕,可怕的是卡住的任务不释放线程/进程/显存,最终拖垮整批任务。建议:
- 对单任务设置超时。
- 对批量任务设置整体进度监控。
- 超时后记录堆栈和输入信息,避免下次再踩。
7.4 幂等与重试设计
如果任务重试后状态没有正确重置,可能造成“重试永远失败但永远在重试”。例如:
- 任务已经处理完成,但状态没更新,重启后又重新处理。
- 输入目录和输出目录混用,任务互相覆盖文件。
- 重试时没有清空临时目录,旧文件被当作新结果。
批量任务要尽量做到:输入不可变、输出隔离、状态可查询、失败可重试且重试结果一致。
8. API 服务层的连接卡死与资源泄漏
serving nothing在 API 服务中往往表现为“连接建立成功,但一直等到超时”。这个现象可以从连接生命周期来排查。
8.1 连接池配置问题
线程池或连接池过小,任务并发量上来之后,请求只有一个假象:进程还在运行,但新请求已经无法获取可用连接。
以 Pythonrequests.Session为例,默认连接池大小是 10。如果某个批量服务用同一个 Session 发起大量并发请求,很容易出现连接池耗尽:
import requests from requests.adapters import HTTPAdapter session = requests.Session() adapter = HTTPAdapter(pool_connections=20, pool_maxsize=20, max_retries=3) session.mount("http://", adapter) session.mount("https://", adapter)这只是一个通用示例,实际连接池大小需要根据服务并发数调整。关键是:连接池不是越大越好,过大会压垮下游,过小则会导致请求排队。
8.2 服务端线程模型
如果服务端使用多线程模型,每个请求创建一个线程,任务卡住时线程不会释放。线程数量达到上限后,新请求无法被处理,但服务主进程仍然存活。
排查命令:
# 查看进程线程数 cat /proc/<pid>/status | grep Threads # 或通过 ps 查看 ps -eLf | grep <服务进程名> | wc -l一旦线程数持续增长或接近上限,基本可以判断存在线程泄漏。需要从代码层面约束并发数,例如使用 Semaphore 或任务队列。
8.3 请求超时设置
很多空跑问题其实是因为客户端没有设置超时。默认情况下,某些 HTTP 客户端在连接建立后会无限等待响应。此时服务端可能已经崩溃或卡住,但客户端毫不知情。
# 通用 curl 示例,设置连接超时和总超时时间 curl --connect-timeout 10 --max-time 120 \ -H "Content-Type: application/json" \ -d '{"prompt":"test"}' \ http://127.0.0.1:8000/api/generate代码中也应该显式设置超时:
import requests try: resp = requests.post( "http://127.0.0.1:8000/api/generate", json={"prompt": "test"}, timeout=(10, 120), # (connect timeout, read timeout) ) resp.raise_for_status() except requests.exceptions.Timeout: print("request timeout, need check server status") except requests.exceptions.RequestException as exc: print(f"request failed: {exc}")8.4 反向代理与负载均衡
如果服务前面还有 Nginx 或网关,需要同时检查代理层的超时配置。有些问题发生在服务端已经返回结果,但代理层没有及时转发给客户端,导致客户端一直空等。排查时可以绕过代理直接访问服务端口,对比响应时间。
9. 资源占用与性能观察的工程化方法
只做一次nvidia-smi和top不足以定位长时空跑,更重要的是形成持续观测能力。资源占用本身不会告诉你“为什么空跑”,但能帮你缩小范围。
9.1 建立黄金指标
建议给推理服务建立四组指标:
| 指标组 | 典型指标 |
|---|---|
| 流量 | QPS、请求进入数、完成数、失败数 |
| 延迟 | P50 / P95 / P99 延迟、平均响应时长 |
| 资源 | GPU-Util、显存使用、CPU 使用率、内存占用 |
| 队列 | 队列长度、任务入队时间、任务完成时间 |
如果“请求进入数”远大于“请求完成数”,说明任务在积压。如果两者都很少但 GPU-Util 很高,说明可能在做无用计算或死循环。
9.2 慢请求日志与采样
当任务执行时间超过阈值时,主动记录一条结构化日志。日志不要只记录“任务超时”,还要记录输入标识、执行到哪个阶段、资源占用快照。
import time import logging logger = logging.getLogger("inference") class TimingMiddleware: def __init__(self, app, slow_threshold: int = 30): self.app = app self.slow_threshold = slow_threshold async def __call__(self, scope, receive, send): start = time.time() await self.app(scope, receive, send) elapsed = time.time() - start if elapsed > self.slow_threshold: logger.warning("slow request, elapsed=%.2f, scope=%s", elapsed, scope.get("path"))具体中间件写法要视 Web 框架而定,但核心思想一致:只有主动记录慢请求,才不用在出问题时靠猜。
9.3 周期性抓取堆栈
如果服务已经空跑数小时,只能通过堆栈判断当时卡在哪里。具体做法是周期性执行py-spy dump或jstack,保存多次快照。如果多次快照都停在同一个位置,基本定位到瓶颈。
9.4 保留“最小可复现样本”
每次排查后,把触发问题的最小输入样本单独保存。不要只保存完整数据集。最小样本加上当时的日志、堆栈和资源占用,就是下一次排查最有效的起点。这比任何告警规则都更能降低问题定位成本。
10. 常见问题与排查对照表
这里把实际排查中最高频的问题整理成表格,便于对照。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 服务启动后端口可访问,但所有请求无响应 | 主循环阻塞、事件循环卡死、锁竞争 | 健康检查接口、堆栈快照 | 定位死锁或 I/O 阻塞;增加主循环看门狗 |
| GPU 显存占用高但利用率低 | 模型已加载但未推理;显存碎片 | 多次采样 nvidia-smi,结合任务状态 | 检查请求是否真正进入推理阶段;必要时重启释放显存 |
| 日志没有任何输出 | 输出缓冲、日志级别过高、日志写入失败 | 确认输出是否重定向;用-u或--log-level debug | 调整日志配置;增加启动时间戳标记 |
| 批量任务队列堆积 | worker 卡死、并发过大、失败任务未跳过 | 查询队列长度;查看 worker 线程 | 增加任务级超时、失败跳过、并发限制 |
| 小请求正常,大请求卡住 | 显存不足、数据预处理太重、请求体过大 | 逐步缩小输入;观察任务阶段日志 | 分批处理、降低输入规模、显存不足时加合理错误处理 |
| 客户端超时,但服务端认为已完成 | 反向代理超时、响应未 flush、连接被关闭 | 绕过代理直连测试;抓包对比返回 | 调整代理超时;修复流式响应 flush |
| 进程长时间 CPU 100% 但无输出 | 死循环、正则灾难、批量处理逻辑异常 | py-spy dump或top -H | 定位热点代码;增加循环次数保护 |
| 重启服务后恢复 | 资源泄漏、句柄耗竭、CUDA 上下文损坏 | 对比启动前后资源变化 | 增加资源监控和定期重启策略 |
11. 避免空跑的最佳实践清单
从工程化角度,建议在服务上线前就做以下设计。每一条都能降低4 hours and 37 minutes of serving nothing出现的概率。
11.1 给所有外部调用设置默认超时
无论是模型推理、数据库访问、文件读取还是 HTTP 请求,都要设置超时。不要让任务无限制等待。这是最简单但最有效的一步。
11.2 增加任务级进度回传
如果是批量任务,每个任务完成时都更新状态。不要只记录“队列里有多少任务”,还要记录“正在处理哪个任务、完成多少、失败多少”。这样即使出现问题,也能快速定位到具体任务和输入样本。
11.3 使用结构化日志
日志至少包含:任务 ID、阶段、耗时、输入标识、错误详情。纯文本日志在排查长时空跑时很难过滤。
11.4 独立资源限制
GPU 显存、线程池、连接池、队列大小都要设置上限。无限资源上限意味着问题发生时只能靠重启解决。
11.5 定期最小化自检
服务启动后可以加一个自检任务:提交一个最小请求,确认模型能正常返回。自检失败就标记服务不健康,而不是等到外部调用超时才发现。
11.6 输出与输入严格隔离
输入目录、临时目录、输出目录分开放,任务之间不要共用临时文件。否则一旦某个任务失败留下脏文件,后续任务可能一直读到错误数据。
11.7 显存使用要能回收
对于长驻推理服务,模型权重不重复加载,但中间结果和 feature map 要确保在不同请求之间不残留。如果显存占用持续增长,优先考虑是否存在缓存未清理或自定义算子内存泄漏。
12. 遇到空跑后的快速行动顺序
如果现在你的日志里也出现了长时间没有输出的记录,按这个顺序处理:
- 先看进程状态和端口,判断服务是否假活。
- 发送最小探测请求,验证基本推理链路是否可用。
- 看 GPU / CPU / 磁盘 I/O,确认资源层是否有异常。
- 调到 debug 日志,观察请求进入、预处理、推理、后处理各阶段时间点。
- 如果日志停在某个阶段,抓一次线程堆栈或进程堆栈。
- 检查队列长度和 worker 状态,确认是否批量任务积压。
- 如果有失败子任务被跳过或异常被吞掉,先恢复该任务并记录原因。
- 临时方案优先重启服务,并保留重启前日志和堆栈。
- 长期方案是补上超时、慢请求日志、批量任务监控和自检任务。
这套顺序的核心是:先“止血”,再“定位”,最后“防复发”。不要一上来就翻代码,也不要一上来就全量重启服务。先保留现场,再做最小化验证,这是最快也最稳妥的做法。
4 hours and 37 minutes of serving nothing这类日志不是笑话,是成本。只要把请求生命周期分段观测、给关键环节设置超时、用日志和监控覆盖“静默失败”,大部分空跑问题都能在十几分钟内定位到具体环节,而不是等到几小时后才发现。建议在下一个批量任务上线前,先把最小自检、任务级超时和慢请求日志这三件事补上,它们的性价比远高于任何复杂的监控系统。