PMFlow/backend/app/services/llm_service/mixin_chat_openai.py

269 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)