Files
Video-Tree-TRM5/adapters/llm.py
T
iomgaa ee90fb8e88 fix(adapters): 修复 GovernedLLMClient 流式消费和规格偏差
- _stream_request + _consume_stream 合并为 _call_streaming,
  在 async with self._http.stream() 内直接消费流,
  确保看门狗作用于真实 HTTP 流而非内存列表(Critical 1)
- breaker 检查移到 call_id 生成之前(Critical 2)
- cache/ttft_timeout_s/inter_token_timeout_s 支持 None(Important 1)
- 重试失败路径遥测使用统一 call_id(Important 2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-06 23:08:52 -04:00

581 lines
21 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 | None,
telemetry: Any,
timeout_s: float,
ttft_timeout_s: float | None,
inter_token_timeout_s: float | None,
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 if ttft_timeout_s is not None else timeout_s
self._inter_token_timeout_s = (
inter_token_timeout_s if inter_token_timeout_s is not None else 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 生成之前)
if self._breaker.is_open(self._provider, time.monotonic()):
raise CircuitOpenError(f"熔断器已开启,拒绝调用 provider={self._provider}")
# ② call_id 生成
call_id = str(uuid4())
# ③ 缓存查询(cache 为 None 时跳过)
cached = await self._cache.get(self._model, messages) if self._cache is not None else None
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:
content, thinking_text, ttft_ms, max_itoken_ms, usage = await self._call_streaming(
messages
)
# 熔断器记成功
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,
)
# ④ 写缓存(cache 为 None 时跳过)
if self._cache is not None:
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=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
def _build_request_body(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
"""构造流式 chat 请求体。
参数:
messages: OpenAI 格式消息列表。
返回:
请求体字典。
"""
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))
return payload
async def _call_streaming(
self, messages: list[dict[str, Any]]
) -> tuple[str, str, float | None, float | None, dict]:
"""发起流式 HTTP 请求并在 context manager 内直接消费 SSE 流。
将 HTTP 请求与流消费合并,确保三层活性看门狗作用于真实 HTTP 流而非内存列表。
测试通过 patch 此方法注入假结果。
参数:
messages: OpenAI 格式消息列表。
返回:
(content, thinking, ttft_ms, max_inter_token_ms, usage_dict)。
异常:
httpx.HTTPStatusError: HTTP 状态码错误。
httpx.ConnectError: 连接错误。
StreamLivenessTimeout: 活性超时。
"""
payload = self._build_request_body(messages)
async with self._http.stream("POST", "/chat/completions", json=payload) as resp:
if resp.status_code >= 400:
await resp.aread()
resp.raise_for_status()
# 直接在 context manager 内消费流,看门狗作用于真实 HTTP 流
return await self._consume_stream(resp.aiter_lines())
async def _consume_stream(
self, lines: AsyncIterator[str]
) -> tuple[str, str, float | None, float | None, dict]:
"""消费 SSE 行异步迭代器,累积 content/thinking,测量 TTFT 和 max_inter_token_ms。
参数:
lines: SSE 文本行异步迭代器(直接来自 httpx resp.aiter_lines())。
返回:
(content, thinking, ttft_ms, max_inter_token_ms, usage_dict)。
"""
usage_sink: dict = {}
raw_deltas = _iter_sse_deltas(lines, 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()