合并外部维护版本并同步自动每日功能

This commit is contained in:
2026-09-28 13:55:07 +08:00
parent 2d81615f78
commit 9040bcb5e5
59 changed files with 3176 additions and 179 deletions
+397
View File
@@ -0,0 +1,397 @@
"""LLM 客户端抽象层。
目标:为直播间弹幕回复提供统一的对话接口,屏蔽底层各家协议差异。
结构:
- BaseLLMProvider 抽象基类,定义 async chat(messages) -> str
- OpenAICompatProvider OpenAI /chat/completions 兼容协议
(DeepSeek / 通义千问DashScope兼容模式 / Kimi / 硅基流动 /
Ollama / vLLM / LM Studio 等几乎所有主流服务)
- OllamaNativeProvider Ollama 原生 /api/chat 协议
- AnthropicProvider Anthropic /v1/messages 协议(Claude 系列)
- create_provider() 按 config["llm_reply"]["provider"] 工厂构造
- LLMReplyEngine 弹幕回复业务引擎:过滤、限流、上下文、重试
扩展新协议只需:继承 BaseLLMProvider 实现 chat(),并在 PROVIDERS 注册。
"""
from __future__ import annotations
import asyncio
import logging
import random
import re
import time
from abc import ABC, abstractmethod
from collections import deque
try:
import aiohttp
except ImportError: # pragma: no cover - aiohttp 为运行时必需依赖
aiohttp = None
# ---------------------------------------------------------------------------
# Provider 抽象与实现
# ---------------------------------------------------------------------------
class LLMError(Exception):
"""LLM 调用失败(网络/协议/内容拒绝等)。"""
class BaseLLMProvider(ABC):
"""所有 LLM 协议适配器的公共基类。"""
name = "base"
def __init__(self, base_url: str, api_key: str = "", model: str = "",
timeout_sec: float = 20.0, logger: logging.Logger | None = None):
self.base_url = (base_url or "").rstrip("/")
self.api_key = api_key or ""
self.model = model or ""
self.timeout_sec = max(3.0, float(timeout_sec or 20.0))
self.logger = logger or logging.getLogger(__name__)
@abstractmethod
async def chat(self, messages: list[dict], *, temperature: float = 0.8,
max_tokens: int = 256) -> str:
"""发送一轮对话,返回助手回复文本;失败抛 LLMError。"""
async def _post_json(self, url: str, payload: dict,
headers: dict | None = None) -> dict:
if aiohttp is None:
raise LLMError("缺少 aiohttp 依赖")
timeout = aiohttp.ClientTimeout(total=self.timeout_sec)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=payload,
headers=headers or {}) as resp:
body = await resp.text()
if resp.status != 200:
raise LLMError(
f"HTTP {resp.status}: {body[:200]}")
import json as _json
return _json.loads(body)
except asyncio.TimeoutError as e:
raise LLMError(f"请求超时({self.timeout_sec}s)") from e
except LLMError:
raise
except Exception as e:
raise LLMError(f"请求失败: {e}") from e
class OpenAICompatProvider(BaseLLMProvider):
"""OpenAI /v1/chat/completions 兼容协议。
覆盖:OpenAI、DeepSeek、Kimi(Moonshot)、通义千问(DashScope兼容模式)、
智谱GLM、硅基流动、OpenRouter、Ollama/VoidAI/vLLM/LM Studio 本地端点等。
"""
name = "openai"
DEFAULT_PATH = "/chat/completions"
async def chat(self, messages: list[dict], *, temperature: float = 0.8,
max_tokens: int = 256) -> str:
url = self.base_url + self.DEFAULT_PATH
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
payload = {
"model": self.model,
"messages": messages,
"temperature": max(0.0, min(2.0, temperature)),
"max_tokens": max(16, int(max_tokens)),
"stream": False,
}
data = await self._post_json(url, payload, headers)
try:
content = data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as e:
raise LLMError(f"响应格式异常: {str(data)[:200]}") from e
if not isinstance(content, str):
raise LLMError("响应 content 非文本")
return content.strip()
class OllamaNativeProvider(BaseLLMProvider):
"""Ollama 原生 /api/chat 协议(无需鉴权)。"""
name = "ollama"
async def chat(self, messages: list[dict], *, temperature: float = 0.8,
max_tokens: int = 256) -> str:
url = self.base_url + "/api/chat"
payload = {
"model": self.model,
"messages": messages,
"stream": False,
"options": {
"temperature": max(0.0, min(2.0, temperature)),
"num_predict": max(16, int(max_tokens)),
},
}
data = await self._post_json(url, payload)
content = data.get("message", {}).get("content", "")
if not isinstance(content, str):
raise LLMError("响应 message.content 非文本")
return content.strip()
class AnthropicProvider(BaseLLMProvider):
"""Anthropic /v1/messages 协议(Claude 系列)。"""
name = "anthropic"
ANTHROPIC_VERSION = "2023-06-01"
async def chat(self, messages: list[dict], *, temperature: float = 0.8,
max_tokens: int = 256) -> str:
url = self.base_url + "/v1/messages"
headers = {
"Content-Type": "application/json",
"x-api-key": self.api_key,
"anthropic-version": self.ANTHROPIC_VERSION,
}
# Anthropic 协议中 system 是顶层字段,不放在 messages 里。
system_text = "\n".join(
m["content"] for m in messages if m.get("role") == "system")
chat_msgs = [m for m in messages if m.get("role") != "system"]
payload = {
"model": self.model,
"system": system_text or None,
"messages": [
{"role": m["role"], "content": m["content"]}
for m in chat_msgs
],
"temperature": max(0.0, min(1.0, temperature)),
"max_tokens": max(16, int(max_tokens)),
}
data = await self._post_json(url, payload, headers)
try:
parts = data["content"]
text = "".join(p.get("text", "") for p in parts if isinstance(p, dict))
except (KeyError, TypeError) as e:
raise LLMError(f"响应格式异常: {str(data)[:200]}") from e
return text.strip()
# 协议注册表:新增协议在此注册即可。
PROVIDERS: dict[str, type[BaseLLMProvider]] = {
OpenAICompatProvider.name: OpenAICompatProvider,
OllamaNativeProvider.name: OllamaNativeProvider,
AnthropicProvider.name: AnthropicProvider,
}
def create_provider(cfg: dict, logger: logging.Logger | None = None) -> BaseLLMProvider:
"""根据 llm_reply 配置节构造 provider。"""
cfg = cfg or {}
name = str(cfg.get("provider", "openai") or "openai").strip().lower()
cls = PROVIDERS.get(name)
if cls is None:
raise LLMError(
f"未知 LLM 协议: {name}(可选: {', '.join(sorted(PROVIDERS))})")
base_url = str(cfg.get("base_url", "") or "").strip()
if not base_url:
raise LLMError("未配置 base_url")
return cls(
base_url=base_url,
api_key=str(cfg.get("api_key", "") or ""),
model=str(cfg.get("model", "") or "").strip(),
timeout_sec=float(cfg.get("timeout_sec", 20) or 20),
logger=logger,
)
# ---------------------------------------------------------------------------
# 弹幕回复业务引擎
# ---------------------------------------------------------------------------
class LLMReplyEngine:
"""把观众弹幕交给 LLM 生成回复。
职责边界:本类只负责"生成回复文本",不负责发送——
由调用方(CommandHandler)决定走弹幕/TTS 通道。
内置:
- 开关与触发过滤(长度、忽略前缀、黑名单)
- 频率限制(全局每分钟上限 + 单人冷却)
- 短期上下文(最近 N 条观众发言与机器人回复)
- 失败重试(指数退避,最多 retry_attempts 次)
- 并发保护(同一时刻只允许一个在途请求,忙时直接跳过该条)
"""
def __init__(self, cfg: dict | None = None, logger: logging.Logger | None = None,
config_getter=None):
"""config_getter: 可选的可调用对象,返回最新 llm_reply 配置 dict,
用于跟随 Config 热更新(reload 后 data 节点可能是新对象)。"""
self.logger = logger or logging.getLogger(__name__)
self._cfg = cfg or {}
self._config_getter = config_getter
self._provider: BaseLLMProvider | None = None
self._provider_built_at = 0.0
self._context: deque[tuple[str, str]] = deque() # (speaker, text)
self._reply_times: deque[float] = deque() # 全局滑动窗口
self._user_last_reply: dict[int, float] = {}
self._inflight = asyncio.Lock()
# 配置指纹:热更新后自动重建 provider。
self._cfg_fingerprint = ""
# -- 配置 ---------------------------------------------------------------
def _config(self) -> dict:
if self._config_getter is not None:
try:
cfg = self._config_getter()
if cfg:
self._cfg = cfg
except Exception:
pass
return self._cfg or {}
def update_config(self, cfg: dict):
"""配置热更新入口。"""
self._cfg = cfg or {}
@property
def enabled(self) -> bool:
return bool(self._config().get("enabled", False))
def _provider_fingerprint(self) -> str:
c = self._config()
return "|".join(str(c.get(k, "")) for k in
("provider", "base_url", "api_key", "model", "timeout_sec"))
def _get_provider(self) -> BaseLLMProvider:
fp = self._provider_fingerprint()
if self._provider is None or fp != self._cfg_fingerprint:
self._provider = create_provider(self._config(), self.logger)
self._cfg_fingerprint = fp
self.logger.info(f"[LLM] 使用协议 {self._provider.name} "
f"模型 {self._provider.model or '(默认)'}")
return self._provider
# -- 过滤 ---------------------------------------------------------------
def should_reply(self, text: str, uid: int, uname: str) -> str | None:
"""返回需要回复的规范化文本;不需要回复返回 None。"""
cfg = self._config()
if not self.enabled:
return None
text = (text or "").strip()
raw = cfg.get("min_text_len", 2)
min_len = int(raw) if raw is not None else 2
if len(text) < min_len:
return None
for prefix in cfg.get("ignore_prefixes", []) or []:
prefix = str(prefix or "").strip()
if prefix and text.startswith(prefix):
return None
blacklist = {str(u) for u in (cfg.get("blacklist_uids", []) or [])}
if str(uid) in blacklist:
return None
# 单人冷却(0 表示禁用冷却)
raw = cfg.get("per_user_cooldown_sec", 30)
cooldown = float(raw) if raw is not None else 30.0
if cooldown > 0:
last = self._user_last_reply.get(int(uid), 0.0)
if time.time() - last < cooldown:
return None
# 全局限流(0 表示不限流)
raw = cfg.get("replies_per_minute", 6)
rpm = int(raw) if raw is not None else 6
if rpm > 0:
now = time.time()
while self._reply_times and now - self._reply_times[0] > 60:
self._reply_times.popleft()
if len(self._reply_times) >= rpm:
return None
return text
# -- 上下文 ---------------------------------------------------------------
def _build_messages(self, text: str, uname: str) -> list[dict]:
cfg = self._config()
system_prompt = str(cfg.get("system_prompt", "") or "").strip()
messages: list[dict] = []
emotion_instruction = (
"每次回复必须先输出一个情绪标签,只能从[开心]、[很开心]、[平静]、[生气]、[很生气]中选一个,"
"紧接着输出给观众的回复正文。标签只用于桌宠状态,不属于口播内容。"
)
messages.append({"role": "system", "content": "\n".join(
part for part in (system_prompt, emotion_instruction) if part
)})
for speaker, content in list(self._context):
role = "assistant" if speaker == "bot" else "user"
content = f"{speaker}: {content}" if role == "user" else content
messages.append({"role": role, "content": content})
messages.append({"role": "user", "content": f"{uname}: {text}"})
return messages
def _remember(self, uname: str, text: str, reply: str | None):
max_turns = int(self._config().get("context_turns", 6) or 6)
if max_turns <= 0:
return
self._context.append((uname, text))
if reply:
self._context.append(("bot", reply))
while len(self._context) > max_turns * 2:
self._context.popleft()
# -- 主入口 ---------------------------------------------------------------
async def generate_reply(self, text: str, uid: int, uname: str) -> str | None:
"""生成回复;被过滤/限流/失败返回 None。非阻塞——忙时立即放弃。"""
text = self.should_reply(text, uid, uname)
if text is None:
return None
if self._inflight.locked():
# 已有请求在途:丢弃本条,避免弹幕高峰打爆 LLM。
return None
async with self._inflight:
cfg = self._config()
provider = self._get_provider()
messages = self._build_messages(text, uname or "观众")
temperature = float(cfg.get("temperature", 0.8) or 0.8)
max_tokens = int(cfg.get("max_tokens", 128) or 128)
attempts = max(1, int(cfg.get("retry_attempts", 2) or 1))
last_err: Exception | None = None
for i in range(attempts):
try:
raw = await provider.chat(
messages, temperature=temperature, max_tokens=max_tokens)
reply = self._sanitize(raw)
if reply:
now = time.time()
self._reply_times.append(now)
self._user_last_reply[int(uid)] = now
self._remember(uname or "观众", text, reply)
return reply
return None # 模型返回空内容,不重试
except LLMError as e:
last_err = e
if i < attempts - 1:
await asyncio.sleep(0.8 * (2 ** i) + random.random() * 0.4)
self.logger.warning(f"[LLM] 回复失败({last_err})")
return None
def _sanitize(self, raw: str) -> str:
"""清洗模型输出:去空白、截断到 max_reply_chars、去 markdown 粗体。
开头的情绪标签(如 [开心])豁免截断长度,保证不会被截坏;
标签本身由调用方(danmu_queue)在弹幕/语音输出前剥离。
"""
cfg = self._config()
text = str(raw or "").strip()
# 直播口播场景不需要 markdown 标记
for token in ("**", "__", "```"):
text = text.replace(token, "")
text = " ".join(text.split())
m = re.match(r"^(\[[^\[\]]{1,8}\])\s*", text)
tag = m.group(1) if m else ""
body = text[m.end():] if m else text
raw = cfg.get("max_reply_chars", 60)
max_chars = int(raw) if raw is not None else 60
if max_chars > 0 and len(tag) + len(body) > max_chars:
body = body[:max(1, max_chars - len(tag))].rstrip() + "…"
return tag + body