diff --git a/.env.example b/.env.example index 6c91411..ed8fa40 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,16 @@ TENCENT_TMT_QUOTA_BUFFER=0.05 # 单次请求最大字符 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 + # ===== 本地翻译(降级) ===== # 不启用就留空:不会用本地模�?LOCAL_TRANSLATE_ENABLED=false LOCAL_TRANSLATE_MODEL=nllb-200-distilled-600M diff --git a/backend/app/config.py b/backend/app/config.py index 9dd4ccc..fc5a2e1 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -70,6 +70,13 @@ class Settings(BaseSettings): tencent_tmt_quota_buffer: float = 0.05 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") @classmethod def _check_buffer(cls, v: float) -> float: diff --git a/backend/app/services/translation/service.py b/backend/app/services/translation/service.py index 5bd2b57..6cdfcd6 100644 --- a/backend/app/services/translation/service.py +++ b/backend/app/services/translation/service.py @@ -1,15 +1,18 @@ """翻译服务门面:配额检查 + 缓存 + 引擎选择 + 月度计数。 引擎链路(优先级降序): -1. tencent TMT(主,按月配额;快满时主动切走) -2. tencent_maas(备用,OpenAI 兼容,无配额;主失败/TMT 配额耗尽时启用) -3. agnes(第三级,通用 LLM 做翻译;MaaS 不可用时启用 — 质量次之但够用) -4. local(最后兜底,需 settings.local_translate_enabled=true) +1. spark(主,Lite 免费;spark_api_password 配了才用) +2. tencent TMT(第二级,按月配额;快满时主动切走) +3. tencent_maas(备用,OpenAI 兼容,无配额;主失败/TMT 配额耗尽时启用) +4. agnes(第三级,通用 LLM 做翻译;MaaS 不可用时启用 — 质量次之但够用) +5. local(最后兜底,需 settings.local_translate_enabled=true) 注: - TMT 是按月计费的(腾讯云后台可能计费口径是请求字节,我们 redis 累加的是字符数, 差异约 2-3x);用户从腾讯云后台看"已用 2M"时,我们 redis 显示约 80 万字符 - 用户决策:以腾讯云后台数字为准,快满时降级 +- spark 是 Lite 免费,默认走它;spark 不可用时降级到 tencent(继续吃配额)。 + 想要完全绕开 tencent,把 TENCENTCLOUD_SECRET_ID 留空即可。 """ 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.base import BaseTranslator, TranslationResult 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_maas import TencentMaaSTranslator @@ -43,6 +47,7 @@ def _month_key() -> str: class TranslationService: def __init__(self): + self._spark: BaseTranslator | None = None self._tencent: BaseTranslator | None = None self._tencent_maas: BaseTranslator | None = None self._agnes: BaseTranslator | None = None @@ -50,8 +55,18 @@ class TranslationService: # 串行:1 个并发;避免触发腾讯 TMT 限速 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: - """主引擎:腾讯 TMT(初始化失败返回 None 表示不可用)。""" + """第二级:腾讯 TMT(初始化失败返回 None 表示不可用)。""" if self._tencent is None: try: self._tencent = TencentTranslator() @@ -139,19 +154,21 @@ class TranslationService: if cached is not None: return TranslationResult(text=cached, engine="cache", chars=chars, cached=True) - # 2) 选引擎(主 → maas 备用 → local 兜底) - use_tencent = await self.can_use_tencent(chars) - if use_tencent: - engine: BaseTranslator | None = self._primary() + # 2) 选引擎 + # 优先级:spark → tencent(配额)→ maas → agnes → local + engine: BaseTranslator | None = None + 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: - # TMT 配了 key 但初始化失败 → 直接走 maas logger.warning("TMT unavailable, falling back to MaaS") engine = self._maas() else: engine = None if engine is None: - # 配额耗尽 / TMT 不可用:走备用链(maas → agnes → local) + # spark 不可用 + 配额耗尽 / TMT 不可用:走备用链(maas → agnes → local) engine = self._fallback() if engine is None: # 全无可用:返回原文 + 标记 @@ -161,7 +178,7 @@ class TranslationService: chars=chars, ) logger.info( - "tencent quota exhausted, fallback to %s for %d chars", + "primary engines unavailable, fallback to %s for %d chars", engine.name, chars, ) @@ -172,8 +189,16 @@ class TranslationService: res = await engine.translate(text, source=source, target=target) except Exception as e: logger.exception("translate failed with %s: %s", engine.name, e) - # 按 maas → local 顺序找一个不同的 fallback - fb = self._maas() if engine.name != "tencent_maas" else None + # 失败时按 tencent_maas → local 顺序找一个不同的 fallback + # 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": fb = self._local_translator() if fb is not None: @@ -186,19 +211,15 @@ class TranslationService: if res is None: raise RuntimeError(f"translation failed for {chars} chars (engine={engine.name})") - # 注:engine 已经设好但运行时降级需要重新判断 fallback 链 - # 上面 translate() 调用失败时,会重试 _fallback() 里下一个可用引擎 - # 这里 engine 已经在 _fallback() 中按顺序选了一个最合适的,直接使用即可 - # 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: try: await r.set(ck, res.text, ex=60 * 60 * 24 * 30) # 30 天 except Exception: pass - # 5) 计数(只在 tencent TMT 上计;maas / agnes / local 都不计腾讯云配额) + # 5) 计数(只在 tencent TMT 上计;spark / maas / agnes / local 都不计腾讯云配额) if res.engine == "tencent": try: await self.add_usage(res.chars or chars) diff --git a/backend/app/services/translation/spark.py b/backend/app/services/translation/spark.py new file mode 100644 index 0000000..121f1bd --- /dev/null +++ b/backend/app/services/translation/spark.py @@ -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