Files
iomgaa 6bdb802f01 chore: track claude skills, tools, templates, reference code and research-wiki
- Add all claude skills (brainstorming, commit, debugging, TDD, etc.)
- Add claude hooks (pre-commit-guard, post-edit-quality)
- Add research templates (experiment plan, research brief, etc.)
- Add claude tools (arxiv/semantic_scholar/openalex fetch, wiki, exa)
- Add TRM4 reference implementation as algorithm fidelity baseline
- Add research-wiki content (plans, index, graph, query_pack)
- Update .gitignore to exclude .graphify_version runtime state
2026-07-06 20:59:03 -04:00

422 lines
15 KiB
Python

"""
LLM/VLM 客户端模块
==================
统一封装 LLM(纯文本)和 VLM(多模态)API 调用。
仅支持 OpenAI-compatible 接口,通过配置 api_url + model 适配不同服务商
(如 Qwen DashScope、OpenAI、本地推理服务等)。
提供同步版(chat / chat_with_images)和异步版(chat_async / chat_with_images_async)。
异步版基于 openai.AsyncOpenAI,适配 asyncio 事件循环,零线程阻塞。
使用方式::
from video_tree_trm.llm_client import LLMClient
from video_tree_trm.config import Config
cfg = Config.load("config/default.yaml")
vlm = LLMClient(cfg.vlm)
# 同步
answer = vlm.chat_with_images("图中有什么?", images=["frame.jpg"])
# 异步
import asyncio
answer = asyncio.run(vlm.chat_with_images_async("图中有什么?", images=["frame.jpg"]))
"""
from __future__ import annotations
import asyncio
import base64
import os
import re
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Dict, List, Optional, Union
import httpx
import openai
from utils.logger_system import log_exception, log_msg
from video_tree_trm.config import LLMConfig, VLMConfig
# 502/503 时的重试参数
_RETRY_STATUS_CODES = {502, 503}
_MAX_RETRIES = 20 # 最多重试次数(约等待 20+ 分钟)
_RETRY_BASE_WAIT = 60 # 首次等待 60 秒
_RETRY_MAX_WAIT = 300 # 单次等待上限 5 分钟
def _call_with_retry(fn, label: str):
"""对 fn() 调用执行指数退避重试(重试 502/503 及超时)。
参数:
fn: 无参调用的函数,返回 API response。
label: 日志标识(如方法名)。
返回:
fn() 的返回值。
异常:
openai.OpenAIError: 超过最大重试次数或非可重试错误时抛出。
"""
wait = _RETRY_BASE_WAIT
for attempt in range(1, _MAX_RETRIES + 1):
try:
return fn()
except openai.APITimeoutError:
log_msg(
"WARNING",
f"{label} 请求超时,等待 {wait}s 后重试",
attempt=attempt,
max_retries=_MAX_RETRIES,
)
time.sleep(wait)
wait = min(wait * 2, _RETRY_MAX_WAIT)
except openai.InternalServerError as exc:
status = getattr(exc, "status_code", None)
if status not in _RETRY_STATUS_CODES:
raise
log_msg(
"WARNING",
f"{label} 遇到 {status},等待 {wait}s 后重试",
attempt=attempt,
max_retries=_MAX_RETRIES,
)
time.sleep(wait)
wait = min(wait * 2, _RETRY_MAX_WAIT)
raise RuntimeError(f"{label} 已重试 {_MAX_RETRIES} 次仍失败")
async def _async_call_with_retry(coro_fn, label: str):
"""异步版指数退避重试,适配 asyncio 事件循环。
参数:
coro_fn: 无参调用的协程工厂函数(每次调用返回新协程)。
label: 日志标识(如方法名)。
返回:
coro_fn() 的返回值。
实现细节:
使用 await asyncio.sleep() 替代 time.sleep(),不阻塞事件循环。
每次重试需重新调用 coro_fn() 构造新协程(协程不可复用)。
"""
wait = _RETRY_BASE_WAIT
for attempt in range(1, _MAX_RETRIES + 1):
try:
return await coro_fn()
except openai.APITimeoutError:
log_msg(
"WARNING",
f"{label} 请求超时,等待 {wait}s 后重试",
attempt=attempt,
max_retries=_MAX_RETRIES,
)
await asyncio.sleep(wait)
wait = min(wait * 2, _RETRY_MAX_WAIT)
except openai.InternalServerError as exc:
status = getattr(exc, "status_code", None)
if status not in _RETRY_STATUS_CODES:
raise
log_msg(
"WARNING",
f"{label} 遇到 {status},等待 {wait}s 后重试",
attempt=attempt,
max_retries=_MAX_RETRIES,
)
await asyncio.sleep(wait)
wait = min(wait * 2, _RETRY_MAX_WAIT)
raise RuntimeError(f"{label} 已重试 {_MAX_RETRIES} 次仍失败")
class LLMClient:
"""OpenAI-compatible LLM/VLM 统一客户端。
同时提供同步接口(chat / chat_with_images)和异步接口(chat_async / chat_with_images_async)。
异步接口使用独立的 AsyncOpenAI 实例,零线程阻塞,与 asyncio.Semaphore 配合实现真并发。
属性:
_config: LLMConfig 或 VLMConfig 配置对象。
_client: openai.OpenAI 同步客户端。
_async_client: openai.AsyncOpenAI 异步客户端。
_extra_body: 关闭 Qwen3 thinking 模式的额外参数。
"""
def __init__(self, config: Union[LLMConfig, VLMConfig]) -> None:
"""初始化 LLM/VLM 客户端(同步 + 异步双客户端)。
参数:
config: LLMConfig 或 VLMConfig,包含 api_key、api_url、model 等参数。
异常:
ValueError: api_key 或 api_url 为空时抛出。
"""
if not config.api_key:
raise ValueError(
"LLMClient 初始化失败: config.api_key 不能为空,请在 .env 中设置"
)
if not config.api_url:
raise ValueError(
"LLMClient 初始化失败: config.api_url 不能为空,请在 config/default.yaml 中设置"
)
self._config = config
# 同步客户端(向后兼容)
self._client = openai.OpenAI(
api_key=config.api_key,
base_url=config.api_url,
http_client=httpx.Client(proxy=None),
)
# 异步客户端(asyncio 场景,零阻塞)
self._async_client = openai.AsyncOpenAI(
api_key=config.api_key,
base_url=config.api_url,
http_client=httpx.AsyncClient(proxy=None),
)
# 关闭 Qwen3 thinking 模式(vLLM 正确格式)
self._extra_body: Dict = {"chat_template_kwargs": {"enable_thinking": False}}
log_msg(
"INFO", "LLMClient 初始化完成", model=config.model, api_url=config.api_url
)
# ── 同步接口(向后兼容)─────────────────────────────────────────────────
def chat(self, prompt: str, max_tokens: Optional[int] = None) -> str:
"""纯文本单轮对话(同步)。
参数:
prompt: 用户输入文本。
max_tokens: 最大生成 token 数,为 None 时使用 config.max_tokens。
返回:
生成的文本字符串。
"""
messages = self._build_messages(prompt)
tokens = max_tokens if max_tokens is not None else self._config.max_tokens
try:
response = _call_with_retry(
lambda: self._client.chat.completions.create(
model=self._config.model,
messages=messages,
max_tokens=tokens,
temperature=self._config.temperature,
extra_body=self._extra_body,
),
label="LLMClient.chat",
)
return self._strip_thinking(response.choices[0].message.content)
except Exception as exc:
log_exception("LLMClient.chat 调用失败", exc)
raise
def chat_with_images(
self,
prompt: str,
images: List[str],
max_tokens: Optional[int] = None,
) -> str:
"""多模态单轮对话(VLM,同步)。
参数:
prompt: 文本指令。
images: 图像列表,每项可为本地文件路径或已编码的 base64 字符串。
max_tokens: 最大生成 token 数,为 None 时使用 config.max_tokens。
返回:
生成的文本字符串。
"""
encoded = [self._encode_image(img) for img in images]
messages = self._build_messages(prompt, images=encoded)
tokens = max_tokens if max_tokens is not None else self._config.max_tokens
try:
response = _call_with_retry(
lambda: self._client.chat.completions.create(
model=self._config.model,
messages=messages,
max_tokens=tokens,
temperature=self._config.temperature,
extra_body=self._extra_body,
),
label="LLMClient.chat_with_images",
)
return self._strip_thinking(response.choices[0].message.content)
except Exception as exc:
log_exception("LLMClient.chat_with_images 调用失败", exc)
raise
def batch_chat(
self,
prompts: List[str],
max_tokens: Optional[int] = None,
) -> List[str]:
"""批量纯文本并发对话,保序返回(同步)。
参数:
prompts: 文本输入列表。
max_tokens: 最大生成 token 数。
返回:
与 prompts 等长的生成文本列表,顺序与输入对应。
"""
results: List[str] = [""] * len(prompts)
with ThreadPoolExecutor(max_workers=8) as executor:
future_to_idx = {
executor.submit(self.chat, prompt, max_tokens): idx
for idx, prompt in enumerate(prompts)
}
for future in as_completed(future_to_idx):
idx = future_to_idx[future]
results[idx] = future.result()
return results
# ── 异步接口(asyncio 事件循环,零阻塞)──────────────────────────────────
async def chat_async(self, prompt: str, max_tokens: Optional[int] = None) -> str:
"""纯文本单轮对话(异步,零线程阻塞)。
参数:
prompt: 用户输入文本。
max_tokens: 最大生成 token 数,为 None 时使用 config.max_tokens。
返回:
生成的文本字符串。
实现细节:
使用 AsyncOpenAI 客户端,await 期间事件循环可处理其他协程,
配合 asyncio.Semaphore 实现受控并发。
"""
messages = self._build_messages(prompt)
tokens = max_tokens if max_tokens is not None else self._config.max_tokens
try:
response = await _async_call_with_retry(
lambda: self._async_client.chat.completions.create(
model=self._config.model,
messages=messages,
max_tokens=tokens,
temperature=self._config.temperature,
extra_body=self._extra_body,
),
label="LLMClient.chat_async",
)
return self._strip_thinking(response.choices[0].message.content)
except Exception as exc:
log_exception("LLMClient.chat_async 调用失败", exc)
raise
async def chat_with_images_async(
self,
prompt: str,
images: List[str],
max_tokens: Optional[int] = None,
) -> str:
"""多模态单轮对话(VLM,异步,零线程阻塞)。
参数:
prompt: 文本指令。
images: 图像列表,每项可为本地文件路径或已编码的 base64 字符串。
max_tokens: 最大生成 token 数,为 None 时使用 config.max_tokens。
返回:
生成的文本字符串。
实现细节:
图像编码(磁盘读取 + base64)在默认线程池执行器中并行执行,
避免阻塞事件循环;VLM API 调用通过 AsyncOpenAI 零阻塞。
"""
loop = asyncio.get_event_loop()
# 并行编码所有图像(I/O 密集,交给线程池)
encoded: List[str] = await asyncio.gather(
*[loop.run_in_executor(None, self._encode_image, img) for img in images]
)
messages = self._build_messages(prompt, images=list(encoded))
tokens = max_tokens if max_tokens is not None else self._config.max_tokens
try:
response = await _async_call_with_retry(
lambda: self._async_client.chat.completions.create(
model=self._config.model,
messages=messages,
max_tokens=tokens,
temperature=self._config.temperature,
extra_body=self._extra_body,
),
label="LLMClient.chat_with_images_async",
)
return self._strip_thinking(response.choices[0].message.content)
except Exception as exc:
log_exception("LLMClient.chat_with_images_async 调用失败", exc)
raise
# ── 私有辅助方法 ──────────────────────────────────────────────────────────
@staticmethod
def _strip_thinking(content: str) -> str:
"""剥离 Qwen3 thinking 模式生成的 <think>...</think> 块。
参数:
content: VLM/LLM 原始返回文本(可能含 <think> 块)。
返回:
去除 think 块后的纯净文本。
实现细节:
当 API 参数无法完全禁用 thinking 时作为兜底保障。
<think> 块可能跨多行,使用 DOTALL 模式匹配。
"""
cleaned = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL)
return cleaned.strip()
def _encode_image(self, path_or_b64: str) -> str:
"""将图像转换为 data URI 格式的 base64 字符串。
参数:
path_or_b64: 本地文件路径,或已是 "data:image/...;base64,..." 格式的字符串。
返回:
"data:image/jpeg;base64,<base64数据>" 格式字符串。
异常:
FileNotFoundError: 指定路径文件不存在时抛出。
"""
if "base64," in path_or_b64:
return path_or_b64
if not os.path.exists(path_or_b64):
raise FileNotFoundError(f"图像文件不存在: {path_or_b64}")
with open(path_or_b64, "rb") as f:
raw = f.read()
b64_data = base64.b64encode(raw).decode("utf-8")
ext = os.path.splitext(path_or_b64)[1].lower()
mime = "image/png" if ext == ".png" else "image/jpeg"
return f"data:{mime};base64,{b64_data}"
def _build_messages(
self,
prompt: str,
images: Optional[List[str]] = None,
) -> List[Dict]:
"""拼装 OpenAI-compatible 消息结构。
参数:
prompt: 文本指令。
images: 已编码的 base64 data URI 列表(可为 None)。
返回:
OpenAI messages 格式的列表。
实现细节:
- 无图像:content 为纯字符串。
- 有图像:content 为列表,图像在前,文本在后。
"""
if not images:
return [{"role": "user", "content": prompt}]
content: List[Dict] = [
{"type": "image_url", "image_url": {"url": img}} for img in images
]
content.append({"type": "text", "text": prompt})
return [{"role": "user", "content": content}]