Serverless部署机器学习模型:Azure Functions实战指南

📅 2026/7/14 11:43:11
Serverless部署机器学习模型:Azure Functions实战指南
1. 为什么把机器学习模型塞进 Serverless API 是个务实选择我带过六七个从零起步的数据科学团队几乎每个团队都经历过这样的尴尬时刻模型在 Jupyter Notebook 里跑得飞起AUC 0.92特征重要性图漂亮得能当屏保可一到业务方问“这个模型怎么嵌进我们订单系统里”整个团队就陷入沉默。不是模型不行是交付形态卡住了——它还躺在本地环境里像一把没装进枪套的刀再锋利也派不上用场。Azure Functions 就是那个现成的、轻量级的、按需启停的枪套。它不让你操心服务器配置、负载均衡、自动扩缩容这些事你只管把训练好的模型逻辑写清楚HTTP 请求一来它就拉起一个干净的运行时跑完立刻释放资源。这不是什么高大上的架构玄学而是我在给三家零售客户做 RFM 客户分群项目时反复验证过的路径用 Azure Functions 包一层 K-means 模型API 响应时间稳定在 800ms 内月度计算成本比维持一台常驻的 t3.medium EC2 实例低 67%。关键词Azure在这里不是云厂商的广告位而是指代一套被充分验证的、开箱即用的基础设施抽象层——它把数据库连接、日志埋点、密钥管理、HTTPS 终止这些琐碎但致命的环节全打包成os.environ.get(DB_CONNECTION_STRING)这样一行代码就能调用的服务。你不需要成为 Azure 专家但得明白Serverless 的核心价值不在“无服务器”这个噱头而在于把数据科学家最不擅长的工程化负担替换成他们最熟悉的 Python 函数签名def main(req: func.HttpRequest) - func.HttpResponse。这背后是认知负荷的转移从“怎么让服务器不死”变成“怎么让函数返回正确的 JSON”。对中小团队而言这省下的不是几小时运维时间而是避免了因环境不一致导致的线上模型预测漂移——我亲眼见过一个团队因为测试环境用的是 pandas 1.3.5生产环境误装了 1.5.0pd.read_sql的parse_dates行为差异让整个月度客户分群结果错乱了 12%。2. 核心设计思路与方案选型背后的硬逻辑2.1 为什么选 Azure Functions 而非其他 Serverless 平台很多人看到“Serverless”第一反应是 AWS Lambda但在我实际落地的 11 个生产级 ML API 项目中Azure Functions 的胜出不是偶然。关键在三个实操痛点的解决上冷启动可控性、依赖包管理成熟度、以及与企业现有数据栈的咬合度。先说冷启动——Lambda 在首次调用时可能卡顿 3-5 秒这对实时推荐类 API 是灾难。Azure Functions 提供了“预热实例”Always On选项虽然会增加基础费用但实测下来将冷启动从平均 2.8 秒压到 0.3 秒以内且这个功能在 Portal 界面里勾选即可不用改一行代码。再看依赖包K-means 看似简单但scikit-learn依赖numpy、scipy而scipy编译又牵扯到 BLAS 库版本。AWS Lambda 的自定义运行时需要你手动打包.so文件稍有不慎就报ImportError: libopenblas.so.0: cannot open shared object file。Azure Functions 则直接支持requirements.txt它会在部署时自动解析依赖树用pip install --target ./bin安装到函数目录连wheel编译失败的兜底方案如--find-links https://download.pytorch.org/whl/torch_stable.html都预留了配置入口。最后是数据栈咬合客户用的是 Azure SQL Database 或 Synapse AnalyticsAzure Functions 的托管身份Managed Identity能直接授权访问完全绕过密码明文存储的风险。我试过用 Lambda 访问同一家客户的 Azure SQL最终不得不妥协于在 Secrets Manager 里存凭证而 Secrets Manager 的轮换策略又和 Azure AD 的权限生命周期不同步导致凌晨三点收到告警邮件。所以选 Azure Functions不是因为它是微软亲儿子而是因为它把数据科学家最怕的“环境地狱”问题用平台能力封进了黑盒。2.2 为什么坚持模块化设计而非单文件巨兽原文中把get_data.py、preprocess.py、train.py、app.py拆成四个文件有人觉得多此一举。但我在重构一个金融风控模型 API 时就栽在这上面最初所有逻辑堆在__init__.py里2000 行代码每次改一个特征工程逻辑都要重新部署整个函数CI/CD 流水线跑满 8 分钟。后来拆成模块后preprocess.py单元测试可以独立运行用pytest tests/test_preprocess.py12 秒内完成错误定位精准到ValueError: Tenure column contains negative values。模块化不是为了炫技而是为了隔离变更影响域。比如客户突然要求把MonetaryValue的 log 变换改成np.log1p处理零值你只需要改preprocess.py里的两行代码train.py和get_data.py完全不动。更关键的是调试效率本地测试时我可以单独运行python -m preprocess test_data.csv把原始 CSV 文件喂进去直接在终端看到Tenure列是否正确生成而不是在 HTTP 响应里扒拉 JSON 字段。这种“可插拔”的设计让每个模块都能成为独立的质量门禁——get_data.py的单元测试必须覆盖数据库连接超时、空结果集、字段缺失三种异常preprocess.py的测试必须校验Tenure计算精度毫秒级时间差是否四舍五入到天train.py的测试则要断言kmeans_pipe.predict()返回的标签数严格等于n_clusters4。当所有模块的单元测试覆盖率超过 85%整个函数的稳定性才真正有了根基。否则所谓“Serverless”只是把单点故障从服务器转移到了函数实例上。2.3 为什么用 HTTP 触发器而非 Blob 或 Queue 触发器原文选择了 HTTP Trigger这看似最直白的选择但背后有明确的业务语义。RFM 分群不是后台批处理任务而是按需触发的决策支持服务。市场部同事在 Dashboard 上点“刷新客户分群”前端发一个 GET 请求到/api/CusClusterCRM 系统在新客户注册后调用 POST 接口传入{customer_id: CUST-789}获取其初始分群标签。这种“请求-响应”模式天然匹配 HTTP。如果换成 Blob Trigger监听 Azure Storage 中新上传的 CSV就会引入不必要的异步复杂度谁负责上传文件上传失败如何重试文件格式错误怎么通知业务方我见过一个团队用 Blob Trigger 做模型更新结果因为上传脚本没加--fail-fast参数一个损坏的 CSV 把整个分群流水线卡死三天。HTTP Trigger 的优势在于端到端可观测性Azure Monitor 里能直接看到每个请求的耗时、状态码、入参开启 Application Insights 后甚至能下钻到get_data.py里pd.read_sql的执行时间。当某次请求耗时飙升到 5 秒我打开 Log Analytics输入traces | where message contains Connection success立刻发现是数据库连接池耗尽——因为get_connection()每次都新建引擎没复用。这种问题在异步触发器里会被层层掩盖。所以别被“Serverless 支持多种触发器”的宣传迷惑选触发器的本质是选业务契约HTTP “我现在就要结果”Blob “等我准备好数据再处理”你的模型服务到底属于哪一种3. 核心细节解析与实操要点3.1 数据库连接的安全实践告别明文密码原文中app.py直接从db.cfg读取密码这在本地开发没问题但一旦部署到 Azure就是严重安全隐患。Azure Functions 提供了两种更安全的方案我强烈推荐后者方案一应用设置App Settings 环境变量推荐在 Azure Portal 的 Function App 设置里添加应用设置DB_USER prod_reader DB_PASSWORD SecureString DB_HOST my-sql-server.database.windows.net DB_PORT 1433 DB_NAME retail_db注意DB_PASSWORD的值不要填真实密码而是用 Azure Key Vault 的机密 URI格式为Microsoft.KeyVault(SecretUrihttps://mykeyvault.vault.azure.net/secrets/db-password/xxxxx)。然后在代码里这样用import os from sqlalchemy import create_engine def get_connection(): user os.environ.get(DB_USER) password os.environ.get(DB_PASSWORD) # Azure 自动解密 host os.environ.get(DB_HOST) port os.environ.get(DB_PORT) database os.environ.get(DB_NAME) return create_engine(fmysqlpymysql://{user}:{password}{host}:{port}/{database})这样做的好处是密码永远不会出现在代码仓库或部署包里Key Vault 的访问权限可以精细控制到具体函数应用且密码轮换只需在 Key Vault 里操作函数代码零修改。方案二托管身份Managed Identity——终极方案如果数据库是 Azure SQL直接禁用密码认证启用 Azure AD 集成。在 Function App 的“标识”设置里开启系统分配的托管身份然后在 Azure SQL 中执行CREATE USER [my-function-app] FROM EXTERNAL PROVIDER; ALTER ROLE db_datareader ADD MEMBER [my-function-app];连接字符串变成from azure.identity import DefaultAzureCredential from sqlalchemy import create_engine def get_connection(): credential DefaultAzureCredential() token credential.get_token(https://database.windows.net/.default).token return create_engine( fmssqlpyodbc:///?odbc_connect fDRIVER{{ODBC Driver 17 for SQL Server}}; fSERVER{host};DATABASE{database}; fAuthenticationActiveDirectoryAccessToken; fAccessToken{token} )这种方式连密码概念都不存在彻底杜绝泄露风险。我在一个医疗客户项目中强制推行此方案审计时直接通过了 HIPAA 合规检查。3.2 特征工程中的时间陷阱datetime.now()的坑原文preprocess.py里这行代码很危险data[Tenure] (datetime.now() - data[Customer_Activation_date]).dt.days问题在于datetime.now()返回的是函数执行时的本地时间而 Azure Functions 的运行时区域Region可能和你的业务时区不一致。比如函数部署在East US但客户数据是Asia/Shanghai时区datetime.now()返回的是美国东部时间计算出的Tenure会少 13 小时导致刚激活的客户Tenure为 0 天而实际上已过 13 小时。正确做法是显式指定时区from datetime import datetime import pytz def pre_process(data): # 明确指定业务时区 tz pytz.timezone(Asia/Shanghai) now datetime.now(tz) # 确保 Customer_Activation_date 也是同一时区 if data[Customer_Activation_date].dt.tz is None: data[Customer_Activation_date] data[Customer_Activation_date].dt.tz_localize(tz) else: data[Customer_Activation_date] data[Customer_Activation_date].dt.tz_convert(tz) data[Tenure] (now - data[Customer_Activation_date]).dt.days # ... 其余逻辑更进一步Tenure应该用pd.Timedelta计算避免跨日历月份的误差。比如 1 月 31 日到 2 月 28 日简单减法可能算成 28 天但实际是 28 天闰年或 27 天平年。用pd.Timedelta能保证精度data[Tenure] (now - data[Customer_Activation_date]).dt.total_seconds() // 864003.3 模型训练的确定性保障optimal_init的持久化原文把optimal_init数组硬编码在app.py里这在快速原型阶段可行但生产环境必须升级。K-means 的init参数决定了聚类中心的初始位置直接影响最终结果。如果每次部署都用新生成的随机种子客户分群结果会漂移业务方无法建立稳定预期。我的做法是训练时保存最优 init在离线训练脚本中用KMeans(n_init100).fit(X)找出inertia_最小的那次初始化并将kmeans.cluster_centers_保存为optimal_init.npy。部署时加载在train.py开头加入import numpy as np def load_optimal_init(): try: # 优先从 Azure Blob Storage 加载 from azure.storage.blob import BlobServiceClient blob_service BlobServiceClient.from_connection_string( os.environ[STORAGE_CONNECTION_STRING] ) blob_client blob_service.get_blob_client( containerml-models, blobkmeans/optimal_init.npy ) init_bytes blob_client.download_blob().readall() return np.load(io.BytesIO(init_bytes)) except: # 降级到本地文件 return np.load(optimal_init.npy)版本控制optimal_init.npy文件名带上时间戳如optimal_init_20230801.npy并在应用设置里配置OPTIMAL_INIT_VERSION 20230801。这样模型迭代时旧版本 API 仍可用旧 init新版本 API 用新 init实现灰度发布。4. 实操过程与核心环节实现4.1 本地开发环境搭建VS Code Azure Functions Extension 的避坑指南安装 Azure Functions Extension 后创建新函数的流程看似简单但有三个极易踩的坑坑一Python 解释器选择VS Code 提示选择 Interpreter 时千万别选系统自带的/usr/bin/python3。Azure Functions 运行时要求 Python 3.8 或 3.9截至 2023 年而 macOS 自带的是 3.9Ubuntu 22.04 是 3.10都会报错Runtime version 3.10 is not supported。正确做法是用pyenv安装指定版本pyenv install 3.9.16 pyenv local 3.9.16然后在 VS Code 的 Interpreter 选择里找到~/.pyenv/versions/3.9.16/bin/python。这样创建的虚拟环境才是兼容的。坑二func host start启动失败常见报错Could not find a version that satisfies the requirement azure-functions这是因为 VS Code 创建的项目默认用pip安装依赖但 Azure Functions Core Tools 要求azure-functions必须是4.0.0,5.0.0。解决方案是在项目根目录创建requirements-dev.txtazure-functions4.12.0 azure-storage-blob12.18.0 pymysql1.0.2然后执行pip install -r requirements-dev.txt func host start坑三本地测试数据库连接get_data.py里的pd.read_sql在本地跑不通因为db.cfg里的HOST是内网地址。我的做法是在app.py顶部加一个开关import os IS_LOCAL os.environ.get(IS_LOCAL, false).lower() true if IS_LOCAL: # 本地用 SQLite 模拟 import sqlite3 def get_connection(): conn sqlite3.connect(test_rfm.db) # 创建测试表并插入模拟数据 conn.execute( CREATE TABLE IF NOT EXISTS RFM_table ( Customer_id TEXT, Recency INTEGER, Frequency INTEGER, MonetaryValue REAL, Customer_Activation_date TEXT ) ) return conn else: # 生产用 Azure SQL def get_connection(): # 原有逻辑然后在终端启动时export IS_LOCALtrue func host start这样本地开发完全脱离生产数据库测试速度提升 10 倍。4.2 函数代码改造从脚本到 HTTP API 的三步手术将app.py改造成 Azure Function 的__init__.py不是简单替换函数签名而是三步深度重构第一步HTTP 请求解析与参数校验原文main()函数没有输入但 HTTP API 必须处理请求体。我增加了对 GET/POST 的兼容import json import logging import azure.functions as func def main(req: func.HttpRequest) - func.HttpResponse: logging.info(CusCluster function processed a request.) # 解析请求参数 customer_id req.params.get(customer_id) # GET 参数 if not customer_id: try: req_body req.get_json() customer_id req_body.get(customer_id) # POST JSON except ValueError: pass # 强制校验 if not customer_id or not isinstance(customer_id, str): return func.HttpResponse( json.dumps({error: Missing or invalid customer_id}), status_code400, mimetypeapplication/json ) # ... 后续逻辑第二步模型输出的健壮序列化原文RFM.to_json()会把datetime类型转成 ISO 字符串但cluster_labels是 int64JSON 不认。必须显式转换# 在 train.py 的 train() 函数末尾 def train(data, optimal_init): # ... 原有训练逻辑 data[cluster_labels] kmeans_pipe.predict(data) # 关键确保所有列都是 JSON 友好类型 for col in data.columns: if data[col].dtype int64: data[col] data[col].astype(int) elif data[col].dtype float64: data[col] data[col].astype(float) elif data[col].dtype datetime64[ns]: data[col] data[col].dt.strftime(%Y-%m-%d %H:%M:%S) return data # 在 __init__.py 里 resp RFM.to_json(orientrecords, date_formatiso, date_units) return func.HttpResponse( resp, status_code200, mimetypeapplication/json )第三步错误处理的分级响应不能让任何异常穿透到 HTTP 层。我建立了三级错误捕获try: RFM get_data(engine) except Exception as e: logging.error(fDatabase error: {str(e)}) return func.HttpResponse( json.dumps({error: Database connection failed}), status_code503, mimetypeapplication/json ) try: RFM pre_process(RFM) except ValueError as e: logging.error(fPreprocessing error: {str(e)}) return func.HttpResponse( json.dumps({error: Invalid data format}), status_code400, mimetypeapplication/json ) try: RFM train(RFM, optimal_init) except Exception as e: logging.error(fTraining error: {str(e)}) return func.HttpResponse( json.dumps({error: Model training failed}), status_code500, mimetypeapplication/json )这样业务方能根据状态码精准判断问题根源而不是收到一个笼统的 500。4.3 部署全流程从func deploy到生产监控部署不是点一下“Deploy”按钮就完事而是包含五个关键动作动作一生成精准的requirements.txt在激活的虚拟环境中不要用pip freeze requirements.txt它会把azure-functions-core-tools这类开发依赖也写进去。正确命令是pipreqs . --force --ignore venv,testspipreqs会静态分析代码中的import只列出真正用到的包且自动加上版本号如pandas1.5.3。动作二配置部署时的环境变量在local.settings.json里写的配置不会自动同步到 Azure。必须在 Portal 的 Function App → “配置” → “应用程序设置”里手动添加。特别注意WEBSITE_RUN_FROM_PACKAGE 1启用从 ZIP 包运行提升启动速度。PYTHONPATH /home/site/wwwroot确保模块导入路径正确。SCM_DO_BUILD_DURING_DEPLOYMENT true让 Kudu 构建引擎在部署时自动运行pip install。动作三部署命令的黄金组合VS Code 的图形化部署有时会失败我坚持用 CLI# 登录 Azure az login # 设置订阅 az account set --subscription My Production Subscription # 部署指定资源组、函数应用名、存储账户 func azure functionapp publish my-function-app \ --resource-group my-rg \ --storage-account mystorage \ --build-native-deps--build-native-deps参数至关重要它会在 Linux 容器里编译numpy、scipy的 C 扩展避免运行时报ImportError: libgfortran.so.5: cannot open shared object file。动作四部署后验证部署成功不等于 API 可用。必须立即验证在 Portal 的 Function App → “函数”里点击你的函数看“代码 测试”页签是否显示Function app is running。点击“获取函数 URL”复制链接在 Postman 里发 GET 请求检查响应体是否为有效 JSON。查看“监视” → “日志流”确认没有ModuleNotFoundError或ConnectionRefusedError。动作五启用 Application Insights在 Function App → “监视” → “Application Insights”里启用。然后在代码里加一行import logging import azure.functions as func def main(req: func.HttpRequest) - func.HttpResponse: # 这行会自动上报到 App Insights logging.info(fProcessing customer_id: {customer_id}) # ... 其余逻辑在 App Insights 的 Logs 里可以查requests | where timestamp ago(1h) | project timestamp, name, resultCode, duration, customDimensions | order by timestamp desc一眼看出哪个请求慢、哪个失败。5. 常见问题与排查技巧实录5.1 冷启动耗时过长从 3.2 秒降到 0.4 秒的实操记录现象首次调用 API响应时间 3200ms后续调用降至 800ms。业务方投诉“第一次用太慢”。排查路径在 Application Insights 的dependencies表里查dependencies | where type SQL and timestamp ago(1h) | project timestamp, name, duration, resultCode | order by duration desc发现get_data.py的pd.read_sql耗时 2100ms远超正常值应 300ms。检查数据库连接字符串发现?connect_timeout30缺失默认是 15 秒但网络抖动时会重试。在get_connection()里显式加超时from sqlalchemy import create_engine engine create_engine( fmysqlpymysql://{user}:{password}{host}:{port}/{database}, connect_args{connect_timeout: 5} )更关键的是发现get_data.py每次都新建连接没复用。改为连接池from sqlalchemy import create_engine, text from sqlalchemy.pool import QueuePool engine create_engine( fmysqlpymysql://{user}:{password}{host}:{port}/{database}, poolclassQueuePool, pool_size5, max_overflow10, pool_timeout30, pool_recycle3600 )效果冷启动耗时从 3200ms 降至 420ms其中数据库连接从 2100ms 降至 180ms。根本原因是连接池复用了 TCP 连接避免了三次握手开销。5.2 模型预测结果不一致n_init1的隐藏陷阱现象同一customer_id连续两次调用 API返回的cluster_labels不同如第一次是 2第二次是 3。排查路径在train.py的train()函数里加日志logging.info(foptimal_init shape: {optimal_init.shape}) logging.info(foptimal_init first row: {optimal_init[0]})发现日志里optimal_init的值每次都不一样。检查app.py发现optimal_init是在if __name__ __main__:块里定义的但 Azure Functions 的main()函数每次调用都会重新导入模块导致optimal_init被重复初始化。正确做法是把optimal_init提到模块顶层且用lru_cache缓存from functools import lru_cache import numpy as np lru_cache(maxsize1) def get_optimal_init(): return np.array([[0.53110261, -1.65631567, -0.46662182, -0.36120566], [0.36156456, 0.35716547, -0.50222171, -0.47586815], [-0.09470791, 0.41834683, 1.39116768, 1.17794181], [-1.66140645, 0.42296787, -0.10251636, 0.02357708]])效果cluster_labels100% 一致。lru_cache确保get_optimal_init()只执行一次后续调用直接返回缓存值。5.3 依赖包冲突scipy版本引发的ImportError现象部署后函数报错ImportError: libgfortran.so.5: cannot open shared object file。排查路径在本地用func azure functionapp publish部署时加--build-native-deps参数但依然失败。登录到 Azure Function 的 Kudu 控制台https://function-app-name.scm.azurewebsites.net/DebugConsole进入site/wwwroot目录执行ls -la /home/site/wwwroot/.python_packages/lib/site-packages/scipy/发现scipy目录下没有libgfortran.so.5。查requirements.txt发现scipy1.10.1而 Azure Functions 的 Python 3.9 运行时预装的是scipy1.9.3版本不兼容。解决方案方案 A推荐降级scipy到1.9.3在requirements.txt里写死scipy1.9.3 numpy1.23.5方案 B用--no-cache-dir强制重新编译func azure functionapp publish my-function-app --no-cache-dir效果方案 A 成功率 100%部署时间缩短 2 分钟因为跳过了scipy的编译步骤。5.4 生产环境监控用 Log Analytics 定制告警需求当 API 错误率超过 5% 时自动发邮件给值班工程师。实操步骤在 Azure Monitor → “日志”里创建新查询requests | where timestamp ago(5m) | where resultCode startswith 5 | summarize failCount count(), totalCount count() by bin(timestamp, 1m) | extend errorRate (failCount * 100.0) / totalCount | where errorRate 5点击“新建警报规则”设置条件当查询结果 0时触发严重性严重操作组选择已配置的邮件通知组在函数代码里主动打点关键指标import logging from opencensus.ext.azure.log_exporter import AzureLogHandler logger logging.getLogger(__name__) logger.addHandler(AzureLogHandler( connection_stringInstrumentationKeyxxxxx )) def main(req: func.HttpRequest) - func.HttpResponse: start_time time.time() # ... 业务逻辑 duration_ms (time.time() - start_time) * 1000 logger.info(fCluster prediction completed, extra{ custom_dimensions: { duration_ms: duration_ms, customer_id: customer_id } })效果告警延迟 2 分钟比传统基于日志文件的监控快 10 倍。工程师手机收到邮件时问题还在发生中能立刻介入。6. 模型评估与持续迭代让 Serverless API 活起来6.1 在线评估把Assess.py改造成健康检查端点原文的Assess.py是离线脚本但生产环境需要实时健康度反馈。我把它改造成一个/api/health端点# __init__.py 里新增 health 函数 def health(req: func.HttpRequest) - func.HttpResponse: try: # 1. 检查数据库连通性 engine get_connection() with engine.connect() as conn: conn.execute(text(SELECT 1)) # 2. 检查模型加载 optimal_init get_optimal_init() assert optimal_init.shape (4, 4) # 3. 抽样评估用最近 100 条数据 RFM get_data(engine).tail(100) RFM pre_process(RFM) RFM train(RFM, optimal_init) # 计算轮廓系数Silhouette Score from sklearn.metrics import silhouette_score score silhouette_score( RFM[[Recency, Frequency, MonetaryValue, Tenure]], RFM[cluster_labels] ) return func.HttpResponse( json.dumps({ status: healthy, silhouette_score: round(score, 3), sample_size: len(RFM) }), status_code200, mimetypeapplication/json ) except Exception as e: return func.HttpResponse( json.dumps({status: unhealthy, error: str(e)}), status_code503, mimetypeapplication/json )这个端点被 Prometheus 抓取当silhouette_score 0.3时触发告警提示模型可能需要重新训练。6.2 自动化再训练用 Timer Trigger 触发周期性更新HTTP Trigger 是按需的但模型需要定期更新。我添加了一个 Timer Trigger 函数import datetime import logging import azure.functions as func from train import train from get_data import get_data from preprocess import pre_process from app import get_connection, get_optimal_init def main(mytimer: func.TimerRequest) - None: utc_timestamp datetime.datetime.utcnow().replace( tzinfodatetime.timezone.utc).isoformat() if mytimer.past_due: logging.info(The timer is past due!) logging.info(Python timer trigger function ran at %s, utc_timestamp) try: engine get_connection() RFM get_data(engine) RFM pre_process(RFM) # 用新数据重新训练但保持 init 不变 RFM train(RFM, get_optimal_init()) # 将新模型保存回 Blob Storage from azure.storage.blob import BlobServiceClient import io import joblib model_bytes io.BytesIO() joblib.dump(RFM, model_bytes) model_bytes.seek(0) blob_service BlobServiceClient.from_connection_string( os.environ[STORAGE_CONNECTION_STRING] ) blob_client blob_service.get_blob_client( containerml-models, blobkmeans/latest.pkl ) blob_client.upload_blob(model_bytes, overwriteTrue) logging.info(Model retrained and saved successfully) except Exception as e: logging.error(fRetraining failed: {e})在function.json里配置 cron 表达式0 0 2 * * *