269 lines
12 KiB
Python
269 lines
12 KiB
Python
from __future__ import annotations
|
||
|
||
from .shared import *
|
||
|
||
|
||
class LLMEmptyContentError(ValueError):
|
||
"""Raised when the model still returns empty content after one forced retry."""
|
||
|
||
|
||
class LLMChatOpenAIMixin:
|
||
@staticmethod
|
||
def _append_direct_answer_retry_instruction(messages: list[dict[str, str]]) -> None:
|
||
retry_instruction = "请直接输出答案"
|
||
for idx in range(len(messages) - 1, -1, -1):
|
||
row = messages[idx]
|
||
if not isinstance(row, dict):
|
||
continue
|
||
role = str(row.get("role") or "").strip().lower()
|
||
if role != "user":
|
||
continue
|
||
original = str(row.get("content") or "").strip()
|
||
if retry_instruction in original:
|
||
return
|
||
patched = dict(row)
|
||
patched["content"] = f"{original}\n\n{retry_instruction}" if original else retry_instruction
|
||
messages[idx] = patched
|
||
return
|
||
messages.append({"role": "user", "content": retry_instruction})
|
||
|
||
async def _chat_completion_via_openai_compatible(
|
||
self,
|
||
*,
|
||
key: str,
|
||
base_url: str,
|
||
model: str,
|
||
temperature: float,
|
||
max_tokens: int,
|
||
messages: list[dict[str, str]],
|
||
request_timeout: float,
|
||
structured_output: bool,
|
||
function_label: str,
|
||
provider_name: str,
|
||
is_control_plane_gateway_request: bool,
|
||
control_plane_agents_timeout_seconds: int,
|
||
request_started_at_perf: float,
|
||
request_trace_id: str,
|
||
control_plane_agent_timeout: Optional[int],
|
||
) -> ChatCompletionResult:
|
||
endpoint = f"{base_url.rstrip('/')}/chat/completions"
|
||
headers = {
|
||
"Authorization": f"Bearer {key}",
|
||
"Content-Type": "application/json",
|
||
}
|
||
current_max_tokens = max(int(max_tokens or 0), 1)
|
||
http_status_retry_budget = self._http_status_retry_budget_for_function(function_label)
|
||
http_status_retry_attempt = 0
|
||
network_retry_budget = 1
|
||
network_retry_attempt = 0
|
||
empty_chat_retry_budget = self._empty_chat_retry_budget(
|
||
function_name=function_label,
|
||
current_max_tokens=current_max_tokens,
|
||
)
|
||
use_json_response_format = bool(structured_output)
|
||
direct_answer_retry_used = False
|
||
while True:
|
||
payload = {
|
||
"model": model,
|
||
"messages": messages,
|
||
"temperature": temperature,
|
||
"max_tokens": current_max_tokens,
|
||
}
|
||
if use_json_response_format:
|
||
payload["response_format"] = {"type": "json_object"}
|
||
if is_control_plane_gateway_request:
|
||
payload["agents"] = {
|
||
"defaults": {
|
||
"timeoutSeconds": int(control_plane_agents_timeout_seconds),
|
||
}
|
||
}
|
||
try:
|
||
async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client:
|
||
resp = await client.post(endpoint, headers=headers, json=payload)
|
||
except httpx.RequestError as exc:
|
||
if network_retry_budget > 0:
|
||
retry_delay = min(0.8 * (2 ** network_retry_attempt), 3.0)
|
||
_runtime_log(
|
||
"LLM",
|
||
(
|
||
"网络异常重试:"
|
||
f"function={function_label}, "
|
||
f"retry_in={retry_delay:.2f}s, left={network_retry_budget - 1}, "
|
||
f"error={type(exc).__name__}"
|
||
),
|
||
)
|
||
logger.warning(
|
||
"llm_network_retry trace_id=%s function_name=%s retry_attempt=%s retry_delay_seconds=%.2f error_type=%s error=%s",
|
||
request_trace_id,
|
||
function_label,
|
||
network_retry_attempt,
|
||
retry_delay,
|
||
type(exc).__name__,
|
||
str(exc)[:260],
|
||
)
|
||
network_retry_budget -= 1
|
||
network_retry_attempt += 1
|
||
await asyncio.sleep(retry_delay)
|
||
continue
|
||
raise
|
||
else:
|
||
if is_control_plane_gateway_request:
|
||
elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0
|
||
_runtime_log(
|
||
"PMFlow 本地路由层调用",
|
||
(
|
||
f"http ts={_cn_now_text_ms()} trace_id={request_trace_id} "
|
||
f"function={function_label} status={resp.status_code} elapsed_ms={elapsed_ms:.0f}"
|
||
),
|
||
)
|
||
if resp.status_code >= 400:
|
||
body_preview = str(resp.text or "").replace("\n", " ").strip()[:260]
|
||
if is_control_plane_gateway_request:
|
||
_runtime_log(
|
||
"PMFlow 本地路由层调用",
|
||
(
|
||
f"http_error ts={_cn_now_text_ms()} trace_id={request_trace_id} "
|
||
f"function={function_label} status={resp.status_code} body={body_preview}"
|
||
),
|
||
)
|
||
status_code = int(resp.status_code)
|
||
can_retry_http_status = (
|
||
http_status_retry_budget > 0
|
||
and self._is_retryable_http_status(status_code)
|
||
)
|
||
if can_retry_http_status:
|
||
retry_delay = self._http_retry_delay_seconds(
|
||
status_code=status_code,
|
||
attempt=http_status_retry_attempt,
|
||
retry_after_header=resp.headers.get("Retry-After"),
|
||
)
|
||
_runtime_log(
|
||
"LLM",
|
||
(
|
||
"HTTP状态重试:"
|
||
f"function={function_label}, "
|
||
f"status={status_code}, retry_in={retry_delay:.2f}s, "
|
||
f"left={http_status_retry_budget - 1}"
|
||
),
|
||
)
|
||
http_status_retry_budget -= 1
|
||
http_status_retry_attempt += 1
|
||
await asyncio.sleep(retry_delay)
|
||
continue
|
||
if use_json_response_format and status_code in {400, 422}:
|
||
lower_preview = body_preview.lower()
|
||
if "response_format" in lower_preview or "json_object" in lower_preview or "json schema" in lower_preview:
|
||
_runtime_log(
|
||
"LLM",
|
||
(
|
||
"结构化参数降级重试:"
|
||
f"function={function_label}, status={status_code}"
|
||
),
|
||
)
|
||
use_json_response_format = False
|
||
continue
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
choices = data.get("choices") or []
|
||
if not choices:
|
||
raise ValueError("LLM 响应无 choices")
|
||
first_choice = choices[0] if isinstance(choices[0], dict) else {}
|
||
message = first_choice.get("message") if isinstance(first_choice, dict) else {}
|
||
text = self._extract_text_from_chat_response(
|
||
message=message,
|
||
choice=first_choice,
|
||
response=data,
|
||
structured_output=structured_output,
|
||
)
|
||
if not text and structured_output:
|
||
reasoning_text = self._extract_reasoning_fallback_text(
|
||
message=message,
|
||
choice=first_choice,
|
||
response=data,
|
||
)
|
||
normalized_reasoning = self._strip_code_fence(reasoning_text)
|
||
if self._contains_json_container(normalized_reasoning):
|
||
candidate = self._extract_json_candidate(normalized_reasoning)
|
||
if candidate:
|
||
_runtime_log(
|
||
"LLM",
|
||
(
|
||
"结构化reasoning_content回退:"
|
||
f"function={function_label}, provider={provider_name}"
|
||
),
|
||
)
|
||
text = candidate
|
||
if text:
|
||
break
|
||
|
||
msg_keys = list(message.keys()) if isinstance(message, dict) else []
|
||
choice_keys = list(first_choice.keys()) if isinstance(first_choice, dict) else []
|
||
choice_preview = ""
|
||
try:
|
||
choice_preview = json.dumps(first_choice, ensure_ascii=False)[:300]
|
||
except Exception:
|
||
choice_preview = str(first_choice)[:300]
|
||
empty_error = LLMEmptyContentError(
|
||
"LLM 返回空内容: "
|
||
f"message_keys={msg_keys}, choice_keys={choice_keys}, choice_preview={choice_preview}"
|
||
)
|
||
if not direct_answer_retry_used:
|
||
direct_answer_retry_used = True
|
||
self._append_direct_answer_retry_instruction(messages)
|
||
_runtime_log(
|
||
"LLM",
|
||
(
|
||
"空content定向重试:"
|
||
f"function={function_label}, retry_with=请直接输出答案"
|
||
),
|
||
)
|
||
continue
|
||
if empty_chat_retry_budget > 0 and self._should_retry_empty_chat_response(
|
||
structured_output=structured_output,
|
||
message=message,
|
||
choice=first_choice,
|
||
current_max_tokens=current_max_tokens,
|
||
):
|
||
next_max_tokens = self._next_retry_max_tokens_for_empty_chat(current_max_tokens)
|
||
if next_max_tokens > current_max_tokens:
|
||
empty_chat_retry_budget -= 1
|
||
current_max_tokens = next_max_tokens
|
||
_runtime_log(
|
||
"LLM",
|
||
(
|
||
"空content扩容重试:"
|
||
f"function={function_label}, next_max_tokens={current_max_tokens}, "
|
||
f"retries_left={empty_chat_retry_budget}"
|
||
),
|
||
)
|
||
continue
|
||
raise empty_error
|
||
if _is_control_plane_timeout_message(text):
|
||
elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0
|
||
_runtime_log(
|
||
"PMFlow 本地路由层超时" if is_control_plane_gateway_request else "LLM超时文本",
|
||
(
|
||
f"detected ts={_cn_now_text_ms()} trace_id={request_trace_id} "
|
||
f"function={function_label} elapsed_ms={elapsed_ms:.0f} "
|
||
f"text={text.replace(chr(10), ' ')[:260]}"
|
||
),
|
||
)
|
||
logger.warning(
|
||
"llm_timeout_payload_detected",
|
||
trace_id=request_trace_id,
|
||
function_name=function_label,
|
||
request_timeout_seconds=float(request_timeout),
|
||
agent_timeout_seconds=control_plane_agent_timeout,
|
||
text_preview=text.replace("\n", " ")[:260],
|
||
)
|
||
if is_control_plane_gateway_request:
|
||
elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0
|
||
_runtime_log(
|
||
"PMFlow 本地路由层调用",
|
||
(
|
||
f"end ts={_cn_now_text_ms()} trace_id={request_trace_id} "
|
||
f"function={function_label} elapsed_ms={elapsed_ms:.0f} text_len={len(text)}"
|
||
),
|
||
)
|
||
return ChatCompletionResult(text=text, raw_response=data)
|