1. 这不是“又一个Flask教程”而是一份能上线、能扛压、能迭代的ML Web App实战手记我带过六支AI产品团队亲手把三十多个机器学习模型从Jupyter Notebook里拽出来塞进真实业务流程里跑通。其中超过三分之二的失败根本不是模型不准而是卡在“怎么让业务同事点开浏览器就能用”这一步——他们不会装Python环境不理解conda和pip的区别更别说改requirements.txt里的版本冲突了。你手头那个在本地跑得飞起的XGBoost分类器或者刚调完超参的PyTorch图像分割模型一旦要变成一个带上传按钮、实时返回结果、还能被销售总监转发给客户的网页立刻暴露三个致命断层模型封装不隔离、API接口不健壮、前端交互不收敛。这篇指南不讲“如何用Flask写一个/hello路由”它直击工业级部署中90%新手踩坑的现场为什么你的pickle.load()在服务器上总报ModuleNotFoundError为什么用户上传一张2MB图片后端直接504超时为什么测试时一切正常一上生产环境就内存爆满答案不在框架文档里而在Nginx配置的client_max_body_size参数里在gunicorn的--preload开关里在joblib保存模型时漏掉的compress3选项里。我会用一个真实可运行的信用卡欺诈检测Web App为例模型用LightGBM前端用纯HTMLJS部署到Ubuntu 22.04云服务器从git clone开始到curl -X POST验证线上接口每一步都标注清楚“为什么必须这样”而不是“按教程做就行”。适合两类人一是刚跑通第一个Kaggle比赛、想把成果落地的算法同学二是被老板催着“下周上线个AI功能”的全栈工程师——你们不需要成为DevOps专家但必须知道systemd服务文件里RestartSec10这个数字背后是模型加载失败后重试的黄金等待时间。2. 整体架构设计为什么放弃Streamlit、为什么不用Docker Compose第一版2.1 架构选型的三次推倒重来最早我用Streamlit搭过三个项目表面看是“写完Python脚本streamlit run app.py就上线”实际交付时发现三个硬伤第一Streamlit默认开启--server.port但不支持反向代理的X-Forwarded-For头导致Nginx日志里所有请求IP都是127.0.0.1安全审计直接挂掉第二它的st.cache_data在多进程模式下会生成重复缓存文件当并发用户超50时磁盘IO打满第三也是最致命的——它把前端渲染逻辑和模型推理混在同一Python进程中一旦某个用户上传恶意构造的CSV文件触发pandas.read_csv()内存溢出整个服务进程就崩了其他用户全部中断。所以这次我彻底放弃“快速原型工具”回归经典三层分离模型层独立进程、API层WSGI服务、网关层Nginx。为什么不用Docker Compose起步因为我在客户现场见过太多次运维同事看到docker-compose.yml里写着build: .第一反应是“这镜像得自己编译”然后花两天配GCC环境最后发现只是因为requirements.txt里torch2.0.1没指定cpu后缀导致pip install卡死在CUDA编译上。所以本方案采用裸机部署Bare Metal Deployment所有依赖用apt和pip分层安装好处是每一步都能ps aux | grep gunicorn看到真实进程出问题时journalctl -u mymlapp直接定位到哪一行日志报错。Docker当然更优雅但优雅的前提是团队有专职运维——而现实是90%的初创团队和中小企业的AI项目就是算法工程师自己SSH连服务器敲命令。最终架构图长这样文字描述版用户浏览器 → Nginx监听80端口处理HTTPS终止、静态文件、负载均衡Nginx → Gunicorn监听127.0.0.1:8000管理4个worker进程每个worker加载完整模型Gunicorn → Flask App核心是predict()函数接收JSON/FormData调用预加载的LightGBM模型模型文件 →/opt/mlmodels/fraud_v2.lgb二进制格式由joblib.dump(model, path, compress3)生成这个架构的关键决策点在于模型加载时机。我试过三种方式① 每次请求时joblib.load()慢100ms延迟② Gunicorn启动时--preload加载快但worker间内存不共享4个worker吃掉4倍内存③ 用multiprocessing.Manager做共享内存复杂调试困难。最终选择②因为实测LightGBM模型文件压缩后仅12MB4个worker共占用48MB内存远低于服务器8GB总内存的5%而换来的是首请求延迟从320ms降到45ms——这对用户体验是质变。2.2 技术栈版本锁定为什么是Python 3.9.18而不是3.11Python版本选择不是跟风。我对比过3.9.18、3.10.12、3.11.6三个版本在Ubuntu 22.04上的表现lightgbm4.3.0在3.11上编译需要gcc-12但系统默认gcc-11apt install gcc-12会引发libc6版本冲突numpy1.24.4在3.10上np.frombuffer()对bytes对象的处理有bug导致Base64解码后的图像数据错位3.9.18是Ubuntu 22.04官方仓库python3-dev包的基准版本apt install python3-dev后pip install lightgbm能直接编译成功零配置。所以我的setup.sh第一行永远是sudo apt update sudo apt install -y python3.9 python3.9-venv python3.9-dev然后创建虚拟环境python3.9 -m venv /opt/mymlapp/venv source /opt/mymlapp/venv/bin/activate pip install --upgrade pip setuptools wheel注意这里没写pip install -r requirements.txt因为requirements.txt必须手动锁定所有二级依赖。比如pandas2.0.3会自动拉取numpy1.21.0但线上环境需要精确到numpy1.24.4否则某天pip install突然升级到1.25.0pandas.DataFrame.to_dict(orientrecords)的返回结构就变了——前端JS解析JSON时data[0].probability突然变成data[0].pred_proba整个页面白屏。因此我的requirements.txt长这样Flask2.3.3 gunicorn21.2.0 lightgbm4.3.0 numpy1.24.4 pandas2.0.3 scikit-learn1.3.0 joblib1.3.2每个版本号都经过python -c import pandas; print(pandas.__version__)实测绝不相信PyPI的兼容性声明。2.3 安全边界设计模型沙箱化的三个物理隔离层很多教程忽略一个事实用户上传的文件是最高危输入源。CSV里可能藏有__import__(os).system(rm -rf /)的恶意payload虽然pandas默认不执行PNG图片的EXIF字段能注入PHP代码当后端用PIL.Image.open()未清理元数据时。所以我设置了三层物理隔离第一层Nginx文件大小限制在/etc/nginx/sites-available/mymlapp里强制client_max_body_size 5M; # 超过5MB直接413错误不进后端 client_header_timeout 10; client_body_timeout 30;为什么是5M因为信用卡欺诈检测的输入是结构化数据最多100列数值CSV文本本身不会超100KB如果用户非要传200MB的原始交易日志那是ETL流程的问题不该由Web App承担。第二层Flask的文件读取沙箱不直接用request.files[file].save()而是from werkzeug.utils import secure_filename import os def safe_read_csv(file): # 1. 文件名消毒 filename secure_filename(file.filename) if not filename.endswith(.csv): raise ValueError(Only CSV files allowed) # 2. 内存中读取不落盘 file_content file.read() # 读入bytes if len(file_content) 5 * 1024 * 1024: # 再次检查5MB raise ValueError(File too large) # 3. 用pandas StringIO避免临时文件 import io import pandas as pd df pd.read_csv(io.StringIO(file_content.decode(utf-8))) return df第三层模型预测的输入校验LightGBM模型加载后我额外加了一层schema检查# 加载时记录特征名和类型 MODEL_FEATURES [amount, time_since_last_tx, merchant_risk_score, ...] MODEL_DTYPES {amount: float64, time_since_last_tx: int64, ...} def validate_input(df): if set(df.columns) ! set(MODEL_FEATURES): raise ValueError(fMissing columns: {set(MODEL_FEATURES) - set(df.columns)}) for col, dtype in MODEL_DTYPES.items(): if str(df[col].dtype) ! dtype: # 尝试强制转换 try: df[col] df[col].astype(dtype) except: raise ValueError(fColumn {col} has invalid dtype: {df[col].dtype}) return df这三层下来即使用户上传一个伪造的.csv文件攻击链在Nginx层就被截断根本到不了Python进程。这才是生产环境该有的防御纵深。3. 核心细节拆解从模型保存到API响应的17个关键决策点3.1 模型序列化为什么用joblib不用pickle以及compress3的玄机pickle是Python原生序列化但它有两个致命缺陷第一不同Python版本间不兼容3.9 dump的模型在3.10 load会报UnicodeDecodeError第二它会序列化整个模块路径比如模型里用了from sklearn.ensemble import RandomForestClassifierpickle会把sklearn.ensemble这个字符串也存进去一旦线上环境sklearn版本升级pickle.load()就找不到模块。joblib专为科学计算设计它只序列化NumPy数组的二进制数据模块路径由调用方保证所以更稳定。但joblib.dump()默认不压缩一个100万行的LightGBM模型文件能到80MB。我测试过不同compress参数compress0无压缩82MBjoblib.load()耗时1.2scompress1zlib level 138MB耗时0.8scompress3zlib level 322MB耗时0.45s ←最优平衡点compress9zlib level 912MB耗时0.3s但CPU占用飙升影响并发所以我的模型保存脚本是import joblib import lightgbm as lgb # 训练完模型model后 joblib.dump( model, /opt/mlmodels/fraud_v2.lgb, compress3, # 关键压缩率与加载速度的黄金平衡 protocol4 # Python 3.7推荐protocol 4比默认的3更快 )提示protocol4要求Python≥3.7而我们锁定了3.9.18所以放心用。实测比protocol3快17%因为protocol 4对大型NumPy数组做了专门优化。3.2 Flask应用初始化全局模型加载与异常熔断Flask的app.py不能写成这样常见错误# ❌ 错误示范每次请求都加载模型 app.route(/predict, methods[POST]) def predict(): model joblib.load(/opt/mlmodels/fraud_v2.lgb) # 每次请求都磁盘IO # ...后续逻辑正确做法是利用Flask的before_first_request钩子但要注意Gunicorn有--preload模式此时before_first_request不触发。所以必须双保险# app.py import joblib from flask import Flask, request, jsonify import logging app Flask(__name__) # 全局变量存储模型 _model None _model_lock threading.Lock() def load_model(): global _model with _model_lock: if _model is None: logging.info(Loading model from disk...) _model joblib.load(/opt/mlmodels/fraud_v2.lgb) logging.info(Model loaded successfully) # Gunicorn preload时调用 load_model() app.before_first_request def load_model_on_first_request(): load_model() app.route(/health, methods[GET]) def health_check(): return jsonify({status: ok, model_loaded: _model is not None}) app.route(/predict, methods[POST]) def predict(): if _model is None: return jsonify({error: Model not loaded}), 503 try: # ...预测逻辑 result _model.predict_proba(df)[:, 1] return jsonify({fraud_probability: float(result[0])}) except Exception as e: logging.error(fPrediction failed: {str(e)}) return jsonify({error: Internal server error}), 500这里的关键是_model_lock——当Gunicorn启动多个worker时每个worker进程都有自己的内存空间_model是进程内变量不存在跨进程竞争。_model_lock其实是冗余的但我保留它是为了未来扩展比如加模型热更新。更重要的是/health接口它不触发预测只检查_model是否为None这是Kubernetes readiness probe或Nginx健康检查的必备接口。3.3 Gunicorn配置worker数量、超时、preload的实测参数Gunicorn是WSGI服务器它的配置直接决定吞吐量。我在AWS t3.small2核2GB上做了压力测试ab -n 1000 -c 50 http://localhost:8000/healthworkerstimeoutpreload平均延迟(ms)CPU使用率内存占用230False12045%320MB430True4568%580MB4120True4872%580MB830True5292%920MB结论很清晰4个workers 30秒timeout --preload是最佳组合。为什么不是8个因为t3.small只有2个vCPU8个worker会导致上下文切换开销剧增CPU使用率92%时实际QPS反而比68%时低12%。timeout设为30秒是因为最长的预测请求用户上传10MB CSV后端解析特征工程预测实测耗时22秒留8秒缓冲防网络抖动。我的gunicorn.conf.py# /opt/mymlapp/gunicorn.conf.py import multiprocessing bind 127.0.0.1:8000 bind_address 127.0.0.1:8000 workers multiprocessing.cpu_count() * 2 1 # 自动适配CPU核心数 worker_class sync worker_connections 1000 timeout 30 keepalive 5 max_requests 1000 max_requests_jitter 100 # 关键preload模型到每个worker preload True # 日志 accesslog /var/log/mymlapp/access.log errorlog /var/log/mymlapp/error.log loglevel info capture_output True注意max_requests1000Gunicorn worker处理1000个请求后自动重启防止内存泄漏累积。max_requests_jitter100让重启时间随机化900~1100之间避免所有worker同时重启导致服务雪崩。3.4 Nginx反向代理为什么location /api/比/更安全很多人把Flask直接暴露在80端口这是大忌。Nginx不仅是性能代理更是安全网关。我的/etc/nginx/sites-available/mymlapp配置upstream mlapp_backend { server 127.0.0.1:8000; keepalive 32; } server { listen 80; server_name mlapp.example.com; # 强制HTTPS return 301 https://$server_name$request_uri; } server { listen 443 ssl http2; server_name mlapp.example.com; ssl_certificate /etc/letsencrypt/live/mlapp.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/mlapp.example.com/privkey.pem; # 静态文件直出不走Flask location /static/ { alias /opt/mymlapp/static/; expires 1h; add_header Cache-Control public, immutable; } # API路由加安全头 location /api/ { proxy_pass http://mlapp_backend/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; # 关键安全头 proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; # 防止HTTP头注入 proxy_hide_header X-Powered-By; proxy_hide_header Server; # 超时设置匹配Gunicorn proxy_connect_timeout 30s; proxy_send_timeout 30s; proxy_read_timeout 30s; } # 根路径给前端HTML location / { root /opt/mymlapp/frontend; try_files $uri $uri/ /index.html; } }为什么API用/api/前缀因为前端JS调用时明确知道fetch(/api/predict)避免相对路径混乱Nginx可以针对/api/单独配置limit_req限流比如limit_req zonemlapi burst10 nodelay防止暴力请求打垮模型安全扫描工具如OWASP ZAP默认检查/api/路径下的敏感接口便于合规审计。注意proxy_pass http://mlapp_backend/;结尾的/很重要没有它/api/predict会被代理成http://127.0.0.1:8000/api/predict而Flask路由是app.route(/predict)路径不匹配直接404。加了/后/api/predict被重写为/predict完美匹配。4. 实操全流程从零开始部署一个可验证的欺诈检测App4.1 服务器初始化Ubuntu 22.04的12个必做步骤我用的是腾讯云轻量应用服务器2核2GBUbuntu 22.04以下命令需逐条执行顺序不能乱# 1. 更新系统并安装基础工具 sudo apt update sudo apt upgrade -y sudo apt install -y curl wget git nginx vim htop tmux # 2. 创建部署用户禁止root直接登录 sudo adduser --disabled-password --gecos mlapp sudo usermod -aG sudo mlapp # 然后用ssh-copy-id把公钥复制过去禁用密码登录 # 3. 安装Python 3.9.18从deadsnakes PPA sudo add-apt-repository ppa:deadsnakes/ppa -y sudo apt update sudo apt install -y python3.9 python3.9-venv python3.9-dev # 4. 创建项目目录结构 sudo mkdir -p /opt/mymlapp/{frontend,backend,mlmodels,logs} sudo chown -R mlapp:mlapp /opt/mymlapp sudo chmod -R 755 /opt/mymlapp # 5. 安装Lets Encrypt证书用certbot sudo apt install -y certbot python3-certbot-nginx sudo certbot --nginx -d mlapp.example.com # 替换为你的真实域名 # 6. 配置防火墙UFW sudo ufw allow OpenSSH sudo ufw allow Nginx Full sudo ufw enable # 7. 创建systemd服务文件关键 sudo tee /etc/systemd/system/mymlapp.service EOF [Unit] DescriptionMy ML Web App Afternetwork.target [Service] Typesimple Usermlapp WorkingDirectory/opt/mymlapp/backend ExecStart/opt/mymlapp/venv/bin/gunicorn --config /opt/mymlapp/backend/gunicorn.conf.py app:app Restartalways RestartSec10 StandardOutputjournal StandardErrorjournal SyslogIdentifiermymlapp EnvironmentPATH/opt/mymlapp/venv/bin [Install] WantedBymulti-user.target EOF # 8. 启用并启动服务 sudo systemctl daemon-reload sudo systemctl enable mymlapp sudo systemctl start mymlapp # 9. 配置Nginx前面已贴配置这里启用 sudo ln -sf /etc/nginx/sites-available/mymlapp /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx # 10. 创建日志轮转logrotate sudo tee /etc/logrotate.d/mymlapp EOF /var/log/mymlapp/*.log { daily missingok rotate 14 compress delaycompress notifempty create 644 mlapp mlapp sharedscripts postrotate systemctl kill --signalSIGHUP --kill-whomain mymlapp endscript } EOF # 11. 设置定时任务每天凌晨2点重启服务释放内存 (crontab -l 2/dev/null; echo 0 2 * * * systemctl restart mymlapp) | crontab - # 12. 最后检查 sudo systemctl status mymlapp sudo journalctl -u mymlapp -n 50 --no-pager这12步里第7步systemd服务和第10步logrotate最容易被跳过但它们是生产环境稳定性的基石。RestartSec10不是随便写的——Gunicorn worker崩溃后systemd等10秒再重启给日志系统足够时间写入错误堆栈postrotate里的systemctl kill --signalSIGHUP是优雅重载让Gunicorn重新打开日志文件句柄避免logrotate切日志后新日志还写在旧文件里。4.2 前端HTML实现零框架、纯原生的上传与预测流程前端不用React/Vue就一个index.html因为减少JavaScript bundle体积首屏加载100KB避免前端框架的兼容性问题比如Vue 3的Composition API在旧版iOS Safari上失效所有逻辑可控调试时console.log()直接看到原始数据。/opt/mymlapp/frontend/index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title信用卡欺诈检测/title style body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto; margin: 40px; } .upload-area { border: 2px dashed #ccc; padding: 40px; text-align: center; margin: 20px 0; } .btn { background: #007bff; color: white; border: none; padding: 10px 20px; cursor: pointer; } .result { margin-top: 20px; padding: 15px; background: #f8f9fa; } /style /head body h1 信用卡欺诈风险检测/h1 p上传CSV文件检测每笔交易的欺诈概率0-1/p div classupload-area iddropArea p拖拽CSV文件到这里或点击选择/p input typefile idfileInput accept.csv styledisplay:none; button classbtn onclickdocument.getElementById(fileInput).click()选择文件/button /div div idresult classresult styledisplay:none;/div script const dropArea document.getElementById(dropArea); const fileInput document.getElementById(fileInput); const resultDiv document.getElementById(result); // 拖拽事件 [dragenter, dragover, dragleave, drop].forEach(eventName { dropArea.addEventListener(eventName, preventDefaults, false); }); function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); } [dragenter, dragover].forEach(eventName { dropArea.addEventListener(eventName, highlight, false); }); [dragleave, drop].forEach(eventName { dropArea.addEventListener(eventName, unhighlight, false); }); function highlight() { dropArea.style.borderColor #007bff; } function unhighlight() { dropArea.style.borderColor #ccc; } dropArea.addEventListener(drop, handleDrop, false); function handleDrop(e) { const dt e.dataTransfer; const files dt.files; handleFiles(files); } fileInput.addEventListener(change, function() { handleFiles(this.files); }); function handleFiles(files) { if (files.length 0) return; const file files[0]; // 基础校验 if (!file.name.endsWith(.csv)) { alert(请上传CSV文件); return; } if (file.size 5 * 1024 * 1024) { alert(文件不能超过5MB); return; } // 显示加载状态 resultDiv.style.display block; resultDiv.innerHTML p正在上传并分析.../p; // 读取文件内容 const reader new FileReader(); reader.onload function(e) { const csvContent e.target.result; // 发送POST请求 fetch(/api/predict, { method: POST, headers: { Content-Type: text/csv }, body: csvContent }) .then(response response.json()) .then(data { if (data.error) { resultDiv.innerHTML p stylecolor:red;错误: ${data.error}/p; } else { resultDiv.innerHTML h3检测结果/h3 pstrong欺诈概率:/strong ${(data.fraud_probability * 100).toFixed(2)}%/p pstrong建议操作:/strong ${data.fraud_probability 0.7 ? 立即冻结账户 : data.fraud_probability 0.3 ? 人工复核 : 正常交易}/p ; } }) .catch(error { resultDiv.innerHTML p stylecolor:red;网络错误: ${error.message}/p; }); }; reader.readAsText(file); } /script /body /html这个HTML的关键在于所有文件读取都在浏览器内存完成不经过后端临时文件。FileReader.readAsText()把CSV内容读成字符串直接作为fetch的body发送后端request.get_data()就能拿到原始字节。这样既避免了后端磁盘IO瓶颈又杜绝了临时文件被恶意利用的风险比如上传.htaccess覆盖Nginx配置。4.3 后端Flask实现从请求解析到模型预测的完整链路/opt/mymlapp/backend/app.pyimport os import joblib import numpy as np import pandas as pd from flask import Flask, request, jsonify from werkzeug.exceptions import BadRequest import logging import threading # 配置日志 logging.basicConfig( levellogging.INFO, format%(asctime)s - %(name)s - %(levelname)s - %(message)s, handlers[ logging.FileHandler(/var/log/mymlapp/app.log), logging.StreamHandler() ] ) logger logging.getLogger(__name__) app Flask(__name__) # 全局模型变量 _model None _model_lock threading.Lock() # 特征定义必须与训练时完全一致 FEATURE_NAMES [ amount, time_since_last_tx, merchant_risk_score, card_age_days, transaction_velocity_24h, is_weekend, is_night, country_risk_level ] EXPECTED_COLUMNS set(FEATURE_NAMES) def load_model(): 安全加载模型带异常捕获 global _model with _model_lock: if _model is None: try: logger.info(开始加载模型...) model_path /opt/mlmodels/fraud_v2.lgb if not os.path.exists(model_path): raise FileNotFoundError(f模型文件不存在: {model_path}) _model joblib.load(model_path) logger.info(模型加载成功) except Exception as e: logger.error(f模型加载失败: {str(e)}) raise # 预加载模型 load_model() app.route(/health, methods[GET]) def health_check(): 健康检查接口 return jsonify({ status: healthy, timestamp: pd.Timestamp.now().isoformat(), model_loaded: _model is not None, model_features: len(FEATURE_NAMES) if _model else 0 }) app.route(/predict, methods[POST]) def predict(): 主预测接口 if _model is None: return jsonify({error: 服务未就绪请稍候重试}), 503 try: # 1. 解析请求数据 if request.headers.get(Content-Type) text/csv: # CSV上传 csv_data request.get_data(as_textTrue) if not csv_data.strip(): raise BadRequest(CSV内容为空) # 用pandas读取自动处理header from io import StringIO df pd.read_csv(StringIO(csv_data)) elif request.is_json: # JSON上传备用方案 json_data request.get_json() df pd.DataFrame([json_data]) else: raise BadRequest(不支持的内容类型仅支持text/csv或application/json) # 2. 输入校验 if df.empty: raise BadRequest(CSV文件为空) if not EXPECTED_COLUMNS.issubset(set(df.columns)): missing EXPECTED_COLUMNS - set(df.columns) raise BadRequest(f缺少必要字段: {missing}) # 3. 数据清洗与类型转换 for col in FEATURE_NAMES: if col not in df.columns: continue if df[col].dtype object: # 尝试转换为数值 df[col] pd.to_numeric(df[col], errorscoerce) # 4. 特征工程示例时间特征 if time_since_last_tx in df.columns: df[time_since_last_tx] df[time_since_last_tx].clip(upper3600*24*30) # 限制最大30天 # 5. 模型预测 X df[FEATURE_NAMES].values.astype(np.float32) predictions _model.predict_proba(X)[:, 1] # 返回欺诈概率 # 6. 构建响应 results [] for i, prob in enumerate(predictions): risk_level 高风险 if prob 0.7 else 中风险 if prob 0.3 else 低风险 results.append({ row_index: int(i), fraud_probability: float(prob), risk_level: risk_level, recommendation: 立即冻结账户 if prob 0.7 else 人工复核 if prob 0.3 else 正常交易 }) logger.info(f成功预测 {len(results)} 条记录) return jsonify({results: results}) except BadRequest as e: logger.warning(f客户端错误: {str(e)}) return jsonify({error: str(e)}), 400 except Exception as e: logger.error(f预测过程异常: {str(e)}, exc_infoTrue) return jsonify({error: 内部服务器错误}), 500 if __name__ __main__: # 仅用于开发生产环境由Gunicorn启动 app.run(host0.0.0.0, port5000, debugFalse)这个后端的核心亮点是错误分类处理BadRequest返回400告诉前端是它的问题比如传了错的CSV