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:
@@ -23,9 +23,7 @@ class _FakeRedisCache:
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, LLMResponse] = {}
|
||||
|
||||
async def get(
|
||||
self, model: str, messages: list[dict[str, str]]
|
||||
) -> LLMResponse | None:
|
||||
async def get(self, model: str, messages: list[dict[str, str]]) -> LLMResponse | None:
|
||||
key = f"{model}:{json.dumps(messages, sort_keys=True)}"
|
||||
return self._store.get(key)
|
||||
|
||||
@@ -49,52 +47,6 @@ class _FakeTelemetry:
|
||||
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(
|
||||
*,
|
||||
breaker: CircuitBreaker | None = None,
|
||||
@@ -180,16 +132,18 @@ async def test_successful_streaming_call():
|
||||
client = _build_client(telemetry=telemetry)
|
||||
messages = [{"role": "user", "content": "hello"}]
|
||||
|
||||
sse_lines = _make_sse_lines(
|
||||
["Hello", " world"],
|
||||
reasoning_chunks=["Let me", " think"],
|
||||
usage={"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30},
|
||||
)
|
||||
async def fake_call_streaming(
|
||||
_messages: Any,
|
||||
) -> tuple[str, str, float | None, float | None, dict]:
|
||||
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]:
|
||||
return sse_lines
|
||||
|
||||
with patch.object(client, "_stream_request", side_effect=fake_stream_request):
|
||||
with patch.object(client, "_call_streaming", side_effect=fake_call_streaming):
|
||||
result = await client.chat(messages)
|
||||
|
||||
assert result.content == "Hello world"
|
||||
@@ -213,17 +167,24 @@ async def test_transient_error_retries_and_records_telemetry():
|
||||
client = _build_client(telemetry=telemetry)
|
||||
messages = [{"role": "user", "content": "hello"}]
|
||||
|
||||
sse_lines = _make_sse_lines(["OK"])
|
||||
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
|
||||
call_count += 1
|
||||
if call_count <= 2:
|
||||
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)
|
||||
|
||||
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]
|
||||
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
|
||||
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"),
|
||||
)
|
||||
|
||||
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(
|
||||
"Unauthorized", request=mock_response.request, response=mock_response
|
||||
)
|
||||
|
||||
with patch.object(client, "_stream_request", side_effect=auth_fail), \
|
||||
pytest.raises(httpx.HTTPStatusError):
|
||||
with (
|
||||
patch.object(client, "_call_streaming", side_effect=auth_fail),
|
||||
pytest.raises(httpx.HTTPStatusError),
|
||||
):
|
||||
await client.chat(messages)
|
||||
|
||||
# 熔断器已被 force_open
|
||||
@@ -291,16 +260,20 @@ async def test_parent_call_id_forwarded_to_telemetry():
|
||||
client = _build_client(telemetry=telemetry)
|
||||
messages = [{"role": "user", "content": "hello"}]
|
||||
|
||||
sse_lines = _make_sse_lines(["result"])
|
||||
|
||||
async def fake_stream(_messages: Any) -> list[str]:
|
||||
return sse_lines
|
||||
|
||||
with patch.object(client, "_stream_request", side_effect=fake_stream):
|
||||
await client.chat(
|
||||
messages, session_id="sess-42", parent_call_id="parent-99"
|
||||
async def fake_call_streaming(
|
||||
_messages: Any,
|
||||
) -> tuple[str, str, float | None, float | None, dict]:
|
||||
return (
|
||||
"result",
|
||||
"",
|
||||
1.0,
|
||||
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 telemetry.calls[0]["session_id"] == "sess-42"
|
||||
assert telemetry.calls[0]["parent_call_id"] == "parent-99"
|
||||
|
||||
Reference in New Issue
Block a user