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:
Mavis
2026-06-10 23:14:20 +08:00
parent 759eefabc3
commit b27643123e
4 changed files with 172 additions and 20 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View 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