feat(adapters): 实现 GovernedLLMClient 四层治理栈
流式 SSE + 三层看门狗 + 重试退避 + 熔断 + Redis 缓存 + 遥测。 provider 差异处理(DeepSeek reasoning_content vs Qwen think 标签)。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+574
@@ -0,0 +1,574 @@
|
||||
"""GovernedLLMClient — 四层治理栈统一 LLM 调用网关。
|
||||
|
||||
组合熔断器、Redis 缓存、指数退避重试、流式 SSE 消费 + 三层活性看门狗,
|
||||
并在每次调用后自动写入遥测。provider 差异处理覆盖 DeepSeek(reasoning_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_content(thinking 模型的思考 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()
|
||||
Reference in New Issue
Block a user