398 lines
16 KiB
Python
398 lines
16 KiB
Python
"""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
|