好的,为了尽可能减小镜像体积,并且满足您“不用pytorch”的要求(因为PyTorch确实占用空间较大),我们将采用将模型转换为ONNX格式并在ONNX Runtime中运行它的策略。这样可以在最终的运行环境中避免庞大的PyTorch库。

这个过程会稍微复杂一些,因为它涉及到:

  1. 模型转换阶段:在构建镜像的过程中,我们会使用sentence-transformers和torch(仅在此阶段)来加载原始模型,并将其转换为ONNX格式。
  2. 运行阶段:最终的镜像将只包含ONNX Runtime和模型运行所需的最小依赖,不再包含torch。

这需要修改您的sentence.py以适应ONNX模型的加载和推理。

以下是建议的方案:

文件结构:

.
├── Dockerfile
├── build.sh
├── requirements_builder.txt   # 用于构建阶段(模型转换)的依赖
├── requirements_runtime.txt   # 用于运行阶段的依赖
├── convert_to_onnx.py         # 模型转换脚本
└── sentence_onnx.py           # 修改后使用ONNX模型的应用脚本 (替换原 sentence.py)

1. requirements_builder.txt (用于模型转换)

Plaintext

sentence-transformers==2.7.0
torch>=2.0.0 # 需要torch来加载原始模型进行转换 (CPU版本即可)
optimum[onnxruntime]>=1.16.0 # Hugging Face Optimum库,简化ONNX转换和使用
transformers>=4.30.0 # sentence-transformers的依赖,也用于tokenizer
# fastapi, uvicorn, pydantic, numpy 将在runtime中安装,这里不需要
  • 我们在这里指定torch,因为它是在构建阶段将模型转换为ONNX所必需的。最终的运行时镜像中不会包含它。
  • optimum 库可以很方便地将Hugging Face Transformers模型(sentence-transformers底层使用的模型)导出为ONNX格式。

2. requirements_runtime.txt (用于最终运行环境)

Plaintext

fastapi==0.110.0
uvicorn[standard]==0.29.0
pydantic>=2.0
numpy>=1.20
optimum[onnxruntime]>=1.16.0 # 用于加载和运行ONNX模型
# 注意:这里不再有 torch 和 sentence-transformers (原始库)
# transformers 会作为 optimum 的依赖被安装,主要用于加载tokenizer
  • 这里只包含运行FastAPI服务和ONNX模型所需的最小依赖。onnxruntime本身比torch小得多。

3. convert_to_onnx.py (模型转换脚本)

Python

import sys
from pathlib import Path
from optimum.onnxruntime import ORTModelForFeatureExtraction
from transformers import AutoTokenizer

def convert_sbert_to_onnx(model_name_or_path: str, output_dir: str):
    """
    Converts a SentenceTransformer model to ONNX format using Optimum.
    The underlying Hugging Face model name is typically 'sentence-transformers/' + model_name_or_path.
    """
    hf_model_name = f"sentence-transformers/{model_name_or_path}"
    output_path = Path(output_dir)
    output_path.mkdir(parents=True, exist_ok=True)

    print(f"Loading Hugging Face model '{hf_model_name}' for ONNX conversion...")
    
    # 1. Export the model to ONNX
    # ORTModelForFeatureExtraction will handle the conversion from a PyTorch checkpoint.
    # It needs the underlying Hugging Face compatible model, not the SentenceTransformer wrapper directly.
    ort_model = ORTModelForFeatureExtraction.from_pretrained(hf_model_name, export=True)
    
    # 2. Save the ONNX model and its tokenizer
    ort_model.save_pretrained(output_path)
    
    # Tokenizer is usually saved by the above command as well, but ensure it explicitly.
    # SentenceTransformer models have their tokenizers accessible. For ONNX, we typically
    # save the tokenizer from the underlying Hugging Face model.
    tokenizer = AutoTokenizer.from_pretrained(hf_model_name)
    tokenizer.save_pretrained(output_path)

    print(f"ONNX model and tokenizer saved to {output_path}")

if __name__ == "__main__":
    if len(sys.argv) < 3:
        print("Usage: python convert_to_onnx.py <model_name> <output_directory>")
        print("Example: python convert_to_onnx.py paraphrase-multilingual-MiniLM-L12-v2 /app/onnx_model")
        sys.exit(1)
    
    model_name = sys.argv[1]
    output_directory = sys.argv[2]
    convert_sbert_to_onnx(model_name, output_directory)

4. sentence_onnx.py (修改后的应用脚本,替换原 sentence.py)

Python

from fastapi import FastAPI
from pydantic import BaseModel
import numpy as np
from optimum.onnxruntime import ORTModelForFeatureExtraction
from transformers import AutoTokenizer # To load the tokenizer

app = FastAPI()

