Files
Video-Tree-TRM5/adapters/llm.py
T
iomgaa cfd3277f80 style: format adapters/llm.py
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-06 23:01:06 -04:00

569 lines
20 KiB
Python
Raw 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.
"""GovernedLLMClient — 四层治理栈统一 LLM 调用网关。
组合熔断器、Redis 缓存、指数退避重试、流式 SSE 消费 + 三层活性看门狗,
并在每次调用后自动写入遥测。provider 差异处理覆盖 DeepSeekreasoning_content
和 Qwen<think> 标签剥离)。
治理栈五层(ARCHITECTURE.md §5:
1. 硬超时 — asyncio.wait_for + StreamLivenessTimeout
2. 指数退避重试 — max_retries + base_delay + max_delay
3. 熔断器 — CircuitBreaker(连续 N 失败开路,冷却后半开探针)
4. Redis 响应缓存 — content-addressed hash(model + messages)
5. 遥测 — TelemetryRecorder(每次调用必录)
"""
from __future__ import annotations
import asyncio
import json
import re
import time
from typing import TYPE_CHECKING, Any
from uuid import uuid4
import httpx
from loguru import logger
from adapters.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
from core.types import LLMResponse
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from adapters.breaker import CircuitBreaker
class CircuitOpenError(Exception):
"""熔断器已开启,拒绝调用。"""
class _SseAnomaly(Exception): # noqa: N818 — 参考 CHSAnalyzer2 命名
"""SSE 协议异常(malformed_json 等)。"""
def __init__(self, kind: str) -> None:
self.kind = kind
super().__init__(f"SSE 协议异常: {kind}")
# ── SSE 解析(模块级纯函数) ────────────────────────────────────────
def _sse_data_payload(raw: str) -> str | None:
"""提取一行 SSE 的 data 段;ping/空行/非 data 行返回 None。
参数:
raw: 原始 SSE 行文本。
返回:
data 段 payload 字符串,或 None(应被跳过的行)。
"""
line = raw.strip()
if not line or line.startswith(":") or not line.startswith("data:"):
return None
return line[len("data:") :].strip()
def _sse_delta(chunk: dict, usage_sink: dict) -> tuple[bool, str] | None:
"""旁路收集 usage 并提取一帧增量 token。
返回 (True, content) 或 (False, reasoning_content),无增量则 None。
优先 content(正式输出);否则取 reasoning_contentthinking 模型的思考 token)。
参数:
chunk: 一帧解析后的 SSE JSON。
usage_sink: 旁路字典;含 usage 的帧写入 usage_sink["usage"]。
返回:
(is_content, text) 或 None。
"""
if chunk.get("usage"):
usage_sink["usage"] = chunk["usage"]
choices = chunk.get("choices") or []
if not choices:
return None
delta = choices[0].get("delta") or {}
content = delta.get("content")
if content:
return (True, content)
reasoning = delta.get("reasoning_content")
if reasoning:
return (False, reasoning)
return None
async def _iter_sse_deltas(
lines: AsyncIterator[str], usage_sink: dict
) -> AsyncIterator[tuple[bool, str]]:
"""OpenAI 兼容 SSE 行流 → 逐帧产出 (是否 content, 文本)。
usage 旁路写入 usage_sink。内部消化 ping/空行/[DONE]/usage-only 帧。
参数:
lines: 底层 SSE 文本行异步迭代器。
usage_sink: 旁路字典。
产出:
(is_content, text)。
异常:
_SseAnomaly: data 段 JSON 解析失败。
"""
async for raw in lines:
data = _sse_data_payload(raw)
if data is None:
continue
if data == "[DONE]":
usage_sink["done"] = True
return
try:
chunk = json.loads(data)
except json.JSONDecodeError as exc:
raise _SseAnomaly("malformed_json") from exc
delta = _sse_delta(chunk, usage_sink)
if delta is not None:
yield delta
# ── Provider 差异处理 ────────────────────────────────────────────────
def _build_thinking_body(provider: str) -> dict:
"""构造 thinking/reasoning 启用参数(provider 差异处理)。
参数:
provider: provider 标识(如 "deepseek"、"qwen" 等)。
返回:
合并进请求体的字典片段。
"""
provider_lower = provider.lower()
if "deepseek" in provider_lower:
return {"thinking": {"type": "enabled"}}
if "qwen" in provider_lower:
return {"enable_thinking": True}
return {}
_THINK_PATTERN = re.compile(r"<think>(.*?)</think>", re.DOTALL)
def _strip_qwen_thinking(content: str) -> tuple[str, str]:
"""剥离 Qwen 模型 <think>...</think> 标签,拆分为 (正文, 思考内容)。
参数:
content: 含有可能的 <think> 标签的模型原始输出。
返回:
(stripped_content, thinking_text)。无 think 标签时 thinking 为空字符串。
"""
match = _THINK_PATTERN.search(content)
if match is None:
return content, ""
thinking = match.group(1).strip()
stripped = _THINK_PATTERN.sub("", content).strip()
return stripped, thinking
# ── 瞬时错误判定 ─────────────────────────────────────────────────────
_TRANSIENT_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
_FATAL_STATUS_CODES = frozenset({401, 403})
def _is_transient_error(exc: Exception) -> bool:
"""判断异常是否为瞬时可重试错误。
参数:
exc: 捕获的异常。
返回:
True 表示可重试,False 表示不可重试。
"""
if isinstance(exc, (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout)):
return True
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code in _TRANSIENT_STATUS_CODES
return isinstance(exc, (StreamLivenessTimeout, _SseAnomaly))
def _is_fatal_auth_error(exc: Exception) -> bool:
"""判断异常是否为不可恢复的认证/授权错误。
参数:
exc: 捕获的异常。
返回:
True 表示应立即 force_open 熔断器。
"""
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code in _FATAL_STATUS_CODES
return False
# ── GovernedLLMClient ────────────────────────────────────────────────
class GovernedLLMClient:
"""四层治理栈统一 LLM 调用网关,实现 LLMProvider Protocol。
构造函数接收所有组件(依赖注入),不从全局状态获取任何配置。
参数:
model: 模型名称。
base_url: API 基础 URL(如 http://localhost:8000/v1)。
api_key: API 密钥。
provider: provider 标识(如 "deepseek"、"qwen")。
thinking: 是否启用 thinking/reasoning 模式。
breaker: 熔断器实例。
cache: Redis 响应缓存实例。
telemetry: 遥测记录器实例。
timeout_s: 总超时秒数。
ttft_timeout_s: 首 token 超时秒数。
inter_token_timeout_s: token 间超时秒数。
max_retries: 最大重试次数。
retry_base_delay_s: 重试基础延迟秒数。
retry_max_delay_s: 重试最大延迟秒数。
"""
def __init__(
self,
*,
model: str,
base_url: str,
api_key: str,
provider: str,
thinking: bool,
breaker: CircuitBreaker,
cache: Any,
telemetry: Any,
timeout_s: float,
ttft_timeout_s: float,
inter_token_timeout_s: float,
max_retries: int,
retry_base_delay_s: float,
retry_max_delay_s: float,
) -> None:
self._model = model
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._provider = provider
self._thinking = thinking
self._breaker = breaker
self._cache = cache
self._telemetry = telemetry
self._timeout_s = timeout_s
self._ttft_timeout_s = ttft_timeout_s
self._inter_token_timeout_s = inter_token_timeout_s
self._max_retries = max_retries
self._retry_base_delay_s = retry_base_delay_s
self._retry_max_delay_s = retry_max_delay_s
self._http = httpx.AsyncClient(
base_url=self._base_url,
headers={
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
},
timeout=httpx.Timeout(timeout_s),
)
async def chat(
self,
messages: list[dict[str, Any]],
*,
session_id: str | None = None,
parent_call_id: str | None = None,
) -> LLMResponse:
"""发起 LLM 调用,经四层治理栈:熔断 → 缓存 → 重试+流式 → 遥测。
参数:
messages: OpenAI 格式消息列表。
session_id: 会话 ID(传递到遥测)。
parent_call_id: 父调用 ID(传递到遥测)。
返回:
LLMResponse 统一响应。
异常:
CircuitOpenError: 熔断器开路。
httpx.HTTPStatusError: 不可恢复的 HTTP 错误(401/403)。
"""
started = time.monotonic()
call_id = str(uuid4())
# ① 熔断器检查
if self._breaker.is_open(self._provider, time.monotonic()):
raise CircuitOpenError(f"熔断器已开启,拒绝调用 provider={self._provider}")
# ② 缓存查询
cached = await self._cache.get(self._model, messages)
if cached is not None:
response = LLMResponse(
content=cached.content,
thinking=cached.thinking,
model=cached.model,
provider=cached.provider,
prompt_tokens=cached.prompt_tokens,
completion_tokens=cached.completion_tokens,
latency_ms=0,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=True,
call_id=call_id,
)
await self._telemetry.record_llm_call(
call_id=call_id,
parent_call_id=parent_call_id,
session_id=session_id,
model_name=self._model,
provider=self._provider,
messages=json.dumps(messages, ensure_ascii=False),
response=response.content,
thinking=response.thinking,
prompt_tokens=response.prompt_tokens,
completion_tokens=response.completion_tokens,
latency_ms=0,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=True,
error=None,
)
return response
# ③ 重试循环 + 流式消费
last_exc: Exception | None = None
for attempt in range(self._max_retries):
attempt_start = time.monotonic()
try:
sse_lines = await self._stream_request(messages)
content, thinking_text, ttft_ms, max_itoken_ms, usage = await self._consume_stream(
sse_lines
)
# 熔断器记成功
self._breaker.record_success(self._provider)
# provider 差异:Qwen think 标签剥离
if "qwen" in self._provider.lower() and "<think>" in content:
content, qwen_thinking = _strip_qwen_thinking(content)
if qwen_thinking:
thinking_text = qwen_thinking
latency_ms = int((time.monotonic() - started) * 1000)
prompt_tokens = usage.get("prompt_tokens", 0)
completion_tokens = usage.get("completion_tokens", 0)
response = LLMResponse(
content=content,
thinking=thinking_text,
model=self._model,
provider=self._provider,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
latency_ms=latency_ms,
ttft_ms=ttft_ms,
max_inter_token_ms=max_itoken_ms,
cache_hit=False,
call_id=call_id,
)
# ④ 写缓存
await self._cache.set(self._model, messages, response)
# ⑤ 遥测
await self._telemetry.record_llm_call(
call_id=call_id,
parent_call_id=parent_call_id,
session_id=session_id,
model_name=self._model,
provider=self._provider,
messages=json.dumps(messages, ensure_ascii=False),
response=content,
thinking=thinking_text,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
latency_ms=latency_ms,
ttft_ms=ttft_ms,
max_inter_token_ms=max_itoken_ms,
cache_hit=False,
error=None,
)
return response
except Exception as exc:
last_exc = exc
attempt_latency = int((time.monotonic() - attempt_start) * 1000)
# 致命错误(401/403)→ force_open + 遥测 + 立即抛出
if _is_fatal_auth_error(exc):
self._breaker.force_open(self._provider, time.monotonic())
await self._telemetry.record_llm_call(
call_id=call_id,
parent_call_id=parent_call_id,
session_id=session_id,
model_name=self._model,
provider=self._provider,
messages=json.dumps(messages, ensure_ascii=False),
response="",
thinking="",
prompt_tokens=0,
completion_tokens=0,
latency_ms=attempt_latency,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=False,
error=str(exc),
)
raise
# 瞬时错误 → 记失败 + 遥测 + 退避重试
if _is_transient_error(exc):
self._breaker.record_failure(self._provider, time.monotonic())
await self._telemetry.record_llm_call(
call_id=str(uuid4()), # 每次重试用新 call_id
parent_call_id=parent_call_id,
session_id=session_id,
model_name=self._model,
provider=self._provider,
messages=json.dumps(messages, ensure_ascii=False),
response="",
thinking="",
prompt_tokens=0,
completion_tokens=0,
latency_ms=attempt_latency,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=False,
error=str(exc),
)
logger.warning(
"llm.call.transient_error",
extra={
"provider": self._provider,
"attempt": attempt + 1,
"max_retries": self._max_retries,
"error": str(exc),
},
)
if attempt < self._max_retries - 1:
delay = min(
self._retry_base_delay_s * (2**attempt),
self._retry_max_delay_s,
)
if delay > 0:
await asyncio.sleep(delay)
continue
# 非瞬时、非致命 → 记遥测后直接抛出
await self._telemetry.record_llm_call(
call_id=call_id,
parent_call_id=parent_call_id,
session_id=session_id,
model_name=self._model,
provider=self._provider,
messages=json.dumps(messages, ensure_ascii=False),
response="",
thinking="",
prompt_tokens=0,
completion_tokens=0,
latency_ms=attempt_latency,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=False,
error=str(exc),
)
raise
# 所有重试耗尽
assert last_exc is not None
raise last_exc
async def _stream_request(self, messages: list[dict[str, Any]]) -> list[str]:
"""发起流式 HTTP 请求并收集全部 SSE 行。
此方法为主要的 HTTP 交互点,测试通过 patch 替换以注入假 SSE 行。
参数:
messages: OpenAI 格式消息列表。
返回:
SSE 文本行列表。
异常:
httpx.HTTPStatusError: HTTP 状态码错误。
httpx.ConnectError: 连接错误。
"""
payload: dict[str, Any] = {
"model": self._model,
"messages": messages,
"stream": True,
"stream_options": {"include_usage": True},
}
if self._thinking:
payload.update(_build_thinking_body(self._provider))
collected_lines: list[str] = []
async with self._http.stream("POST", "/chat/completions", json=payload) as resp:
if resp.status_code >= 400:
await resp.aread()
resp.raise_for_status()
async for line in resp.aiter_lines():
collected_lines.append(line)
return collected_lines
async def _consume_stream(
self, sse_lines: list[str]
) -> tuple[str, str, float | None, float | None, dict]:
"""消费 SSE 行序列,累积 content/thinking,测量 TTFT 和 max_inter_token_ms。
参数:
sse_lines: SSE 文本行列表。
返回:
(content, thinking, ttft_ms, max_inter_token_ms, usage_dict)。
"""
usage_sink: dict = {}
async def _lines_iter() -> AsyncIterator[str]:
for line in sse_lines:
yield line
raw_deltas = _iter_sse_deltas(_lines_iter(), usage_sink)
guarded = stream_with_liveness_timeouts(
raw_deltas,
ttft_s=self._ttft_timeout_s,
inter_token_s=self._inter_token_timeout_s,
total_s=self._timeout_s,
)
content_parts: list[str] = []
thinking_parts: list[str] = []
ttft_ms: float | None = None
max_inter_token_ms: float | None = None
stream_start = time.monotonic()
last_token_time = stream_start
async for is_content, text in guarded:
now = time.monotonic()
if ttft_ms is None:
ttft_ms = (now - stream_start) * 1000
else:
gap_ms = (now - last_token_time) * 1000
if max_inter_token_ms is None or gap_ms > max_inter_token_ms:
max_inter_token_ms = gap_ms
last_token_time = now
if is_content:
content_parts.append(text)
else:
thinking_parts.append(text)
content = "".join(content_parts)
thinking = "".join(thinking_parts)
usage = usage_sink.get("usage", {})
return content, thinking, ttft_ms, max_inter_token_ms, usage
async def close(self) -> None:
"""关闭底层 HTTP 客户端。"""
await self._http.aclose()