bert_base/services/address/app.py

349 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import sys
from flask import Flask, request, jsonify
import torch
from transformers import BertTokenizer, BertForSequenceClassification
import joblib
import re
from functools import lru_cache
from concurrent.futures import ThreadPoolExecutor
from typing import List, Dict
import threading
import logging
import atexit
from datetime import datetime
import time
# ==================== 从环境变量读取配置 ====================
SERVICE_NAME = os.environ.get('SERVICE_NAME', 'address_classifier')
SERVICE_PORT = int(os.environ.get('SERVICE_PORT', 5003))
MODEL_ID = os.environ.get('MODEL_ID', 'circles1/address_model')
MODEL_DIR = os.environ.get('MODEL_DIR', '/app/services/bert_address/model')
# 模型文件路径
LABEL_ENCODER_PATH = os.path.join(MODEL_DIR, 'label_encoder.pkl')
# 如果上面路径不存在,尝试其他可能的文件名
if not os.path.exists(LABEL_ENCODER_PATH):
import glob
encoder_files = glob.glob(os.path.join(MODEL_DIR, 'label_encoder*.pkl'))
if encoder_files:
LABEL_ENCODER_PATH = encoder_files[0]
# ==================== 配置参数 ====================
MAX_LENGTH = 512
DEVICE = os.environ.get('DEVICE', 'cuda' if torch.cuda.is_available() else 'cpu')
# 如果设备设置为cuda但不可用回退到cpu
if DEVICE == 'cuda' and not torch.cuda.is_available():
DEVICE = 'cpu'
MAX_WORKERS = int(os.environ.get('MAX_WORKERS', 4))
CACHE_SIZE = int(os.environ.get('CACHE_SIZE', 2000))
TOKEN_CACHE_SIZE = int(os.environ.get('TOKEN_CACHE_SIZE', 1000))
# ==================== 日志配置 ====================
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# 打印启动信息
logger.info("=" * 50)
logger.info(f"Starting service: {SERVICE_NAME}")
logger.info(f"Service port: {SERVICE_PORT}")
logger.info(f"Model ID: {MODEL_ID}")
logger.info(f"Model directory: {MODEL_DIR}")
logger.info(f"Label encoder: {LABEL_ENCODER_PATH}")
logger.info(f"Device: {DEVICE}")
logger.info(f"Max workers: {MAX_WORKERS}")
logger.info("=" * 50)
app = Flask(__name__)
app.config['JSON_AS_ASCII'] = False
# 全局变量锁
model_lock = threading.Lock()
class Predictor:
"""预测器类,用于管理模型和分词器的生命周期"""
_instance = None
def __new__(cls):
if cls._instance is None:
with model_lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
logger.info(f"Initializing Predictor on device: {DEVICE}")
start_time = time.time()
try:
# 检查模型目录是否存在
if not os.path.exists(MODEL_DIR):
raise FileNotFoundError(f"Model directory not found: {MODEL_DIR}")
# 检查label_encoder是否存在
if not os.path.exists(LABEL_ENCODER_PATH):
raise FileNotFoundError(f"Label encoder not found: {LABEL_ENCODER_PATH}")
# 加载模型和分词器
logger.info(f"Loading tokenizer and model from {MODEL_DIR}...")
self.tokenizer = BertTokenizer.from_pretrained(MODEL_DIR)
self.model = BertForSequenceClassification.from_pretrained(MODEL_DIR).to(DEVICE)
self.model.eval()
logger.info("Model loaded successfully")
# 加载标签映射器
logger.info(f"Loading label encoder from {LABEL_ENCODER_PATH}...")
self.label_encoder = joblib.load(LABEL_ENCODER_PATH)
self.label_map = {i: label for i, label in enumerate(self.label_encoder.classes_)}
logger.info(f"Loaded {len(self.label_map)} labels: {list(self.label_map.values())}")
# 创建线程池
self.executor = ThreadPoolExecutor(max_workers=MAX_WORKERS)
logger.info(f"ThreadPoolExecutor created with {MAX_WORKERS} workers")
# 注册关闭钩子
atexit.register(self._shutdown)
# 模型预热
self._warmup()
self._initialized = True
elapsed_time = time.time() - start_time
logger.info(f"Predictor initialized successfully in {elapsed_time:.2f}s")
except Exception as e:
logger.error(f"Failed to initialize Predictor: {e}")
raise
def _shutdown(self):
"""清理资源"""
logger.info("Shutting down executor...")
self.executor.shutdown(wait=True)
logger.info("Executor shutdown complete")
def _warmup(self):
"""模型预热确保CUDA初始化"""
logger.info("Warming up model...")
try:
test_text = "测试地址"
self.predict_single(test_text)
logger.info("Model warmup completed")
except Exception as e:
logger.warning(f"Model warmup failed: {e}")
@lru_cache(maxsize=CACHE_SIZE)
def clean_text(self, text: str) -> str:
"""文本清洗函数,带缓存"""
if not isinstance(text, str):
return ""
# 只保留中文字符
cleaned_text = re.sub(r'[^\u4e00-\u9fa5]', '', text)
return cleaned_text.strip()
def predict_single(self, text: str) -> Dict:
"""单个文本预测"""
if not text or not isinstance(text, str):
return {"address": "", "confidence": 0.0, "error": "Invalid input"}
try:
cleaned_text = self.clean_text(text)
if not cleaned_text:
logger.warning(f"Empty text after cleaning: {text[:50]}...")
return {"address": "", "confidence": 0.0}
inputs = self.tokenizer(
cleaned_text,
return_tensors="pt",
truncation=True,
padding=True,
max_length=MAX_LENGTH
).to(DEVICE)
with torch.no_grad():
outputs = self.model(**inputs)
probs = torch.softmax(outputs.logits, dim=1).cpu()
top_prob, top_idx = torch.topk(probs, k=1)
return {
"address": self.label_map[top_idx.item()],
"confidence": round(top_prob.item(), 4)
}
except torch.cuda.OutOfMemoryError as e:
logger.error(f"CUDA OOM error: {e}")
torch.cuda.empty_cache()
return {"address": "", "confidence": 0.0, "error": "GPU memory exhausted"}
except Exception as e:
logger.error(f"Prediction error for text '{text[:50]}...': {e}")
return {"address": "", "confidence": 0.0, "error": str(e)}
def batch_predict(self, texts: List[str]) -> List[Dict]:
"""批量预测(并发处理)"""
if not texts:
return []
logger.info(f"Processing {len(texts)} texts")
start_time = time.time()
# 使用线程池并发处理
futures = [self.executor.submit(self.predict_single, text) for text in texts]
results = [future.result() for future in futures]
elapsed_time = time.time() - start_time
logger.info(f"Batch prediction completed in {elapsed_time:.2f}s for {len(texts)} texts")
return results
def get_stats(self) -> Dict:
"""获取预测器统计信息"""
return {
"service_name": SERVICE_NAME,
"model_id": MODEL_ID,
"model_dir": MODEL_DIR,
"device": DEVICE,
"num_labels": len(self.label_map),
"labels": list(self.label_map.values()),
"max_length": MAX_LENGTH,
"max_workers": MAX_WORKERS
}
# 初始化预测器
try:
predictor = Predictor()
logger.info("Predictor instance created successfully")
except Exception as e:
logger.error(f"Failed to create predictor instance: {e}")
raise
@app.route('/predict', methods=['POST'])
def predict():
"""预测接口"""
try:
data = request.get_json()
if not data:
return jsonify({
"status": "error",
"error": "Invalid request, JSON body required"
}), 400
if 'address' not in data:
return jsonify({
"status": "error",
"error": "Invalid request, 'address' is required"
}), 400
addresses = data['address']
if not isinstance(addresses, list):
addresses = [addresses]
# 限制最大处理数量
max_items = data.get('max_items', 1000)
if len(addresses) > max_items:
logger.warning(f"Too many items: {len(addresses)} > {max_items}, truncating")
addresses = addresses[:max_items]
# 去重(可选)
if data.get('deduplicate', False):
addresses = list(dict.fromkeys(addresses))
logger.info(f"After deduplication: {len(addresses)} unique texts")
# 批量预测
results = predictor.batch_predict(addresses)
return jsonify({
"status": "success",
"predictions": results,
"metadata": {
"total": len(results),
"timestamp": datetime.now().isoformat(),
"service": SERVICE_NAME,
"device": DEVICE
}
})
except Exception as e:
logger.error(f"Prediction endpoint error: {e}", exc_info=True)
return jsonify({
"status": "error",
"error": f"Internal server error: {str(e)}"
}), 500
@app.route('/health', methods=['GET'])
def health_check():
"""健康检查接口"""
try:
stats = predictor.get_stats()
return jsonify({
"status": "healthy",
"timestamp": datetime.now().isoformat(),
"stats": stats
})
except Exception as e:
return jsonify({
"status": "unhealthy",
"error": str(e)
}), 500
@app.route('/stats', methods=['GET'])
def get_stats():
"""获取预测器统计信息"""
try:
stats = predictor.get_stats()
return jsonify({
"status": "success",
"stats": stats
})
except Exception as e:
return jsonify({
"status": "error",
"error": str(e)
}), 500
@app.errorhandler(404)
def not_found(error):
return jsonify({
"status": "error",
"error": "Endpoint not found"
}), 404
@app.errorhandler(500)
def internal_error(error):
return jsonify({
"status": "error",
"error": "Internal server error"
}), 500
if __name__ == '__main__':
logger.info(f"Starting Flask application: {SERVICE_NAME}")
logger.info(f"Model path: {MODEL_DIR}")
logger.info(f"Device: {DEVICE}")
logger.info(f"Port: {SERVICE_PORT}")
app.run(
host='0.0.0.0',
port=SERVICE_PORT,
threaded=True,
debug=False
)
else:
# 兼容 WSGI 标准(如 Gunicorn
application = app
logger.info(f"Application loaded for WSGI server: {SERVICE_NAME}")