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>
This commit is contained in:
+49
-37
@@ -234,11 +234,11 @@ class GovernedLLMClient:
|
|||||||
provider: str,
|
provider: str,
|
||||||
thinking: bool,
|
thinking: bool,
|
||||||
breaker: CircuitBreaker,
|
breaker: CircuitBreaker,
|
||||||
cache: Any,
|
cache: Any | None,
|
||||||
telemetry: Any,
|
telemetry: Any,
|
||||||
timeout_s: float,
|
timeout_s: float,
|
||||||
ttft_timeout_s: float,
|
ttft_timeout_s: float | None,
|
||||||
inter_token_timeout_s: float,
|
inter_token_timeout_s: float | None,
|
||||||
max_retries: int,
|
max_retries: int,
|
||||||
retry_base_delay_s: float,
|
retry_base_delay_s: float,
|
||||||
retry_max_delay_s: float,
|
retry_max_delay_s: float,
|
||||||
@@ -252,8 +252,10 @@ class GovernedLLMClient:
|
|||||||
self._cache = cache
|
self._cache = cache
|
||||||
self._telemetry = telemetry
|
self._telemetry = telemetry
|
||||||
self._timeout_s = timeout_s
|
self._timeout_s = timeout_s
|
||||||
self._ttft_timeout_s = ttft_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
|
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._max_retries = max_retries
|
||||||
self._retry_base_delay_s = retry_base_delay_s
|
self._retry_base_delay_s = retry_base_delay_s
|
||||||
self._retry_max_delay_s = retry_max_delay_s
|
self._retry_max_delay_s = retry_max_delay_s
|
||||||
@@ -288,14 +290,16 @@ class GovernedLLMClient:
|
|||||||
httpx.HTTPStatusError: 不可恢复的 HTTP 错误(401/403)。
|
httpx.HTTPStatusError: 不可恢复的 HTTP 错误(401/403)。
|
||||||
"""
|
"""
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
call_id = str(uuid4())
|
|
||||||
|
|
||||||
# ① 熔断器检查
|
# ① 熔断器检查(规格要求在 call_id 生成之前)
|
||||||
if self._breaker.is_open(self._provider, time.monotonic()):
|
if self._breaker.is_open(self._provider, time.monotonic()):
|
||||||
raise CircuitOpenError(f"熔断器已开启,拒绝调用 provider={self._provider}")
|
raise CircuitOpenError(f"熔断器已开启,拒绝调用 provider={self._provider}")
|
||||||
|
|
||||||
# ② 缓存查询
|
# ② call_id 生成
|
||||||
cached = await self._cache.get(self._model, messages)
|
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:
|
if cached is not None:
|
||||||
response = LLMResponse(
|
response = LLMResponse(
|
||||||
content=cached.content,
|
content=cached.content,
|
||||||
@@ -329,14 +333,13 @@ class GovernedLLMClient:
|
|||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# ③ 重试循环 + 流式消费
|
# ④ 重试循环 + 流式消费
|
||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
for attempt in range(self._max_retries):
|
for attempt in range(self._max_retries):
|
||||||
attempt_start = time.monotonic()
|
attempt_start = time.monotonic()
|
||||||
try:
|
try:
|
||||||
sse_lines = await self._stream_request(messages)
|
content, thinking_text, ttft_ms, max_itoken_ms, usage = await self._call_streaming(
|
||||||
content, thinking_text, ttft_ms, max_itoken_ms, usage = await self._consume_stream(
|
messages
|
||||||
sse_lines
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 熔断器记成功
|
# 熔断器记成功
|
||||||
@@ -366,7 +369,8 @@ class GovernedLLMClient:
|
|||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ④ 写缓存
|
# ④ 写缓存(cache 为 None 时跳过)
|
||||||
|
if self._cache is not None:
|
||||||
await self._cache.set(self._model, messages, response)
|
await self._cache.set(self._model, messages, response)
|
||||||
|
|
||||||
# ⑤ 遥测
|
# ⑤ 遥测
|
||||||
@@ -420,7 +424,7 @@ class GovernedLLMClient:
|
|||||||
if _is_transient_error(exc):
|
if _is_transient_error(exc):
|
||||||
self._breaker.record_failure(self._provider, time.monotonic())
|
self._breaker.record_failure(self._provider, time.monotonic())
|
||||||
await self._telemetry.record_llm_call(
|
await self._telemetry.record_llm_call(
|
||||||
call_id=str(uuid4()), # 每次重试用新 call_id
|
call_id=call_id,
|
||||||
parent_call_id=parent_call_id,
|
parent_call_id=parent_call_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
model_name=self._model,
|
model_name=self._model,
|
||||||
@@ -478,20 +482,14 @@ class GovernedLLMClient:
|
|||||||
assert last_exc is not None
|
assert last_exc is not None
|
||||||
raise last_exc
|
raise last_exc
|
||||||
|
|
||||||
async def _stream_request(self, messages: list[dict[str, Any]]) -> list[str]:
|
def _build_request_body(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
"""发起流式 HTTP 请求并收集全部 SSE 行。
|
"""构造流式 chat 请求体。
|
||||||
|
|
||||||
此方法为主要的 HTTP 交互点,测试通过 patch 替换以注入假 SSE 行。
|
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
messages: OpenAI 格式消息列表。
|
messages: OpenAI 格式消息列表。
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
SSE 文本行列表。
|
请求体字典。
|
||||||
|
|
||||||
异常:
|
|
||||||
httpx.HTTPStatusError: HTTP 状态码错误。
|
|
||||||
httpx.ConnectError: 连接错误。
|
|
||||||
"""
|
"""
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"model": self._model,
|
"model": self._model,
|
||||||
@@ -501,34 +499,48 @@ class GovernedLLMClient:
|
|||||||
}
|
}
|
||||||
if self._thinking:
|
if self._thinking:
|
||||||
payload.update(_build_thinking_body(self._provider))
|
payload.update(_build_thinking_body(self._provider))
|
||||||
|
return payload
|
||||||
|
|
||||||
collected_lines: list[str] = []
|
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:
|
async with self._http.stream("POST", "/chat/completions", json=payload) as resp:
|
||||||
if resp.status_code >= 400:
|
if resp.status_code >= 400:
|
||||||
await resp.aread()
|
await resp.aread()
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
async for line in resp.aiter_lines():
|
# 直接在 context manager 内消费流,看门狗作用于真实 HTTP 流
|
||||||
collected_lines.append(line)
|
return await self._consume_stream(resp.aiter_lines())
|
||||||
return collected_lines
|
|
||||||
|
|
||||||
async def _consume_stream(
|
async def _consume_stream(
|
||||||
self, sse_lines: list[str]
|
self, lines: AsyncIterator[str]
|
||||||
) -> tuple[str, str, float | None, float | None, dict]:
|
) -> tuple[str, str, float | None, float | None, dict]:
|
||||||
"""消费 SSE 行序列,累积 content/thinking,测量 TTFT 和 max_inter_token_ms。
|
"""消费 SSE 行异步迭代器,累积 content/thinking,测量 TTFT 和 max_inter_token_ms。
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
sse_lines: SSE 文本行列表。
|
lines: SSE 文本行异步迭代器(直接来自 httpx resp.aiter_lines())。
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
(content, thinking, ttft_ms, max_inter_token_ms, usage_dict)。
|
(content, thinking, ttft_ms, max_inter_token_ms, usage_dict)。
|
||||||
"""
|
"""
|
||||||
usage_sink: dict = {}
|
usage_sink: dict = {}
|
||||||
|
raw_deltas = _iter_sse_deltas(lines, usage_sink)
|
||||||
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(
|
guarded = stream_with_liveness_timeouts(
|
||||||
raw_deltas,
|
raw_deltas,
|
||||||
ttft_s=self._ttft_timeout_s,
|
ttft_s=self._ttft_timeout_s,
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ class _FakeRedisCache:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._store: dict[str, LLMResponse] = {}
|
self._store: dict[str, LLMResponse] = {}
|
||||||
|
|
||||||
async def get(
|
async def get(self, model: str, messages: list[dict[str, str]]) -> LLMResponse | None:
|
||||||
self, model: str, messages: list[dict[str, str]]
|
|
||||||
) -> LLMResponse | None:
|
|
||||||
key = f"{model}:{json.dumps(messages, sort_keys=True)}"
|
key = f"{model}:{json.dumps(messages, sort_keys=True)}"
|
||||||
return self._store.get(key)
|
return self._store.get(key)
|
||||||
|
|
||||||
@@ -49,52 +47,6 @@ class _FakeTelemetry:
|
|||||||
self.calls.append(kwargs)
|
self.calls.append(kwargs)
|
||||||
|
|
||||||
|
|
||||||
# ── 工具函数 ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
async def _async_line_iter(lines: list[str]):
|
|
||||||
"""将字符串列表转为异步行迭代器。"""
|
|
||||||
for line in lines:
|
|
||||||
yield line
|
|
||||||
|
|
||||||
|
|
||||||
def _make_sse_lines(
|
|
||||||
content_chunks: list[str],
|
|
||||||
*,
|
|
||||||
reasoning_chunks: list[str] | None = None,
|
|
||||||
model: str = "test-model",
|
|
||||||
usage: dict[str, int] | None = None,
|
|
||||||
) -> list[str]:
|
|
||||||
"""构造 SSE 文本行序列,模拟 OpenAI 兼容流式响应。"""
|
|
||||||
lines: list[str] = []
|
|
||||||
|
|
||||||
# 先产出 reasoning 帧
|
|
||||||
for chunk in reasoning_chunks or []:
|
|
||||||
frame = {
|
|
||||||
"choices": [{"delta": {"reasoning_content": chunk}}],
|
|
||||||
"model": model,
|
|
||||||
}
|
|
||||||
lines.append(f"data: {json.dumps(frame)}\n")
|
|
||||||
|
|
||||||
# 再产出 content 帧
|
|
||||||
for chunk in content_chunks:
|
|
||||||
frame = {
|
|
||||||
"choices": [{"delta": {"content": chunk}}],
|
|
||||||
"model": model,
|
|
||||||
}
|
|
||||||
lines.append(f"data: {json.dumps(frame)}\n")
|
|
||||||
|
|
||||||
# usage 帧
|
|
||||||
if usage is None:
|
|
||||||
usage = {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
|
||||||
frame_usage = {"choices": [], "usage": usage, "model": model}
|
|
||||||
lines.append(f"data: {json.dumps(frame_usage)}\n")
|
|
||||||
|
|
||||||
# 终止标记
|
|
||||||
lines.append("data: [DONE]\n")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def _build_client(
|
def _build_client(
|
||||||
*,
|
*,
|
||||||
breaker: CircuitBreaker | None = None,
|
breaker: CircuitBreaker | None = None,
|
||||||
@@ -180,16 +132,18 @@ async def test_successful_streaming_call():
|
|||||||
client = _build_client(telemetry=telemetry)
|
client = _build_client(telemetry=telemetry)
|
||||||
messages = [{"role": "user", "content": "hello"}]
|
messages = [{"role": "user", "content": "hello"}]
|
||||||
|
|
||||||
sse_lines = _make_sse_lines(
|
async def fake_call_streaming(
|
||||||
["Hello", " world"],
|
_messages: Any,
|
||||||
reasoning_chunks=["Let me", " think"],
|
) -> tuple[str, str, float | None, float | None, dict]:
|
||||||
usage={"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30},
|
return (
|
||||||
|
"Hello world",
|
||||||
|
"Let me think",
|
||||||
|
1.5, # ttft_ms
|
||||||
|
0.8, # max_inter_token_ms
|
||||||
|
{"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def fake_stream_request(_messages: Any) -> list[str]:
|
with patch.object(client, "_call_streaming", side_effect=fake_call_streaming):
|
||||||
return sse_lines
|
|
||||||
|
|
||||||
with patch.object(client, "_stream_request", side_effect=fake_stream_request):
|
|
||||||
result = await client.chat(messages)
|
result = await client.chat(messages)
|
||||||
|
|
||||||
assert result.content == "Hello world"
|
assert result.content == "Hello world"
|
||||||
@@ -213,17 +167,24 @@ async def test_transient_error_retries_and_records_telemetry():
|
|||||||
client = _build_client(telemetry=telemetry)
|
client = _build_client(telemetry=telemetry)
|
||||||
messages = [{"role": "user", "content": "hello"}]
|
messages = [{"role": "user", "content": "hello"}]
|
||||||
|
|
||||||
sse_lines = _make_sse_lines(["OK"])
|
|
||||||
call_count = 0
|
call_count = 0
|
||||||
|
|
||||||
async def flaky_stream(_messages: Any) -> list[str]:
|
async def flaky_call_streaming(
|
||||||
|
_messages: Any,
|
||||||
|
) -> tuple[str, str, float | None, float | None, dict]:
|
||||||
nonlocal call_count
|
nonlocal call_count
|
||||||
call_count += 1
|
call_count += 1
|
||||||
if call_count <= 2:
|
if call_count <= 2:
|
||||||
raise httpx.ConnectError("connection refused")
|
raise httpx.ConnectError("connection refused")
|
||||||
return sse_lines
|
return (
|
||||||
|
"OK",
|
||||||
|
"",
|
||||||
|
1.0,
|
||||||
|
None,
|
||||||
|
{"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
||||||
|
)
|
||||||
|
|
||||||
with patch.object(client, "_stream_request", side_effect=flaky_stream):
|
with patch.object(client, "_call_streaming", side_effect=flaky_call_streaming):
|
||||||
result = await client.chat(messages)
|
result = await client.chat(messages)
|
||||||
|
|
||||||
assert result.content == "OK"
|
assert result.content == "OK"
|
||||||
@@ -236,6 +197,10 @@ async def test_transient_error_retries_and_records_telemetry():
|
|||||||
success_calls = [c for c in telemetry.calls if c.get("error") is None]
|
success_calls = [c for c in telemetry.calls if c.get("error") is None]
|
||||||
assert len(success_calls) == 1
|
assert len(success_calls) == 1
|
||||||
|
|
||||||
|
# 所有遥测记录应使用同一个 call_id(Important 2 修复验证)
|
||||||
|
call_ids = {c["call_id"] for c in telemetry.calls}
|
||||||
|
assert len(call_ids) == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fatal_error_force_opens_breaker():
|
async def test_fatal_error_force_opens_breaker():
|
||||||
@@ -250,13 +215,17 @@ async def test_fatal_error_force_opens_breaker():
|
|||||||
request=httpx.Request("POST", "http://localhost:8000/v1/chat/completions"),
|
request=httpx.Request("POST", "http://localhost:8000/v1/chat/completions"),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def auth_fail(_messages: Any) -> list[str]:
|
async def auth_fail(
|
||||||
|
_messages: Any,
|
||||||
|
) -> tuple[str, str, float | None, float | None, dict]:
|
||||||
raise httpx.HTTPStatusError(
|
raise httpx.HTTPStatusError(
|
||||||
"Unauthorized", request=mock_response.request, response=mock_response
|
"Unauthorized", request=mock_response.request, response=mock_response
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch.object(client, "_stream_request", side_effect=auth_fail), \
|
with (
|
||||||
pytest.raises(httpx.HTTPStatusError):
|
patch.object(client, "_call_streaming", side_effect=auth_fail),
|
||||||
|
pytest.raises(httpx.HTTPStatusError),
|
||||||
|
):
|
||||||
await client.chat(messages)
|
await client.chat(messages)
|
||||||
|
|
||||||
# 熔断器已被 force_open
|
# 熔断器已被 force_open
|
||||||
@@ -291,16 +260,20 @@ async def test_parent_call_id_forwarded_to_telemetry():
|
|||||||
client = _build_client(telemetry=telemetry)
|
client = _build_client(telemetry=telemetry)
|
||||||
messages = [{"role": "user", "content": "hello"}]
|
messages = [{"role": "user", "content": "hello"}]
|
||||||
|
|
||||||
sse_lines = _make_sse_lines(["result"])
|
async def fake_call_streaming(
|
||||||
|
_messages: Any,
|
||||||
async def fake_stream(_messages: Any) -> list[str]:
|
) -> tuple[str, str, float | None, float | None, dict]:
|
||||||
return sse_lines
|
return (
|
||||||
|
"result",
|
||||||
with patch.object(client, "_stream_request", side_effect=fake_stream):
|
"",
|
||||||
await client.chat(
|
1.0,
|
||||||
messages, session_id="sess-42", parent_call_id="parent-99"
|
None,
|
||||||
|
{"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with patch.object(client, "_call_streaming", side_effect=fake_call_streaming):
|
||||||
|
await client.chat(messages, session_id="sess-42", parent_call_id="parent-99")
|
||||||
|
|
||||||
assert len(telemetry.calls) == 1
|
assert len(telemetry.calls) == 1
|
||||||
assert telemetry.calls[0]["session_id"] == "sess-42"
|
assert telemetry.calls[0]["session_id"] == "sess-42"
|
||||||
assert telemetry.calls[0]["parent_call_id"] == "parent-99"
|
assert telemetry.calls[0]["parent_call_id"] == "parent-99"
|
||||||
|
|||||||
Reference in New Issue
Block a user