1. 项目概述:为什么“影子部署”是模型上线前最值得花时间的一道工序
在 Amazon SageMaker 上部署一个新训练好的机器学习模型,往往只需要十几行代码、几分钟等待——但真正决定这个模型能否长期稳定服务用户的,从来不是部署那一刻的顺利,而是它上线前那几十小时、甚至几天里,有没有被真实流量“悄悄检验过”。我做过不下二十个面向公众的 NLP 服务项目,从电商评论情感分析到金融舆情预警,踩过最多的坑,几乎都出在同一个环节:把未经实测的新模型直接推上生产环境,结果发现它在特定长尾样本上延迟飙升、在某类编码格式下解析失败、或者准确率比老模型低了 3.2%,而这个数字直到用户投诉才被发现。这种问题不是靠单元测试能覆盖的,也不是靠离线评估报告能预判的。它需要一种机制,让新模型和老模型在完全相同的生产请求流里并肩跑一程,不干扰用户,不改变响应,只默默记录、对比、验证。这就是 SageMaker 在 re:Invent 2022 正式推出的Shadow Deployment(影子部署)的核心价值。它不是另一个花哨的功能噱头,而是把“上线即事故”的高风险动作,变成了一次可度量、可回溯、可决策的工程实践。关键词 AWS 在这里不是泛指云平台,而是特指 SageMaker 这套深度集成的 MLOps 工具链所提供的原生能力——它把原本需要自己搭 Kafka 做流量镜像、写脚本做日志对齐、用 Airflow 调度评估任务的整套复杂流程,压缩成几个 API 调用和一个 S3 路径配置。你不需要重写基础设施,也不需要绕开 SageMaker 自己搞一套影子服务;你只需要理解它的设计哲学:生产变体(Production Variant)负责“干活”,影子变体(Shadow Variant)负责“旁听”,SageMaker 负责“记账”和“分发”。这篇文章要讲的,就是如何把这套哲学落地为可复现、可调试、可扩展的具体操作。它适合三类人:正在为模型迭代上线提心吊胆的算法工程师、需要向业务方证明新模型价值的数据科学家,以及负责保障线上服务 SLA 的 MLOps 工程师。如果你还在用“先切 5% 流量,看半天再决定是否全量”的方式做灰度,那你真的该认真看看影子部署了——它解决的不是“要不要切”,而是“切之前,我们到底已经知道多少”。
2. 核心设计逻辑:影子部署不是“灰度”,而是“无感验证”
2.1 影子部署与金丝雀(Canary)部署的本质区别
很多人第一次接触影子部署时,会下意识把它等同于金丝雀发布。这是个危险的误解,必须立刻厘清。金丝雀的核心是“分流+响应”:把一部分真实用户请求(比如 5%)路由给新版本,新版本必须实时返回预测结果给用户。这意味着,如果新模型有 bug,这 5% 的用户就会直接感知到错误、延迟或异常响应。而影子部署的核心是“镜像+捕获”:所有请求都由生产模型处理并返回给用户,同时,系统会自动复制一份完全相同的请求,发送给影子模型;影子模型的输出绝不返回给用户,也绝不参与任何业务逻辑,它只被完整地记录下来,存入 S3。你可以把它想象成在高速公路收费站旁边,架设了一台完全独立的、不联网的高清摄像机,它拍下每一辆车的车牌、车型、通过时间,但不会拦车、不会收费、不会影响任何一辆车的通行。这种设计带来了三个不可替代的优势。第一,零业务风险。无论影子模型返回什么结果——是空值、是乱码、是耗时 10 秒的超时,都不会影响任何一个终端用户。第二,全量数据覆盖。金丝雀只能看到那 5% 的请求,而影子部署能看到 100% 的真实流量,包括那些极其稀有的、可能触发模型边界条件的长尾样本。第三,异步深度分析。你不再需要在毫秒级内判断新模型是否“合格”,而是可以花几小时甚至几天,用完整的统计学方法,去分析影子模型在不同数据分布下的表现差异。我去年在一个新闻分类项目里就吃过亏:金丝雀切了 10% 流量,监控显示一切正常,但上线一周后,运营同事反馈某些突发社会事件的报道被错误归类。回查才发现,这类事件的文本特征(大量短句、高频感叹号、非标准标点)在训练集里占比不到 0.3%,金丝雀流量根本没覆盖到。而如果我们当时用了影子部署,这些样本的影子输出早就躺在 S3 里,等着我们用 Pandas 做交叉分析了。
2.2 SageMaker 影子部署的三层架构解析
SageMaker 的影子部署能力不是凭空出现的,它建立在 SageMaker 推理服务(Inference)已有的坚实架构之上,主要涉及三个关键组件的协同工作。第一层是Endpoint Config(端点配置)。这是整个影子部署的“大脑”。在创建 Endpoint Config 时,你必须明确指定两个数组:ProductionVariants和ShadowProductionVariants。前者定义谁来“干活”,后者定义谁来“旁听”。注意,ShadowProductionVariants是一个独立的参数,不是ProductionVariants的子属性,这说明 SageMaker 在设计上就将两者视为职责完全分离的实体。第二层是Data Capture(数据捕获)。这是影子部署的“眼睛和耳朵”。它不是一个可选的附加功能,而是影子部署生效的必要前提。你必须在 Endpoint Config 中启用DataCaptureConfig,并指定EnableCapture=True。此时,SageMaker 会自动为每一个到达端点的请求,生成一个唯一的eventId,并将该请求的原始输入(Input)和生产模型的输出(Output)都序列化为 JSON,存入你指定的 S3 路径。关键在于,当影子变体被配置后,SageMaker 会为影子模型的每一次调用,也生成一个eventId,并在其eventMetadata字段中明确标记"invocationSource":"ShadowExperiment"。这个字段是后续数据清洗和对齐的唯一可靠依据。第三层是Traffic Distribution(流量分发)。这是最容易被误解的一环。InitialVariantWeight参数在这里的作用,不是控制“多少比例的请求发给影子模型”,而是控制“影子模型收到的请求副本占总请求的比例”。例如,当你设置InitialVariantWeight=0.5时,意味着 SageMaker 会为 50% 的生产请求,生成一份副本发给影子模型。这个权重只影响影子模型的负载,完全不影响生产模型的响应路径。生产模型依然处理 100% 的请求,并返回 100% 的响应。这个设计保证了影子模型的资源消耗(CPU、内存)是可控的、可预测的,你完全可以给影子模型分配比生产模型更小的实例类型(比如ml.t3.medium),因为它不需要承担任何实时响应压力。
2.3 为什么选择 BERT 模型作为演示案例
这篇文章选用 Hugging Face BERT 模型进行全流程演示,并非随意为之,而是基于三个非常实际的工程考量。首先,BERT 类模型是当前 NLP 服务的绝对主流。无论是社交媒体内容审核、客服对话意图识别,还是金融研报摘要生成,BERT 及其变体(RoBERTa, DistilBERT)占据了线上推理服务的 70% 以上份额。其次,BERT 模型的迭代成本高、风险大。一次微调(Fine-tuning)往往需要数小时 GPU 计算,而模型大小动辄几百 MB 到几 GB,部署后冷启动时间长,更新一次 endpoint 的代价巨大。在这种背景下,影子部署的价值被放大了——它让你能用极小的额外开销(主要是 S3 存储和少量影子实例),换取对一次昂贵模型迭代的全面验证。最后,BERT 模型的输出具有丰富的可分析维度。它不仅返回最终的分类标签(如 “Real disaster” 或 “Not a disaster”),还返回每个类别的概率分数(prob_score)。这个分数是进行 A/B 对比分析的黄金指标。你可以计算新旧模型在相同输入下的概率差值分布、标准差、95% 分位数,甚至构建混淆矩阵,而不仅仅是看一个笼统的准确率。在我实际操作中,一个prob_score提升 0.05 的模型,在业务上可能就意味着每天少处理 2000 条误报的工单。这种颗粒度的洞察,是影子部署赋予我们的核心能力。
3. 实操细节拆解:从模型准备到数据对齐的每一步陷阱
3.1 模型 Artifact 的准备与路径规范
模型 Artifact 的准备,看似是最简单的一步,却是后续所有步骤失败的首要原因。我见过太多团队卡在这一步,反复报错ModelDataUrl not found或Failed to download model data。问题根源往往不在代码,而在 S3 路径的细节。首先,路径必须是完整的、带协议的 S3 URI。s3://my-bucket/path/to/model.tar.gz是正确的,而my-bucket/path/to/model.tar.gz或./model.tar.gz是绝对错误的。其次,.tar.gz文件的内部结构必须严格符合 SageMaker 的约定。对于 PyTorch 模型,压缩包内必须包含一个code/目录,里面放着你的inference.py(或train_deploy.py)推理脚本,以及所有依赖的.py文件;同时,模型权重文件(如pytorch_model.bin)和配置文件(config.json,tokenizer.json)必须放在压缩包的根目录下,或者一个名为model/的子目录里。我建议采用后者,因为更清晰。第三,S3 存储桶的权限必须精确配置。你的 SageMaker 执行角色(Execution Role)必须拥有对该 S3 路径的s3:GetObject权限。一个简单但有效的自查方法是:在 SageMaker Studio 的 Terminal 里,手动运行aws s3 cp s3://your-bucket/path/to/model.tar.gz /tmp/,看是否能成功下载。如果这一步失败,后面的所有部署都会失败。另外,关于模型版本的命名,强烈建议在ModelName中加入时间戳和版本号,例如tweet-classifier-v1-20231027-1430。这不仅能避免命名冲突,更重要的是,在后续排查问题时,你能一眼从 CloudWatch 日志或 S3 路径中,定位到具体是哪个时间点、哪个版本的模型在运行。不要图省事用tweet-classifier-v1这样的静态名称,它会让你在多轮迭代中陷入巨大的混乱。
3.2 创建 Production Variant 的关键参数详解
创建 Production Variant 的过程,核心是create_model和create_endpoint_config两个 API 调用。其中,create_model的PrimaryContainer参数是重中之重。Image字段指定了用于加载和运行模型的 Docker 镜像。SageMaker 提供了官方的预构建镜像,但你必须确保它与你的模型框架、Python 版本、PyTorch/TensorFlow 版本完全匹配。例如,文中使用了sagemaker.image_uris.retrieve(framework="pytorch", region=region, py_version="py38", image_scope="inference", version="1.9", instance_type="ml.m5.xlarge")。这里的version="1.9"指的是 PyTorch 1.9,py_version="py38"指的是 Python 3.8。如果你的模型是在 PyTorch 2.0 下训练的,而你用了 1.9 的镜像,那么模型加载时大概率会报AttributeError或ImportError。一个安全的做法是,在训练脚本的requirements.txt中明确写出框架版本,然后在部署时,用sagemaker.image_uris.retrieve的version参数去精确匹配。ModelDataUrl就是前面准备好的 S3 URI。Environment字段则用于传递运行时环境变量。SAGEMAKER_PROGRAM必须指向你的入口脚本,这个脚本里必须定义model_fn,input_fn,predict_fn,output_fn这四个函数,这是 SageMaker 推理容器的标准接口。SAGEMAKER_SUBMIT_DIRECTORY的值,应该是包含code/目录的 S3 路径,而不是模型权重的路径。这是一个常见的混淆点:ModelDataUrl指向模型权重,SAGEMAKER_SUBMIT_DIRECTORY指向代码。如果填反了,容器启动时会找不到inference.py。最后,在create_endpoint_config中,InitialVariantWeight设置为1,表示生产变体承担全部流量。InstanceType的选择,必须基于你的模型大小和预期 QPS 进行压测。一个ml.m5.xlarge实例(4 vCPU, 16GB RAM)对于一个 500MB 的 BERT-base 模型,在 50 QPS 下是稳妥的;但如果模型是 BERT-large(1.2GB),或者你需要支撑 200 QPS,那就必须升级到ml.m5.2xlarge或更高规格。别指望 SageMaker 会自动帮你扩容,它只会按你配置的规格去启动实例。
3.3 配置 Shadow Variant 的魔鬼细节
配置 Shadow Variant 是整个流程中最容易出错的环节,因为它的参数组合更复杂,且文档描述相对模糊。第一个关键点是ShadowProductionVariants数组的结构。它是一个对象数组,每个对象代表一个影子变体。ModelName必须是你之前用create_model创建好的、指向新模型 Artifact 的模型名。VariantName可以自定义,但建议与production_variant_name保持风格一致,比如shadow或v2-shadow,方便在控制台和日志中快速识别。InitialInstanceCount和InstanceType的选择,要遵循“够用就好”的原则。既然影子模型不响应用户,它的资源需求远低于生产模型。我通常会给影子变体分配比生产变体小一级的实例,例如生产用ml.m5.xlarge,影子就用ml.t3.medium(2 vCPU, 4GB RAM)。这能显著降低成本,而且完全够用。第二个关键点是InitialVariantWeight的含义。再次强调,它不是分流比例,而是影子变体的“副本接收权重”。设置为0.5,意味着 50% 的请求会被镜像。设置为1.0,意味着 100% 的请求都会被镜像。这个值可以根据你的验证需求动态调整。如果你只想快速验证几个关键样本,可以先设为0.1;如果你想做全量分析,就设为1.0。第三个,也是最常被忽略的点,是DataCaptureConfig的配置。EnableCapture=True是必须的。InitialSamplingPercentage=100表示捕获 100% 的请求数据,这是为了保证分析的完整性。DestinationS3Uri必须是一个有效的、你有写入权限的 S3 路径。CaptureOptions必须同时包含'Input'和'Output',缺一不可。只捕获 Input,你无法知道影子模型的输出是什么;只捕获 Output,你无法将输出与原始请求关联起来。CaptureContentTypeHeader用于告诉 SageMaker 如何解析你的数据。对于 JSON 输入,JsonContentTypes: ['application/json']是标准配置。如果你的输入是二进制图像,就需要配置CsvContentTypes或BinaryContentTypes。最后,update_endpoint调用是激活影子部署的最后一步。它会将新的 Endpoint Config 应用到已存在的 Endpoint 上。这个过程是滚动更新的,生产流量不会中断。但请注意,更新需要几分钟时间,期间 Endpoint 状态会变为Updating。你可以在控制台或通过describe_endpointAPI 查看状态。一旦状态变为InService,影子部署就正式开始了。
3.4 从 S3 中提取、清洗与对齐影子数据
数据捕获完成后,S3 中的数据是原始的、未加工的 JSON Lines(JSONL)格式,每一行是一个独立的 JSON 对象。直接用 Pandas 读取会报错,因为pd.read_json()默认期望一个单一的 JSON 对象,而不是多行。正确的做法是,先用 Python 的json.loads()逐行解析。但更大的挑战在于数据对齐。生产变体和影子变体的数据是分别存储在 S3 的不同子路径下的(/production/和/shadow/),它们的eventId是唯一的,但生成时间有微小差异(毫秒级),且文件名是随机的。因此,不能指望它们的文件顺序或行号能一一对应。唯一的、可靠的对齐键,就是eventId。下面是我经过多次实战优化后的数据清洗脚本的核心逻辑。首先,定义一个通用的函数,用于从 S3 路径读取所有 JSONL 文件,并将其解析为一个 Pandas DataFrame:
import boto3 import json import pandas as pd from io import StringIO def load_s3_jsonl_to_df(bucket, prefix): """ 从 S3 的指定 prefix 下加载所有 .jsonl 文件,合并为一个 DataFrame。 """ s3 = boto3.client('s3') # 列出所有对象 paginator = s3.get_paginator('list_objects_v2') pages = paginator.paginate(Bucket=bucket, Prefix=prefix) all_lines = [] for page in pages: if 'Contents' not in page: continue for obj in page['Contents']: if not obj['Key'].endswith('.jsonl'): continue # 获取对象内容 response = s3.get_object(Bucket=bucket, Key=obj['Key']) content = response['Body'].read().decode('utf-8') # 按行分割并解析 for line in content.strip().split('\n'): if line.strip(): try: parsed = json.loads(line) all_lines.append(parsed) except json.JSONDecodeError as e: print(f"JSON 解析错误,跳过此行: {line[:100]}... Error: {e}") continue if not all_lines: raise ValueError(f"在 S3 路径 {bucket}/{prefix} 下未找到有效数据") return pd.json_normalize(all_lines) # 使用示例 prod_df = load_s3_jsonl_to_df(bucket='your-bucket-name', prefix='sagemaker/social-media/models/tweet-classifier-ep/production/') shadow_df = load_s3_jsonl_to_df(bucket='your-bucket-name', prefix='sagemaker/social-media/models/tweet-classifier-ep/shadow/')这个函数的关键在于,它会遍历指定prefix下的所有.jsonl文件,逐行读取、逐行解析,完美处理了 SageMaker 数据捕获的分片存储特性。接下来,进行数据清洗和对齐:
# 清洗生产数据 def clean_production_data(df): """ 从生产数据中提取关键字段。 """ df_clean = df.copy() # 提取 input 和 output df_clean['input'] = df_clean['captureData.endpointInput.data'].apply( lambda x: json.loads(x) if isinstance(x, str) else x ) df_clean['label'] = df_clean['captureData.endpointOutput.data'].apply( lambda x: json.loads(x)['label'] if isinstance(x, str) else None ) df_clean['prob_score'] = df_clean['captureData.endpointOutput.data'].apply( lambda x: float(json.loads(x)['prob_score']) if isinstance(x, str) else None ) df_clean['eventId'] = df_clean['eventMetadata.eventId'] return df_clean[['eventId', 'input', 'label', 'prob_score']] # 清洗影子数据 def clean_shadow_data(df): """ 从影子数据中提取关键字段,并过滤掉非影子来源的数据。 """ df_clean = df.copy() # 关键!只保留影子实验的数据 df_clean = df_clean[df_clean['eventMetadata.invocationSource'] == 'ShadowExperiment'] df_clean['label'] = df_clean['captureData.endpointOutput.data'].apply( lambda x: json.loads(x)['label'] if isinstance(x, str) else None ) df_clean['prob_score'] = df_clean['captureData.endpointOutput.data'].apply( lambda x: float(json.loads(x)['prob_score']) if isinstance(x, str) else None ) df_clean['eventId'] = df_clean['eventMetadata.eventId'] return df_clean[['eventId', 'label', 'prob_score']] # 执行清洗 prod_clean = clean_production_data(prod_df) shadow_clean = clean_shadow_data(shadow_df) # 基于 eventId 进行外连接(outer join),确保所有影子数据都有对应的生产数据 final_df = pd.merge(prod_clean, shadow_clean, on='eventId', how='outer', suffixes=('_prod', '_shadow'))这段代码的精妙之处在于how='outer'。它确保了即使某个eventId在生产数据中缺失(理论上不应该,但网络抖动或日志延迟可能导致),我们也能在最终的 DataFrame 中看到它,从而更容易发现数据捕获的异常。suffixes参数则清晰地标明了每个字段的来源。至此,你就拥有了一个结构化的、可用于深度分析的 DataFrame,每一行都代表一个被影子部署捕获的真实请求,以及它在新旧两个模型上的完整表现。
4. 深度分析与决策:如何从影子数据中提炼出“是否上线”的答案
4.1 构建多维度的模型性能对比仪表盘
仅仅比较prob_score的平均值是远远不够的。一个稳健的上线决策,需要一张覆盖多个维度的“性能仪表盘”。我通常会构建以下五个核心指标,并用 Seaborn 绘制成直观的图表。第一个是概率分数差异分布(Prob Score Diff Distribution)。这是最基础的指标,计算公式为prob_score_shadow - prob_score_prod。我习惯用sns.histplot()绘制直方图,并叠加一条垂直的x=0线。如果整个分布的峰值明显在x>0区域,且大部分数据点都在正半轴,这说明新模型在绝大多数样本上给出了更高的置信度,这是一个强烈的积极信号。但要注意,如果分布非常宽(标准差很大),则意味着新模型的表现不稳定,可能在某些子集上表现极好,在另一些子集上表现极差,这需要进一步下钻分析。第二个是标签一致性(Label Agreement Rate)。计算新旧模型输出相同标签的样本占比。final_df['label_prod'] == final_df['label_shadow']。这个比率应该非常高(>98%),如果低于 95%,就需要警惕。但这并不意味着新模型不好,它可能只是对一些模棱两可的样本做出了更合理的判断。这时,你需要结合第三个指标——分歧样本的深入分析。我通常会筛选出所有label_prod != label_shadow的行,然后人工检查这些输入文本。你会发现,这些文本往往是模型的“灰色地带”,比如:“The fire was contained, but the damage is severe.”(火灾已被控制,但损失严重。)这句话既包含了“fire”(火灾)这个灾难关键词,又包含了“contained”(已控制)这个非灾难性描述。老模型可能只看到了“fire”,判为“Real disaster”,而新模型可能综合了上下文,判为“Not a disaster”。这种分歧,恰恰是新模型“更聪明”的体现。第四个指标是关键业务指标的提升幅度(Business Metric Lift)。这需要你将模型输出映射到业务结果。例如,在我们的推特分类项目中,“Real disaster” 标签会触发一个紧急告警流程。那么,我们可以计算:新模型将多少条本应被忽略的“Real disaster”样本(老模型判为“Not a disaster”)正确识别了出来?这个数量就是“召回提升量”。同样,新模型又将多少条本不应告警的“Not a disaster”样本(老模型判为“Real disaster”)正确排除了?这个数量就是“误报降低量”。这两个数字,比任何学术指标都更能说服业务方。第五个指标是长尾场景覆盖率(Long-Tail Coverage)。将输入文本按长度、词频、特殊字符数量等维度分桶,然后计算每个桶内新旧模型的prob_score差异均值。你会发现,新模型可能在常规文本上提升不大,但在超长文本(>512 tokens)或包含大量 emoji 的文本上,提升非常明显。这正是影子部署揭示出的、离线评估永远无法发现的宝贵洞见。
4.2 用统计学方法验证提升的显著性
一个常见的误区是,看到新模型的平均prob_score提升了 0.02,就认为它“更好”。但这个提升是真实的,还是仅仅是随机波动?我们必须用统计学方法来验证。最常用、也最适合我们场景的方法是配对样本 t 检验(Paired Sample t-test)。因为我们对每一个eventId都有新旧两个模型的prob_score,这构成了完美的配对样本。其零假设(H0)是:新旧模型的平均概率分数没有差异(即mean(diff) = 0)。备择假设(H1)是:新模型的平均概率分数更高(即mean(diff) > 0)。Python 中,scipy.stats.ttest_rel函数可以一键完成:
from scipy import stats import numpy as np # 提取差异数组,过滤掉 NaN 值(可能因数据捕获失败导致) diff_array = final_df['prob_score_shadow'] - final_df['prob_score_prod'] diff_array = diff_array.dropna() # 执行单侧 t 检验 t_stat, p_value = stats.ttest_rel(final_df['prob_score_shadow'].dropna(), final_df['prob_score_prod'].dropna(), alternative='greater') print(f"t-statistic: {t_stat:.4f}") print(f"p-value: {p_value:.4f}") if p_value < 0.05: print("结论:在 95% 置信水平下,新模型的平均概率分数显著高于旧模型。") else: print("结论:没有足够证据表明新模型的平均概率分数显著高于旧模型。")p-value < 0.05是一个硬性门槛。如果 p 值大于 0.05,无论平均提升看起来多么诱人,我们都应该暂停上线,去检查模型、数据或分析过程。此外,我还会计算Cohen's d 效应量(Effect Size),它衡量提升的“实际大小”,而不受样本量影响。d = mean(diff) / std(diff)。d > 0.2为小效应,d > 0.5为中等效应,d > 0.8为大效应。一个p-value < 0.05但d = 0.1的结果,虽然统计显著,但业务价值可能微乎其微,不值得为此付出上线成本。
4.3 从影子部署到正式上线的平滑过渡
当所有分析都指向“新模型更优”时,下一步就是将影子变体“扶正”,成为新的生产变体。这个过程在 SageMaker 中被称为Promotion(晋升)。它的核心思想是:创建一个新的 Endpoint Config,其中ProductionVariants数组只包含那个曾经是影子的模型,并将InitialVariantWeight设为1。然后,用update_endpoint将这个新的配置应用到现有的 Endpoint 上。这个过程是无缝的、无停机的。Endpoint 的 DNS 名称和 URL 完全不变,所有客户端代码无需任何修改。但背后,SageMaker 会优雅地执行一个滚动更新:它会先启动新的、承载新模型的实例,待其健康检查通过后,再逐步将流量从旧的生产实例上切走。整个过程对用户完全透明。然而,这里有一个至关重要的实操心得:在执行 Promotion 之前,务必先删除旧的、不再需要的 Endpoint Config 和 Model。为什么?因为 SageMaker 的资源是有配额限制的。一个账户默认最多只能有 100 个 Endpoint Config。如果你每次迭代都创建新的 Config 却不清理旧的,很快就会达到配额上限,导致后续所有部署都失败。清理的顺序也很重要:先delete_endpoint_config,再delete_model。因为 Model 可能被多个 Config 引用,只有当没有任何 Config 引用它时,delete_model才会成功。我写了一个简单的清理函数,每次 Promotion 后都自动运行:
def cleanup_old_resources(old_epc_name, old_model_name_v1, old_model_name_v2): """ 清理旧的 Endpoint Config 和 Model。 """ try: sm_boto3.delete_endpoint_config(EndpointConfigName=old_epc_name) print(f"已删除 Endpoint Config: {old_epc_name}") except Exception as e: print(f"删除 Endpoint Config {old_epc_name} 失败: {e}") try: sm_boto3.delete_model(ModelName=old_model_name_v1) print(f"已删除 Model: {old_model_name_v1}") except Exception as e: print(f"删除 Model {old_model_name_v1} 失败: {e}") try: # 注意:v2 模型现在是新的生产模型,暂时不删 # sm_boto3.delete_model(ModelName=old_model_name_v2) pass except Exception as e: print(f"删除 Model {old_model_name_v2} 失败: {e}") # 在 Promotion 成功后调用 cleanup_old_resources( old_epc_name=tweet_cls_v1_epc_name, old_model_name_v1=tweet_cls_v1_model_name, old_model_name_v2=tweet_cls_v2_model_name )这个函数用try-except包裹,确保即使某个资源已被删除,也不会中断整个流程。记住,自动化清理不是锦上添花,而是保证你这个 MLOps 流水线能长期、健康运转的基石。
5. 常见问题与避坑指南:那些只有亲手踩过才知道的坑
5.1 “No data captured” —— 数据捕获失效的七种可能
这是影子部署中最令人抓狂的问题:Endpoint 配置明明启用了DataCaptureConfig,但等了半天,S3 目录下空空如也。根据我的经验,这个问题有七个最常见的根源,按排查优先级排序如下。第一,Endpoint 状态不是InService。这是最基础、也最容易被忽略的。请务必在控制台或通过describe_endpointAPI 确认 Endpoint 的Status字段是InService,而不是Creating、Updating或Failed。第二,DataCaptureConfig的EnableCapture设置为False。检查你创建 Endpoint Config 的代码,确认EnableCapture=True是布尔值True,而不是字符串"true"。第三,S3 目标路径的权限问题。你的 SageMaker 执行角色必须拥有对该 S3 路径的s3:PutObject权限。一个快速验证方法是:在 SageMaker Studio 的 Terminal 里,运行aws s3 cp /tmp/test.txt s3://your-bucket/your-path/,看是否能成功上传。第四,DestinationS3Uri的路径末尾没有斜杠/。SageMaker 会将这个 URI 视为一个“前缀”,并在其后自动添加日期、时间、Endpoint 名称等子路径。如果DestinationS3Uri写成了s3://my-bucket/data,它可能会尝试写入s3://my-bucket/data2023/10/27/...,而这个路径可能因权限不足而失败。正确的写法是s3://my-bucket/data/(末尾带/)。第五,CaptureOptions配置不全。必须同时包含'Input'和'Output'。如果只写了{'CaptureMode':'Input'},那么只有输入会被捕获,你将看不到任何模型输出,也就无法进行对比。第六,CaptureContentTypeHeader不匹配。如果你的请求Content-Type是application/json; charset=utf-8,而你在配置中只写了['application/json'],SageMaker 可能会因为不完全匹配而拒绝捕获。保险起见,可以写成['application/json', 'application/json; charset=utf-8']。第七,InitialSamplingPercentage设置过低。虽然你设了100,但如果在create_endpoint_config之后,又用update_endpoint_config修改了它,而新值是0,那自然就没有数据了。检查 CloudWatch Logs 中的SageMaker-Endpoint-Config-Update事件,确认这个值始终是100。
5.2 “Shadow variant not receiving traffic” —— 影子变体失联诊断
影子变体“失联”,指的是 Endpoint 运行正常,生产变体能正常响应,但 S3 中完全没有影子变体的数据。这通常意味着ShadowProductionVariants配置没有生效。首要排查点是create_endpoint_config的调用日志。检查ShadowProductionVariants参数是否被正确传入。一个致命的错误是,将ShadowProductionVariants错误地写在了ProductionVariants数组的内部,例如:
# ❌ 错误!ShadowProductionVariants 不是 ProductionVariants 的子项 ProductionVariants=[{ 'InstanceType': 'ml.m5.xlarge', 'InitialInstanceCount': 1, 'ModelName': 'prod-model', 'VariantName': 'production', 'InitialVariantWeight': 1, 'ShadowProductionVariants': [...] # 这是非法的! }]正确的结构是,ShadowProductionVariants和ProductionVariants是同级的、并列的参数。第二个排查点是InitialVariantWeight的值。如果它被设为0,影子变体当然不会收到任何流量。第三个,也是最隐蔽的