Compare commits

...

2 Commits

Author SHA1 Message Date
Mavis
3f183d14db feat(translate): service.py 接 zhipu / config 加 zhipu_* / .env.example 加 ZHIPU 配置
配 zhipu.py(zhipu 模块)一起使用:
- service.py: 新增 _zhipu_translator(),引擎选择链路 spark → zhipu → tencent → maas → agnes → local
- config.py: 新增 zhipu_api_key / zhipu_base_url / zhipu_model / zhipu_interval_sec
- .env.example: 补 ZHIPU_* 字段说明

留空 zhipu_api_key = spark 不可用时直接降级 tencent(向后兼容)
2026-06-10 23:48:14 +08:00
Mavis
b6fc1b322f feat(translate): 加智谱 GLM 作为第二序位翻译引擎(spark → zhipu → tencent)
- 新增 app/services/translation/zhipu.py: OpenAI 兼容协议客户端,
  URL = https://open.bigmodel.cn/api/paas/v4/chat/completions,
  鉴权 = Bearer <api_key>, model = glm-4-flash(默认,免费)/glm-4-air/glm-4.5 等
- service.py 引擎链路调整为: spark → zhipu → tencent(配额)→ maas → agnes → local
- 配置: 新增 ZHIPU_API_KEY / ZHIPU_BASE_URL / ZHIPU_MODEL / ZHIPU_INTERVAL_SEC
  (留空 ZHIPU_API_KEY = spark 不可用时直接降级 tencent,向后兼容)
- 实测 GLM-4-Flash 2.3s 返回 OK;GLM-4.7-Flash 当前限流 1305
2026-06-10 23:47:55 +08:00
4 changed files with 157 additions and 11 deletions

View File

@@ -45,6 +45,15 @@ SPARK_MODEL=lite
# 单次调用间隔(秒),避免被限速
SPARK_INTERVAL_SEC=1.0
# ===== 智谱 GLM(第二序位翻译;glm-4-flash 免费)=====
# 留空 = 不启用智谱(spark 不可用时直接走 tencent)
# 控制台 https://open.bigmodel.cn/ → API Keys → 新建
ZHIPU_API_KEY=your_zhipu_api_key
ZHIPU_BASE_URL=https://open.bigmodel.cn/api/paas/v4
# 模型:glm-4-flash(默认,免费) / glm-4-air / glm-4-airx / glm-4 / glm-4-plus / glm-4.5 / glm-4.6
ZHIPU_MODEL=glm-4-flash
ZHIPU_INTERVAL_SEC=1.0
# ===== 本地翻译(降级) =====
# 不启用就留空:不会用本地模<E59CB0>?LOCAL_TRANSLATE_ENABLED=false
LOCAL_TRANSLATE_MODEL=nllb-200-distilled-600M

View File

@@ -77,6 +77,13 @@ class Settings(BaseSettings):
spark_model: str = "lite"
spark_interval_sec: float = 1.0
# ===== 智谱 GLM(第二序位翻译;GLM-4-Flash 免费)=====
# 留空 = 不启用智谱(spark 不可用时直接降级到 tencent)
zhipu_api_key: str = ""
zhipu_base_url: str = "https://open.bigmodel.cn/api/paas/v4"
zhipu_model: str = "glm-4-flash"
zhipu_interval_sec: float = 1.0
@field_validator("tencent_tmt_quota_buffer")
@classmethod
def _check_buffer(cls, v: float) -> float:

View File

