fix: 流式响应健壮性增强与候选排序稳定性修复

- test-model 接口优先使用流式请求,失败自动回退非流式
- CLI 流式处理在 StreamClosed/RemoteProtocolError 时 flush 残余 SSE 数据,捕获尾部 usage
- 流未正常完成时兜底估算 tokens,避免 usage 记录为 0
- ProviderCandidate 添加 __lt__ 解决 tuple 排序 TypeError
- sorted() 添加显式 key 参数避免隐式比较候选对象
- 异常日志改用 logger.opt(exception=e) 替代手动 traceback
This commit is contained in:
fawney19
2026-02-06 18:06:38 +08:00
parent b88fb6273b
commit 62dae22a2c
6 changed files with 306 additions and 49 deletions

View File

@@ -19,7 +19,6 @@ Chat Adapter 通用基类
from __future__ import annotations
import time
import traceback
from abc import abstractmethod
from typing import Any, ClassVar
@@ -438,18 +437,10 @@ class ChatAdapterBase(ApiAdapter):
) -> JSONResponse:
"""处理未预期的异常"""
if isinstance(e, ProxyException):
logger.error(f"{self.FORMAT_ID} 请求处理业务异常: {type(e).__name__}")
logger.error(f"{self.FORMAT_ID} 请求处理业务异常: {type(e).__name__}: {e}")
else:
logger.error(
f"{self.FORMAT_ID} 请求处理意外异常",
exception=e,
extra_data={
"exception_class": e.__class__.__name__,
"processing_stage": "request_processing",
"model": model,
"stream": stream,
"traceback_preview": str(traceback.format_exc())[:500],
},
logger.opt(exception=e).error(
f"{self.FORMAT_ID} 请求处理意外异常: {type(e).__name__}: {e}"
)
response_time = int((time.time() - start_time) * 1000)

View File

@@ -18,7 +18,6 @@ CLI Adapter 通用基类
from __future__ import annotations
import time
import traceback
from typing import Any, ClassVar
import httpx
@@ -417,18 +416,10 @@ class CliAdapterBase(ApiAdapter):
) -> JSONResponse:
"""处理未预期的异常"""
if isinstance(e, ProxyException):
logger.error(f"{self.FORMAT_ID} 请求处理业务异常: {type(e).__name__}")
logger.error(f"{self.FORMAT_ID} 请求处理业务异常: {type(e).__name__}: {e}")
else:
logger.error(
f"{self.FORMAT_ID} 请求处理意外异常",
exception=e,
extra_data={
"exception_class": e.__class__.__name__,
"processing_stage": "request_processing",
"model": model,
"stream": stream,
"traceback_preview": str(traceback.format_exc())[:500],
},
logger.opt(exception=e).error(
f"{self.FORMAT_ID} 请求处理意外异常: {type(e).__name__}: {e}"
)
response_time = int((time.time() - start_time) * 1000)

View File

@@ -1353,6 +1353,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
except GeneratorExit:
raise
except httpx.StreamClosed:
# 连接关闭前 flush 残余数据,尝试捕获尾部事件(如 response.completed 中的 usage
self._flush_remaining_sse_data(
ctx, buffer, decoder, sse_parser, record_chunk=not needs_conversion
)
if ctx.data_count == 0:
# 流已开始,发送错误事件而不是抛出异常
logger.warning(f"Provider '{ctx.provider_name}' 流连接关闭且无数据")
@@ -1369,6 +1373,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
}
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode()
except httpx.RemoteProtocolError:
# 连接异常关闭前 flush 残余数据,尝试捕获尾部事件(如 response.completed 中的 usage
self._flush_remaining_sse_data(
ctx, buffer, decoder, sse_parser, record_chunk=not needs_conversion
)
if ctx.data_count > 0:
error_event = {
"type": "error",
@@ -1390,6 +1398,106 @@ class CliMessageHandlerBase(BaseMessageHandler):
except Exception:
pass
def _flush_remaining_sse_data(
self,
ctx: StreamContext,
buffer: bytes,
decoder: codecs.IncrementalDecoder,
sse_parser: SSEEventParser,
*,
record_chunk: bool = True,
) -> None:
"""
异常发生时 flush 残留的字节 buffer 和 SSE parser 内部缓冲区。
用于 StreamClosed / RemoteProtocolError 等场景:
连接断开可能恰好发生在最后一个 SSE 事件(如 response.completed
的 data 行已收到、但终止空行尚未到达之时。此方法确保这些事件仍能被处理,
从而正确捕获 usage 等关键信息。
"""
try:
# 1) flush 字节 buffer 中的残余行
if buffer:
remaining = decoder.decode(buffer, True)
for line in remaining.split("\n"):
stripped = line.rstrip("\r")
events = sse_parser.feed_line(stripped)
for event in events:
self._handle_sse_event(
ctx,
event.get("event"),
event.get("data") or "",
record_chunk=record_chunk,
)
# 2) flush SSE parser 内部累积的未完成事件
for event in sse_parser.flush():
self._handle_sse_event(
ctx,
event.get("event"),
event.get("data") or "",
record_chunk=record_chunk,
)
except Exception:
# best-effort: 不应因 flush 失败影响后续流程
pass
def _estimate_tokens_for_incomplete_stream(
self,
ctx: StreamContext,
request_body: dict[str, Any],
) -> None:
"""
流未正常完成(无 response.completed且 token 均为 0 时的兜底估算。
从已收集的输出文本和请求体粗略估算 token 数,确保 usage 记录不为 0。
估算采用 ~4 字符/token 的保守比例。
"""
# 输出 tokens从已收集的文本估算
collected = ctx.collected_text
if collected:
ctx.output_tokens = max(1, len(collected) // 4)
# 输入 tokens从请求体文本内容估算
try:
total_input_len = 0
instructions = request_body.get("instructions")
if isinstance(instructions, str):
total_input_len += len(instructions)
# OpenAI Responses API 使用 input 字段Claude 使用 messages
input_items = request_body.get("input") or request_body.get("messages") or []
if isinstance(input_items, list):
for item in input_items:
if isinstance(item, str):
total_input_len += len(item)
elif isinstance(item, dict):
content = item.get("content", "")
if isinstance(content, str):
total_input_len += len(content)
elif isinstance(content, list):
for block in content:
if isinstance(block, dict):
text = block.get("text", "")
if isinstance(text, str):
total_input_len += len(text)
if total_input_len > 0:
ctx.input_tokens = max(1, total_input_len // 4)
else:
# fallback: 整个请求体 JSON 大小
body_str = json.dumps(request_body, ensure_ascii=False)
ctx.input_tokens = max(1, len(body_str) // 4)
except Exception:
pass
if ctx.input_tokens > 0 or ctx.output_tokens > 0:
logger.warning(
"[{}] 流未正常完成 (has_completion=False, data_count={}), "
"使用估算 tokens: in={}, out={}",
ctx.request_id,
ctx.data_count,
ctx.input_tokens,
ctx.output_tokens,
)
async def _prefetch_and_check_embedded_error(
self,
byte_iterator: Any,
@@ -1787,6 +1895,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
except GeneratorExit:
raise
except httpx.StreamClosed:
# 连接关闭前 flush 残余数据,尝试捕获尾部事件(如 response.completed 中的 usage
self._flush_remaining_sse_data(
ctx, buffer, decoder, sse_parser, record_chunk=not needs_conversion
)
if ctx.data_count == 0:
logger.warning(f"Provider '{ctx.provider_name}' 流连接关闭且无数据")
# 设置错误状态用于后续记录
@@ -1802,6 +1914,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
}
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode()
except httpx.RemoteProtocolError:
# 连接异常关闭前 flush 残余数据,尝试捕获尾部事件(如 response.completed 中的 usage
self._flush_remaining_sse_data(
ctx, buffer, decoder, sse_parser, record_chunk=not needs_conversion
)
if ctx.data_count > 0:
error_event = {
"type": "error",
@@ -2357,6 +2473,16 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 在记录统计前,允许子类从 parsed_chunks 中提取额外的元数据
self._finalize_stream_metadata(ctx)
# 流未正常完成(如上游截断/连接中断)且无 token 数据时,
# 从已收集的文本和请求体估算 tokens避免 usage 记录为 0
if (
not ctx.has_completion
and ctx.data_count > 0
and ctx.input_tokens == 0
and ctx.output_tokens == 0
):
self._estimate_tokens_for_incomplete_stream(ctx, actual_request_body)
# 流式成功时,返回给客户端的是提供商响应头 + SSE 必需头
client_response_headers = filter_proxy_response_headers(ctx.response_headers)
client_response_headers.update(