初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,3 @@
|
||||
"""元炁"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
元炁配置管理。
|
||||
|
||||
从环境变量加载配置,遵循魂镜统一配置规范(所有配置通过环境变量注入,不使用 .env 文件)。
|
||||
模型文件统一存放在项目根目录 models/ 下,各引擎从对应子目录加载。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
_DEFAULT_MODELS_DIR = _PROJECT_ROOT / "models"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""元炁应用配置。"""
|
||||
|
||||
# 服务
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 11434
|
||||
|
||||
# ── 模型文件路径 ────────────────────────────────────
|
||||
models_dir: str = str(_DEFAULT_MODELS_DIR)
|
||||
|
||||
# 下载源:auto / huggingface / modelscope
|
||||
# auto 表示优先 ModelScope(国内快),失败后切 HuggingFace。
|
||||
model_source: str = "auto"
|
||||
|
||||
# LLM:GGUF 模型文件路径。默认扫描 $models_dir/gguf/ 下第一个 .gguf
|
||||
llm_model_path: str = ""
|
||||
|
||||
# Embedding:HuggingFace 模型名称。首次调用自动下载到 models/FlagModel/<模型名>。
|
||||
embedding_model: str = "BAAI/bge-m3"
|
||||
|
||||
# Reranker:HuggingFace 模型名称。首次调用自动下载到 models/transformers/<模型名>。
|
||||
reranker_model: str = "BAAI/bge-reranker-v2-m3"
|
||||
|
||||
# Vision(多模态):GGUF 多模态模型路径。默认扫描含 vision / multimodal / llava 的 .gguf
|
||||
vision_model_path: str = ""
|
||||
|
||||
# ASR(语音识别):FunASR 模型名称。自动下载到 models/funasr/<模型名>。
|
||||
asr_model: str = "iic/SenseVoiceSmall"
|
||||
|
||||
# 推理设备:auto / cuda / mps / cpu。
|
||||
# auto 表示自动检测最优可用加速器(CUDA > MPS > CPU)。
|
||||
device: str = "auto"
|
||||
|
||||
# LLM 默认参数
|
||||
llm_max_tokens: int = 2048
|
||||
llm_temperature: float = 0.7
|
||||
llm_ctx_size: int = 4096
|
||||
|
||||
# 日志级别
|
||||
log_level: str = "info"
|
||||
|
||||
@property
|
||||
def models_dir_resolved(self) -> Path:
|
||||
return Path(self.models_dir).resolve()
|
||||
|
||||
@property
|
||||
def llm_model_path_resolved(self) -> str:
|
||||
if self.llm_model_path:
|
||||
return str(Path(self.llm_model_path).resolve())
|
||||
gguf_dir = self.models_dir_resolved / "gguf"
|
||||
if gguf_dir.exists():
|
||||
files = list(gguf_dir.glob("*.gguf"))
|
||||
if files:
|
||||
return str(files[0])
|
||||
return ""
|
||||
|
||||
@property
|
||||
def vision_model_path_resolved(self) -> str:
|
||||
if self.vision_model_path:
|
||||
return str(Path(self.vision_model_path).resolve())
|
||||
gguf_dir = self.models_dir_resolved / "gguf"
|
||||
if gguf_dir.exists():
|
||||
for pat in ("*vision*", "*multimodal*", "*llava*", "*mmproj*"):
|
||||
matches = list(gguf_dir.glob(pat))
|
||||
if matches:
|
||||
return str(matches[0])
|
||||
return ""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="yuanqi_",
|
||||
env_file=None,
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
Whitespace-only changes.
@@ -0,0 +1,156 @@
|
||||
"""ASR 端点(语音转文本)。
|
||||
|
||||
提供 OpenAI Whisper 兼容的 ``POST /v1/audio/transcriptions`` 和自定义
|
||||
``POST /v1/asr`` 两个转写接口。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
|
||||
from src.engine.asr import _SUPPORTED_FORMATS, SenseVoiceASREngine
|
||||
from src.models.schemas import ASRResponse, ASRTranscriptionResponse
|
||||
from src.utils.logging import logger
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
asr_engine: SenseVoiceASREngine | None = None
|
||||
|
||||
|
||||
def init(engine: SenseVoiceASREngine) -> None:
|
||||
global asr_engine
|
||||
asr_engine = engine
|
||||
|
||||
|
||||
def _ensure_loaded() -> SenseVoiceASREngine:
|
||||
"""确保引擎已就绪,否则抛出 503。"""
|
||||
if asr_engine is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={"code": "asr_unavailable", "message": "ASR 引擎未就绪"},
|
||||
)
|
||||
if not asr_engine.model_loaded:
|
||||
# 懒加载
|
||||
import asyncio
|
||||
asyncio.create_task(asr_engine.load())
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={"code": "model_loading", "message": "ASR 模型正在加载中,请稍后重试"},
|
||||
)
|
||||
return asr_engine
|
||||
|
||||
|
||||
def _validate_format(filename: str) -> str:
|
||||
"""校验文件扩展名是否支持。"""
|
||||
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
if ext not in _SUPPORTED_FORMATS:
|
||||
supported = ", ".join(sorted(_SUPPORTED_FORMATS))
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "unsupported_format",
|
||||
"message": f"不支持的音频格式 '{ext}',支持格式: {supported}",
|
||||
},
|
||||
)
|
||||
return ext
|
||||
|
||||
|
||||
# ── OpenAI Whisper 兼容端点 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/audio/transcriptions", response_model=ASRTranscriptionResponse)
|
||||
async def transcribe_whisper(
|
||||
file: UploadFile = File(...),
|
||||
language: str | None = Form(default=None),
|
||||
) -> ASRTranscriptionResponse:
|
||||
"""OpenAI Whisper 兼容的转写端点。
|
||||
|
||||
通过 ``multipart/form-data`` 上传音频文件,返回 JSON 格式的转写文本。
|
||||
|
||||
请求格式(OpenAI Whisper 兼容)::
|
||||
|
||||
POST /v1/audio/transcriptions
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file: <audio_file>
|
||||
language: zh (可选)
|
||||
|
||||
响应::
|
||||
|
||||
{"text": "转写后的文本"}
|
||||
|
||||
Args:
|
||||
file: 音频文件(WAV / MP3 / FLAC / M4A / OGG 等)。
|
||||
language: 可选的语言提示(ISO-639-1:zh/en/ja/ko/yue 等)。
|
||||
|
||||
Returns:
|
||||
OpenAI Whisper 格式的响应。
|
||||
"""
|
||||
engine = _ensure_loaded()
|
||||
fmt = _validate_format(file.filename or "audio.wav")
|
||||
audio_bytes = await file.read()
|
||||
|
||||
logger.info("ASR 请求 | file={} format={} language={} size={}",
|
||||
file.filename, fmt, language, len(audio_bytes))
|
||||
|
||||
result = await engine.transcribe(
|
||||
audio_data=audio_bytes,
|
||||
format=fmt,
|
||||
language=language,
|
||||
)
|
||||
logger.info("ASR Whisper 响应 | text={}", result["text"])
|
||||
return ASRTranscriptionResponse(text=result["text"])
|
||||
|
||||
|
||||
# ── 自定义 ASR 端点 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/asr", response_model=ASRResponse)
|
||||
async def transcribe_custom(
|
||||
file: UploadFile = File(...),
|
||||
language: str | None = Form(default=None),
|
||||
) -> ASRResponse:
|
||||
"""自定义 ASR 转写端点(含更多元信息)。
|
||||
|
||||
请求格式::
|
||||
|
||||
POST /v1/asr
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file: <audio_file>
|
||||
language: zh (可选)
|
||||
|
||||
响应::
|
||||
|
||||
{
|
||||
"text": "转写后的文本",
|
||||
"model": "SenseVoiceSmall",
|
||||
"language": "zh"
|
||||
}
|
||||
|
||||
Args:
|
||||
file: 音频文件(WAV / MP3 / FLAC / M4A / OGG 等)。
|
||||
language: 可选的语言提示。
|
||||
|
||||
Returns:
|
||||
含文本、模型名、语言的转写结果。
|
||||
"""
|
||||
engine = _ensure_loaded()
|
||||
fmt = _validate_format(file.filename or "audio.wav")
|
||||
audio_bytes = await file.read()
|
||||
|
||||
logger.info("ASR 请求 | file={} format={} language={} size={}",
|
||||
file.filename, fmt, language, len(audio_bytes))
|
||||
|
||||
result = await engine.transcribe(
|
||||
audio_data=audio_bytes,
|
||||
format=fmt,
|
||||
language=language,
|
||||
)
|
||||
logger.info("ASR 自定义响应 | model={} lang={} text={}",
|
||||
result["model"], result["language"], result["text"])
|
||||
return ASRResponse(
|
||||
text=result["text"],
|
||||
model=result["model"],
|
||||
language=result["language"],
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Chat Completions 端点(LLM 对话 + 多模态视觉理解)。
|
||||
|
||||
兼容 OpenAI Chat Completions 格式,由本地 llama-cpp-python 直接推理。
|
||||
流式输出通过 text/event-stream 逐块返回。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from src.config import settings
|
||||
from src.engine.llm import LlamaEngine
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
llm: LlamaEngine | None = None
|
||||
|
||||
|
||||
def init(llm_engine: LlamaEngine) -> None:
|
||||
global llm
|
||||
llm = llm_engine
|
||||
|
||||
|
||||
@router.post("/chat/completions")
|
||||
async def chat_completions(request: Request) -> Any:
|
||||
if llm is None:
|
||||
raise HTTPException(status_code=503, detail={"code": "llm_unavailable", "message": "LLM 引擎未就绪"})
|
||||
|
||||
if not llm.model_loaded:
|
||||
# 尝试懒加载
|
||||
if not settings.llm_model_path_resolved:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={"code": "model_not_configured", "message": "未配置 LLM 模型路径"},
|
||||
)
|
||||
await llm.load()
|
||||
if not llm.model_loaded:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={"code": "model_load_failed", "message": "LLM 模型加载失败"},
|
||||
)
|
||||
|
||||
body: dict[str, Any] = await request.json()
|
||||
stream = body.get("stream", False)
|
||||
|
||||
try:
|
||||
result = await llm.chat_completion(body)
|
||||
|
||||
# 检查错误
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=502, detail=result["error"])
|
||||
|
||||
# 流式响应
|
||||
if stream and "_stream" in result:
|
||||
return StreamingResponse(
|
||||
_stream_generator(result["_stream"]),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail={"code": "inference_error", "message": str(e)})
|
||||
|
||||
|
||||
async def _stream_generator(stream):
|
||||
"""将 llama-cpp-python 流式输出转换为 SSE 格式。"""
|
||||
import json
|
||||
|
||||
for chunk in stream:
|
||||
yield f"data: {json.dumps(chunk)}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Embeddings 端点(文本向量嵌入)。
|
||||
|
||||
兼容 OpenAI Embeddings API(dense),
|
||||
通过 ``output_types`` 参数扩展支持 sparse / colbert 多向量输出。
|
||||
|
||||
Response 遵循全局 Embedding API 协议设计:
|
||||
|
||||
- ``data[].embedding`` — OpenAI 兼容稠密向量
|
||||
- ``usage.details.input`` — 输入统计(count / tokens / total_tokens)
|
||||
- ``usage.details.representations[]`` — 输出表示描述数组,每项含
|
||||
``type`` / ``modality`` 及类型专属字段(dimension / non_zero_count / vector_count 等)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from src.engine.embedding import EmbeddingEngine
|
||||
from src.models.schemas import EmbeddingRequest
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
embedding: EmbeddingEngine | None = None
|
||||
|
||||
|
||||
def init(embedding_engine: EmbeddingEngine) -> None:
|
||||
global embedding
|
||||
embedding = embedding_engine
|
||||
|
||||
|
||||
def _embeddings_to_list(results: list[dict]) -> list[dict]:
|
||||
"""将 embed() 引擎输出转为统一 data 列表格式。
|
||||
|
||||
每个 item 始终包含 ``embedding``(稠密向量),
|
||||
当引擎返回 sparse / colbert 时额外包含对应字段。
|
||||
"""
|
||||
data: list[dict] = []
|
||||
for i, r in enumerate(results):
|
||||
item: dict[str, object] = {
|
||||
"object": "embedding",
|
||||
"index": i,
|
||||
"embedding": r.get("embedding", []),
|
||||
}
|
||||
if "sparse" in r:
|
||||
item["sparse_embedding"] = {
|
||||
"indices": r["sparse"].get("indices", []),
|
||||
"values": r["sparse"].get("values", []),
|
||||
}
|
||||
if "colbert" in r:
|
||||
item["colbert_embedding"] = r["colbert"]
|
||||
data.append(item)
|
||||
return data
|
||||
|
||||
|
||||
def _build_representations(
|
||||
data: list[dict],
|
||||
dim: int,
|
||||
has_sparse: bool,
|
||||
has_colbert: bool,
|
||||
) -> list[dict]:
|
||||
"""构建 ``usage.details.representations`` 数组。
|
||||
|
||||
始终包含 dense 条目,按需追加 sparse / colbert 条目。
|
||||
批量输入时附带聚合统计(total_non_zero_count / total_vector_count),
|
||||
单条输入时仅给出该条的值。
|
||||
"""
|
||||
reps: list[dict] = []
|
||||
|
||||
# 1. Dense(始终存在)
|
||||
reps.append({
|
||||
"type": "dense",
|
||||
"modality": "text",
|
||||
"dimension": dim,
|
||||
})
|
||||
|
||||
# 2. Sparse
|
||||
if has_sparse:
|
||||
nnz_list = [
|
||||
len((item.get("sparse_embedding") or {}).get("indices", []))
|
||||
for item in data
|
||||
]
|
||||
entry: dict = {
|
||||
"type": "sparse",
|
||||
"modality": "text",
|
||||
}
|
||||
if len(nnz_list) > 1:
|
||||
entry["non_zero_count"] = nnz_list
|
||||
entry["total_non_zero_count"] = sum(nnz_list)
|
||||
else:
|
||||
entry["non_zero_count"] = nnz_list[0] if nnz_list else 0
|
||||
reps.append(entry)
|
||||
|
||||
# 3. ColBERT
|
||||
if has_colbert:
|
||||
vec_counts = [len(item.get("colbert_embedding") or []) for item in data]
|
||||
entry: dict = {
|
||||
"type": "colbert",
|
||||
"modality": "text",
|
||||
"dimension": dim,
|
||||
}
|
||||
if len(vec_counts) > 1:
|
||||
entry["vector_count"] = vec_counts
|
||||
entry["total_vector_count"] = sum(vec_counts)
|
||||
else:
|
||||
entry["vector_count"] = vec_counts[0] if vec_counts else 0
|
||||
reps.append(entry)
|
||||
|
||||
return reps
|
||||
|
||||
|
||||
def _build_usage(
|
||||
token_counts: list[int],
|
||||
representations: list[dict],
|
||||
) -> dict:
|
||||
"""组装 usage 响应。
|
||||
|
||||
OpenAI 兼容字段:prompt_tokens / total_tokens。
|
||||
扩展字段:details(嵌套 input 统计 + representations 数组)。
|
||||
"""
|
||||
total = sum(token_counts)
|
||||
return {
|
||||
"prompt_tokens": total,
|
||||
"total_tokens": total,
|
||||
"details": {
|
||||
"input": {
|
||||
"count": len(token_counts),
|
||||
"tokens": token_counts,
|
||||
"total_tokens": total,
|
||||
},
|
||||
"representations": representations,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/embeddings")
|
||||
async def embeddings(req: EmbeddingRequest) -> dict:
|
||||
if embedding is None:
|
||||
raise HTTPException(status_code=503, detail={"code": "embedding_unavailable", "message": "Embedding 引擎未就绪"})
|
||||
|
||||
if not embedding.model_loaded:
|
||||
await embedding.load()
|
||||
if not embedding.model_loaded:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={"code": "model_load_failed", "message": "Embedding 模型加载失败"},
|
||||
)
|
||||
|
||||
input_data = req.input
|
||||
output_types = req.output_types
|
||||
|
||||
# 统一为列表
|
||||
if isinstance(input_data, str):
|
||||
input_data = [input_data]
|
||||
|
||||
# 映射 output_types 到引擎参数
|
||||
return_sparse = "sparse" in output_types
|
||||
return_colbert = "colbert" in output_types
|
||||
# 引擎始终返回 dense
|
||||
encoding_format = "dense"
|
||||
if return_sparse and return_colbert:
|
||||
encoding_format = "all"
|
||||
elif return_sparse:
|
||||
encoding_format = "sparse"
|
||||
elif return_colbert:
|
||||
encoding_format = "colbert"
|
||||
|
||||
try:
|
||||
results = await embedding.embed(input_data, encoding_format=encoding_format)
|
||||
token_counts = embedding.count_tokens(input_data)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail={"code": "embedding_error", "message": str(e)})
|
||||
|
||||
# 组装 data
|
||||
data = _embeddings_to_list(results)
|
||||
|
||||
# 从实际数据推导稠密向量维度
|
||||
dim = len(data[0]["embedding"]) if data and data[0].get("embedding") else 0
|
||||
|
||||
# 组装 representations 数组
|
||||
representations = _build_representations(data, dim, return_sparse, return_colbert)
|
||||
|
||||
# 组装 usage
|
||||
usage = _build_usage(token_counts, representations)
|
||||
|
||||
return {
|
||||
"object": "list",
|
||||
"model": req.model,
|
||||
"data": data,
|
||||
"usage": usage,
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"""健康检查端点。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src import __version__
|
||||
from src.engine.embedding import EmbeddingEngine
|
||||
from src.engine.llm import LlamaEngine
|
||||
from src.engine.reranker import RerankerEngine
|
||||
from src.engine.asr import SenseVoiceASREngine
|
||||
from src.models.schemas import EngineStatus, HealthResponse, ModelStatus
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 全局引擎实例(由 main.py startup 注入)
|
||||
llm: LlamaEngine | None = None
|
||||
embedding: EmbeddingEngine | None = None
|
||||
reranker: RerankerEngine | None = None
|
||||
asr: SenseVoiceASREngine | None = None
|
||||
|
||||
|
||||
def init(
|
||||
llm_engine: LlamaEngine,
|
||||
embedding_engine: EmbeddingEngine,
|
||||
reranker_engine: RerankerEngine,
|
||||
asr_engine: SenseVoiceASREngine,
|
||||
) -> None:
|
||||
"""注入引擎实例。"""
|
||||
global llm, embedding, reranker, asr
|
||||
llm = llm_engine
|
||||
embedding = embedding_engine
|
||||
reranker = reranker_engine
|
||||
asr = asr_engine
|
||||
|
||||
|
||||
@router.get("/healthz", response_model=HealthResponse)
|
||||
async def health() -> HealthResponse:
|
||||
engines_status = {}
|
||||
loaded_models: list[ModelStatus] = []
|
||||
|
||||
for name, engine in [("llm", llm), ("embedding", embedding), ("reranker", reranker), ("asr", asr)]:
|
||||
if engine is None:
|
||||
engines_status[name] = EngineStatus(status="unavailable")
|
||||
else:
|
||||
h = await engine.health()
|
||||
engines_status[name] = EngineStatus(status=h.get("status", "error"))
|
||||
if engine.model_loaded:
|
||||
loaded_models.append(
|
||||
ModelStatus(
|
||||
name=h.get("model", name),
|
||||
type=name,
|
||||
status="loaded",
|
||||
)
|
||||
)
|
||||
|
||||
# 整体状态:所有就绪→ok,部分就绪→degraded,全不可用→error
|
||||
ok_count = sum(1 for e in engines_status.values() if e.status == "ok")
|
||||
unavailable_count = sum(1 for e in engines_status.values() if e.status == "unavailable")
|
||||
total = len(engines_status)
|
||||
if ok_count == total:
|
||||
overall = "ok"
|
||||
elif unavailable_count == total:
|
||||
overall = "error"
|
||||
else:
|
||||
overall = "degraded"
|
||||
|
||||
return HealthResponse(
|
||||
status=overall,
|
||||
engines=engines_status,
|
||||
loaded_models=loaded_models,
|
||||
version=__version__,
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Reranker 端点(重排序)。
|
||||
|
||||
使用 transformers CrossEncoder 对查询和文档进行相关性排序。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from src.engine.reranker import RerankerEngine
|
||||
from src.models.schemas import RerankRequest, RerankResponse, RerankResult
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
reranker: RerankerEngine | None = None
|
||||
|
||||
|
||||
def init(reranker_engine: RerankerEngine) -> None:
|
||||
global reranker
|
||||
reranker = reranker_engine
|
||||
|
||||
|
||||
@router.post("/rerank", response_model=RerankResponse)
|
||||
async def rerank(req: RerankRequest) -> RerankResponse:
|
||||
if reranker is None:
|
||||
raise HTTPException(status_code=503, detail={"code": "reranker_unavailable", "message": "Reranker 引擎未就绪"})
|
||||
|
||||
if not reranker.model_loaded:
|
||||
await reranker.load()
|
||||
if not reranker.model_loaded:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={"code": "model_load_failed", "message": "Reranker 模型加载失败"},
|
||||
)
|
||||
|
||||
try:
|
||||
results = await reranker.rerank(
|
||||
query=req.query,
|
||||
documents=req.documents,
|
||||
top_n=req.top_n,
|
||||
)
|
||||
usage = reranker.count_tokens(req.query, req.documents)
|
||||
return RerankResponse(
|
||||
model=req.model,
|
||||
results=[RerankResult(**r) for r in results],
|
||||
usage=usage,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail={"code": "reranker_error", "message": str(e)})
|
||||
Whitespace-only changes.
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
本地 ASR 推理引擎 — FunASR + SenseVoiceSmall。
|
||||
|
||||
基于阿里达摩院 FunASR 框架加载 SenseVoiceSmall 模型,提供本地离线语音转文字能力。
|
||||
|
||||
支持语言:中文、粤语、英文、日文、韩文等。
|
||||
额外能力:情感识别、事件检测(笑声、掌声、音乐等)。
|
||||
|
||||
模型下载位置(按优先级):
|
||||
1. ``YUANQI_ASR_MODEL`` 为本地路径时,直接从该路径加载
|
||||
2. ``YUANQI_ASR_MODEL`` 为 HuggingFace/ModelScope 模型名时,下载到
|
||||
``models/funasr/<模型名>`` 后再加载
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging as _logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# 在 funasr / modelscope 导入前压制其冗余 INFO 日志
|
||||
_logging.getLogger("modelscope").setLevel(_logging.WARNING)
|
||||
_logging.getLogger("modelscope_hub").setLevel(_logging.WARNING)
|
||||
|
||||
from src.config import settings # noqa: E402
|
||||
from src.utils.logging import logger # noqa: E402
|
||||
|
||||
# funasr 支持的音频格式白名单
|
||||
_SUPPORTED_FORMATS = frozenset({
|
||||
"wav", "mp3", "flac", "ogg", "m4a", "opus",
|
||||
"aac", "pcm", "amr", "webm",
|
||||
})
|
||||
|
||||
# 语言代码映射(ISO-639-1 → SenseVoice 内部标签)
|
||||
_LANG_MAP: dict[str, str] = {
|
||||
"auto": "auto", # 自动检测
|
||||
"zh": "zh", # 中文
|
||||
"en": "en", # 英文
|
||||
"ja": "ja", # 日文
|
||||
"ko": "ko", # 韩文
|
||||
"yue": "yue", # 粤语
|
||||
"pt": "pt", # 葡萄牙语
|
||||
"fr": "fr", # 法语
|
||||
"es": "es", # 西班牙语
|
||||
"ru": "ru", # 俄语
|
||||
"ar": "ar", # 阿拉伯语
|
||||
"vi": "vi", # 越南语
|
||||
"th": "th", # 泰语
|
||||
"id": "id", # 印度尼西亚语
|
||||
}
|
||||
|
||||
|
||||
# SenseVoiceSmall 情感标签
|
||||
_SENSEVOICE_EMOTIONS = frozenset({
|
||||
"NEUTRAL", "HAPPY", "SAD", "ANGRY", "SURPRISED",
|
||||
"FEARFUL", "DISGUSTED", "EMO_UNKNOWN",
|
||||
})
|
||||
|
||||
# SenseVoiceSmall 事件标签
|
||||
_SENSEVOICE_EVENTS = frozenset({
|
||||
"Speech", "laughter", "applause", "music", "sing",
|
||||
"cry", "breath", "cough", "sneeze", "nospeech",
|
||||
})
|
||||
|
||||
# SenseVoiceSmall 内部标志(不暴露给用户)
|
||||
_SENSEVOICE_INTERNAL = frozenset({
|
||||
"withitn", "woitn",
|
||||
})
|
||||
|
||||
# 匹配所有 <|...|> 标签
|
||||
_TAG_PATTERN = re.compile(r"<\|([^|]+)\|>")
|
||||
|
||||
|
||||
def _clean_sensevoice_text(raw: str) -> tuple[str, str | None, list[str], list[str]]:
|
||||
"""清洗 SenseVoiceSmall 原始输出,提取标签并从文本中剥离。
|
||||
|
||||
SenseVoiceSmall 返回示例::
|
||||
|
||||
<|zh|><|NEUTRAL|><|Speech|><|withitn|>你好呀,今天天气真好。
|
||||
|
||||
Returns:
|
||||
(cleaned_text, detected_lang, emotions, events)
|
||||
"""
|
||||
tags = _TAG_PATTERN.findall(raw)
|
||||
cleaned = _TAG_PATTERN.sub("", raw).strip()
|
||||
|
||||
detected_lang = None
|
||||
emotions: list[str] = []
|
||||
events: list[str] = []
|
||||
|
||||
for tag in tags:
|
||||
t = tag.strip()
|
||||
if t in _SENSEVOICE_EMOTIONS:
|
||||
emotions.append(t)
|
||||
elif t in _SENSEVOICE_EVENTS:
|
||||
events.append(t)
|
||||
elif t in _SENSEVOICE_INTERNAL:
|
||||
pass # 内部标志不返回
|
||||
elif t in _LANG_MAP:
|
||||
detected_lang = t
|
||||
# 其他未知标签忽略
|
||||
|
||||
return cleaned, detected_lang, emotions, events
|
||||
|
||||
|
||||
class SenseVoiceASREngine:
|
||||
"""本地 ASR 引擎 — FunASR + SenseVoiceSmall。"""
|
||||
|
||||
name: str = "asr"
|
||||
model_loaded: bool = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._model_name = settings.asr_model or "iic/SenseVoiceSmall"
|
||||
self._model = None
|
||||
logger.info(
|
||||
"ASR 引擎配置 | model={}",
|
||||
self._model_name,
|
||||
)
|
||||
|
||||
def _resolve_model_path(self) -> str:
|
||||
"""解析模型路径并下载到本地。
|
||||
|
||||
同 embedding / reranker 的路径解析模式。
|
||||
"""
|
||||
# 1) 直接是本地路径
|
||||
direct = Path(self._model_name)
|
||||
if direct.is_absolute() and direct.exists():
|
||||
return str(direct)
|
||||
candidate = settings.models_dir_resolved / self._model_name
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
|
||||
# 2) 已下载到 models/funasr/<repo_name>
|
||||
repo_name = Path(self._model_name).name
|
||||
target_dir = settings.models_dir_resolved / "funasr" / repo_name
|
||||
if target_dir.exists():
|
||||
return str(target_dir)
|
||||
|
||||
# 3) 下载到 models/funasr/<repo_name>
|
||||
target = target_dir
|
||||
logger.info("本地 ASR 模型不存在,从 {} 下载到 {}", settings.model_source, target)
|
||||
from src.utils.downloader import download_model
|
||||
|
||||
if download_model(self._model_name, target, settings.model_source):
|
||||
return str(target)
|
||||
logger.warning("ASR 模型下载失败,回退到 HF 缓存加载")
|
||||
return self._model_name
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
if not self.model_loaded:
|
||||
return {"status": "unavailable", "model": self._model_name}
|
||||
return {"status": "ok", "model": self._model_name}
|
||||
|
||||
async def load(self) -> None:
|
||||
"""加载 FunASR 模型(SenseVoiceSmall)。
|
||||
|
||||
模型加载完成后,引擎即可使用 ``transcribe()`` 对音频进行转写。
|
||||
"""
|
||||
if self.model_loaded:
|
||||
return
|
||||
try:
|
||||
model_path = self._resolve_model_path()
|
||||
from funasr import AutoModel
|
||||
|
||||
logger.info("正在加载 ASR 模型: {}", model_path)
|
||||
self._model = AutoModel(
|
||||
model="iic/SenseVoiceSmall",
|
||||
model_dir=model_path,
|
||||
disable_update=True,
|
||||
log_level="WARNING",
|
||||
)
|
||||
self.model_loaded = True
|
||||
logger.info("ASR 模型加载完成: {}", model_path)
|
||||
except ImportError:
|
||||
logger.error(
|
||||
"funasr 未安装。请运行: pip install 'hunjing-yuanqi[asr]' 或 "
|
||||
"cd yuanqi && uv add funasr"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("ASR 模型加载失败: {}", e)
|
||||
|
||||
def _normalize_language(self, language: str | None) -> str:
|
||||
"""将用户指定的语言代码归一化为 SenseVoice 支持的标签。
|
||||
|
||||
完整支持语言列表见 FunASR 文档。
|
||||
"""
|
||||
if not language or language.lower() in ("auto", ""):
|
||||
return "auto"
|
||||
lang = _LANG_MAP.get(language.lower())
|
||||
return lang if lang else "auto"
|
||||
|
||||
async def transcribe(
|
||||
self,
|
||||
audio_data: bytes,
|
||||
format: str = "wav",
|
||||
language: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""将音频字节转写为文本。
|
||||
|
||||
Args:
|
||||
audio_data: 原始音频字节。
|
||||
format: 音频容器格式(如 ``wav``、``mp3``、``m4a``)。
|
||||
language: 可选的语言提示(ISO-639-1),不传则自动检测。
|
||||
|
||||
Returns:
|
||||
dict:
|
||||
- ``text``: 转写文本
|
||||
- ``model``: 使用的模型名
|
||||
- ``language``: 检测到的语言
|
||||
- ``emotions``: 可选的情感标签(如果模型返回)
|
||||
- ``events``: 可选的事件标签(笑声/掌声等)
|
||||
"""
|
||||
if not self.model_loaded or self._model is None:
|
||||
logger.warning("ASR 模型未加载,返回空结果")
|
||||
return {"text": "", "model": self._model_name, "language": None}
|
||||
|
||||
lang_tag = self._normalize_language(language)
|
||||
|
||||
# 将音频字节写入临时文件进行处理
|
||||
suffix = f".{format}" if format.startswith(".") else f".{format}"
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||
tmp.write(audio_data)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
result = self._model.generate(
|
||||
input=tmp_path,
|
||||
language=lang_tag,
|
||||
use_itn=True, # 逆文本正规化(数字、标点、单位)
|
||||
ban_emo_unk=False, # 保留情感/事件标签
|
||||
# 可选:hotword="xxx", 热词增强
|
||||
)
|
||||
|
||||
# 解析结果 —— 格式:[{"key": "xxx", "text": "...", "timestamp": "..."}]
|
||||
text = ""
|
||||
detected_lang = language
|
||||
emotions: list[str] = []
|
||||
events: list[str] = []
|
||||
|
||||
if isinstance(result, list) and len(result) > 0:
|
||||
raw_text = (
|
||||
result[0].get("text", "")
|
||||
if isinstance(result[0], dict)
|
||||
else str(result[0])
|
||||
)
|
||||
|
||||
# 清洗 SenseVoiceSmall 内部标签,提取元信息
|
||||
cleaned_text, tag_lang, tag_emotions, tag_events = _clean_sensevoice_text(raw_text)
|
||||
text = cleaned_text
|
||||
|
||||
# 模型检测到的语言优先于用户指定的 language hint
|
||||
if tag_lang and language in (None, "auto"):
|
||||
detected_lang = tag_lang
|
||||
|
||||
emotions = tag_emotions
|
||||
events = tag_events
|
||||
|
||||
# 也检查 result dict 中的额外字段
|
||||
if isinstance(result[0], dict):
|
||||
emo = result[0].get("emotion")
|
||||
if emo and emo not in emotions:
|
||||
emotions.append(emo)
|
||||
|
||||
logger.info(
|
||||
"ASR 转写完成 | format={} lang={} text_len={}",
|
||||
format, detected_lang, len(text),
|
||||
)
|
||||
if text:
|
||||
logger.info("ASR 识别结果: {}", text)
|
||||
return {
|
||||
"text": text,
|
||||
"model": self._model_name,
|
||||
"language": detected_lang or "auto",
|
||||
"emotions": emotions if emotions else None,
|
||||
"events": events if events else None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("ASR 转写失败: {}", e)
|
||||
return {"text": "", "model": self._model_name, "language": None}
|
||||
|
||||
finally:
|
||||
# 清理临时文件
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def unload(self) -> None:
|
||||
self._model = None
|
||||
self.model_loaded = False
|
||||
logger.info("ASR 模型已卸载")
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.unload()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
推理引擎抽象基类。
|
||||
|
||||
定义元炁支持的所有引擎的统一接口。所有引擎独立加载模型、独立管理生命周期。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class InferenceEngine(ABC):
|
||||
"""推理引擎基类。"""
|
||||
|
||||
name: str = "base"
|
||||
model_loaded: bool = False
|
||||
|
||||
@abstractmethod
|
||||
async def health(self) -> dict[str, Any]:
|
||||
"""返回引擎健康状态。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def load(self) -> None:
|
||||
"""加载模型(启动时或首次调用时懒加载)。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def unload(self) -> None:
|
||||
"""卸载模型,释放资源。"""
|
||||
...
|
||||
|
||||
async def close(self) -> None:
|
||||
"""释放引擎资源。"""
|
||||
await self.unload()
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
本地 Embedding 推理引擎。
|
||||
|
||||
基于 FlagEmbedding(BAAI 官方库)加载 BGE 系列嵌入模型(如 BGE-M3),
|
||||
支持稠密(dense)、稀疏(sparse)和多向量(colbert)三种嵌入模式,
|
||||
默认输出稠密向量(dense embedding)。
|
||||
|
||||
模型下载位置(按优先级):
|
||||
1. ``YUANQI_EMBEDDING_MODEL`` 为本地路径时,直接从该路径加载
|
||||
2. ``YUANQI_EMBEDDING_MODEL`` 为 HuggingFace 模型名时,下载到
|
||||
``models/FlagModel/<模型名>`` 后再加载
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from src.config import settings
|
||||
from src.utils.device import detect_device
|
||||
from src.utils.logging import logger
|
||||
|
||||
|
||||
class EmbeddingEngine:
|
||||
"""本地 Embedding 引擎。"""
|
||||
|
||||
name: str = "embedding"
|
||||
model_loaded: bool = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._device = detect_device(settings.device)
|
||||
self._model_name = settings.embedding_model or "FlagModel/bge-m3"
|
||||
self._model = None
|
||||
self._tokenizer = None # 用于 token 计数
|
||||
logger.info(
|
||||
"Embedding 引擎配置 | model={} device={}",
|
||||
self._model_name,
|
||||
self._device,
|
||||
)
|
||||
|
||||
def _resolve_model_path(self) -> str:
|
||||
"""解析模型路径。
|
||||
|
||||
``embedding_model`` 是 HuggingFace 模型名时,下载到
|
||||
``models/FlagModel/<模型名>`` 后再加载。
|
||||
"""
|
||||
# 1) 直接是本地路径
|
||||
direct = Path(self._model_name)
|
||||
if direct.is_absolute() and direct.exists():
|
||||
return str(direct)
|
||||
candidate = settings.models_dir_resolved / self._model_name
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
|
||||
# 2) 已下载到目标目录
|
||||
target_dir = settings.models_dir_resolved / "FlagModel" / self._model_name
|
||||
if target_dir.exists():
|
||||
return str(target_dir)
|
||||
|
||||
# 3) 已下载到 models/FlagModel/<repo_name>
|
||||
repo_name = Path(self._model_name).name
|
||||
target_dir2 = settings.models_dir_resolved / "FlagModel" / repo_name
|
||||
if target_dir2.exists():
|
||||
return str(target_dir2)
|
||||
|
||||
# 4) 下载到 models/FlagModel/<repo_name>
|
||||
target = target_dir2
|
||||
logger.info("本地模型不存在,从 {} 下载到 {}", settings.model_source, target)
|
||||
from src.utils.downloader import download_model
|
||||
|
||||
if download_model(self._model_name, target, settings.model_source):
|
||||
return str(target)
|
||||
logger.warning("模型下载失败,回退到 HF 缓存加载")
|
||||
return self._model_name
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
if not self.model_loaded:
|
||||
return {"status": "unavailable", "model": self._model_name}
|
||||
return {"status": "ok", "model": self._model_name}
|
||||
|
||||
async def load(self) -> None:
|
||||
"""加载 FlagEmbedding 模型和对应的 tokenizer。"""
|
||||
if self.model_loaded:
|
||||
return
|
||||
try:
|
||||
model_path = self._resolve_model_path()
|
||||
from FlagEmbedding import BGEM3FlagModel
|
||||
|
||||
logger.info("正在加载 Embedding 模型: {}", model_path)
|
||||
self._model = BGEM3FlagModel(
|
||||
model_path,
|
||||
use_fp16=self._device in ("cuda", "mps"),
|
||||
device=self._device,
|
||||
)
|
||||
# 加载同 tokenizer 用于计数
|
||||
try:
|
||||
from transformers import AutoTokenizer
|
||||
self._tokenizer = AutoTokenizer.from_pretrained(model_path)
|
||||
except Exception:
|
||||
self._tokenizer = None
|
||||
self.model_loaded = True
|
||||
logger.info("Embedding 模型加载完成: {}", model_path)
|
||||
except ImportError:
|
||||
logger.error(
|
||||
"FlagEmbedding 未安装。请运行: pip install 'hunjing-yuanqi[embedding]'"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Embedding 模型加载失败: {}", e)
|
||||
|
||||
def count_tokens(self, texts: list[str]) -> list[int]:
|
||||
"""统计每段文本的 token 数。"""
|
||||
from src.utils.token_counter import TokenCounter, EmbeddingTokenCountStrategy
|
||||
|
||||
if self._tokenizer is None:
|
||||
return [0] * len(texts)
|
||||
strategy = EmbeddingTokenCountStrategy()
|
||||
return [
|
||||
strategy.count(self._tokenizer, text=t) for t in texts
|
||||
]
|
||||
|
||||
async def embed(
|
||||
self,
|
||||
texts: list[str],
|
||||
encoding_format: str = "dense",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""将文本列表转为向量嵌入。
|
||||
|
||||
Args:
|
||||
texts: 待编码的文本列表。
|
||||
encoding_format: 嵌入格式。
|
||||
``dense`` — 稠密向量(默认,兼容 OpenAI API)。
|
||||
``sparse`` — 稀疏向量 {indices, values}。
|
||||
``colbert`` — ColBERT 多向量。
|
||||
``all`` — 同时返回 dense + sparse + colbert。
|
||||
|
||||
Returns:
|
||||
向量列表,每项为 dict,不同格式含不同 key:
|
||||
|
||||
- ``dense``: ``{"embedding": [float, ...]}``
|
||||
- ``sparse``: ``{"sparse": {"indices": [int, ...], "values": [float, ...]}}``
|
||||
- ``colbert``: ``{"colbert": [[float, ...], ...]}``
|
||||
- ``all``: 同时含以上三个 key。
|
||||
"""
|
||||
if not self.model_loaded or self._model is None:
|
||||
empty: list[dict[str, Any]] = [{"embedding": []} for _ in texts]
|
||||
return empty
|
||||
|
||||
return_sparse = encoding_format in ("sparse", "all")
|
||||
return_colbert = encoding_format in ("colbert", "all")
|
||||
|
||||
output = self._model.encode(
|
||||
texts,
|
||||
return_dense=True,
|
||||
return_sparse=return_sparse,
|
||||
return_colbert_vecs=return_colbert,
|
||||
)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
for i in range(len(texts)):
|
||||
item: dict[str, Any] = {}
|
||||
|
||||
# 稠密
|
||||
dense_vec = output["dense_vecs"][i].tolist()
|
||||
item["embedding"] = dense_vec
|
||||
|
||||
# 稀疏
|
||||
if return_sparse:
|
||||
lw = output["lexical_weights"][i]
|
||||
# lexical_weights 是 defaultdict(token_id_str → weight)
|
||||
# 转为 {indices: [int, ...], values: [float, ...]}
|
||||
indices = sorted(int(k) for k in lw.keys())
|
||||
values = [float(lw[str(idx)]) for idx in indices]
|
||||
item["sparse"] = {"indices": indices, "values": values}
|
||||
|
||||
# ColBERT 多向量
|
||||
if return_colbert:
|
||||
colbert_data = output["colbert_vecs"][i]
|
||||
# numpy/torch 矩阵 → list of list
|
||||
if hasattr(colbert_data, "tolist"):
|
||||
item["colbert"] = colbert_data.tolist()
|
||||
else:
|
||||
item["colbert"] = [v.tolist() if hasattr(v, "tolist") else list(v) for v in colbert_data]
|
||||
|
||||
results.append(item)
|
||||
|
||||
return results
|
||||
|
||||
async def unload(self) -> None:
|
||||
self._model = None
|
||||
self.model_loaded = False
|
||||
logger.info("Embedding 模型已卸载")
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.unload()
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
本地 LLM 推理引擎。
|
||||
|
||||
基于 llama-cpp-python 直接加载 GGUF 模型,提供 OpenAI 兼容的 Chat Completions 接口。
|
||||
多模态(视觉理解)通过 llava-llama.cpp 的 mmproj 支持。
|
||||
|
||||
模型文件统一放在项目根目录 models/gguf/ 下。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.config import settings
|
||||
from src.utils.device import detect_device
|
||||
from src.utils.logging import logger
|
||||
|
||||
|
||||
class LlamaEngine:
|
||||
"""本地 GGUF LLM 推理引擎。"""
|
||||
|
||||
name: str = "llm"
|
||||
model_loaded: bool = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._model_path = settings.llm_model_path_resolved
|
||||
self._vision_path = settings.vision_model_path_resolved
|
||||
self._device = detect_device(settings.device)
|
||||
self._ctx_size = settings.llm_ctx_size
|
||||
self._llm = None # llama_cpp.Llama 实例
|
||||
logger.info(
|
||||
"LLM 引擎初始化 | model={} vision={} device={} ctx={}",
|
||||
self._model_path or "(未配置)",
|
||||
self._vision_path or "(未配置)",
|
||||
self._device,
|
||||
self._ctx_size,
|
||||
)
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
if not self.model_loaded:
|
||||
return {"status": "unavailable", "model": self._model_path or ""}
|
||||
return {"status": "ok", "model": self._model_path}
|
||||
|
||||
async def load(self) -> None:
|
||||
"""加载 GGUF 模型(同步操作;llama-cpp-python 的初始化在构造时完成)。"""
|
||||
if self.model_loaded or not self._model_path:
|
||||
return
|
||||
try:
|
||||
import llama_cpp # noqa: F811
|
||||
|
||||
logger.info("正在加载 LLM 模型: {}", self._model_path)
|
||||
self._llm = llama_cpp.Llama(
|
||||
model_path=self._model_path,
|
||||
n_ctx=self._ctx_size,
|
||||
n_gpu_layers=-1 if self._device in ("cuda", "mps") else 0,
|
||||
verbose=False,
|
||||
)
|
||||
self.model_loaded = True
|
||||
logger.info("LLM 模型加载完成")
|
||||
except ImportError:
|
||||
logger.warning("llama-cpp-python 未安装,LLM 引擎不可用(pip install 'hunjing-yuanqi[llm]')")
|
||||
except Exception as e:
|
||||
logger.error("LLM 模型加载失败: {}", e)
|
||||
|
||||
async def chat_completion(self, body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""执行 Chat Completions 推理。
|
||||
|
||||
Args:
|
||||
body: OpenAI Chat Completions 格式的请求体。
|
||||
|
||||
Returns:
|
||||
OpenAI Chat Completions 格式的响应。
|
||||
"""
|
||||
if not self.model_loaded or self._llm is None:
|
||||
return {
|
||||
"error": {
|
||||
"code": "model_not_loaded",
|
||||
"message": "LLM 模型未加载,请检查模型路径和依赖安装",
|
||||
}
|
||||
}
|
||||
|
||||
messages = body.get("messages", [])
|
||||
max_tokens = body.get("max_tokens", settings.llm_max_tokens)
|
||||
temperature = body.get("temperature", settings.llm_temperature)
|
||||
stream = body.get("stream", False)
|
||||
|
||||
try:
|
||||
# llama-cpp-python 已内置 OpenAI 兼容格式转换
|
||||
response = self._llm.create_chat_completion(
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
stream=stream,
|
||||
)
|
||||
|
||||
if stream:
|
||||
# 对于流式响应,返回 generator
|
||||
return self._stream_response(response)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error("LLM 推理失败: {}", e)
|
||||
return {
|
||||
"error": {
|
||||
"code": "inference_error",
|
||||
"message": str(e),
|
||||
}
|
||||
}
|
||||
|
||||
async def _stream_response(self, response) -> dict[str, Any]:
|
||||
"""将 llama-cpp-python 的流式 generator 转为可序列化结构。
|
||||
|
||||
实际使用时(StreamingResponse),在端点层逐块 yield。
|
||||
此方法作为标记返回,端点层特殊处理。
|
||||
"""
|
||||
return {"_stream": response}
|
||||
|
||||
async def unload(self) -> None:
|
||||
if self._llm is not None:
|
||||
self._llm = None
|
||||
self.model_loaded = False
|
||||
logger.info("LLM 模型已卸载")
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.unload()
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
本地 Reranker 引擎。
|
||||
|
||||
基于 HuggingFace transformers 加载交叉编码器(CrossEncoder)模型
|
||||
(如 BAAI/bge-reranker-v2-m3),对查询和文档对进行相关性评分和排序。
|
||||
|
||||
使用 AutoModelForSequenceClassification + AutoTokenizer 从 transformers 直接加载。
|
||||
|
||||
模型下载位置(按优先级):
|
||||
1. ``YUANQI_RERANKER_MODEL`` 为本地路径时,直接从该路径加载
|
||||
2. ``YUANQI_RERANKER_MODEL`` 为 HuggingFace 模型名时,下载到
|
||||
``models/transformers/<模型名>`` 后再加载
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from src.utils.logging import logger
|
||||
import torch
|
||||
|
||||
from src.config import settings
|
||||
from src.utils.device import detect_device
|
||||
|
||||
|
||||
class RerankerEngine:
|
||||
"""本地 Reranker 引擎(transformers CrossEncoder)。"""
|
||||
|
||||
name: str = "reranker"
|
||||
model_loaded: bool = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
from src.utils.device import detect_device
|
||||
|
||||
self._device = detect_device(settings.device)
|
||||
self._model_name = settings.reranker_model or "transformers/bge-reranker-v2-m3"
|
||||
self._model = None
|
||||
self._tokenizer = None
|
||||
logger.info(
|
||||
"Reranker 引擎配置 | model={} device={}",
|
||||
self._model_name,
|
||||
self._device,
|
||||
)
|
||||
|
||||
def _resolve_model_path(self) -> str:
|
||||
"""解析模型路径。
|
||||
|
||||
若 ``reranker_model`` 是 HuggingFace 模型名时,下载到
|
||||
``models/transformers/<模型名>`` 后再加载。
|
||||
"""
|
||||
# 1) 直接是本地路径
|
||||
direct = Path(self._model_name)
|
||||
if direct.is_absolute() and direct.exists():
|
||||
return str(direct)
|
||||
candidate = settings.models_dir_resolved / self._model_name
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
|
||||
# 2) 已下载到目标目录
|
||||
target_dir = settings.models_dir_resolved / "transformers" / self._model_name
|
||||
if target_dir.exists():
|
||||
return str(target_dir)
|
||||
|
||||
# 3) 已下载到 models/transformers/<repo_name>
|
||||
repo_name = Path(self._model_name).name
|
||||
target_dir2 = settings.models_dir_resolved / "transformers" / repo_name
|
||||
if target_dir2.exists():
|
||||
return str(target_dir2)
|
||||
|
||||
# 4) 下载到 models/transformers/<repo_name>
|
||||
target = target_dir2
|
||||
logger.info("本地模型不存在,从 {} 下载到 {}", settings.model_source, target)
|
||||
from src.utils.downloader import download_model
|
||||
|
||||
if download_model(self._model_name, target, settings.model_source):
|
||||
return str(target)
|
||||
logger.warning("模型下载失败,回退到 HF 缓存加载")
|
||||
return self._model_name
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
if not self.model_loaded:
|
||||
return {"status": "unavailable", "model": self._model_name}
|
||||
return {"status": "ok", "model": self._model_name}
|
||||
|
||||
async def load(self) -> None:
|
||||
"""加载 transformers 交叉编码器模型。
|
||||
|
||||
优先使用本地路径;本地不存在时用 HuggingFace 模型名首次下载到 HF 缓存。
|
||||
"""
|
||||
if self.model_loaded:
|
||||
return
|
||||
try:
|
||||
model_path = self._resolve_model_path()
|
||||
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||
|
||||
logger.info("正在加载 Reranker 模型: {}", model_path)
|
||||
self._tokenizer = AutoTokenizer.from_pretrained(model_path)
|
||||
self._model = AutoModelForSequenceClassification.from_pretrained(model_path)
|
||||
self._model.eval()
|
||||
self._model.to(self._device)
|
||||
self.model_loaded = True
|
||||
logger.info("Reranker 模型加载完成: {}", model_path)
|
||||
except Exception as e:
|
||||
logger.error("Reranker 模型加载失败: {}", e)
|
||||
|
||||
def count_tokens(self, query: str, documents: list[str]) -> dict:
|
||||
"""统计 reranker 完整 token 用量。
|
||||
|
||||
Returns:
|
||||
含 ``input``(query_tokens / document_tokens[] / total_tokens)
|
||||
和 ``compute``(pairs / tokens)的嵌套 dict。
|
||||
"""
|
||||
from src.utils.token_counter import RerankerTokenCountStrategy
|
||||
|
||||
if self._tokenizer is None:
|
||||
return {
|
||||
"input": {
|
||||
"query_tokens": 0,
|
||||
"document_tokens": [0] * len(documents),
|
||||
"total_tokens": 0,
|
||||
},
|
||||
"compute": {"pairs": len(documents), "tokens": 0},
|
||||
}
|
||||
strategy = RerankerTokenCountStrategy()
|
||||
usage = strategy.count(self._tokenizer, query=query, documents=documents)
|
||||
return usage.to_dict()
|
||||
|
||||
async def rerank(
|
||||
self,
|
||||
query: str,
|
||||
documents: list[str],
|
||||
top_n: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""对文档执行重排序。
|
||||
|
||||
Args:
|
||||
query: 搜索查询文本。
|
||||
documents: 待排序文档列表。
|
||||
top_n: 返回前 N 个结果(默认返回全部排序)。
|
||||
|
||||
Returns:
|
||||
按相关性降序排列的 [{index, document, relevance_score}, ...] 列表。
|
||||
"""
|
||||
if not self.model_loaded or self._model is None or self._tokenizer is None:
|
||||
return [
|
||||
{"index": i, "document": doc, "relevance_score": 0.0}
|
||||
for i, doc in enumerate(documents)
|
||||
]
|
||||
|
||||
pairs = [[query, doc] for doc in documents]
|
||||
inputs = self._tokenizer(
|
||||
pairs,
|
||||
padding=True,
|
||||
truncation=True,
|
||||
return_tensors="pt",
|
||||
max_length=512,
|
||||
).to(self._device)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = self._model(**inputs)
|
||||
scores = outputs.logits.sigmoid().flatten().tolist()
|
||||
|
||||
scored = [
|
||||
{"index": i, "document": documents[i], "relevance_score": float(score)}
|
||||
for i, score in enumerate(scores)
|
||||
]
|
||||
scored.sort(key=lambda x: x["relevance_score"], reverse=True)
|
||||
|
||||
if top_n is not None and top_n < len(scored):
|
||||
scored = scored[:top_n]
|
||||
return scored
|
||||
|
||||
async def unload(self) -> None:
|
||||
self._model = None
|
||||
self._tokenizer = None
|
||||
self.model_loaded = False
|
||||
logger.info("Reranker 模型已卸载")
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.unload()
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
元炁 · 魂镜本地推理引擎 — FastAPI 应用入口。
|
||||
|
||||
提供 OpenAI 兼容的 Chat Completions / Embeddings API,以及自定义 Reranker API。
|
||||
所有模型由本地引擎直接加载推理,不依赖外部服务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
# 抑制进程退出时 multiprocessing 信号量泄漏警告
|
||||
os.environ.setdefault("PYTHONWARNINGS", "ignore::UserWarning")
|
||||
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
|
||||
|
||||
import gc
|
||||
import warnings
|
||||
warnings.filterwarnings("ignore", message="resource_tracker:")
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from src import __version__
|
||||
from src.config import settings
|
||||
from src.engine.embedding import EmbeddingEngine
|
||||
from src.engine.llm import LlamaEngine
|
||||
from src.engine.reranker import RerankerEngine
|
||||
from src.engine.asr import SenseVoiceASREngine
|
||||
from src.endpoints import chat, embeddings, health, rerank, asr
|
||||
from src.utils.logging import logger, setup_logging
|
||||
|
||||
setup_logging(settings.log_level)
|
||||
|
||||
app = FastAPI(
|
||||
title="元炁 · 魂镜本地推理引擎",
|
||||
version=__version__,
|
||||
description="纯 Python 本地推理引擎 — LLM / Embedding / Reranker / ASR / Multimodal",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 全局引擎实例
|
||||
llm_engine = LlamaEngine()
|
||||
embedding_engine = EmbeddingEngine()
|
||||
reranker_engine = RerankerEngine()
|
||||
asr_engine = SenseVoiceASREngine()
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup() -> None:
|
||||
logger.info(
|
||||
"元炁启动 | host={} port={} device={} models_dir={}",
|
||||
settings.host,
|
||||
settings.port,
|
||||
settings.device,
|
||||
settings.models_dir_resolved,
|
||||
)
|
||||
# 注入引擎实例到端点模块
|
||||
health.init(llm_engine, embedding_engine, reranker_engine, asr_engine)
|
||||
chat.init(llm_engine)
|
||||
embeddings.init(embedding_engine)
|
||||
rerank.init(reranker_engine)
|
||||
asr.init(asr_engine)
|
||||
|
||||
# 尝试加载模型(不阻塞启动,失败仅告警)
|
||||
if settings.llm_model_path_resolved:
|
||||
await llm_engine.load()
|
||||
if settings.embedding_model or (settings.models_dir_resolved / "FlagModel").exists():
|
||||
await embedding_engine.load()
|
||||
if settings.reranker_model or (settings.models_dir_resolved / "transformers").exists():
|
||||
await reranker_engine.load()
|
||||
if settings.asr_model:
|
||||
await asr_engine.load()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown() -> None:
|
||||
await llm_engine.close()
|
||||
await embedding_engine.close()
|
||||
await reranker_engine.close()
|
||||
await asr_engine.close()
|
||||
gc.collect()
|
||||
logger.info("元炁停止")
|
||||
|
||||
|
||||
# 注册路由
|
||||
app.include_router(health.router, tags=["health"])
|
||||
app.include_router(chat.router, prefix="/v1", tags=["chat"])
|
||||
app.include_router(embeddings.router, prefix="/v1", tags=["embeddings"])
|
||||
app.include_router(rerank.router, prefix="/v1", tags=["rerank"])
|
||||
app.include_router(asr.router, prefix="/v1", tags=["asr"])
|
||||
Whitespace-only changes.
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
请求 / 响应 Pydantic 模型定义。
|
||||
|
||||
Chat 透传 OpenAI 兼容格式,Embeddings 需组装响应,Reranker 使用自定义格式。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ── ASR(语音识别)─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ASRRequest(BaseModel):
|
||||
model: str = Field(default="SenseVoiceSmall", description="ASR 模型名称")
|
||||
language: str | None = Field(
|
||||
default=None,
|
||||
description="语言提示(ISO-639-1:zh/en/ja/ko/yue 等),auto 表示自动检测",
|
||||
)
|
||||
|
||||
|
||||
class ASRResponse(BaseModel):
|
||||
text: str = Field(description="转写文本")
|
||||
model: str = Field(description="使用的模型名称")
|
||||
language: str | None = Field(default=None, description="检测到的语言")
|
||||
|
||||
|
||||
class ASRTranscriptionResponse(BaseModel):
|
||||
"""OpenAI Whisper 兼容的转写响应。"""
|
||||
|
||||
text: str = Field(description="转写文本")
|
||||
|
||||
|
||||
# ── Embedding ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class EmbeddingRequest(BaseModel):
|
||||
model: str = Field(description="模型名称")
|
||||
input: str | list[str] = Field(description="输入文本,单条字符串或字符串列表")
|
||||
output_types: list[str] = Field(
|
||||
default_factory=lambda: ["dense"],
|
||||
description="输出表示类型,默认为 ['dense']。支持值:\n"
|
||||
"- ``dense`` — 稠密向量(OpenAI 兼容)\n"
|
||||
"- ``sparse`` — 稀疏向量(BGE-M3 lexical weights)\n"
|
||||
"- ``colbert`` — 多向量表示(BGE-M3 ColBERT)\n"
|
||||
"扩展语法(框架预留,引擎就绪后生效):\n"
|
||||
"- ``dense:N`` — Matryoshka 截断维度(如 ``dense:384``)\n"
|
||||
"- ``dense:image`` — 图像模态向量\n"
|
||||
"- ``dense:audio`` — 音频模态向量\n"
|
||||
"- ``hidden_state:layerN`` — 指定层 Transformer 输出",
|
||||
)
|
||||
|
||||
|
||||
class EmbeddingResponse(BaseModel):
|
||||
object: str = "list"
|
||||
model: str = Field(description="使用的模型名称")
|
||||
data: list[dict[str, Any]] = Field(
|
||||
description="嵌入结果列表,每项必含 object/index/embedding(稠密向量),"
|
||||
"请求稀疏/ColBERT 时额外含 sparse_embedding / colbert_embedding"
|
||||
)
|
||||
usage: dict = Field(
|
||||
default_factory=lambda: {"prompt_tokens": 0, "total_tokens": 0},
|
||||
description="Token 用量与输出表示元数据。\n"
|
||||
"OpenAI 兼容:prompt_tokens / total_tokens\n"
|
||||
"扩展 details.input:count / tokens / total_tokens\n"
|
||||
"扩展 details.representations[]:每项含 type / modality 及类型专属元数据",
|
||||
)
|
||||
|
||||
|
||||
# ── Reranker ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RerankRequest(BaseModel):
|
||||
model: str = Field(description="重排序模型名称")
|
||||
query: str = Field(description="搜索查询文本")
|
||||
documents: list[str] = Field(description="待排序文档列表", min_length=1)
|
||||
top_n: int | None = Field(None, description="返回前 N 个最相关结果,默认返回全部排序")
|
||||
|
||||
|
||||
class RerankResult(BaseModel):
|
||||
index: int = Field(description="在原始 documents 中的位置")
|
||||
document: str = Field(description="文档内容")
|
||||
relevance_score: float = Field(description="相关性分数(0~1,越高越相关)")
|
||||
|
||||
|
||||
class RerankResponse(BaseModel):
|
||||
model: str = Field(description="使用的模型名称")
|
||||
results: list[RerankResult] = Field(description="排序结果(按相关性降序)")
|
||||
usage: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"input": {"query_tokens": 0, "document_tokens": [], "total_tokens": 0},
|
||||
"compute": {"pairs": 0, "tokens": 0},
|
||||
},
|
||||
description="Token 用量:input 为用户输入(document_tokens 按文档逐条),"
|
||||
"compute 为实际推理开销",
|
||||
)
|
||||
|
||||
|
||||
# ── 健康检查 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class EngineStatus(BaseModel):
|
||||
status: str = Field(description="引擎状态:ok / error / unavailable")
|
||||
|
||||
|
||||
class ModelStatus(BaseModel):
|
||||
name: str = Field(description="模型名称")
|
||||
type: str = Field(description="模型类型:llm / embedding / vision / reranker")
|
||||
status: str = Field(description="状态:loaded / loading / error")
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = Field(description="服务整体状态:ok / degraded / error")
|
||||
engines: dict[str, EngineStatus] = Field(description="各引擎状态")
|
||||
loaded_models: list[ModelStatus] = Field(default_factory=list, description="已加载模型列表")
|
||||
version: str = Field(default=__import__("src").__version__, description="元炁版本")
|
||||
|
||||
|
||||
# ── 错误响应 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
code: str = Field(description="错误码")
|
||||
message: str = Field(description="错误描述")
|
||||
detail: Any | None = Field(None, description="额外详情")
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error: ErrorDetail
|
||||
Whitespace-only changes.
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
设备自动检测工具。
|
||||
|
||||
优先检测可用硬件加速器,不可用时自动降级到 CPU。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
DeviceType = Literal["cuda", "mps", "cpu"]
|
||||
|
||||
|
||||
def detect_device(override: str | None = None) -> DeviceType:
|
||||
"""检测最优可用推理设备。
|
||||
|
||||
优先级:
|
||||
1. 如果传入了非空非 auto 的显式值,直接使用(信任用户指定)
|
||||
2. CUDA 可用 → ``cuda``
|
||||
3. Apple Silicon (MPS) 可用 → ``mps``
|
||||
4. 否则 → ``cpu``
|
||||
|
||||
Args:
|
||||
override: 用户指定的设备值(来自环境变量),可为空或 "auto"。
|
||||
|
||||
Returns:
|
||||
检测到的设备名。
|
||||
"""
|
||||
if override and override.strip().lower() not in ("", "auto"):
|
||||
return override.strip().lower() # type: ignore[return-value]
|
||||
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available() and torch.backends.mps.is_built():
|
||||
return "mps"
|
||||
|
||||
return "cpu"
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
模型下载工具,统一支持 HuggingFace 和 ModelScope 源。
|
||||
|
||||
优先级:ModelScope(国内快)> HuggingFace(全球通用)。
|
||||
可通过 ``YUANQI_MODEL_SOURCE`` 固定为 huggingface / modelscope。
|
||||
|
||||
只下载 PyTorch 格式(``.bin`` / ``.safetensors``),跳过 ONNX / Flax / TF 等冗余格式。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from src.utils.logging import logger
|
||||
|
||||
ModelSource = Literal["auto", "huggingface", "modelscope"]
|
||||
|
||||
# 不下载的冗余格式
|
||||
_IGNORE_PATTERNS = [
|
||||
"*.onnx",
|
||||
"*.onnx_data",
|
||||
"flax_model.msgpack",
|
||||
"tf_model.h5",
|
||||
"tf_model.hdf5",
|
||||
]
|
||||
|
||||
|
||||
def _hf_download(repo_id: str, target_dir: str) -> bool:
|
||||
"""从 HuggingFace Hub 下载模型。"""
|
||||
try:
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
snapshot_download(
|
||||
repo_id=repo_id,
|
||||
local_dir=target_dir,
|
||||
local_dir_use_symlinks=False,
|
||||
ignore_patterns=_IGNORE_PATTERNS,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("HuggingFace 下载失败: {}", e)
|
||||
return False
|
||||
|
||||
|
||||
def _ms_download(repo_id: str, target_dir: str) -> bool:
|
||||
"""从 ModelScope 下载模型。
|
||||
|
||||
ModelScope 默认用 cache_dir 结构(套多层 models/<name>/snapshots/…),
|
||||
下载后把快照内容拍平拷贝到 target_dir,确保与 HuggingFace local_dir 一致。
|
||||
"""
|
||||
try:
|
||||
from modelscope.hub.snapshot_download import snapshot_download
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
snapshot_path = snapshot_download(
|
||||
model_id=repo_id,
|
||||
cache_dir=tmp,
|
||||
ignore_patterns=_IGNORE_PATTERNS,
|
||||
)
|
||||
if snapshot_path and Path(snapshot_path).exists():
|
||||
shutil.copytree(snapshot_path, target_dir, dirs_exist_ok=True)
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("ModelScope 下载失败: {}", e)
|
||||
return False
|
||||
|
||||
|
||||
def download_model(
|
||||
repo_id: str,
|
||||
target_dir: str | Path,
|
||||
source: ModelSource = "auto",
|
||||
) -> bool:
|
||||
"""下载模型到指定目录。
|
||||
|
||||
Args:
|
||||
repo_id: 模型标识(如 ``BAAI/bge-m3``)。
|
||||
target_dir: 目标目录(需已创建父目录)。
|
||||
source: 下载源。
|
||||
``auto`` — 先试 ModelScope,失败后试 HuggingFace。
|
||||
``huggingface`` — 仅 HuggingFace。
|
||||
``modelscope`` — 仅 ModelScope。
|
||||
|
||||
Returns:
|
||||
下载是否成功。
|
||||
"""
|
||||
target = str(target_dir)
|
||||
logger.info("下载模型 {} → {}", repo_id, target)
|
||||
Path(target).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if source == "huggingface":
|
||||
return _hf_download(repo_id, target)
|
||||
elif source == "modelscope":
|
||||
return _ms_download(repo_id, target)
|
||||
|
||||
# auto: 先试 ModelScope(国内快),失败切 HF
|
||||
logger.info("auto 模式:优先 ModelScope")
|
||||
if _ms_download(repo_id, target):
|
||||
return True
|
||||
logger.info("ModelScope 不可用,切到 HuggingFace")
|
||||
return _hf_download(repo_id, target)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
统一日志配置。
|
||||
|
||||
所有模块统一从这里导入 ``logger``,避免 loguru 配置散落在各处。
|
||||
在应用启动时调用 ``setup_logging()`` 完成初始化。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import loguru
|
||||
|
||||
logger = loguru.logger
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO") -> None:
|
||||
"""配置 loguru:清空默认 handler,写入 stderr(结构化 + 彩色输出)。
|
||||
|
||||
Args:
|
||||
level: 日志级别(DEBUG / INFO / WARNING / ERROR)。
|
||||
"""
|
||||
logger.remove()
|
||||
logger.add(
|
||||
sink=sys.stderr,
|
||||
format=(
|
||||
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> "
|
||||
"| <level>{level: <7}</level> "
|
||||
"| <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> "
|
||||
"- <level>{message}</level>"
|
||||
),
|
||||
level=level.upper(),
|
||||
colorize=True,
|
||||
enqueue=True,
|
||||
)
|
||||
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
策略模式的 Token 计数器。
|
||||
|
||||
为本地部署的 HuggingFace 模型提供统一的 Token 计数能力。
|
||||
支持 embedding(单条文本)和 reranker(查询+文档对)两种策略。
|
||||
新增模型类型只需注册新策略,无需修改调用方。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Literal
|
||||
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
from src.utils.logging import logger
|
||||
|
||||
ModelType = Literal["embedding", "reranker"]
|
||||
|
||||
|
||||
# ── 策略接口 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TokenCountStrategy(ABC):
|
||||
"""Token 计数策略接口。"""
|
||||
|
||||
@abstractmethod
|
||||
def count(self, tokenizer: AutoTokenizer, **kwargs: Any) -> int:
|
||||
"""计算 token 总数。
|
||||
|
||||
Args:
|
||||
tokenizer: 已加载的 HuggingFace tokenizer。
|
||||
**kwargs: 策略特定的输入参数。
|
||||
|
||||
Returns:
|
||||
总 token 数。
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def count_batch(
|
||||
self, tokenizer: AutoTokenizer, **kwargs: Any
|
||||
) -> list[int]:
|
||||
"""批量计算各条数据的 token 数。
|
||||
|
||||
Args:
|
||||
tokenizer: 已加载的 HuggingFace tokenizer。
|
||||
**kwargs: 策略特定的输入参数。
|
||||
|
||||
Returns:
|
||||
每条数据各自的 token 数列表。
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ── 具体策略:Embedding ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class EmbeddingTokenCountStrategy(TokenCountStrategy):
|
||||
"""Embedding 模型:计算单条文本的 token 数。"""
|
||||
|
||||
def count(
|
||||
self, tokenizer: AutoTokenizer, text: str, **kwargs: Any
|
||||
) -> int:
|
||||
return len(tokenizer.encode(text, add_special_tokens=True))
|
||||
|
||||
def count_batch(
|
||||
self, tokenizer: AutoTokenizer, texts: list[str], **kwargs: Any
|
||||
) -> list[int]:
|
||||
return [
|
||||
len(tokenizer.encode(t, add_special_tokens=True)) for t in texts
|
||||
]
|
||||
|
||||
|
||||
# ── 具体策略:Reranker ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RerankerTokenCountStrategy(TokenCountStrategy):
|
||||
"""Reranker 模型:交叉编码器逐对处理,需区分 input 与 compute 开销。
|
||||
|
||||
- input: query_tokens + 各 document 原始 token 数
|
||||
- compute: 模型实际处理的 (query+doc_i) 对的总和
|
||||
"""
|
||||
|
||||
def count(
|
||||
self,
|
||||
tokenizer: AutoTokenizer,
|
||||
query: str,
|
||||
documents: list[str],
|
||||
**kwargs: Any,
|
||||
) -> RerankerTokenUsage:
|
||||
"""计算 reranker 的完整 token 用量。
|
||||
|
||||
Returns:
|
||||
RerankerTokenUsage,含 input / compute 两层统计。
|
||||
"""
|
||||
return self._calc(tokenizer, query, documents)
|
||||
|
||||
def count_batch(
|
||||
self,
|
||||
tokenizer: AutoTokenizer,
|
||||
query: str,
|
||||
documents: list[str],
|
||||
**kwargs: Any,
|
||||
) -> RerankerTokenUsage:
|
||||
return self._calc(tokenizer, query, documents)
|
||||
|
||||
def _calc(
|
||||
self,
|
||||
tokenizer: AutoTokenizer,
|
||||
query: str,
|
||||
documents: list[str],
|
||||
) -> RerankerTokenUsage:
|
||||
q_tokens = len(tokenizer.encode(query, add_special_tokens=True))
|
||||
doc_tokens_list = [
|
||||
len(tokenizer.encode(d, add_special_tokens=True))
|
||||
for d in documents
|
||||
]
|
||||
|
||||
# compute:每对 (query, doc_i) 都需处理一次
|
||||
compute_tokens = sum(q_tokens + dt for dt in doc_tokens_list)
|
||||
|
||||
return RerankerTokenUsage(
|
||||
query_tokens=q_tokens,
|
||||
document_tokens=doc_tokens_list, # 每文档单独计数
|
||||
total_tokens=q_tokens + sum(doc_tokens_list),
|
||||
compute_pairs=len(documents),
|
||||
compute_tokens=compute_tokens,
|
||||
)
|
||||
|
||||
|
||||
# ── 数据类 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RerankerTokenUsage:
|
||||
"""Reranker 的完整 token 用量明细。"""
|
||||
|
||||
__slots__ = (
|
||||
"query_tokens",
|
||||
"document_tokens",
|
||||
"total_tokens",
|
||||
"compute_pairs",
|
||||
"compute_tokens",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
query_tokens: int = 0,
|
||||
document_tokens: int | list[int] = 0,
|
||||
total_tokens: int = 0,
|
||||
compute_pairs: int = 0,
|
||||
compute_tokens: int = 0,
|
||||
) -> None:
|
||||
self.query_tokens = query_tokens
|
||||
self.document_tokens = document_tokens
|
||||
self.total_tokens = total_tokens
|
||||
self.compute_pairs = compute_pairs
|
||||
self.compute_tokens = compute_tokens
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = {
|
||||
"input": {
|
||||
"query_tokens": self.query_tokens,
|
||||
"total_tokens": self.total_tokens,
|
||||
},
|
||||
"compute": {
|
||||
"pairs": self.compute_pairs,
|
||||
"tokens": self.compute_tokens,
|
||||
},
|
||||
}
|
||||
if isinstance(self.document_tokens, list):
|
||||
d["input"]["document_tokens"] = self.document_tokens
|
||||
else:
|
||||
d["input"]["document_tokens_total"] = self.document_tokens
|
||||
return d
|
||||
|
||||
|
||||
# ── 策略注册表 ──────────────────────────────────────────────────────────
|
||||
|
||||
_STRATEGIES: dict[ModelType, type[TokenCountStrategy]] = {
|
||||
"embedding": EmbeddingTokenCountStrategy,
|
||||
"reranker": RerankerTokenCountStrategy,
|
||||
}
|
||||
|
||||
|
||||
def register_strategy(
|
||||
model_type: str, strategy_cls: type[TokenCountStrategy]
|
||||
) -> None:
|
||||
"""注册自定义策略(便于扩展新模型类型)。"""
|
||||
_STRATEGIES[model_type] = strategy_cls
|
||||
|
||||
|
||||
# ── 门面类 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TokenCounter:
|
||||
"""统一 Token 计数器(门面)。
|
||||
|
||||
自动加载对应模型的 tokenizer 并选择正确的计数策略。
|
||||
|
||||
Usage::
|
||||
|
||||
# Embedding
|
||||
counter = TokenCounter("BAAI/bge-m3", model_type="embedding")
|
||||
total = counter.count(text="你好世界")
|
||||
per_text = counter.count_batch(texts=["你好", "世界"])
|
||||
|
||||
# Reranker
|
||||
counter = TokenCounter("BAAI/bge-reranker-large", model_type="reranker")
|
||||
usage = counter.count(query="搜索", documents=["文档A", "文档B"])
|
||||
print(usage.to_dict())
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str,
|
||||
model_type: ModelType = "embedding",
|
||||
tokenizer: AutoTokenizer | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
model_name: HuggingFace 模型名称或本地路径。
|
||||
model_type: 模型类型,影响计数策略。
|
||||
tokenizer: 可复用外部已加载的 tokenizer(避免重复加载)。
|
||||
"""
|
||||
if tokenizer is not None:
|
||||
self._tokenizer = tokenizer
|
||||
else:
|
||||
logger.info("加载 Tokenizer: {}", model_name)
|
||||
self._tokenizer = AutoTokenizer.from_pretrained(model_name)
|
||||
|
||||
strategy_cls = _STRATEGIES.get(model_type)
|
||||
if strategy_cls is None:
|
||||
raise ValueError(
|
||||
f"不支持的模型类型: {model_type},可用: {list(_STRATEGIES.keys())}"
|
||||
)
|
||||
self._strategy = strategy_cls()
|
||||
self._model_type = model_type
|
||||
|
||||
@property
|
||||
def tokenizer(self) -> AutoTokenizer:
|
||||
"""获取内部 tokenizer(可复用给其他场景)。"""
|
||||
return self._tokenizer
|
||||
|
||||
def count(self, **kwargs: Any) -> int | RerankerTokenUsage:
|
||||
"""计算 token 总数。
|
||||
|
||||
Keyword Args:
|
||||
透传给具体策略的 count() 方法。
|
||||
|
||||
Returns:
|
||||
embedding 返回 int;reranker 返回 RerankerTokenUsage。
|
||||
"""
|
||||
return self._strategy.count(self._tokenizer, **kwargs)
|
||||
|
||||
def count_batch(self, **kwargs: Any) -> list[int] | RerankerTokenUsage:
|
||||
"""批量计算各条数据的 token 数。
|
||||
|
||||
Keyword Args:
|
||||
透传给具体策略的 count_batch() 方法。
|
||||
|
||||
Returns:
|
||||
embedding 返回 list[int];reranker 返回 RerankerTokenUsage。
|
||||
"""
|
||||
return self._strategy.count_batch(self._tokenizer, **kwargs)
|
||||
|
||||
|
||||
# ── 快捷函数(无需创建门面实例,一次调用) ────────────────────────────
|
||||
|
||||
|
||||
def count_tokens(
|
||||
model_name: str,
|
||||
model_type: ModelType = "embedding",
|
||||
**kwargs: Any,
|
||||
) -> int:
|
||||
"""单次调用的快捷函数。
|
||||
|
||||
Usage::
|
||||
|
||||
n = count_tokens("BAAI/bge-m3", model_type="embedding", text="你好")
|
||||
"""
|
||||
return TokenCounter(model_name, model_type).count(**kwargs)
|
||||
Reference in new issue
Block a user