初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
"""元炁"""
__version__ = "0.1.0"
+92
View File
@@ -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()
View File
Whitespace-only changes.
+156
View File
@@ -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"],
)
+78
View File
@@ -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"
+190
View File
@@ -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,
}
+73
View File
@@ -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__,
)
+49
View File
@@ -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)})
View File
Whitespace-only changes.
+298
View File
@@ -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()
+36
View File
@@ -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()
+197
View File
@@ -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()
+125
View File
@@ -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()
+181
View File
@@ -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()
+97
View File
@@ -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"])
View File
Whitespace-only changes.
+131
View File
@@ -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
View File
Whitespace-only changes.
+40
View File
@@ -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"
+104
View File
@@ -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)
+35
View File
@@ -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,
)
+282
View File
@@ -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)