# 定义模型和tokenizer的路径 (在Dockerfile中会被复制到这个路径)
MODEL_DIR = "/app/onnx_model" 

# 加载 ONNX 模型和分词器
print(f"Loading ONNX model and tokenizer from {MODEL_DIR}...")
try:
    tokenizer = AutoTokenizer.from_pretrained(MODEL_DIR)
    model = ORTModelForFeatureExtraction.from_pretrained(MODEL_DIR) # Optimum will use ONNX Runtime
    print("ONNX model and tokenizer loaded successfully.")
except Exception as e:
    print(f"Error loading ONNX model or tokenizer: {e}")
    # 在这种情况下,服务可能无法正常工作,后续请求会失败
    # 考虑更健壮的错误处理或健康检查端点
    tokenizer = None
    model = None

class TextPayload(BaseModel):
    texts: str # 原始代码期望单个字符串

def mean_pooling(model_output_last_hidden_state: np.ndarray, attention_mask: np.ndarray) -> np.ndarray:
    """
    Performs mean pooling on the token embeddings using the attention mask.
    model_output_last_hidden_state: (batch_size, sequence_length, hidden_size)
    attention_mask: (batch_size, sequence_length)
    """
    token_embeddings = model_output_last_hidden_state
    input_mask_expanded = np.expand_dims(attention_mask, axis=-1).astype(float)
    sum_embeddings = np.sum(token_embeddings * input_mask_expanded, axis=1)
    sum_mask = np.sum(input_mask_expanded, axis=1)
    sum_mask = np.maximum(sum_mask, 1e-9) # 防止除以零
    return sum_embeddings / sum_mask

@app.post("/embed")
async def embed(payload: TextPayload):
    if not tokenizer or not model:
        return {"error": "Model not loaded properly"}, 500

    # 1. 使用分词器处理输入文本
    # ONNX Runtime 需要 NumPy 数组作为输入
    inputs = tokenizer(payload.texts, return_tensors="np", padding=True, truncation=True)
    
    # 2. ONNX 模型推理
    # optimum的ORTModelForFeatureExtraction的输出是一个类似字典的对象,
    # 通常包含'last_hidden_state'。
    outputs = model(**inputs)
    last_hidden_state = outputs.last_hidden_state # (batch_size, seq_len, hidden_dim)
    
    # 3. 应用 Mean Pooling 以获得句子嵌入
    # 'paraphrase-multilingual-MiniLM-L12-v2' 使用 mean pooling
    sentence_embedding_np = mean_pooling(last_hidden_state, inputs['attention_mask'])
    
    # 因为输入 payload.texts 是单个字符串, batch_size 是 1.
    # 我们返回单个嵌入向量。
    return {"embeddings": sentence_embedding_np[0].tolist()}

if __name__ == "__main__":
    import uvicorn
    # 确保模型已加载再启动服务
    if tokenizer and model:
        uvicorn.run(app, host="0.0.0.0", port=5005)
    else:
        print("Failed to load model, Uvicorn server will not start.")

  • 重要:sentence-transformers库本身对PyTorch有很强的依赖性来加载和运行模型。为了完全移除PyTorch,我们不能直接使用SentenceTransformer类来加载ONNX模型。相反,我们使用optimum库中的ORTModelForFeatureExtraction和Hugging Face的AutoTokenizer来加载和运行已转换的ONNX模型和相应的分词器。
  • 句子嵌入通常是通过对模型输出的词元嵌入(token embeddings)进行池化(pooling)操作得到的。对于paraphrase-multilingual-MiniLM-L12-v2这类SBERT模型,常用的是均值池化(mean pooling)。上面的代码中添加了mean_pooling函数来实现这一点。

5. Dockerfile (优化后)

Dockerfile

#-----------------------------------------------------------------------------
# Stage 1: Builder - Convert model to ONNX and install build dependencies
#-----------------------------------------------------------------------------
FROM python:3.12-slim-bullseye as builder

WORKDIR /app

# 设置环境变量,避免生成 .pyc 文件,并使Python输出不缓冲
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# 安装构建阶段的Python依赖 (包括 torch, sentence-transformers, optimum 用于转换)
COPY requirements_builder.txt .
RUN pip install --no-cache-dir -r requirements_builder.txt \
    --index-url https://pypi.tuna.tsinghua.edu.cn/simple \
    --trusted-host pypi.tuna.tsinghua.edu.cn

# 复制转换脚本并执行模型转换
COPY convert_to_onnx.py .
# 将 'paraphrase-multilingual-MiniLM-L12-v2' 模型转换为ONNX格式并存放到 /app/onnx_model
# 这个模型名称与您原 sentence.py 中使用的一致
RUN python convert_to_onnx.py paraphrase-multilingual-MiniLM-L12-v2 /app/onnx_model

