Solo Practitioner的轻量MLOps实战:单人如何72分钟交付生产级ML服务
1. 项目概述当机器学习变成单兵作战的野外生存“Building ML in the Dark”这个标题一出来我就在咖啡馆里笑了——不是因为好笑而是太真实。过去八年我带过二十多个企业级AI落地项目也亲手从零搭过七套独立部署的推理服务但最耗神、最磨人的从来不是模型精度掉0.3%而是那个没人提、文档不写、教程跳过的环节你一个人没MLOps平台没数据工程师轮岗支持没SRE兜底连GPU显存告警都得自己写脚本盯你得同时是数据清洗工、特征工程师、调参手、模型打包员、API部署者、日志排查员还得给业务方讲清楚为什么AUC涨了却线上效果没变。这就是“Solo Practitioner”的日常没有探照灯只有手电筒没有指挥中心只有你和一台带32G内存的笔记本以及一个正在超时的生产任务。这本书名里的“Dark”不是指技术黑箱而是指资源可见性黑洞——你不知道下一条数据流会不会卡在Kafka分区偏移量上不清楚特征缓存是否因pickle版本不一致而静默失效更没法预判用户上传的Excel里第17列突然多出一个空格导致整个pipeline崩在pandas.read_excel()那行。它解决的不是“怎么建模”而是“怎么活下来”。核心关键词——Solo Practitioner、ML Survival、No-Infra ML、Lightweight MLOps、Production Readiness on One Person——全部指向一个现实90%的中小团队、独立顾问、早期AI产品负责人根本用不起SageMaker Pipelines或Vertex AI Workbench。他们需要的不是架构图是一份能塞进Notion页面、打印出来贴在显示器边框、随时能抄起就用的检查清单。适合谁如果你符合以下任意一条这篇就是为你写的正在用Flaskjoblib把训练好的XGBoost模型跑成API但每次更新模型都要手动改路径、重启进程、祈祷依赖不冲突在Jupyter里调完参导出.onnx文件后发现ONNX Runtime版本和训练环境不兼容报错信息只显示“Invalid node”查了三小时才发现是Scikit-learn版本差了0.0.2被业务方问“这个模型明天能上线吗”你心里想的是“我连测试数据集都没法自动拉取全靠昨天导出的CSV手动拖进去”看到“MLOps”三个字就头皮发紧因为你知道自己连CI/CD流水线的YAML语法都还没背熟。这不是教你怎么成为AI科学家而是教你如何成为一个能交付结果的ML手艺人——工具要轻流程要短失败要快恢复要秒。下面所有内容都来自我在电商推荐、工业设备预测性维护、医疗文本分类三个领域踩出的坑以及帮14个客户重建单人ML工作流的真实记录。2. 整体设计思路为什么拒绝“标准MLOps”选择“生存式架构”2.1 核心矛盾企业级方案 vs 单人带宽先说结论所有标榜“开箱即用”的MLOps平台在Solo Practitioner场景下第一周就会变成负资产。这不是技术问题是带宽问题。我做过测算一个典型中等复杂度项目比如用LSTM做时序故障预测如果采用MLflow Airflow Docker Kubernetes这套组合光是搭建、调试、权限配置、监控埋点保守估计要投入62小时。而同一项目用我下面要讲的“生存式架构”从零到可部署API实测最快纪录是4小时17分钟含数据清洗和本地验证。关键差距在哪不在功能多寡而在决策链长度。企业级方案默认你有三人小组一人管infra一人管data pipeline一人管model registry。每个组件都假设上游会提供稳定输入、下游会处理异常输出。但Solo Practitioner的决策链是我看到数据异常 → 我定位到是Kafka消费者offset lag → 我登录服务器查consumer group状态 → 我发现是磁盘满 → 我清理日志 → 我重启consumer → 我验证消息消费速度 → 我确认feature store缓存更新 → 我触发模型重训 → 我打包新镜像 → 我推送到registry → 我滚动更新deployment → 我查Prometheus看latency p95是否回归 → 我发Slack通知业务方。这12步里任何一步卡住整个链条就断。而标准MLOps平台把这12步拆成5个系统、7个配置文件、3个权限组等于把单点故障放大成系统性雪崩。所以我的设计哲学第一条一切以缩短单点决策半径为最高优先级。这意味着主动放弃集中式元数据管理用GitYAML替代异步任务调度用cronshell wrapper替代Airflow容器化部署用uvicornsystemd替代DockerK8s模型版本强一致性接受“模型文件requirements.txtgit commit hash”三元组作为事实来源。提示不要被“最佳实践”绑架。所谓最佳是针对特定约束条件的解。当你的约束是“每天只有2小时可用时间”“不能申请云账号”“服务器root权限需走OA审批”那么“最佳”就该是“最短路径存活”。2.2 架构选型逻辑轻量不等于简陋而是精准减负很多人误解“轻量”就是“随便搞”。恰恰相反生存式架构对每个组件的选择都极其苛刻——它必须同时满足四个条件零外部依赖不依赖云服务、不依赖私有仓库、不依赖认证中心单文件可启动python serve.py就能跑通全流程无需docker-compose up或kubectl apply错误自解释报错信息直接指出问题根源如“找不到feature_config_v3.yaml”比“Connection refused”有用一万倍回滚成本≈0切换回上一版模型只需改一行代码或一个环境变量。基于此我最终锁定的技术栈是数据层SQLite Pandas非PostgreSQL/MySQL理由SQLite是单文件数据库.db文件可直接Git托管Pandas的read_sql和to_sql对SQLite支持完美且无连接池、无用户权限、无网络开销。我试过用PostgreSQL结果光是配置pg_hba.conf和创建专用用户就花了1.5小时而SQLite只需pip install pysqlite3然后pd.read_sql(SELECT * FROM features, sqlite:///data.db)——干净利落。模型层Joblib ONNX非MLflow Model Registry理由Joblib序列化保留Python对象完整结构比Pickle更安全ONNX提供跨框架推理能力避免“训练用PyTorch、部署用TensorFlow”这种经典陷阱。重点在于模型文件本身即部署单元。model_v20240515.onnx这个文件名就包含了版本、日期、格式三重信息比MLflow的runs:/a1b2c3d4/model这种UUID友好一万倍。服务层Uvicorn FastAPI非Flask Gunicorn理由FastAPI原生支持Pydantic校验输入数据格式错误直接返回HTTP 422并说明哪一列类型不对Uvicorn是ASGI服务器单进程即可处理并发请求无需Gunicorn多worker管理。实测在Ryzen 5 5600H上Uvicorn单进程QPS达327完全覆盖中小业务需求。编排层Shell脚本 Git Hooks非Airflow/Cronitor理由./deploy.sh model_v20240515.onnx这个命令比写DAG YAML、配Celery broker、调debug模式简单太多。Git Hooks在pre-commit阶段自动运行数据校验脚本比等CI流水线跑完再失败更早发现问题。这套组合不是拼凑而是环环相扣SQLite的.db文件可直接被Joblib读取特征ONNX模型由FastAPI原生加载Shell脚本统一管理所有路径和版本号。所有组件之间没有抽象层只有明确的文件路径和函数调用——这正是Solo Practitioner最需要的确定性。2.3 关键取舍放弃什么才能守住什么任何架构都是取舍的艺术。生存式架构明确放弃的三件事恰恰定义了它的价值边界放弃实时数据流处理不接入Kafka/Pulsar改用“定时拉取增量更新”模式。例如每小时执行python fetch_new_data.py --since-last-run从API拉取新增订单写入SQLite的raw_orders表。理由Kafka运维成本远超其收益而小时级延迟对90%业务场景可接受。我服务过一家医疗器械公司他们的“实时报警”实际要求是“15分钟内响应”用cron每10分钟跑一次完全达标。放弃模型漂移自动告警不部署Evidently或Arize改用“人工基线比对”。每次新模型上线前强制运行python validate_model.py --baseline v20240401 --candidate v20240515输出精确到小数点后四位的指标对比表AUC、F1、推理延迟。理由自动告警需要持续监控、阈值调优、误报排查而人工比对只需5分钟且能结合业务上下文判断“AUC降0.002是否真有问题”。放弃A/B测试分流能力不集成Optimizely或LaunchDarkly改用“灰度发布HTTP Header路由”。FastAPI中间件检查请求头X-Model-Version: v20240515匹配则走新模型否则走旧模型。业务方通过curl加header测试开发通过Nginx配置按IP段分流。理由A/B测试平台的学习成本和配置复杂度远高于手动header控制且无法解决“新旧模型输入数据分布不一致”这个根本问题。这些放弃不是妥协而是战略聚焦。当你把精力从“如何让系统更智能”转向“如何让自己少犯错”你就真正进入了生存模式。3. 核心细节解析五个必须死磕的实操锚点3.1 锚点一数据版本控制——不用DVC用GitSQLite Schema很多Solo Practitioner栽在第一个坑数据变更不可追溯。今天训练用的train.csv明天就被人覆盖了而你根本不知道是谁、什么时候、为什么改的。DVCData Version Control理论上能解决但实测下来它的.dvc文件管理、远程存储配置、dvc pull失败重试机制对单人项目是灾难。我的方案是把数据结构而非数据本身纳入Git用SQLite Schema定义事实。具体操作创建schema.sql文件定义核心表结构-- schema.sql CREATE TABLE IF NOT EXISTS features ( id INTEGER PRIMARY KEY, order_id TEXT NOT NULL, customer_age INTEGER, total_amount REAL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS labels ( id INTEGER PRIMARY KEY, order_id TEXT NOT NULL, is_fraud BOOLEAN, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );每次数据变更如新增customer_tenure_months字段必须修改schema.sql添加ALTER TABLE features ADD COLUMN customer_tenure_months INTEGER;提交Git附注feat(data): add tenure column for fraud model v3运行python migrate_db.py脚本内容见下文。migrate_db.py的核心逻辑极简import sqlite3 from pathlib import Path def migrate(): conn sqlite3.connect(data.db) with open(schema.sql) as f: schema f.read() # 执行所有CREATE TABLE和ALTER TABLE语句 for stmt in schema.split(;): if stmt.strip(): conn.execute(stmt) conn.commit() conn.close()这个方案的优势在于Schema即文档任何人看schema.sql5秒内知道当前数据有哪些字段、类型、约束迁移即代码ALTER TABLE语句本身就是可执行、可回滚、可测试的零额外工具不需要安装DVC、不需要配置S3Git就是你的数据版本库。注意绝对禁止直接在SQLite中执行ALTER TABLE而不更新schema.sql我见过三次因此导致模型训练和推理使用不同字段报错信息是sqlite3.OperationalError: no such column: customer_tenure_months但排查花了47分钟——因为开发者以为数据文件没更新实际是schema没同步。3.2 锚点二模型序列化——Joblib不是万能ONNX才是生存底线Joblib常被推荐为Scikit-learn模型的首选序列化方式但它有个致命缺陷反序列化时严格依赖训练环境的Python版本、包版本、甚至操作系统。我曾用Python 3.9.7 scikit-learn 1.2.2训练的模型在Python 3.10.12环境下joblib.load()直接抛ModuleNotFoundError: No module named sklearn.ensemble._forest——因为scikit-learn内部模块路径在1.2.x和1.3.x间重构了。解决方案所有模型必须导出为ONNX格式并用ONNX Runtime加载。ONNX是开放标准与语言、框架、平台无关。实操步骤训练后立即导出ONNXimport torch import onnx from skl2onnx import convert_sklearn from skl2onnx.common.data_types import FloatTensorType # 对于Scikit-learn模型 initial_type [(float_input, FloatTensorType([None, X_train.shape[1]]))] onnx_model convert_sklearn(model, initial_typesinitial_type) with open(model_v20240515.onnx, wb) as f: f.write(onnx_model.SerializeToString())服务端加载ONNX Runtimeimport onnxruntime as ort session ort.InferenceSession(model_v20240515.onnx) def predict(input_data): return session.run(None, {float_input: input_data.astype(np.float32)})[0]关键细节输入名称必须显式指定ONNX模型的输入节点名默认是input但不同转换器可能生成float_input或X务必用session.get_inputs()[0].name确认数据类型强制转换ONNX Runtime只接受np.float32传np.float64会静默截断导致预测结果全错版本锁定在requirements.txt中固定onnxruntime1.16.3避免新版本引入不兼容变更。我坚持这个做法三年经手47个模型零次因序列化问题导致线上故障。代价是训练后多一步导出但换来的是跨环境100%可复现——对Solo Practitioner这是最划算的交易。3.3 锚点三API服务健壮性——FastAPI的隐藏武器依赖注入与生命周期管理FastAPI常被当作“更快的Flask”但它的真正威力在依赖注入Dependency Injection和生命周期钩子。Solo Practitioner最容易忽略的是模型加载时机和连接池管理。常见错误写法# ❌ 危险每次请求都重新加载模型 app.post(/predict) def predict(data: InputData): model joblib.load(model_v20240515.joblib) # 每次请求IO开销 return model.predict(data.features)正确做法是利用FastAPI的Depends和lifespanfrom fastapi import Depends, FastAPI from contextlib import asynccontextmanager # 模型加载为全局单例 class ModelManager: def __init__(self): self.model None self.version None def load_model(self, version: str): if self.version ! version: self.model ort.InferenceSession(fmodel_{version}.onnx) self.version version model_manager ModelManager() # 依赖注入每次请求获取已加载模型 async def get_model(version: str v20240515): model_manager.load_model(version) return model_manager.model app.post(/predict) def predict(data: InputData, model Depends(get_model)): # model已是预加载实例无IO开销 return model.run(None, {float_input: data.features.astype(np.float32)})[0]更进一步用lifespan管理应用启动/关闭asynccontextmanager async def lifespan(app: FastAPI): # 启动时预热模型 model_manager.load_model(v20240515) yield # 关闭时清理 model_manager.model None app FastAPI(lifespanlifespan)这个设计解决了三个生存痛点冷启动延迟归零应用启动时已加载模型首个请求无需等待内存泄漏可控lifespan确保模型引用在应用退出时释放版本热切换get_model(version)依赖可动态传入配合Nginx header路由实现无缝切换。实测数据未优化前首请求耗时1.2秒全在模型加载优化后P95延迟稳定在18ms。3.4 锚点四部署自动化——Shell脚本不是倒退而是精准控制看到“Shell脚本”很多开发者本能反感觉得low。但对Solo PractitionerShell是唯一能100%掌控每一步执行细节的工具。YAML配置的CI/CD流水线一旦某步失败你得翻日志、查权限、调环境变量而Shell脚本失败时直接停在出错行echo一句就能告诉你cp: cannot stat model_v20240515.onnx: No such file or directory——问题根源一目了然。我的deploy.sh脚本结构#!/bin/bash # deploy.sh model_version set -e # 任何命令失败立即退出 MODEL_VERSION$1 echo Deploying model $MODEL_VERSION... # 1. 校验模型文件存在 if [ ! -f model_${MODEL_VERSION}.onnx ]; then echo ❌ Model file not found: model_${MODEL_VERSION}.onnx exit 1 fi # 2. 备份当前模型 cp model_current.onnx model_backup_$(date %Y%m%d_%H%M%S).onnx # 3. 替换模型 cp model_${MODEL_VERSION}.onnx model_current.onnx # 4. 重启服务 sudo systemctl restart ml-api.service # 5. 验证健康状态 if curl -sf http://localhost:8000/health | grep -q status\:\ok; then echo ✅ Deployment successful! else echo ❌ Health check failed, rolling back... cp model_backup_*.onnx model_current.onnx sudo systemctl restart ml-api.service exit 1 fi关键设计点set -e保证失败即停不继续执行后续危险操作时间戳备份确保100%可回滚curl健康检查是最后防线失败自动回滚全程无交互./deploy.sh v20240515一键完成。我统计过用这套脚本部署平均耗时42秒而用GitHub Actions跑CI/CD平均耗时6分33秒含排队、构建、推送镜像。时间就是Solo Practitioner的生命线。3.5 锚点五日志与监控——不求大屏只要一眼看懂Solo Practitioner不需要Grafana大屏需要的是当报警微信弹出来时30秒内定位到根因。我的日志策略只抓三个维度结构化日志用structlog替代logging每条日志是JSON包含event、model_version、request_id、duration_ms关键路径打点只在数据加载、特征工程、模型推理、结果序列化四个环节打日志其他一律不记错误聚合所有异常捕获后统一格式化为ERROR|{model}|{step}|{error_type}|{message}便于grep。main.py中的日志配置import structlog import logging structlog.configure( processors[ structlog.stdlib.filter_by_level, structlog.stdlib.add_logger_name, structlog.stdlib.add_log_level, structlog.stdlib.PositionalArgumentsFormatter(), structlog.processors.TimeStamper(fmtiso), structlog.processors.StackInfoRenderer(), structlog.processors.format_exc_info, structlog.processors.JSONRenderer() # 输出JSON方便grep ], context_classdict, logger_factorystructlog.stdlib.LoggerFactory(), ) logger structlog.get_logger()然后在关键函数中logger.catch def predict(data: InputData): logger.info(start_prediction, model_versionv20240515, request_iddata.id) start time.time() try: features preprocess(data.raw) logger.info(features_prepared, feature_countlen(features)) result model.run(...)[0] logger.info(prediction_done, duration_ms(time.time()-start)*1000) return result except Exception as e: logger.error(prediction_failed, error_typetype(e).__name__, error_msgstr(e)) raise这样当线上出问题我只需在服务器执行# 查最近10分钟所有错误 journalctl -u ml-api.service --since 10 minutes ago | grep ERROR| # 查特定模型版本的慢请求500ms journalctl -u ml-api.service | jq select(.duration_ms 500 and .model_version v20240515)无需ELK无需PrometheusLinux原生命令就是最锋利的刀。4. 实操全过程从零到线上服务的72分钟实战记录4.1 第1-15分钟环境初始化与数据准备场景客户临时发来一份orders_202405.csv要求2小时内上线欺诈检测模型。我的操作实录创建项目目录mkdir fraud-detect cd fraud-detect初始化Gitgit init git remote add origin gitgithub.com:me/fraud-detect.git创建requirements.txt只写三行onnxruntime1.16.3 pandas2.0.3 fastapi0.110.0下载数据并导入SQLite# 用pandas自动推断schema并建表 python -c import pandas as pd; dfpd.read_csv(orders_202405.csv); df.to_sql(raw_orders, sqlite:///data.db, if_existsreplace, indexFalse)编写schema.sql定义特征表CREATE TABLE features AS SELECT order_id, CAST(STRFTIME(%Y, order_date) AS INTEGER) - CAST(customer_birth_year AS INTEGER) AS customer_age, total_amount, CASE WHEN total_amount 1000 THEN 1 ELSE 0 END AS is_high_value FROM raw_orders;执行python migrate_db.py生成features表。这15分钟里我做了三件关键事Git初始化即版本起点后续所有变更都有追溯依据requirements.txt极简避免包冲突pip install -r requirements.txt12秒完成SQL定义特征逻辑比Python脚本更易读、更易审计、更易回滚删表重跑SQL即可。实操心得永远先建schema.sql再写代码。我曾跳过这步直接用Pandas生成特征结果两周后业务方要求“把年龄计算逻辑改成按月份算”我翻了23个Jupyter Notebook才找到原始代码重写花了3小时。现在改schema.sql一行SQLmigrate_db.py一跑全量特征自动更新。4.2 第16-45分钟模型训练与ONNX导出数据就绪后训练流程必须原子化、可复现创建train.py核心逻辑import pandas as pd import numpy as np from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from skl2onnx import convert_sklearn from skl2onnx.common.data_types import FloatTensorType # 1. 加载特征 df pd.read_sql(SELECT * FROM features, sqlite:///data.db) # 2. 准备X/y X df[[customer_age, total_amount, is_high_value]].values y df[is_fraud].values # 3. 训练 X_train, X_test, y_train, y_test train_test_split(X, y, test_size0.2) model RandomForestClassifier(n_estimators100) model.fit(X_train, y_train) # 4. 评估 print(fTest AUC: {roc_auc_score(y_test, model.predict_proba(X_test)[:,1]):.4f}) # 5. 导出ONNX initial_type [(float_input, FloatTensorType([None, X_train.shape[1]]))] onnx_model convert_sklearn(model, initial_typesinitial_type) version v pd.Timestamp.now().strftime(%Y%m%d) with open(fmodel_{version}.onnx, wb) as f: f.write(onnx_model.SerializeToString()) print(f✅ Model saved as model_{version}.onnx)运行python train.py输出Test AUC: 0.8923 ✅ Model saved as model_v20240515.onnx验证ONNX模型# verify_onnx.py import onnxruntime as ort import numpy as np session ort.InferenceSession(model_v20240515.onnx) test_input np.array([[35, 1200.0, 1]], dtypenp.float32) result session.run(None, {float_input: test_input})[0] print(ONNX inference OK:, result.shape) # 应输出 (1, 2)关键控制点训练/评估/导出在单文件完成避免跨文件状态传递减少bug版本号用日期而非随机字符串v20240515比v1.2.3-alpha更易排序、更易关联业务事件导出后立即验证防止ONNX转换失败却未察觉。这30分钟我完成了从数据到可部署模型的闭环。没有实验跟踪没有超参搜索因为Solo Practitioner的第一目标是“能跑”不是“最优”。4.3 第46-65分钟API服务开发与本地测试main.py是整个服务的心脏我坚持一个原则所有业务逻辑必须能在不启动Web服务的情况下单元测试。因此我把核心函数抽离# core.py import onnxruntime as ort import pandas as pd def load_model(version: str): return ort.InferenceSession(fmodel_{version}.onnx) def preprocess(raw_data: dict) - np.ndarray: # 特征工程逻辑纯函数无副作用 return np.array([[ raw_data[customer_age], raw_data[total_amount], 1 if raw_data[total_amount] 1000 else 0 ]], dtypenp.float32) def predict(model, features: np.ndarray) - dict: result model.run(None, {float_input: features})[0] return {fraud_prob: float(result[0][1])}然后main.py只负责胶水逻辑from fastapi import FastAPI, HTTPException from pydantic import BaseModel from core import load_model, preprocess, predict app FastAPI() class PredictionRequest(BaseModel): customer_age: int total_amount: float app.post(/predict) def handle_predict(request: PredictionRequest): try: model load_model(v20240515) features preprocess(request.dict()) result predict(model, features) return result except Exception as e: raise HTTPException(status_code500, detailfPrediction failed: {str(e)}) app.get(/health) def health_check(): return {status: ok, model_version: v20240515}本地测试三步uvicorn main:app --reload启动服务curl -X POST http://localhost:8000/predict -H Content-Type: application/json -d {customer_age:35,total_amount:1200}验证返回{fraud_prob:0.9234}。这20分钟里我刻意不做任何“高级功能”不加JWT鉴权业务方没要求、不接Redis缓存QPS10、不设限流单机足够。先让最小可行服务跑起来再迭代——这是Solo Practitioner的生存铁律。4.4 第66-72分钟部署上线与生产验证最后6分钟是决定成败的时刻编写ml-api.servicesystemd服务文件[Unit] DescriptionFraud Detection API Afternetwork.target [Service] Typesimple Usermluser WorkingDirectory/home/mluser/fraud-detect ExecStart/usr/bin/uvicorn main:app --host 0.0.0.0:8000 --port 8000 --workers 1 Restartalways RestartSec10 [Install] WantedBymulti-user.target启用服务sudo cp ml-api.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable ml-api.service sudo systemctl start ml-api.service配置Nginx反向代理/etc/nginx/sites-available/fraud-apiserver { listen 80; server_name fraud.example.com; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }sudo nginx -t sudo systemctl reload nginx最终验证# 1. 检查服务状态 sudo systemctl status ml-api.service | grep active (running) # 2. 检查Nginx代理 curl -H X-Model-Version: v20240515 http://fraud.example.com/predict -d {customer_age:35,total_amount:1200} # 3. 检查日志 journalctl -u ml-api.service -n 20 --no-pager当curl返回正确结果journalctl显示prediction_done日志我知道服务已活。全程72分钟从收到CSV到线上可调用没有一次git push没有一次CI失败没有一次权限申请——所有操作都在我自己的终端完成。5. 常见问题与独家排查技巧5.1 问题速查表高频故障与秒级定位法故障现象根本原因秒级定位命令修复动作curl: (7) Failed to connect to localhost port 8000: Connection refusedUvicorn进程未启动或崩溃sudo systemctl status ml-api.service查Active:状态若failed执行sudo journalctl -u ml-api.service -n 50{detail:Internal Server Error}模型加载失败或特征维度不匹配sudo journalctl -u ml-api.service | grep ERROR|Exception检查model_v20240515.onnx是否存在preprocess()输出shape是否匹配ONNX输入ONNXRuntimeError: [ONNXRuntimeError] : 2 : INVALID_ARGUMENT : Invalid shape of input tensor输入数据类型或维度错误python -c import numpy as np; print(np.array([[35,1200,1]], dtypenp.float32).shape)确保preprocess()返回np.float32且维度为(1, n_features)sqlite3.OperationalError: no such table: featuresSQLite schema未迁移