feat(translate): 加星火 Spark(Lite)作为优先翻译引擎
- 新增 app/services/translation/spark.py: OpenAI 兼容协议客户端, URL = https://spark-api-open.xf-yun.com/v1/chat/completions, 鉴权 = Bearer <APIPassword>, model = lite(默认)/generalv3.5/4.0Ultra 可切换 - service.py 引擎链路调整为: spark → tencent(配额)→ maas → agnes → local。 优先级降序: spark 配了 key 就用它,失败再走 tencent(继续吃配额,不绕过)。 要完全绕开 tencent,把 TENCENTCLOUD_SECRET_ID 留空即可。 - 配置: 新增 SPARK_API_PASSWORD / SPARK_BASE_URL / SPARK_MODEL / SPARK_INTERVAL_SEC (留空 SPARK_API_PASSWORD = 走原 tencent 主链路,向后兼容) - 缓存白名单 / 配额计数逻辑保持原行为,只把 spark 加入允许缓存的引擎集合
This commit is contained in:
10
.env.example
10
.env.example
@@ -35,6 +35,16 @@ TENCENT_TMT_QUOTA_BUFFER=0.05
|
|||||||
# 单次请求最大字符
|
# 单次请求最大字符
|
||||||
TENCENT_TMT_MAX_CHARS_PER_REQ=4500
|
TENCENT_TMT_MAX_CHARS_PER_REQ=4500
|
||||||
|
|
||||||
|
# ===== 星火 Spark(优先翻译;Lite 免费)=====
|
||||||
|
# 留空 = 不启用星火(直接走腾讯 TMT)
|
||||||
|
# 控制台 https://console.xfyun.cn/ → 应用 → Spark Lite → "HTTP 服务接口认证信息" → APIPassword
|
||||||
|
SPARK_API_PASSWORD=your_spark_api_password
|
||||||
|
SPARK_BASE_URL=https://spark-api-open.xf-yun.com/v1
|
||||||
|
# 模型:lite(默认,免费) / generalv3 / generalv3.5 / 4.0Ultra
|
||||||
|
SPARK_MODEL=lite
|
||||||
|
# 单次调用间隔(秒),避免被限速
|
||||||
|
SPARK_INTERVAL_SEC=1.0
|
||||||
|
|
||||||
# ===== 本地翻译(降级) =====
|
# ===== 本地翻译(降级) =====
|
||||||
# 不启用就留空:不会用本地模<E59CB0>?LOCAL_TRANSLATE_ENABLED=false
|
# 不启用就留空:不会用本地模<E59CB0>?LOCAL_TRANSLATE_ENABLED=false
|
||||||
LOCAL_TRANSLATE_MODEL=nllb-200-distilled-600M
|
LOCAL_TRANSLATE_MODEL=nllb-200-distilled-600M
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ class Settings(BaseSettings):
|
|||||||
tencent_tmt_quota_buffer: float = 0.05
|
tencent_tmt_quota_buffer: float = 0.05
|
||||||
tencent_tmt_max_chars_per_req: int = 4500
|
tencent_tmt_max_chars_per_req: int = 4500
|
||||||
|
|
||||||
|
# ===== 星火 Spark(优先翻译;Lite/免费)=====
|
||||||
|
# 留空 = 不启用星火(直接走腾讯 TMT)
|
||||||
|
spark_api_password: str = ""
|
||||||
|
spark_base_url: str = "https://spark-api-open.xf-yun.com/v1"
|
||||||
|
spark_model: str = "lite"
|
||||||
|
spark_interval_sec: float = 1.0
|
||||||
|
|
||||||
@field_validator("tencent_tmt_quota_buffer")
|
@field_validator("tencent_tmt_quota_buffer")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _check_buffer(cls, v: float) -> float:
|
def _check_buffer(cls, v: float) -> float:
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
"""翻译服务门面:配额检查 + 缓存 + 引擎选择 + 月度计数。
|
"""翻译服务门面:配额检查 + 缓存 + 引擎选择 + 月度计数。
|
||||||
|
|
||||||
引擎链路(优先级降序):
|
引擎链路(优先级降序):
|
||||||
1. tencent TMT(主,按月配额;快满时主动切走)
|
1. spark(主,Lite 免费;spark_api_password 配了才用)
|
||||||
2. tencent_maas(备用,OpenAI 兼容,无配额;主失败/TMT 配额耗尽时启用)
|
2. tencent TMT(第二级,按月配额;快满时主动切走)
|
||||||
3. agnes(第三级,通用 LLM 做翻译;MaaS 不可用时启用 — 质量次之但够用)
|
3. tencent_maas(备用,OpenAI 兼容,无配额;主失败/TMT 配额耗尽时启用)
|
||||||
4. local(最后兜底,需 settings.local_translate_enabled=true)
|
4. agnes(第三级,通用 LLM 做翻译;MaaS 不可用时启用 — 质量次之但够用)
|
||||||
|
5. local(最后兜底,需 settings.local_translate_enabled=true)
|
||||||
|
|
||||||
注:
|
注:
|
||||||
- TMT 是按月计费的(腾讯云后台可能计费口径是请求字节,我们 redis 累加的是字符数,
|
- TMT 是按月计费的(腾讯云后台可能计费口径是请求字节,我们 redis 累加的是字符数,
|
||||||
差异约 2-3x);用户从腾讯云后台看"已用 2M"时,我们 redis 显示约 80 万字符
|
差异约 2-3x);用户从腾讯云后台看"已用 2M"时,我们 redis 显示约 80 万字符
|
||||||
- 用户决策:以腾讯云后台数字为准,快满时降级
|
- 用户决策:以腾讯云后台数字为准,快满时降级
|
||||||
|
- spark 是 Lite 免费,默认走它;spark 不可用时降级到 tencent(继续吃配额)。
|
||||||
|
想要完全绕开 tencent,把 TENCENTCLOUD_SECRET_ID 留空即可。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -24,6 +27,7 @@ from app.redis_client import get_redis
|
|||||||
from app.services.translation.agnes import AgnesTranslator
|
from app.services.translation.agnes import AgnesTranslator
|
||||||
from app.services.translation.base import BaseTranslator, TranslationResult
|
from app.services.translation.base import BaseTranslator, TranslationResult
|
||||||
from app.services.translation.local import LocalTranslator
|
from app.services.translation.local import LocalTranslator
|
||||||
|
from app.services.translation.spark import SparkTranslator
|
||||||
from app.services.translation.tencent import TencentTranslator
|
from app.services.translation.tencent import TencentTranslator
|
||||||
from app.services.translation.tencent_maas import TencentMaaSTranslator
|
from app.services.translation.tencent_maas import TencentMaaSTranslator
|
||||||
|
|
||||||
@@ -43,6 +47,7 @@ def _month_key() -> str:
|
|||||||
|
|
||||||
class TranslationService:
|
class TranslationService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
self._spark: BaseTranslator | None = None
|
||||||
self._tencent: BaseTranslator | None = None
|
self._tencent: BaseTranslator | None = None
|
||||||
self._tencent_maas: BaseTranslator | None = None
|
self._tencent_maas: BaseTranslator | None = None
|
||||||
self._agnes: BaseTranslator | None = None
|
self._agnes: BaseTranslator | None = None
|
||||||
@@ -50,8 +55,18 @@ class TranslationService:
|
|||||||
# 串行:1 个并发;避免触发腾讯 TMT 限速
|
# 串行:1 个并发;避免触发腾讯 TMT 限速
|
||||||
self._sem = asyncio.Semaphore(1)
|
self._sem = asyncio.Semaphore(1)
|
||||||
|
|
||||||
|
def _spark_translator(self) -> BaseTranslator | None:
|
||||||
|
"""主引擎:星火 Spark(Lite 免费)。配了 spark_api_password 才启用。"""
|
||||||
|
if self._spark is None and settings.spark_api_password:
|
||||||
|
try:
|
||||||
|
self._spark = SparkTranslator()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("spark init failed: %s", e)
|
||||||
|
self._spark = None
|
||||||
|
return self._spark
|
||||||
|
|
||||||
def _primary(self) -> BaseTranslator | None:
|
def _primary(self) -> BaseTranslator | None:
|
||||||
"""主引擎:腾讯 TMT(初始化失败返回 None 表示不可用)。"""
|
"""第二级:腾讯 TMT(初始化失败返回 None 表示不可用)。"""
|
||||||
if self._tencent is None:
|
if self._tencent is None:
|
||||||
try:
|
try:
|
||||||
self._tencent = TencentTranslator()
|
self._tencent = TencentTranslator()
|
||||||
@@ -139,19 +154,21 @@ class TranslationService:
|
|||||||
if cached is not None:
|
if cached is not None:
|
||||||
return TranslationResult(text=cached, engine="cache", chars=chars, cached=True)
|
return TranslationResult(text=cached, engine="cache", chars=chars, cached=True)
|
||||||
|
|
||||||
# 2) 选引擎(主 → maas 备用 → local 兜底)
|
# 2) 选引擎
|
||||||
use_tencent = await self.can_use_tencent(chars)
|
# 优先级:spark → tencent(配额)→ maas → agnes → local
|
||||||
if use_tencent:
|
engine: BaseTranslator | None = None
|
||||||
engine: BaseTranslator | None = self._primary()
|
if self._spark_translator() is not None:
|
||||||
|
engine = self._spark_translator()
|
||||||
|
elif await self.can_use_tencent(chars):
|
||||||
|
engine = self._primary()
|
||||||
if engine is None:
|
if engine is None:
|
||||||
# TMT 配了 key 但初始化失败 → 直接走 maas
|
|
||||||
logger.warning("TMT unavailable, falling back to MaaS")
|
logger.warning("TMT unavailable, falling back to MaaS")
|
||||||
engine = self._maas()
|
engine = self._maas()
|
||||||
else:
|
else:
|
||||||
engine = None
|
engine = None
|
||||||
|
|
||||||
if engine is None:
|
if engine is None:
|
||||||
# 配额耗尽 / TMT 不可用:走备用链(maas → agnes → local)
|
# spark 不可用 + 配额耗尽 / TMT 不可用:走备用链(maas → agnes → local)
|
||||||
engine = self._fallback()
|
engine = self._fallback()
|
||||||
if engine is None:
|
if engine is None:
|
||||||
# 全无可用:返回原文 + 标记
|
# 全无可用:返回原文 + 标记
|
||||||
@@ -161,7 +178,7 @@ class TranslationService:
|
|||||||
chars=chars,
|
chars=chars,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"tencent quota exhausted, fallback to %s for %d chars",
|
"primary engines unavailable, fallback to %s for %d chars",
|
||||||
engine.name, chars,
|
engine.name, chars,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -172,8 +189,16 @@ class TranslationService:
|
|||||||
res = await engine.translate(text, source=source, target=target)
|
res = await engine.translate(text, source=source, target=target)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("translate failed with %s: %s", engine.name, e)
|
logger.exception("translate failed with %s: %s", engine.name, e)
|
||||||
# 按 maas → local 顺序找一个不同的 fallback
|
# 失败时按 tencent_maas → local 顺序找一个不同的 fallback
|
||||||
fb = self._maas() if engine.name != "tencent_maas" else None
|
# spark 失败时也要走 tencent(继续吃配额,因优先级只是降低不是禁用)
|
||||||
|
fb: BaseTranslator | None = None
|
||||||
|
if engine.name == "spark":
|
||||||
|
if await self.can_use_tencent(chars):
|
||||||
|
fb = self._primary()
|
||||||
|
if fb is None:
|
||||||
|
fb = self._maas() if engine.name != "tencent_maas" else None
|
||||||
|
elif engine.name == "tencent":
|
||||||
|
fb = self._maas() if engine.name != "tencent_maas" else None
|
||||||
if fb is None and settings.local_translate_enabled and engine.name != "local":
|
if fb is None and settings.local_translate_enabled and engine.name != "local":
|
||||||
fb = self._local_translator()
|
fb = self._local_translator()
|
||||||
if fb is not None:
|
if fb is not None:
|
||||||
@@ -186,19 +211,15 @@ class TranslationService:
|
|||||||
if res is None:
|
if res is None:
|
||||||
raise RuntimeError(f"translation failed for {chars} chars (engine={engine.name})")
|
raise RuntimeError(f"translation failed for {chars} chars (engine={engine.name})")
|
||||||
|
|
||||||
# 注:engine 已经设好但运行时降级需要重新判断 fallback 链
|
|
||||||
# 上面 translate() 调用失败时,会重试 _fallback() 里下一个可用引擎
|
|
||||||
# 这里 engine 已经在 _fallback() 中按顺序选了一个最合适的,直接使用即可
|
|
||||||
|
|
||||||
# 4) 写缓存 — 只缓存真实翻译结果;失败/降级文本不缓存(避免污染 30 天)
|
# 4) 写缓存 — 只缓存真实翻译结果;失败/降级文本不缓存(避免污染 30 天)
|
||||||
if res.engine in ("tencent", "tencent_maas", "agnes", "nllb") and not res.cached:
|
if res.engine in ("spark", "tencent", "tencent_maas", "agnes", "nllb") and not res.cached:
|
||||||
if "[翻译失败" not in res.text and "[本条未翻译" not in res.text:
|
if "[翻译失败" not in res.text and "[本条未翻译" not in res.text:
|
||||||
try:
|
try:
|
||||||
await r.set(ck, res.text, ex=60 * 60 * 24 * 30) # 30 天
|
await r.set(ck, res.text, ex=60 * 60 * 24 * 30) # 30 天
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 5) 计数(只在 tencent TMT 上计;maas / agnes / local 都不计腾讯云配额)
|
# 5) 计数(只在 tencent TMT 上计;spark / maas / agnes / local 都不计腾讯云配额)
|
||||||
if res.engine == "tencent":
|
if res.engine == "tencent":
|
||||||
try:
|
try:
|
||||||
await self.add_usage(res.chars or chars)
|
await self.add_usage(res.chars or chars)
|
||||||
|
|||||||
114
backend/app/services/translation/spark.py
Normal file
114
backend/app/services/translation/spark.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""星火 Spark 翻译(OpenAI 兼容协议)。
|
||||||
|
|
||||||
|
- 端点:https://spark-api-open.xf-yun.com/v1/chat/completions
|
||||||
|
- 模型:lite(也支持 generalv3.5 等,但该项目用 Lite,免费额度)
|
||||||
|
- 鉴权:Bearer token(APIPassword 直接当 Bearer 用)
|
||||||
|
- 请求:POST /chat/completions,system prompt 告诉模型做翻译
|
||||||
|
|
||||||
|
设计上独立于 LlmClient,专门走 spark_* 配置,避免和 LLM 智能增强共用 client 的节流。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.services.translation.base import BaseTranslator, TranslationResult
|
||||||
|
|
||||||
|
logger = logging.getLogger("news.translate.spark")
|
||||||
|
|
||||||
|
|
||||||
|
# 经过实测,这套 prompt 在 Spark Lite 上输出稳定(不夹带 reasoning / 注释)。
|
||||||
|
# Lite 不支持 system 角色(早期 Lite 模型),但 v1 OpenAI 兼容的 Lite 接受 system;
|
||||||
|
# 保留 system prompt 走 4.0Ultra/Max/Pro 时也通用。
|
||||||
|
_SYSTEM_PROMPT = """你是一个即时翻译助手。对于用户输入的英文或日文文章,请直接输出对应的简体中文译文。严格遵守:
|
||||||
|
|
||||||
|
不要进行任何分步思考、不要输出分析、不要添加注释或说明。
|
||||||
|
|
||||||
|
只输出中文译文本身,不包含任何额外文字(如"翻译结果:"、"以下是中文:"等)。
|
||||||
|
|
||||||
|
如果输入内容既非英文也非日文,仅回复:"仅支持英文或日文翻译为中文。" """
|
||||||
|
|
||||||
|
|
||||||
|
class SparkTranslator(BaseTranslator):
|
||||||
|
"""星火 Spark 翻译(OpenAI 兼容协议,模型 lite / generalv3.5 等)。"""
|
||||||
|
|
||||||
|
name = "spark"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if not settings.spark_api_password:
|
||||||
|
raise RuntimeError("Spark APIPassword missing")
|
||||||
|
self.api_password = settings.spark_api_password
|
||||||
|
self.base_url = settings.spark_base_url.rstrip("/")
|
||||||
|
self.model = settings.spark_model
|
||||||
|
self.interval_sec = settings.spark_interval_sec
|
||||||
|
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
return bool(self.api_password)
|
||||||
|
|
||||||
|
async def translate(
|
||||||
|
self, text: str, source: str = "auto", target: str = "zh"
|
||||||
|
) -> TranslationResult:
|
||||||
|
"""翻译接口。source/target 当前固定 EN/JA → ZH(由 prompt 控制)。"""
|
||||||
|
if not text.strip():
|
||||||
|
return TranslationResult(text=text, engine=self.name, chars=0)
|
||||||
|
|
||||||
|
if not self.is_configured():
|
||||||
|
raise RuntimeError("Spark APIPassword missing")
|
||||||
|
|
||||||
|
system = _SYSTEM_PROMPT
|
||||||
|
user = text
|
||||||
|
|
||||||
|
url = f"{self.base_url}/chat/completions"
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": user},
|
||||||
|
],
|
||||||
|
"temperature": 0.0,
|
||||||
|
"max_tokens": max(256, len(text) * 3),
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.api_password}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 简单串行 + 重试 1 次
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
r = await client.post(url, json=payload, headers=headers)
|
||||||
|
if r.status_code >= 500:
|
||||||
|
raise RuntimeError(f"Spark 5xx: {r.status_code} {r.text[:200]}")
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise RuntimeError(f"Spark {r.status_code}: {r.text[:300]}")
|
||||||
|
data = r.json()
|
||||||
|
# 错误响应:{"error": {...}}
|
||||||
|
if "error" in data:
|
||||||
|
raise RuntimeError(f"Spark error: {data['error']}")
|
||||||
|
content = (
|
||||||
|
data.get("choices", [{}])[0]
|
||||||
|
.get("message", {})
|
||||||
|
.get("content", "")
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
if not content:
|
||||||
|
raise RuntimeError(f"Spark empty content: {r.text[:300]}")
|
||||||
|
await asyncio.sleep(self.interval_sec)
|
||||||
|
return TranslationResult(
|
||||||
|
text=content, engine=self.name, chars=len(text), cached=False
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
last_exc = e
|
||||||
|
logger.warning("spark attempt %s failed: %s", attempt, e)
|
||||||
|
if attempt == 0:
|
||||||
|
await asyncio.sleep(0.5 + random.random())
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
assert last_exc is not None
|
||||||
|
raise last_exc
|
||||||
Reference in New Issue
Block a user