@@ -2,16 +2,17 @@
引擎链路(优先级降序):
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)
2. zhipu(第二序位,GLM-4-Flash 免费;zhipu_api_key 配了才用)
3. tencent TMT(第三级,按月配额;快满时主动切走)
4. tencent_maas(备用,OpenAI 兼容,无配额;主失败/TMT 配额耗尽时启用)
5. agnes(第四级,通用 LLM 做翻译;MaaS 不可用时启用 — 质量次之但够用)
6. local(最后兜底,需 settings.local_translate_enabled=true)
注:
- TMT 是按月计费的(腾讯云后台可能计费口径是请求字节,我们 redis 累加的是字符数,
差异约 2-3x);用户从腾讯云后台看"已用 2M"时,我们 redis 显示约 80 万字符
- 用户决策:以腾讯云后台数字为准,快满时降级
- spark 是 Lite 免费,默认走它;spark 不可用时降级到 tencent(继续吃配额)。
- spark / zhipu 都是免费模型,默认优先;不可用时降级到 tencent(继续吃配额)。
想要完全绕开 tencent,把 TENCENTCLOUD_SECRET_ID 留空即可。
"""
from __future__ import annotations
@@ -30,6 +31,7 @@ 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
from app.services.translation.zhipu import ZhipuTranslator
logger = logging.getLogger("news.translate.service")
@@ -48,6 +50,7 @@ def _month_key() -> str:
class TranslationService:
def __init__(self):
self._spark: BaseTranslator | None = None
self._zhipu: BaseTranslator | None = None
self._tencent: BaseTranslator | None = None
self._tencent_maas: BaseTranslator | None = None
self._agnes: BaseTranslator | None = None
@@ -65,8 +68,18 @@ class TranslationService:
self._spark = None
return self._spark
def _zhipu_translator(self) -> BaseTranslator | None:
"""第二序位引擎:智谱 GLM(免费)。配了 zhipu_api_key 才启用。"""
if self._zhipu is None and settings.zhipu_api_key:
try:
self._zhipu = ZhipuTranslator()
except Exception as e:
logger.warning("zhipu init failed: %s", e)
self._zhipu = None
return self._zhipu
def _primary(self) -> BaseTranslator | None:
"""级:腾讯 TMT(初始化失败返回 None 表示不可用)。"""
"""级:腾讯 TMT(初始化失败返回 None 表示不可用)。"""
if self._tencent is None:
try:
self._tencent = TencentTranslator()
@@ -155,10 +168,12 @@ class TranslationService:
return TranslationResult(text=cached, engine="cache", chars=chars, cached=True)
# 2) 选引擎
# 优先级:spark → tencent(配额)→ maas → agnes → local
# 优先级:spark → zhipu → tencent(配额)→ maas → agnes → local
engine: BaseTranslator | None = None
if self._spark_translator() is not None:
engine = self._spark_translator()
elif self._zhipu_translator() is not None:
engine = self._zhipu_translator()
elif await self.can_use_tencent(chars):
engine = self._primary()
if engine is None:
@@ -168,7 +183,7 @@ class TranslationService:
engine = None
if engine is None:
# spark 不可用 + 配额耗尽 / TMT 不可用:走备用链(maas → agnes → local)
# spark / zhipu 不可用 + 配额耗尽 / TMT 不可用:走备用链(maas → agnes → local)
engine = self._fallback()
if engine is None:
# 全无可用:返回原文 + 标记
@@ -189,10 +204,17 @@ 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)
# 失败时按 tencent_maas → local 顺序找一个不同的 fallback
# spark 失败时也要走 tencent(继续吃配额,因优先级只是降低不是禁用)
# 失败时按 zhipu → tencentmaas → local 顺序找一个不同的 fallback
# spark / zhipu 失败时也要走 tencent(继续吃配额,因优先级只是降低不是禁用)
fb: BaseTranslator | None = None
if engine.name == "spark":
if self._zhipu_translator() is not None:
fb = self._zhipu_translator()
if fb is None and 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 == "zhipu":
if await self.can_use_tencent(chars):
fb = self._primary()
if fb is None:
@@ -212,7 +234,7 @@ class TranslationService:
raise RuntimeError(f"translation failed for {chars} chars (engine={engine.name})")
# 4) 写缓存 — 只缓存真实翻译结果;失败/降级文本不缓存(避免污染 30 天)
if res.engine in ("spark", "tencent", "tencent_maas", "agnes", "nllb") and not res.cached:
if res.engine in ("spark", "zhipu", "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 天

View File

@@ -0,0 +1,108 @@
"""智谱 GLM 翻译(OpenAI 兼容协议)。
- 端点:https://open.bigmodel.cn/api/paas/v4/chat/completions
- 模型:glm-4-flash(免费,默认) / glm-4.5 / glm-4.6 / glm-4-air 等
- 鉴权:Bearer <api_key>
设计上独立于 LlmClient,专门走 zhipu_* 配置,避免和 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.zhipu")
# 经过实测,这套 prompt 在 GLM-4-Flash 上输出稳定(不夹带 reasoning / 注释)。
_SYSTEM_PROMPT = """你是一个即时翻译助手。对于用户输入的英文或日文文章,请直接输出对应的简体中文译文。严格遵守:
不要进行任何分步思考、不要输出分析、不要添加注释或说明。
只输出中文译文本身,不包含任何额外文字(如"翻译结果:""以下是中文:"等)。
如果输入内容既非英文也非日文,仅回复:"仅支持英文或日文翻译为中文。" """
class ZhipuTranslator(BaseTranslator):
"""智谱 GLM 翻译(OpenAI 兼容协议)。"""
name = "zhipu"
def __init__(self):
if not settings.zhipu_api_key:
raise RuntimeError("Zhipu API key missing")
self.api_key = settings.zhipu_api_key
self.base_url = settings.zhipu_base_url.rstrip("/")
self.model = settings.zhipu_model
self.interval_sec = settings.zhipu_interval_sec
def is_configured(self) -> bool:
return bool(self.api_key)
async def translate(
self, text: str, source: str = "auto", target: str = "zh"
) -> TranslationResult:
if not text.strip():
return TranslationResult(text=text, engine=self.name, chars=0)
if not self.is_configured():
raise RuntimeError("Zhipu API key missing")
url = f"{self.base_url}/chat/completions"
payload = {
"model": self.model,
"messages": [
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": text},
],
"temperature": 0.0,
"max_tokens": max(256, len(text) * 3),
"stream": False,
}
headers = {
"Authorization": f"Bearer {self.api_key}",
"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"Zhipu 5xx: {r.status_code} {r.text[:200]}")
if r.status_code == 429:
raise RuntimeError(f"Zhipu 限流: {r.text[:200]}")
if r.status_code != 200:
raise RuntimeError(f"Zhipu {r.status_code}: {r.text[:300]}")
data = r.json()
if "error" in data:
raise RuntimeError(f"Zhipu error: {data['error']}")
content = (
data.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
.strip()
)
if not content:
raise RuntimeError(f"Zhipu 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("zhipu 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