1. 项目概述:为什么要把预处理和后处理塞进 Triton 服务器里?
你有没有遇到过这种场景:模型在 Triton 上跑得飞快,吞吐量拉满,但一到客户端——那个写 Python 脚本调用 API 的地方——CPU 却开始狂飙,延迟忽高忽低,压测一上 50 QPS 就开始丢请求?我去年在做工业质检平台时就卡在这儿整整两周。当时用的是 ResNet-50 做缺陷分类,客户端用 OpenCV 做图像缩放+归一化,再用 torch.tensor 转成张量,结果单次推理耗时 8ms,预处理却占了 22ms。更糟的是,不同产线送来的图像分辨率五花八门,有的带 EXIF 旋转标记,有的是 BGR 顺序,有的甚至有 ICC 颜色配置文件……客户端代码越堆越厚,版本一更新,下游三个业务系统全得跟着改。
这就是典型的“预处理外溢”陷阱。Triton 的设计哲学很明确:它不是只管模型加载和推理调度的“裸机”,而是一个可编程的推理流水线编排引擎。官方文档里反复强调的一句话是:“Move as much logic as possible into the model repository.” —— 把尽可能多的逻辑放进模型仓库里。这不是为了炫技,而是为了解决真实生产环境里的四个硬骨头:一致性、可维护性、资源隔离性、端到端可观测性。
一致性,指的是无论从 Python 客户端、C++ SDK 还是 HTTP/REST 接口调用,输入原始图像或文本,得到的结果必须完全一致。如果预处理在客户端,A 团队用 PIL.resize(mode='bilinear'),B 团队用 cv2.resize(interpolation=cv2.INTER_AREA),哪怕模型权重一模一样,输出概率分布也会出现肉眼不可见但影响阈值判断的漂移。我们曾在线上发现一个 bug:某批次良品被误判为缺陷,追查到最后,是客户端用了不同的归一化均值([0.485,0.456,0.406] vs [0.5,0.5,0.5]),导致模型最后一层 softmax 输出的置信度偏差了 3.7%,刚好跨过了业务设定的 95% 置信阈值。
可维护性更直接。当你要把 ResNet-50 升级成 EfficientNet-V2,或者把单标签分类改成多标签打标,如果预处理逻辑散落在五个微服务的 client SDK 里,光是协调各团队同步发版就得开三轮跨部门会议。而如果所有预处理都封装在 Triton 的config.pbtxt和model.py里,你只需要更新一个模型仓库,重启一次 tritonserver 进程,全链路就完成了升级。我们内部有个运维 SOP:模型仓库的 Git 提交记录,就是线上推理行为的唯一可信源(Single Source of Truth)。
资源隔离性常被忽略。预处理可能吃 CPU,后处理可能吃内存,而模型推理本身吃 GPU。如果全挤在客户端,一个慢查询可能拖垮整个业务进程;但如果预处理放在 Triton 的 CPU backend,后处理放在另一个独立的 Python backend,你可以用--cpu-only参数给它们分配专属的 CPU 核心组,用 cgroups 限制内存上限,让它们和 GPU 推理进程互不干扰。这就像给工厂流水线装上独立的传送带和质检台,而不是让所有工序都挤在同一个操作台上。
最后是端到端可观测性。Triton 内置的 metrics 指标(nv_inference_request_success,nv_inference_queue_duration_us)默认只统计到模型执行阶段。但当你把 preprocess/postprocess 也写成 Triton 的 custom backend 后,你可以用tritonserver --metrics-interval-ms=1000把每个环节的耗时、错误率、队列堆积深度全部暴露出来。我们上线后发现,90% 的 P99 延迟尖刺,其实来自 NMS 后处理中一个未加锁的全局字典读写竞争——这个 bug 在客户端时代根本无法定位,因为监控只看到“API 响应慢”,看不到慢在哪一步。
所以,这篇要讲的,不是“怎么在 Triton 里写个 hello world”,而是如何构建一个真正能扛住生产流量、经得起审计、方便迭代演进的端到端推理服务。核心就一句话:把预处理和后处理,当成和模型权重同等重要的“一等公民”,放进模型仓库,用 Triton 原生机制来管理、调度、监控。接下来,我会用一个真实的工业视觉检测案例(非 MNIST 这种玩具数据),从零开始,手把手带你把图像缩放、归一化、NMS、坐标映射全部塞进 Triton,不依赖任何外部服务,不绕过 Triton 的调度器,最终达成一个“上传原始 JPG,返回带坐标的 JSON”的完整闭环。
2. 整体架构设计与方案选型:为什么选 Python Backend 而不是 Ensemble 或 Custom C++
在动手写代码前,得先想清楚:Triton 提供了至少三种把预处理/后处理塞进去的路径——Ensemble 模型、Custom C++ Backend、Python Backend。我见过太多团队一开始热血沸腾选了 Custom C++,结果三个月后卡在 CUDA 流同步上,不得不推倒重来。所以这一节,我要掰开揉碎讲清楚每条路的坑在哪,为什么最终锁定 Python Backend 作为主攻方向。
先说 Ensemble。它的思路很美:把预处理、模型推理、后处理拆成三个独立模型,用ensemble类型的 config 文件串起来。比如preprocess模型接收 raw image bytes,输出 normalized tensor;inference模型接收 tensor,输出 raw logits;postprocess模型接收 logits,输出 final JSON。听起来天衣无缝,对吧?但实际踩下去全是深坑。第一个坑是数据格式强耦合。Ensemble 要求上游模型的输出 tensor name 和 shape,必须和下游模型的输入完全匹配。比如你的预处理模型输出INPUT__0: FP32[1,3,640,640],那 inference 模型的 config 里就必须声明input: [{name: "INPUT__0", ...}]。一旦你换了个模型,输入尺寸变成[1,3,768,768],整个 ensemble chain 就崩了,还得手动改 config。我们试过用脚本自动生成 config,结果发现不同框架导出的 ONNX 模型,tensor name 命名规则完全不同(PyTorch 习惯叫input.1,TensorFlow 叫serving_default_input:0),脚本维护成本比人肉改还高。
第二个坑是调试地狱。Ensemble 的错误信息极其晦涩。比如你收到一个INVALID_ARG: unable to get value for input 'INPUT__0',它根本不会告诉你到底是 preprocessor 没输出,还是 inference 模型的 input name 写错了,还是数据类型不匹配(FP32 vs FP16)。我们曾经为一个 dtype 错误排查了 17 小时,最后发现是预处理模型里torch.float32和numpy.float32在内存布局上细微差异导致的。Ensemble 把所有环节黑盒化了,你只能靠日志猜,靠重启试,靠上帝保佑。
再看 Custom C++ Backend。这是官方文档里最“正统”的方案,性能理论上最优。但现实是,它要求你精通 C++17、CUDA 编程、Triton C API、内存生命周期管理。一个简单的 resize 操作,你得自己写 CUDA kernel 做双线性插值,还得处理不同 channel order(RGB/BGR/RGBA)的 stride 计算。我们团队有个资深 CUDA 工程师,他花了 5 天时间才让 bilinear resize 在 GPU 上跑通,结果一测性能,比 CPU 上的 OpenCV 还慢 15%——因为小图 resize 的 GPU kernel launch overhead 太高。更致命的是,一旦你要加个 tokenization,就得引入 HuggingFace Tokenizers 的 C++ binding,编译链瞬间爆炸。我们评估过,用 C++ backend 实现一套覆盖 CV+NLP 的通用预处理库,人力投入至少是 Python 方案的 3 倍,且后续迭代成本极高。
所以,我们最终选择了Python Backend。它不是“性能妥协”,而是工程效率与生产稳定性的最优解。Python Backend 的核心优势在于:它让你用纯 Python 写逻辑,Triton 负责进程管理、内存分配、GPU context 切换、并发调度。你写的model.py会被 Triton 加载为一个独立的 Python 进程(或线程池),通过共享内存和 ZeroMQ 与主进程通信。这意味着你可以毫无顾忌地用cv2.resize、torchvision.transforms、transformers.AutoTokenizer,甚至sklearn.preprocessing,所有 PyPI 上的包,只要pip install进去就能用。我们实测过:一个包含 resize+normalize+NMS 的 Python backend,在 16 核 CPU 上,QPS 能稳定在 320,P99 延迟 42ms,完全满足产线实时质检需求。而且,Python 代码天然可调试——你可以在model.py里加import pdb; pdb.set_trace(),attach 到 Triton 的 Python worker 进程里单步跟,这是 C++ backend 想都不敢想的体验。
当然,Python Backend 也有它的边界。如果你的预处理极度计算密集(比如 4K 视频帧的实时超分),或者对延迟有亚毫秒级要求(高频量化交易),那它确实不合适。但对绝大多数 CV/NLP 场景——图像分类、目标检测、OCR、文本分类、NER——Python Backend 是那个“刚刚好”的选择:够快、够稳、够灵活、够易维护。我们的架构图很简单:客户端上传 JPG → Triton HTTP endpoint → Python Backend (preprocess) → TensorRT Engine (inference) → Python Backend (postprocess) → 返回 JSON。整个链路里,只有模型推理在 GPU 上,其余都在 CPU,资源划分清晰,扩容路径明确(CPU 不够就加节点,GPU 不够就换 A100)。
3. 核心细节解析与实操要点:从 config.pbtxt 到 model.py 的每一行代码
现在进入硬核实操环节。我们以一个真实的工业螺丝缺陷检测模型为例(YOLOv5s 导出的 TensorRT 引擎),来演示如何把完整的预处理和后处理塞进 Triton。整个模型仓库结构如下:
models/ ├── preprocess/ │ ├── 1/ │ │ └── model.py │ └── config.pbtxt ├── yolov5s_trt/ │ ├── 1/ │ │ └── model.plan │ └── config.pbtxt └── postprocess/ ├── 1/ │ └── model.py └── config.pbtxt注意,这里没有 Ensemble,三个模型是完全独立的,靠 Triton 的模型间通信机制串联。下面逐个拆解每个config.pbtxt和model.py的关键细节,解释每一行为什么这么写。
3.1 预处理模型(preprocess)的 config.pbtxt
name: "preprocess" platform: "python" max_batch_size: 8 input [ { name: "RAW_IMAGE" data_type: TYPE_UINT8 dims: [ -1 ] # variable length bytes } ] output [ { name: "PROCESSED_IMAGE" data_type: TYPE_FP32 dims: [ 3, 640, 640 ] }, { name: "ORIGINAL_SHAPE" data_type: TYPE_INT32 dims: [ 2 ] } ] # 关键配置:启用动态批处理,但限制最大 batch size dynamic_batching [ { max_queue_delay_microseconds: 100 } ] # 关键配置:指定 Python backend 的入口点 instance_group [ { count: 4 kind: KIND_CPU } ]这里有几个极易踩坑的点。第一,dims: [ -1 ]表示输入是变长字节数组,这是接收原始 JPG/PNG 的唯一正确方式。很多人误写成dims: [ 1024, 1024, 3 ],结果客户端传 JPG 二进制流时直接报INVALID_ARG。第二,PROCESSED_IMAGE的 dims 必须和你的模型期望输入严格一致。YOLOv5s 的 TensorRT 引擎要求[3,640,640],那这里就不能写[3,640,640,1]或[1,3,640,640],否则 inference 模型会拒绝加载。第三,instance_group里count: 4是经过压测确定的——太少会成为瓶颈,太多会因 GIL 争抢反而降低吞吐。我们用ab -n 10000 -c 100压测,发现count: 4时 QPS 最高且 P99 最稳。
3.2 预处理模型(preprocess)的 model.py
import numpy as np import cv2 import triton_python_backend_utils as pb_utils class TritonPythonModel: def initialize(self, args): # 初始化只执行一次,放在这里避免每次 infer 重复加载 self.target_size = (640, 640) self.mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) self.std = np.array([0.229, 0.224, 0.225], dtype=np.float32) def execute(self, requests): responses = [] for request in requests: # 1. 获取原始图像字节流 raw_image = pb_utils.get_input_tensor_by_name(request, "RAW_IMAGE") image_bytes = raw_image.as_numpy()[0] # [1] 取出 bytes # 2. 解码 JPG -> numpy array (BGR) nparr = np.frombuffer(image_bytes, np.uint8) img_bgr = cv2.imdecode(nparr, cv2.IMREAD_COLOR) if img_bgr is None: raise pb_utils.TritonModelException("Failed to decode image") # 3. 获取原始尺寸,用于后处理坐标映射 orig_h, orig_w = img_bgr.shape[:2] # 4. Letterbox resize: 保持宽高比,pad 黑边 # 这是 YOLO 系列的标准预处理,不能简单用 cv2.resize! r = min(self.target_size[0] / orig_h, self.target_size[1] / orig_w) new_unpad = int(round(orig_w * r)), int(round(orig_h * r)) dw, dh = self.target_size[1] - new_unpad[0], self.target_size[0] - new_unpad[1] dw /= 2 dh /= 2 if orig_w != new_unpad[0] or orig_h != new_unpad[1]: img_bgr = cv2.resize(img_bgr, new_unpad, interpolation=cv2.INTER_LINEAR) top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1)) left, right = int(round(dw - 0.1)), int(round(dw + 0.1)) img_bgr = cv2.copyMakeBorder( img_bgr, top, bottom, left, right, cv2.BORDER_CONSTANT, value=(114, 114, 114) ) # 5. BGR -> RGB -> float32 -> HWC to CHW -> normalize img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) img_f32 = img_rgb.astype(np.float32) / 255.0 img_chw = np.transpose(img_f32, (2, 0, 1)) # HWC -> CHW img_norm = (img_chw - self.mean[:, None, None]) / self.std[:, None, None] # 6. 构造输出 tensor processed_image = pb_utils.Tensor("PROCESSED_IMAGE", img_norm.astype(np.float32)) orig_shape = pb_utils.Tensor("ORIGINAL_SHAPE", np.array([orig_h, orig_w], dtype=np.int32)) responses.append(pb_utils.InferenceResponse(output_tensors=[processed_image, orig_shape])) return responses这段代码里藏着三个关键经验。第一,letterbox resize的实现必须和训练时完全一致。很多团队直接用cv2.resize,结果 mAP 直接掉 5 个点——因为 YOLO 训练时用的是 letterbox(等比缩放+黑边填充),不是拉伸变形。第二,cv2.copyMakeBorder的value=(114,114,114)是 YOLO 默认的 pad 值(对应灰度 114/255≈0.447),不是(0,0,0)。第三,np.transpose和astype的顺序不能错:必须先astype(np.float32)再transpose,否则uint8的transpose会溢出。我们曾因此在 batch > 1 时出现随机乱码,debug 了两天才发现是类型转换时机问题。
3.3 推理模型(yolov5s_trt)的 config.pbtxt
name: "yolov5s_trt" platform: "tensorrt_plan" max_batch_size: 8 input [ { name: "input" data_type: TYPE_FP32 dims: [ 3, 640, 640 ] } ] output [ { name: "output" data_type: TYPE_FP32 dims: [ 25200, 85 ] # YOLOv5s: 3*(80+5) * (80*80 + 40*40 + 20*20) } ] dynamic_batching [ { max_queue_delay_microseconds: 100 } ] instance_group [ { count: 1 kind: KIND_GPU gpus: [0] } ]重点看dims: [ 25200, 85 ]。这个数字不是随便写的,是 YOLOv5s 的 anchor-free 输出头固定尺寸:3 个尺度(80x80, 40x40, 20x20)× 每个格子 3 个 anchor × 每个 anchor 85 维(4 bbox + 1 obj + 80 cls)。如果你用的是 YOLOv8,这里就会变成[8400, 84]。务必用trtexec --onnx=model.onnx --saveEngine=model.plan导出时,用--verbose查看实际输出 shape,不能凭记忆写。
3.4 后处理模型(postprocess)的 config.pbtxt 和 model.py
name: "postprocess" platform: "python" max_batch_size: 8 input [ { name: "DETECTIONS" data_type: TYPE_FP32 dims: [ 25200, 85 ] }, { name: "ORIGINAL_SHAPE" data_type: TYPE_INT32 dims: [ 2 ] } ] output [ { name: "DETECTION_RESULTS" data_type: TYPE_STRING dims: [ -1 ] # variable length string } ]model.py的核心是 NMS 和坐标映射:
import numpy as np import cv2 import json import triton_python_backend_utils as pb_utils def xywh2xyxy(x): # Convert nx4 boxes from [x, y, w, h] to [x1, y1, x2, y2] y = np.copy(x) y[:, 0] = x[:, 0] - x[:, 2] / 2 # top left x y[:, 1] = x[:, 1] - x[:, 3] / 2 # top left y y[:, 2] = x[:, 0] + x[:, 2] / 2 # bottom right x y[:, 3] = x[:, 1] + x[:, 3] / 2 # bottom right y return y def non_max_suppression(prediction, conf_thres=0.25, iou_thres=0.45, classes=None, agnostic=False, multi_label=False): # prediction: [25200, 85] nc = prediction.shape[1] - 5 # number of classes xc = prediction[:, 4] > conf_thres # candidates output = [np.zeros((0, 6))] * prediction.shape[0] # 这里简化了 batch 处理,实际需按 batch 维度切分 x = prediction[xc] # confidence if not x.shape[0]: return output box = xywh2xyxy(x[:, :4]) conf = x[:, 4:5] cls = x[:, 5:] # Apply constraints if multi_label: i, j = (cls > conf_thres).nonzero() x = np.concatenate((box[i], x[i, 4:5] * cls[i, j][:, None], j[:, None].astype(float)), 1) else: conf = np.max(cls, 1, keepdims=True) j = np.argmax(cls, 1) x = np.concatenate((box, conf, j[:, None].astype(float)), 1)[conf.ravel() > conf_thres] # Detections matrix n x 6 (xyxy, conf, cls) if not x.shape[0]: return output # Batched NMS c = x[:, 5:6] * (0 if agnostic else max(nc, 1)) # classes boxes, scores = x[:, :4] + c, x[:, 4] # boxes (offset by class), scores i = cv2.dnn.NMSBoxes(boxes.astype(np.float32), scores.astype(np.float32), conf_thres, iou_thres) if i.shape[0] > 0: output = x[i.flatten()] return output class TritonPythonModel: def execute(self, requests): responses = [] for request in requests: detections = pb_utils.get_input_tensor_by_name(request, "DETECTIONS").as_numpy() orig_shape = pb_utils.get_input_tensor_by_name(request, "ORIGINAL_SHAPE").as_numpy()[0] # 1. 执行 NMS pred = non_max_suppression(detections, conf_thres=0.3, iou_thres=0.5) # 2. 坐标映射回原始图像尺寸 # letterbox 的逆变换:先减去 pad,再按比例缩放 orig_h, orig_w = orig_shape[0], orig_shape[1] r = min(640 / orig_h, 640 / orig_w) new_unpad = int(round(orig_w * r)), int(round(orig_h * r)) dw, dh = 640 - new_unpad[0], 640 - new_unpad[1] # pred[:, :4] 是 xyxy 格式 pred[:, [0, 2]] -= dw / 2 # x padding pred[:, [1, 3]] -= dh / 2 # y padding pred[:, :4] /= r # reverse scale # 3. clip 到原始图像边界 pred[:, [0, 2]] = np.clip(pred[:, [0, 2]], 0, orig_w) pred[:, [1, 3]] = np.clip(pred[:, [1, 3]], 0, orig_h) # 4. 构造 JSON 字符串 results = [] for det in pred: x1, y1, x2, y2, conf, cls = det results.append({ "bbox": [float(x1), float(y1), float(x2), float(y2)], "confidence": float(conf), "class_id": int(cls), "class_name": ["defect", "normal"][int(cls)] }) json_str = json.dumps(results, separators=(',', ':')) output_tensor = pb_utils.Tensor("DETECTION_RESULTS", np.array([json_str.encode('utf-8')], dtype=object)) responses.append(pb_utils.InferenceResponse(output_tensors=[output_tensor])) return responses这里的关键是坐标映射的数学推导。Letterbox 的正向变换是:new_size = round(orig * r),pad = (640 - new_size) / 2。所以逆向就是:pred_coord -= pad,pred_coord /= r。我们曾在这个公式上栽过跟头——把r错当成orig_size / 640,导致所有坐标偏移 20%。后来写了个单元测试,用已知尺寸的 mock 图像跑一遍全流程,对比 OpenCV 手动画框的位置,才彻底验证正确性。
4. 实操过程与核心环节实现:从本地开发到 Kubernetes 生产部署
光有代码还不够,真正的挑战在于如何把它从笔记本跑通,变成每天支撑百万次请求的生产服务。这一节,我分享我们走过的完整路径:从本地 Docker 开发,到 CI/CD 自动化构建,再到 Kubernetes 集群的弹性伸缩。每一个环节,都有血泪教训。
4.1 本地开发与调试:用 docker-compose 搭建最小可行环境
别一上来就搞 Kubernetes。我们团队的标准流程是:先用docker-compose在本地搭一个和生产几乎一致的环境。docker-compose.yml如下:
version: '3.8' services: triton: image: nvcr.io/nvidia/tritonserver:23.08-py3 ports: - "8000:8000" # HTTP - "8001:8001" # GRPC - "8002:8002" # Metrics volumes: - ./models:/models - ./config.pbtxt:/opt/tritonserver/conf/config.pbtxt # 全局配置 command: > tritonserver --model-repository=/models --strict-model-config=false --log-verbose=1 --http-port=8000 --grpc-port=8001 --metrics-port=8002 --allow-http=true --allow-grpc=true --allow-metrics=true --model-control-mode=explicit deploy: resources: limits: memory: 8G devices: - driver: nvidia count: 1 capabilities: [gpu]关键点在于--strict-model-config=false。这个参数允许 Triton 在config.pbtxt缺失某些字段时,用默认值填充,极大加速开发迭代。比如你刚写完preprocess/model.py,还没写config.pbtxt,直接docker-compose up,Triton 会启动并报 warning,而不是 fatal error。等你补全 config,docker-compose restart triton就能热加载。我们把这个流程封装成 Makefile:
.PHONY: dev up down test dev: docker-compose up -d triton up: dev docker-compose logs -f triton down: docker-compose down test: curl -X POST "http://localhost:8000/v2/models/preprocess/infer" \ -H "Content-Type: application/json" \ -d '{"inputs":[{"name":"RAW_IMAGE","shape":[10000],"datatype":"UINT8","data":[1,2,3,...]}]}'test命令里那个[1,2,3,...]不是占位符,我们真的写了个gen_test_image.py,用np.random.randint(0,255,(100,100,3),dtype=np.uint8)生成测试图,再cv2.imencode('.jpg', img)[1].tobytes()转成 bytes list。这样每次make test都是真实数据流,不是 mock。
4.2 CI/CD 自动化:GitHub Actions 构建模型仓库镜像
本地跑通后,下一步是自动化。我们抛弃了传统的“打包 tar.gz 上传服务器”模式,改用OCI 镜像来分发模型仓库。好处是:原子性(要么全成功,要么全失败)、可复现(镜像 hash 即版本)、与 K8s 原生集成。CI 流程如下:
name: Build Triton Model Image on: push: paths: - 'models/**' - '.github/workflows/build-model.yml' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push model image uses: docker/build-push-action@v4 with: context: . push: true tags: | ghcr.io/your-org/triton-models:${{ github.sha }} ghcr.io/your-org/triton-models:latest cache-from: type=gha cache-to: type=gha,mode=max关键创新点是:模型仓库本身就是一个 Docker 镜像。Dockerfile 很简单:
FROM scratch COPY models/ /models/ COPY config.pbtxt /opt/tritonserver/conf/config.pbtxt # 注意:不安装任何 Python 包!Triton 的 Python backend 会自动提供运行时这样构建出的镜像只有几十 MB,且不含任何 Python 依赖冲突风险。部署时,K8s 的initContainer从镜像里cp -r /models /mnt/models到共享存储,主容器挂载即可。我们实测,从 git push 到新模型在集群生效,平均耗时 2 分钟 17 秒。
4.3 Kubernetes 生产部署:HPA + Prometheus + Grafana 全链路监控
生产环境的核心诉求是:自动扩缩容和故障快速定位。我们用 K8s 的 Horizontal Pod Autoscaler (HPA) 基于 Triton 的 metrics 做弹性伸缩。首先,确保 Triton 的 metrics 端口暴露:
apiVersion: v1 kind: Service metadata: name: triton-metrics spec: selector: app: triton ports: - port: 8002 targetPort: 8002 protocol: TCP --- apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: triton-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: triton minReplicas: 2 maxReplicas: 10 metrics: - type: Pods pods: metric: name: nv_inference_request_success target: type: AverageValue averageValue: 1000 # 每秒成功请求数目标但光看成功率不够。我们用 Prometheus 抓取 Triton 的所有指标,特别关注:
nv_inference_queue_duration_us:请求在队列里等多久?超过 100ms 就要告警。nv_inference_compute_input_duration_us:预处理耗时,区分 CPU 和 GPU。nv_inference_compute_output_duration_us:后处理耗时。nv_inference_request_failure:按error_code分组,快速定位是 OOM 还是 NMS 失败。
Grafana 仪表盘里,我们设置了三个黄金信号面板:
- 端到端 SLO:HTTP 2xx / total,P99 延迟热力图(按小时粒度)。
- 模块级健康度:preprocess、inference、postprocess 三个模型各自的
success_rate和avg_latency。 - 资源水位:每个 Triton Pod 的 CPU 使用率(preprocess instance group)、GPU 显存占用(inference instance group)、内存 RSS(postprocess instance group)。
有一次线上事故,P99 延迟突增到 200ms,但整体成功率 100%。我们看模块面板,发现preprocess的avg_latency从 15ms 涨到 85ms,而inference和postprocess不变。立刻登录对应 Pod,top发现一个 Python worker 进程 CPU 100%,strace -p <pid>显示它卡在futex等待——原来是cv2.resize在处理一张 12000x8000 的超大图。我们立刻在preprocess/model.py加了尺寸校验:
if orig_h > 4000 or orig_w > 4000: raise pb_utils.TritonModelException(f"Image too large: {orig_h}x{orig_w}, max allowed 4000x4000")然后用 K8s 的kubectl rollout restart deployment/triton滚动更新,5 分钟内恢复。没有这套细粒度监控,这个故障可能要花几小时才能定位。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
最后,分享我们在真实项目中踩过的、文档里绝不会提的 7 个坑。每一个都附带现场日志、根因分析和永久解决方案。这些不是理论,是真金白银买来的教训。
5.1 问题:INVALID_ARG: unable to get value for input 'INPUT__0'—— Ensemble 链断裂
现场日志:
E0715 10:23