ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零构建AI工程:可运维、可诊断、可演进的生产级AI服务

从零构建AI工程:可运维、可诊断、可演进的生产级AI服务 1. 为什么“从零构建AI工程”不是口号而是必须重走的路最近帮三家公司做过AI落地评估发现一个扎心的事实90%的所谓“AI项目”其实只是把现成模型API调用封装成一个网页表单。用户上传一张图后台扔给某个云服务返回个JSON结果——这叫AI应用不这叫API搬运工。真正卡住业务的从来不是模型好不好而是当你要把一个在Jupyter里跑通的demo变成每天处理50万条请求、能扛住促销峰值、出错时自动降级、日志能精准定位到某一行代码、运维同事半夜能看懂告警含义的系统时那套“from scratch”的能力才真正决定生死。“AI Engineering from Scratch”这个标题表面看是讲技术栈搭建实则是一场认知重构。它拒绝把AI当成黑盒调用而是把它还原成可设计、可拆解、可测试、可运维的工程实体。关键词里没有“LLM”“Transformer”“Fine-tuning”只有两个词AI Engineering和From Scratch。前者定义了领域边界——它不是算法研究不是论文复现而是让AI能力稳定、可靠、可持续地嵌入业务流后者划出了方法论底线——不依赖魔改版SDK不迷信一键部署脚本从Python环境隔离开始到模型服务的健康探针设计每一步都亲手踩过坑、验证过边界、记录过退路。我见过太多团队在“快速上线”压力下跳过这一步用全局conda环境装所有依赖模型版本和PyTorch版本混在一起用pickle序列化模型直接上线结果生产环境Python小版本升级导致反序列化失败把训练脚本和推理服务写在同一份代码里一改全崩。这些不是“小问题”是系统性脆弱的伏笔。而“from scratch”的核心价值恰恰在于强制你面对每一个被封装层掩盖的细节CUDA驱动和cudnn版本如何对齐ONNX导出时哪些算子不支持模型输入预处理的数值范围在训练集、验证集、线上流量中是否一致这些细节不会出现在API文档里但会出现在凌晨三点的告警页面上。所以这篇文章不教你怎么调用ChatGLM也不讲如何微调Llama3。它只做一件事带你亲手搭起一座桥——从本地跑通的.py文件到能放进Kubernetes集群、被Prometheus监控、被业务方当“水电煤”一样稳定使用的AI服务。过程中你会亲手编译一个轻量级推理引擎手动配置gRPC服务的超时熔断用Wireshark抓包验证模型服务的二进制协议头。这些操作本身不难但它们共同构成了一种肌肉记忆当AI不再是个“调用即成功”的魔法而是一串可追溯、可干预、可修复的工程链路时你才算真正拿到了AI工程的入场券。2. 环境筑基为什么连Python虚拟环境都要亲手编译很多人觉得“from scratch”就是写代码其实第一步是重建信任基础——你得相信自己电脑上的每一行字节都是可控、可复现、可审计的。这听起来很原始但在AI工程里恰恰是最容易被跳过的致命环节。我曾接手一个故障模型在测试环境准确率98%上线后跌到62%。排查三天最后发现是Docker镜像里用的pip install torch默认装了CPU版本而GPU节点上CUDA驱动版本太老PyTorch自动fallback到CPU计算但没报错只默默变慢、变不准。这种问题靠“重装一遍”解决不了靠CI/CD流水线也救不回来——根子在环境不可信。所以“from scratch”的第一课是放弃conda create -n ai-env python3.10这种便利。我们要亲手编译Python解释器目的不是炫技而是切断所有隐式依赖链。具体怎么做用pyenv管理源码版本下载CPython 3.10.12源码打上两个补丁一个是禁用--enable-shared避免.so库路径污染另一个是修改setup.py强制sqlite3模块静态链接防止不同Linux发行版SQLite版本差异导致DB读取异常。编译命令不是./configure make make install而是./configure --prefix$HOME/.pyenv/versions/3.10.12-scratch \ --without-ensurepip \ --enable-optimizations \ LDFLAGS-static-libgcc -static-libstdc make -j$(nproc) make install关键点在于-static-libgcc和-static-libstdc。这意味着编译出的Python二进制文件不依赖系统glibc版本。当你把这份Python打包进Docker镜像时哪怕基础镜像是Alpine Linuxmusl libc它也能跑——因为所有C运行时都被静态链接进去了。这解决了跨平台部署最头疼的“libc不兼容”问题。而禁用ensurepip是为了彻底剥离pip这个“黑盒”后续所有包管理都通过python -m venv创建干净虚拟环境再用pip install --no-binary :all:强制源码编译安装关键包如numpy、scipy确保每个C扩展都针对当前CPU指令集AVX2/SSE4.2做了优化。提示--no-binary :all:不是为了性能而是为了可审计性。当你看到Building wheel for numpy (pyproject.toml)时你知道它正在用你的GCC编译而Successfully built numpy后面跟着的SHA256哈希值是你能验证的唯一凭证。这比任何“官方wheel包”都更可信。接下来是CUDA工具链。别急着apt install nvidia-cuda-toolkit。去NVIDIA官网下载对应驱动版本的cuda_12.1.1_530.30.02_linux.run安装包但不执行安装。用--extract参数解压出cuda-toolkit目录然后手动设置环境变量export CUDA_HOME$HOME/cuda-toolkit export PATH$CUDA_HOME/bin:$PATH export LD_LIBRARY_PATH$CUDA_HOME/lib64:$LD_LIBRARY_PATH为什么不用nvidia-smi检测到的驱动自带toolkit因为驱动自带的nvcc版本往往滞后且与PyTorch源码要求的CUDA Toolkit版本不匹配。手动提取才能精确控制nvcc --version输出的版本号。我曾遇到PyTorch 2.1.0源码编译时nvcc报告error: unsupported GNU version查了半天发现是Ubuntu 22.04默认GCC 11.3而CUDA 12.1只认GCC 11.2。解决方案不是降级系统GCC会破坏其他软件而是用gcc-11.2单独编译CUDA Toolkit——这只有亲手拆解toolkit才能做到。最后是cuDNN。别下.deb包下.tar.xz源码包。解压后把include和lib目录内容硬链接到CUDA toolkit目录下ln -sf $CUDNN_INCLUDE $CUDA_HOME/include/cudnn.h。硬链接而非复制是为了确保PyTorch编译时find_package(CUDA)能找到的cuDNN头文件和最终链接的so文件来自完全相同的二进制。这避免了“头文件版本新、so文件版本旧”导致的undefined symbol错误——这种错误在动态链接时才暴露调试成本极高。这套流程看起来繁琐但它建立了一种工程纪律所有依赖必须有明确来源、明确版本、明确构建方式。当你在Kubernetes Pod里看到python --version输出3.10.12 (from source)nvcc --version输出Cuda compilation tools, release 12.1, V12.1.105你就知道这个环境不是“大概能跑”而是“必然可控”。3. 模型服务化从PyTorch Module到gRPC服务的七层解剖模型训练完torch.save(model.state_dict(), model.pth)这只是故事的开始。真正的挑战在于如何让这个.pth文件变成一个能被Java后端、Go网关、甚至嵌入式设备调用的稳定服务很多团队直接用Flask搭个HTTP接口这就像用自行车驮运集装箱——不是不能动而是根本没设计承载力。而“from scratch”的服务化本质是把模型推理过程拆解成七层可独立演进、可单独压测、可精准监控的组件。第一层序列化协议层。别用pickle它不安全、不跨语言、版本敏感。我们选Protocol Buffersprotobuf。定义inference.protosyntax proto3; package ai.serving; message PredictRequest { repeated float features 1; // 归一化后的特征向量 uint32 batch_size 2; // 显式声明batch size避免shape推断错误 } message PredictResponse { repeated float scores 1; // 模型输出logits uint32 status_code 2; // 自定义状态码非HTTP status string error_msg 3; // 结构化错误信息 }关键点在于batch_size字段。PyTorch模型的forward()方法不显式接收batch size但生产环境必须知道——因为内存分配、CUDA stream调度、甚至模型内部的dropout行为都依赖此信息。Protobuf强制你在协议层就声明它堵死了“靠猜shape”的漏洞。第二层模型加载层。不直接torch.load()而是封装成ModelLoader类class ModelLoader: def __init__(self, model_path: str, device: str cuda:0): self.model_path model_path self.device device self.model None self.lock threading.Lock() def load(self) - nn.Module: with self.lock: # 防止并发加载 if self.model is not None: return self.model # 1. 验证模型文件完整性 if not self._verify_checksum(): raise RuntimeError(Model checksum mismatch) # 2. 加载state_dict但不立即to(device) state_dict torch.load(self.model_path, map_locationcpu) # 3. 构建模型骨架必须与训练时完全一致 model MyModel() # 这里必须是训练时的原始类定义 model.load_state_dict(state_dict) # 4. 移动到device并启用eval模式 self.model model.to(self.device).eval() # 5. 预热用dummy input触发CUDA kernel编译 dummy torch.randn(1, 128, deviceself.device) _ self.model(dummy) return self.model这里_verify_checksum()不是简单的MD5而是用sha256sum计算模型文件模型类源码inspect.getsource(MyModel)的联合哈希。因为模型权重变了但代码逻辑错了同样会出错。这个校验把“模型”和“代码”绑定为一个原子单元。第三层预处理管道层。不把预处理逻辑写死在forward()里而是用sklearn.pipeline.Pipeline定义preprocessor Pipeline([ (scaler, StandardScaler()), (pca, PCA(n_components64)), (to_tensor, Lambda(lambda x: torch.from_numpy(x.astype(np.float32))) ])关键点在于Lambda步骤它把NumPy数组转成Tensor但不指定device。设备迁移交给下一层统一处理。这样预处理管道可以纯CPU运行压测时能隔离I/O瓶颈。第四层设备调度层。这才是真正的“AI工程”分水岭。我们不用model.to(device)简单粗暴而是实现DeviceManagerclass DeviceManager: def __init__(self): self.gpu_pool [torch.device(fcuda:{i}) for i in range(torch.cuda.device_count())] self.cpu_device torch.device(cpu) def get_device(self, request: PredictRequest) - torch.device: # 根据batch_size智能选择 if request.batch_size 1000: return self.cpu_device # 大batch用CPU避免GPU OOM elif request.batch_size 100: return self.gpu_pool[0] # 中batch用主GPU else: return self.gpu_pool[0] # 小batch也用主GPU避免多卡通信开销 def move_to_device(self, tensor: torch.Tensor, device: torch.device) - torch.Tensor: if device.type cpu: return tensor.cpu() else: return tensor.cuda(device.index)这个逻辑背后是实测数据在V100上batch_size512时GPU利用率92%但batch_size2048时显存溢出而CPU处理batch_size2000耗时仅比GPU慢1.8倍但稳定性100%。这就是工程权衡——不是“GPU一定快”而是“在什么条件下GPU性价比最高”。第五层gRPC服务层。不用grpcio-tools自动生成代码而是手写InferenceServicerclass InferenceServicer(inference_pb2_grpc.InferenceServiceServicer): def __init__(self, model_loader: ModelLoader, preprocessor: Pipeline, device_manager: DeviceManager): self.model_loader model_loader self.preprocessor preprocessor self.device_manager device_manager def Predict(self, request: inference_pb2.PredictRequest, context) - inference_pb2.PredictResponse: try: # 1. 输入校验 if len(request.features) 0: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details(Empty features) return inference_pb2.PredictResponse() # 2. 预处理CPU X_np np.array(request.features).reshape(-1, 128) # 假设128维 X_processed self.preprocessor.transform(X_np) # 3. 设备调度 device self.device_manager.get_device(request) X_tensor torch.from_numpy(X_processed).to(device) # 4. 模型推理 with torch.no_grad(): logits self.model_loader.load()(X_tensor) # 5. 后处理CPU scores logits.cpu().numpy().flatten().tolist() return inference_pb2.PredictResponse( scoresscores, status_code0 ) except Exception as e: context.set_code(grpc.StatusCode.INTERNAL) context.set_details(fPredict failed: {str(e)}) return inference_pb2.PredictResponse( status_code500, error_msgstr(e) )注意context.set_code()的使用——gRPC的status code是协议层概念比HTTP status更底层。当模型OOM时context.set_code(grpc.StatusCode.RESOURCE_EXHAUSTED)比返回500 HTTP更精准调用方能据此触发降级策略。第六层健康检查层。不依赖/healthz而是gRPC原生HealthCheck服务# health_check.proto service Health { rpc Check(HealthCheckRequest) returns (HealthCheckResponse); rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse); } # 在servicer中实现 def Check(self, request, context): # 1. 检查模型是否加载 if self.model_loader.model is None: return HealthCheckResponse(statusHealthCheckResponse.SERVING_STATUS_NOT_SERVING) # 2. 执行一次轻量级推理不走完整pipeline try: dummy torch.randn(1, 128, deviceself.device_manager.gpu_pool[0]) _ self.model_loader.model(dummy) return HealthCheckResponse(statusHealthCheckResponse.SERVING_STATUS_SERVING) except: return HealthCheckResponse(statusHealthCheckResponse.SERVING_STATUS_NOT_SERVING)第七层可观测性注入层。在Predict方法开头插入OpenTelemetry追踪with tracer.start_as_current_span(inference.predict) as span: span.set_attribute(request.batch_size, request.batch_size) span.set_attribute(device.type, device.type) # ... 推理逻辑 ... span.set_attribute(response.latency_ms, time.time() - start_time)这七层不是理论模型而是你部署时真实存在的七个代码文件。每一层都有独立的单元测试、独立的压测脚本、独立的监控指标。当Predict耗时飙升你能立刻判断是预处理慢CPU负载高、还是模型推理慢GPU显存不足、还是gRPC序列化慢网络带宽打满。这种可诊断性才是“from scratch”服务化的终极价值。4. 持续交付为什么CI/CD流水线要亲手写Makefile而不是用GitHub Actions模板“AI工程”的持续交付和Web开发有本质区别它不仅要跑通单元测试还要验证模型行为一致性、硬件兼容性、性能基线。一个git push触发的流水线如果只做pytest tests/那它只是个玩具。真正的“from scratch”CI/CD必须亲手用Makefile定义每一个原子任务因为只有Makefile能精确控制依赖关系、执行顺序、环境隔离、失败回滚——这些是YAML模板永远无法表达的工程细节。先看核心Makefile结构# Makefile .PHONY: all clean test build-docker deploy # 全局变量 PYTHON : $(HOME)/.pyenv/versions/3.10.12-scratch/bin/python PIP : $(PYTHON) -m pip MODEL_DIR : ./models DOCKER_REGISTRY : registry.example.com # 目标all - 完整验证流程 all: test build-docker # 目标test - 四层验证 test: test-unit test-model-consistency test-hardware-compat test-performance-baseline # 目标test-unit - 单元测试纯Python test-unit: $(PYTHON) -m pytest tests/unit/ -v # 目标test-model-consistency - 模型行为一致性验证 test-model-consistency: $(PYTHON) scripts/validate_model_consistency.py \ --train-model $(MODEL_DIR)/train/model.pth \ --serve-model $(MODEL_DIR)/serve/model.pth \ --test-data $(MODEL_DIR)/test_data.npz # 目标test-hardware-compat - 硬件兼容性验证 test-hardware-compat: echo Testing on CPU... $(PYTHON) scripts/run_on_cpu.py --model $(MODEL_DIR)/serve/model.pth echo Testing on GPU... $(PYTHON) scripts/run_on_gpu.py --model $(MODEL_DIR)/serve/model.pth --gpu-id 0 # 目标test-performance-baseline - 性能基线验证 test-performance-baseline: $(PYTHON) scripts/benchmark.py \ --model $(MODEL_DIR)/serve/model.pth \ --batch-sizes 1 16 64 256 \ --thresholds {latency_ms: {p95: 100}, throughput_qps: {min: 50}} # 目标build-docker - 构建Docker镜像 build-docker: Dockerfile docker build -t $(DOCKER_REGISTRY)/ai-service:$(shell git rev-parse --short HEAD) . # 目标deploy - 部署到K8s deploy: build-docker kubectl set image deployment/ai-service ai-service$(DOCKER_REGISTRY)/ai-service:$(shell git rev-parse --short HEAD)这个Makefile的精妙之处在于每个目标都是一个可独立执行、可组合的原子操作。比如make test-model-consistency它调用的validate_model_consistency.py脚本会做三件事加载训练模型train/model.pth用相同测试数据跑一次记录输出logits加载服务模型serve/model.pth用相同测试数据跑一次记录输出logits逐元素比较计算np.max(np.abs(logits_train - logits_serve))要求1e-5。为什么需要这个因为模型导出如ONNX、量化如FP16、编译如Triton都会引入数值误差。这个测试不是为了“绝对相等”而是为了确认误差在业务可接受范围内。我曾遇到ONNX导出后softmax输出概率分布偏差0.3%导致推荐排序错位——这个偏差在单元测试里根本测不出来只有这种端到端一致性验证才能捕获。再看test-hardware-compat。它不是简单跑个nvidia-smi而是分别执行run_on_cpu.py和run_on_gpu.py。run_on_gpu.py的关键代码def test_gpu_compatibility(model_path: str, gpu_id: int): # 1. 设置CUDA_VISIBLE_DEVICES os.environ[CUDA_VISIBLE_DEVICES] str(gpu_id) # 2. 初始化CUDA上下文 torch.cuda.set_device(gpu_id) torch.cuda.empty_cache() # 3. 加载模型到指定GPU model torch.load(model_path, map_locationfcuda:{gpu_id}) # 4. 执行一次推理捕获CUDA错误 try: dummy torch.randn(1, 128, devicefcuda:{gpu_id}) _ model(dummy) print(fGPU {gpu_id} OK) except torch.cuda.OutOfMemoryError: raise RuntimeError(fGPU {gpu_id} OOM on dummy input) except Exception as e: raise RuntimeError(fGPU {gpu_id} failed: {e})这个测试能提前发现驱动版本不匹配、CUDA Toolkit版本不兼容、甚至GPU显存碎片化等问题。它比任何“环境检查脚本”都更真实因为它真的调用了CUDA API。test-performance-baseline更狠。它不只是测“平均延迟”而是用benchmark.py跑多个batch size生成完整的性能曲线Batch SizeLatency (ms)Throughput (QPS)GPU Util (%)112.381.215%1618.7855.642%6425.12540.278%25642.85980.195%然后对比预设阈值。如果batch_size256时latency_ms.p95 100或者throughput_qps.min 50整个CI就失败。这确保每次提交都不会劣化服务性能基线。Docker构建也不是docker build -t xxx .就完事。我们的Dockerfile严格遵循“多阶段构建最小化基础镜像”# 第一阶段构建环境 FROM nvidia/cuda:12.1.1-devel-ubuntu22.04 AS builder COPY --from0 /usr/local/cuda-12.1 /usr/local/cuda RUN apt-get update apt-get install -y python3.10-dev gcc g rm -rf /var/lib/apt/lists/* # 第二阶段运行环境 FROM ubuntu:22.04 # 只拷贝必要文件不拷贝构建工具 COPY --frombuilder /usr/local/cuda /usr/local/cuda COPY --frombuilder /usr/bin/python3.10 /usr/bin/python3.10 # 手动安装Python标准库不装pip RUN python3.10 -m ensurepip --upgrade --default-pip \ pip3 install --no-cache-dir --no-binary :all: torch2.1.0cu121 -f https://download.pytorch.org/whl/torch_stable.html # 第三阶段最终镜像无root权限 FROM scratch COPY --from1 /usr/bin/python3.10 /usr/bin/python3.10 COPY --from1 /usr/lib/x86_64-linux-gnu/libpython3.10.so.1.0 /usr/lib/libpython3.10.so.1.0 COPY --from1 /usr/local/cuda /usr/local/cuda COPY --from1 /app /app USER 1001:1001 ENTRYPOINT [/app/inference_server]这个Dockerfile的每一行都是对“最小化攻击面”的实践。scratch基础镜像意味着没有shell、没有包管理器、没有不必要的so库。USER 1001:1001强制非root运行。--no-binary :all:确保PyTorch也是源码编译避免wheel包隐藏的CVE。最后是部署。make deploy不调用kubectl apply -f k8s/deployment.yaml而是用kubectl set image因为这是幂等操作无论deployment是否存在它都只更新image字段。配合K8s的RollingUpdate策略能保证零停机发布。更重要的是它把“部署”和“配置”分离——deployment.yaml是基础设施代码image tag是应用版本两者独立演进。这套Makefile CI/CD不是为了替代GitHub Actions而是为了在自动化之上保留人类工程师的决策权。当test-performance-baseline失败时流水线不会自动回滚而是发Slack告警附上性能曲线图由工程师判断是接受性能下降换功能还是优化模型还是扩容GPU这个决策必须由人来做。而Makefile就是把这种决策逻辑编码成可执行、可审计、可复现的工程契约。5. 故障排查一次GPU显存泄漏的七小时溯源实录“from scratch”的最大价值不是让你写出多漂亮的代码而是当系统崩溃时你有底气说“我知道问题在哪而且我能修好它。” 这种底气来自对每一层抽象的亲手触摸。下面是我亲身经历的一次GPU显存泄漏排查全程七小时没有黑盒只有层层剥茧。现象服务上线第三天nvidia-smi显示GPU显存占用从初始1.2GB缓慢爬升到12GBV100显存16GB第48小时OOMPod被K8s杀死重启。重启后重复此过程。第一小时确认现象排除外部干扰首先kubectl top pods确认是ai-servicePod显存异常不是其他Pod抢占。然后kubectl exec -it ai-service -- nvidia-smi确认是容器内显存泄漏不是宿主机问题。接着kubectl logs ai-service | grep -i out of memory发现大量CUDA out of memory错误但时间戳集中在OOM前10分钟——说明泄漏是渐进式的OOM是最终结果。第二小时隔离模型确认泄漏源停掉所有业务流量只用curl发单条请求。watch -n 1 nvidia-smi --query-gpumemory.used --formatcsv观察显存变化。发现每请求一次显存2MB且不释放。这确认了泄漏在推理路径上。然后注释掉模型推理代码只留gRPC服务框架显存稳定。再逐步放开预处理→设备调度→模型加载→推理。定位到model(input)调用后显存上涨。第三小时检查PyTorch上下文怀疑是torch.no_grad()没生效或grad_fn残留。在Predict方法里加日志print(fBefore forward: {torch.cuda.memory_allocated()/1024/1024:.1f} MB) output model(input) print(fAfter forward: {torch.cuda.memory_allocated()/1024/1024:.1f} MB) print(fMax memory: {torch.cuda.max_memory_allocated()/1024/1024:.1f} MB)日志显示Before: 1200.3 MB,After: 1202.5 MB,Max: 1202.5 MB。等等max_memory_allocated没涨说明不是模型内部泄漏而是显存缓存未释放。PyTorch的CUDA缓存机制torch.cuda.empty_cache()只清空未被引用的缓存但model对象一直存活其内部缓存如cuBLAS工作区可能被长期持有。第四小时验证缓存假设写一个独立脚本test_cache.pyimport torch import gc model torch.load(model.pth).cuda().eval() for i in range(100): x torch.randn(1, 128).cuda() with torch.no_grad(): y model(x) print(fIter {i}: {torch.cuda.memory_allocated()/1024/1024:.1f} MB) if i % 10 0: torch.cuda.empty_cache() gc.collect()运行后显存依然缓慢上涨。这证实了缓存泄漏。但empty_cache()无效说明缓存被模型内部张量引用。第五小时深入模型源码查看MyModel定义发现一个可疑点模型里有个self.register_buffer(running_mean, ...)用于BatchNorm统计。但推理时model.eval()应该禁用它。检查torch.nn.BatchNorm2d.forward()源码发现当trainingFalse时它确实不更新running_mean但running_mean张量本身仍驻留在GPU上。问题不在BatchNorm而在模型里一个自定义的AttentionLayer它有一个self.cache_k None在forward里if self.cache_k is None: self.cache_k torch.zeros(...).cuda() # 每次forward都新建啊这里self.cache_k在第一次调用时创建但后续调用没复用而是每次都torch.zeros(...).cuda()——这就在GPU上不断分配新内存旧内存没被GC因为self.cache_k被重新赋值旧张量失去引用但PyTorch的CUDA缓存没及时回收。第六小时修复与验证修复代码# 修复前 if self.cache_k is None: self.cache_k torch.zeros(...).cuda() # 修复后 if self.cache_k is None: self.cache_k torch.zeros(...).cuda() else: # 复用已有buffer只重置值 self.cache_k.zero_()重新构建Docker镜像部署测试。nvidia-smi显示显存稳定在1.2GB100次请求后无增长。但等等torch.cuda.max_memory_allocated()依然在涨——说明还有别的泄漏。第七小时揪出gRPC的隐式引用仔细看gRPC服务代码在Predict方法里logits self.model_loader.load()(X_tensor)。self.model_loader.load()每次返回同一个模型实例但load()方法里有model.to(device)。to(device)如果目标device和当前device不同会返回新张量如果相同返回原张量。但model.to(device)内部会调用_apply()可能创建临时张量。把model.to(device)移到ModelLoader.load()里确保模型只加载一次且to(device)只执行一次。再测max_memory_allocated稳定。最终结论这是一个复合泄漏——主因是模型层cache_k反复分配次因是gRPC服务层model.to(device)在每次请求中冗余调用。修复后服务稳定运行30天显存波动50MB。这次排查没有用任何“AI监控平台”只靠nvidia-smi、print日志、gc.collect()、阅读PyTorch源码。它证明了一件事“from scratch”不是为了炫技而是为了在黑暗中你手里有一盏自己点亮的灯。
返回列表