Triton推理服务端到端预处理与后处理最佳实践

📅 2026/6/18 8:53:20
Triton推理服务端到端预处理与后处理最佳实践
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(modebilinear)B 团队用 cv2.resize(interpolationcv2.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-ms1000把每个环节的耗时、错误率、队列堆积深度全部暴露出来。我们上线后发现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 tensorinference模型接收 tensor输出 raw logitspostprocess模型接收 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.1TensorFlow 叫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。这是官方文档里最“正统”的方案性能理论上最优。但现实是它要求你精通 C17、CUDA 编程、Triton C API、内存生命周期管理。一个简单的 resize 操作你得自己写 CUDA kernel 做双线性插值还得处理不同 channel orderRGB/BGR/RGBA的 stride 计算。我们团队有个资深 CUDA 工程师他花了 5 天时间才让 bilinear resize 在 GPU 上跑通结果一测性能比 CPU 上的 OpenCV 还慢 15%——因为小图 resize 的 GPU kernel launch overhead 太高。更致命的是一旦你要加个 tokenization就得引入 HuggingFace Tokenizers 的 C binding编译链瞬间爆炸。我们评估过用 C backend 实现一套覆盖 CVNLP 的通用预处理库人力投入至少是 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进去就能用。我们实测过一个包含 resizenormalizeNMS 的 Python backend在 16 核 CPU 上QPS 能稳定在 320P99 延迟 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.pbtxtname: 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.pyimport 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], dtypenp.float32) self.std np.array([0.229, 0.224, 0.225], dtypenp.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, interpolationcv2.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], dtypenp.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.pbtxtname: 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*(805) * (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 --onnxmodel.onnx --saveEnginemodel.plan导出时用--verbose查看实际输出 shape不能凭记忆写。3.4 后处理模型postprocess的 config.pbtxt 和 model.pyname: 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_thres0.25, iou_thres0.45, classesNone, agnosticFalse, multi_labelFalse): # 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, keepdimsTrue) 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_thres0.3, iou_thres0.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)], dtypeobject)) 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-configfalse --log-verbose1 --http-port8000 --grpc-port8001 --metrics-port8002 --allow-httptrue --allow-grpctrue --allow-metricstrue --model-control-modeexplicit deploy: resources: limits: memory: 8G devices: - driver: nvidia count: 1 capabilities: [gpu]关键点在于--strict-model-configfalse。这个参数允许 Triton 在config.pbtxt缺失某些字段时用默认值填充极大加速开发迭代。比如你刚写完preprocess/model.py还没写config.pbtxt直接docker-compose upTriton 会启动并报 warning而不是 fatal error。等你补全 configdocker-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),dtypenp.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/checkoutv3 - name: Set up Docker Buildx uses: docker/setup-buildx-actionv3 - name: Login to GitHub Container Registry uses: docker/login-actionv3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push model image uses: docker/build-push-actionv4 with: context: . push: true tags: | ghcr.io/your-org/triton-models:${{ github.sha }} ghcr.io/your-org/triton-models:latest cache-from: typegha cache-to: typegha,modemax关键创新点是模型仓库本身就是一个 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 仪表盘里我们设置了三个黄金信号面板端到端 SLOHTTP 2xx / totalP99 延迟热力图按小时粒度。模块级健康度preprocess、inference、postprocess 三个模型各自的success_rate和avg_latency。资源水位每个 Triton Pod 的 CPU 使用率preprocess instance group、GPU 显存占用inference instance group、内存 RSSpostprocess instance group。有一次线上事故P99 延迟突增到 200ms但整体成功率 100%。我们看模块面板发现preprocess的avg_latency从 15ms 涨到 85ms而inference和postprocess不变。立刻登录对应 Podtop发现一个 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(fImage 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