Files
diary-news/backend/app/services/translation/agnes.py
Mavis 921e674a30 feat(translate): 加 Agnes 翻译 fallback,buffer 改 0.5
腾讯 TMT 月度配额快满时(腾讯后台口径已用 2M/5M),翻译降级链:

1. tencent TMT(主,按月配额)
2. tencent MaaS u2(第二级,翻译专用,无配额)
3. agnes 通用 LLM(第三级,质量次之但够用)
4. local NLLB(最后兜底)

新增 backend/app/services/translation/agnes.py: AgnesTranslator
复用 LlmClient 做限速 + 重试,系统 prompt 强约束只输出译文,
去除 "以下是翻译" 等常见 LLM 翻译前缀。

service.py 改动:
- fallback 链 maas -> agnes -> local
- cache 接受 agnes 结果(30天)
- add_usage 只算 tencent TMT

buffer 调整: TENCENT_TMT_QUOTA_BUFFER 0.05 -> 0.5
腾讯云后台按请求字节计费,与我们 redis 字符累加口径差约 2.5x;
按腾讯后台口径 redis 累加到 1M 字符即触发降级(对应腾讯约 2.5M 字节 =50% 用量),
留足 buffer,避免月底真爆。
2026-06-10 17:44:47 +08:00

118 lines
4.2 KiB
Python

"""Agnes 翻译后端(通用 LLM 做翻译,作为 TMT/MaaS 之后的第三级 fallback)。
- 端点:settings.agnes_base_url/v1/chat/completions
- 模型:settings.agnes_chat_model(agnes-2.0-flash)
- 鉴权:Bearer AGNES_API_KEY
- 复用 LlmClient 的限速(interval_sec)
适用场景:
- TMT 月配额快满(>95%)时,先走 MaaS(u2)
- MaaS 不可用 / 失败时,降到 Agnes(通用 LLM 翻译,质量次之,但够用)
- Agnes 不可用时,本地 NLLB 最后兜底
注意:
- Agnes 不是翻译专用模型,翻译质量不如 TMT / MaaS u2
- 但 Agnes 的 commentary / image / classification 已经走它,客户端有相同 quota 计费体系
- 用一个特殊 system prompt 强约束:只输出译文
"""
from __future__ import annotations
import logging
from app.config import settings
from app.services.llm.client import LlmClient
from app.services.translation.base import BaseTranslator, TranslationResult
logger = logging.getLogger("news.translate.agnes")
# Agnes 做翻译的 system prompt。
# 关键约束:只输出译文,不要思考/分析/注释/标签。
# 这是通用 LLM 翻车最多的地方——会输出 "以下是中文翻译: ..." 前缀,要强约束。
_SYSTEM_PROMPT = """你是即时翻译引擎。把用户输入的文本翻译成简体中文。
严格规则:
1. 只输出译文本身,不要输出任何其他文字
2. 不要输出"以下是翻译""译文:""[Translation]"、引号、破折号等任何包裹
3. 不要分步思考、不要分析、不要注释、不要解释
4. 不要输出 <think>、<reasoning>、<analysis> 等内部标签
5. 如果输入已经是中文,直接原样返回
6. 保持原文段落结构(用换行分隔)"""
class AgnesTranslator(BaseTranslator):
"""Agnes 翻译(通用 LLM 兜底)。"""
name = "agnes"
def __init__(self):
if not settings.agnes_api_key:
raise RuntimeError("AGNES_API_KEY 未配置")
# 用 chat_model,不复用 image_model
self.client = LlmClient(
base_url=settings.agnes_base_url,
api_key=settings.agnes_api_key,
chat_model=settings.agnes_chat_model,
image_model=settings.agnes_image_model,
interval_sec=settings.llm_interval_sec,
)
def is_configured(self) -> bool:
return bool(settings.agnes_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("AGNES_API_KEY 未配置")
# 长文本截断保护:Agnes max_tokens 上限小,避免超限
# 经验值:1 token ≈ 1.5 个英文 / 0.7 个中文字符
# 默认 chat 接口 max_tokens=1500,我们给 4096 留余量
# 输入截到 6000 字符(MaaS 的限制类似)
max_input = 6000
truncated = text[:max_input] if len(text) > max_input else text
# 保留段落结构
user = truncated
# LlmClient.chat 已含 interval_sec 节流
content = await self.client.chat(
system=_SYSTEM_PROMPT,
user=user,
temperature=0.1, # 翻译任务低温度更稳
max_tokens=4096,
)
if not content or not content.strip():
raise RuntimeError("Agnes 返回空 content")
# 清洗:模型有时还会输出 markdown 代码块包裹
cleaned = content.strip()
if cleaned.startswith("```"):
# 去掉代码块围栏
lines = cleaned.split("\n")
cleaned = "\n".join(
l for l in lines if not l.strip().startswith("```")
).strip()
# 去除已知的前缀模式(模型偶尔还是会犯)
prefixes_to_strip = [
"以下是译文:",
"以下是中文翻译:",
"译文:",
"中文翻译:",
"Translation:",
"翻译结果:",
]
for p in prefixes_to_strip:
if cleaned.startswith(p):
cleaned = cleaned[len(p):].lstrip()
break
return TranslationResult(
text=cleaned, engine=self.name, chars=len(text), cached=False
)