合并外部维护版本并同步自动每日功能
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user