Files
Live-streaming/app/llm_client.py

398 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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