#-----------------------------------------------------------------------------
# Stage 2: Final - Create the minimal runtime image
#-----------------------------------------------------------------------------
FROM python:3.12-slim-bullseye as final

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# 创建一个非root用户来运行应用,增强安全性
ARG UID=10001
RUN useradd \
    --uid ${UID} \
    --gid 0 \
    --no-create-home \
    --home /nonexistent \
    --shell /bin/false \
    appuser
# RUN groupadd --gid ${UID} appuser && \
#     useradd --uid ${UID} --gid ${UID} --no-create-home --home /nonexistent --shell /sbin/nologin appuser

# 安装运行阶段的Python依赖 (fastapi, uvicorn, onnxruntime, optimum)
# 注意:这里不包含 torch 和原始的 sentence-transformers
COPY requirements_runtime.txt .
# 创建虚拟环境(可选,但有助于隔离)
# ENV VIRTUAL_ENV=/opt/venv
# RUN python3 -m venv $VIRTUAL_ENV
# ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# RUN $VIRTUAL_ENV/bin/pip install --no-cache-dir -r requirements_runtime.txt \
RUN pip install --no-cache-dir --user -r requirements_runtime.txt \
    --index-url https://pypi.tuna.tsinghua.edu.cn/simple \
    --trusted-host pypi.tuna.tsinghua.edu.cn

# 从构建阶段复制转换好的ONNX模型和tokenizer
COPY --from=builder --chown=appuser:0 /app/onnx_model /app/onnx_model

# 复制修改后的应用代码
COPY --chown=appuser:0 sentence_onnx.py ./sentence.py

# 设置appuser的环境变量,使其能找到安装的包
# 如果使用 --user 安装pip包,它们通常在 /home/appuser/.local/bin 或 /root/.local/bin (取决于USER指令前的用户)
# 我们在切换到appuser之前以root身份安装pip包到用户目录,然后调整PATH
# 或者,更好的方式是系统级安装或虚拟环境,并确保appuser有权访问。
# 为简单起见,如果pip install --user是以root身份执行(默认),则路径是/root/.local
# 如果在USER appuser之后执行,则是/home/appuser/.local
# 我们在USER appuser之前执行,所以是/root/.local/bin
ENV PATH=/root/.local/bin:$PATH

# 切换到非root用户
USER appuser

EXPOSE 5005

# 健康检查 (可选, 但推荐)
# HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
#   CMD curl -f http://localhost:5005/health || exit 1 # 假设你添加了一个 /health 端点

CMD ["python", "sentence.py"]

6. build.sh (基本不变,只需确保文件名对应)

您的build.sh脚本看起来是合理的,它会构建并推送Docker镜像。确保Dockerfile的路径正确。

优化说明:

  • 多阶段构建:
    • builder 阶段:安装包括torch在内的所有转换工具,下载原始模型,并将其转换为ONNX格式。这个阶段会比较大,但其内容不会进入最终镜像。
    • final 阶段:从一个干净的python:3.12-slim基础镜像开始,仅安装运行ONNX模型和FastAPI服务所需的最小依赖(如onnxruntime, optimum, fastapi, uvicorn)。然后从builder阶段复制转换后的ONNX模型文件。
  • ONNX Runtime:onnxruntime库远小于pytorch,这将显著减小最终镜像的体积。模型文件(.onnx)本身的大小与原始PyTorch模型权重文件大小相似(paraphrase-multilingual-MiniLM-L12-v2大约400-500MB),这部分是不可避免的,因为模型参数就是这么多。但我们节省了运行时库的空间。
  • 依赖最小化:requirements_runtime.txt只包含绝对必要的包。
  • Python版本:使用python:3.12-slim-bullseye,这是一个相对较小的官方镜像。
  • 非Root用户:在最终阶段使用非root用户运行应用,这是一个安全最佳实践。
  • 缓存:pip install --no-cache-dir避免了pip缓存的写入,有助于减小层的大小。
  • 国内镜像源:继续使用清华镜像源加速Python包的下载。

使用方法:

  1. 将上述内容的Dockerfile, requirements_builder.txt, requirements_runtime.txt, convert_to_onnx.py, 和 sentence_onnx.py 文件放到您的项目根目录。
  2. 确保您的build.sh脚本可以正确找到Dockerfile。
  3. 运行 ./build.sh docker。

这将首先构建包含PyTorch的builder阶段来转换模型,然后构建一个不含PyTorch但包含ONNX模型和ONNX Runtime的轻量级final镜像。

这种方法应该能显著减小您的镜像体积,同时保留了使用强大转换器模型的能力。请注意,模型文件本身(约450MB for paraphrase-multilingual-MiniLM-L12-v2)仍将是镜像大小的主要组成部分,但您已经从运行时环境中移除了庞大的PyTorch库。