fix(chat): resolve Auto-route failover event-loop freeze (#812)
Root cause: ensure_structured_column() executed a synchronous blocking engine.connect().execute(SELECT information_schema.columns ...) inside the async request path (_build_chat_history / _maybe_compress_history), freezing the uvicorn event loop and hanging all endpoints (~200s, recovered only by docker restart). Fix: - chat_context.py: remove per-request ensure_structured_column() calls; run once idempotently at app startup (main.py lifespan). Use CURRENT_DATABASE() for PostgreSQL compatibility. - main.py: add startup-only ensure_structured_column() hook (try/except). - llm_client.py: trust_env=False + bounded httpx.Timeout/wait_for so broken endpoints fail fast instead of hanging the event loop. - chat_service.py: run media generation in a background daemon thread with its own event loop (_launch_media_task_bg) so SSE end does not cancel image/video tasks (#811); remove TEMP faulthandler diagnostic. Safe-align: persist accumulated live container changes (admin/*, knowledge, market, models, config, observability, mcp_client, platform_knowledge, im_bot_platform, chat_media, capability_registry, requirements) and add workflow_templates. Verified: live failover test (model-006 -> conn-refused) returns in ~1.9s with concurrent login 0.83s/200; event-loop responsiveness restored (sub-second).
This commit is contained in:
1 parent
394a7786e0
commit
a2504bce34
27 files changed
+253
-59
No files matched your search
@@ -1,9 +1,9 @@
|
||||
"""Shared, standards-based LLM client for web chat + IM bot.
|
||||
|
||||
平台红线(07-24):模型层以成熟/开源标准为设计基准。本模块使用官方 OpenAI SDK
|
||||
(`openai.AsyncOpenAI`)——它是 OpenAI 兼容端点的事实标准客户端。平台本身已在讲
|
||||
OpenAI 协议(`/v1/chat/completions`),因此零协议改造成本,并天然获得:
|
||||
* 原生 SSE 流式(`stream=True`);
|
||||
(\"openai.AsyncOpenAI\")——它是 OpenAI 兼容端点的事实标准客户端。平台本身已在讲
|
||||
OpenAI 协议(\"/v1/chat/completions\"),因此零协议改造成本,并天然获得:
|
||||
* 原生 SSE 流式(\"stream=True\");
|
||||
* 结构化的 tool_calls / usage 解析;
|
||||
* 统一的代理(trust_env=False + 显式 proxy)与超时处理。
|
||||
|
||||
@@ -13,13 +13,14 @@ IM bot 共用**同一模型层**、同一套 failover/代理/超时语义——
|
||||
|
||||
API:
|
||||
achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False)
|
||||
- stream=False -> dict,形状对齐上游 `data` 响应:
|
||||
- stream=False -> dict,形状对齐上游 \"data\" 响应:
|
||||
{"choices":[{"message":{"content","tool_calls","reasoning_content"}}], "usage":{...}}
|
||||
- stream=True -> async generator,逐块产出 ("content"|"reasoning", text) 增量
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import asyncio
|
||||
from openai import AsyncOpenAI, OpenAI
|
||||
|
||||
|
||||
@@ -37,10 +38,21 @@ def build_openai_client(api_url, api_key, *, proxy=None, timeout=120) -> AsyncOp
|
||||
# 始终 trust_env=False:避免容器 HTTP_PROXY 对国内端点(siliconflow 等)造成 502;
|
||||
# 代理经 proxy 显式传入(AGNES 等),未传则直连。
|
||||
http_client = httpx.AsyncClient(proxy=proxy, trust_env=False)
|
||||
# 故障快速失败(全栈统一,覆盖 web chat / IM bot / embedding):
|
||||
# connect 封顶 10s —— 坏端点不再让请求挂死事件循环;
|
||||
# read/write 封顶 30s —— 长流式回复只要持续有分块即不受影响;
|
||||
# 保留数值型 timeout 接口,旧调用方无需改动。
|
||||
_num = float(timeout) if isinstance(timeout, (int, float)) else 120.0
|
||||
_to = httpx.Timeout(
|
||||
connect=min(10.0, _num),
|
||||
read=min(30.0, _num),
|
||||
write=min(30.0, _num),
|
||||
pool=10.0,
|
||||
)
|
||||
return AsyncOpenAI(
|
||||
base_url=base,
|
||||
api_key=api_key or "EMPTY",
|
||||
timeout=timeout,
|
||||
timeout=_to,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
@@ -94,10 +106,21 @@ async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=
|
||||
_reasoning_parts = []
|
||||
_tool_acc = {} # index -> {id, type, name, arguments}
|
||||
try:
|
||||
gen = await client.chat.completions.create(
|
||||
**_create_kwargs(request_body, stream=True)
|
||||
_to = min(30.0, float(timeout) if isinstance(timeout, (int, float)) else 120.0)
|
||||
gen = await asyncio.wait_for(
|
||||
client.chat.completions.create(
|
||||
**_create_kwargs(request_body, stream=True)
|
||||
),
|
||||
timeout=_to,
|
||||
)
|
||||
async for chunk in gen:
|
||||
_it = gen.__aiter__()
|
||||
while True:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(_it.__anext__(), timeout=_to)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError("stream chunk read timeout")
|
||||
if not chunk.choices:
|
||||
continue
|
||||
delta = chunk.choices[0].delta
|
||||
@@ -129,7 +152,10 @@ async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=
|
||||
if _fn_args:
|
||||
_slot["arguments"] += _fn_args
|
||||
finally:
|
||||
await client.close()
|
||||
try:
|
||||
await asyncio.wait_for(client.close(), timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
# 流式结束时吐出已组装的完整 message(含 tool_calls),供调用方无缝衔接工具循环
|
||||
_tool_calls = None
|
||||
if _tool_acc:
|
||||
@@ -150,8 +176,12 @@ async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=
|
||||
return _gen()
|
||||
|
||||
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
|
||||
async with client:
|
||||
resp = await client.chat.completions.create(**_create_kwargs(request_body))
|
||||
try:
|
||||
_to = min(30.0, float(timeout) if isinstance(timeout, (int, float)) else 120.0)
|
||||
resp = await asyncio.wait_for(
|
||||
client.chat.completions.create(**_create_kwargs(request_body)),
|
||||
timeout=_to,
|
||||
)
|
||||
msg = resp.choices[0].message
|
||||
return {
|
||||
"choices": [
|
||||
@@ -165,6 +195,11 @@ async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=
|
||||
],
|
||||
"usage": (resp.usage.model_dump() if resp.usage else {}),
|
||||
}
|
||||
finally:
|
||||
try:
|
||||
await asyncio.wait_for(client.close(), timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120) -> OpenAI:
|
||||
@@ -208,9 +243,18 @@ async def aembed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> l
|
||||
嵌入调用收敛到同一标准模型层(与聊天共用代理/超时/failover 语义)。
|
||||
"""
|
||||
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
|
||||
async with client:
|
||||
resp = await client.embeddings.create(**_embed_kwargs(input, model))
|
||||
try:
|
||||
_to = min(60.0, float(timeout) if isinstance(timeout, (int, float)) else 60.0)
|
||||
resp = await asyncio.wait_for(
|
||||
client.embeddings.create(**_embed_kwargs(input, model)),
|
||||
timeout=_to,
|
||||
)
|
||||
return [d.embedding for d in resp.data]
|
||||
finally:
|
||||
try:
|
||||
await asyncio.wait_for(client.close(), timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def embed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list:
|
||||
|
||||
@@ -6,7 +6,7 @@ Qihu-AIFlow 统一可观测基座 (Phase 2.1 + Phase 1.I OTel 增强)
|
||||
|
||||
设计原则(Strangler Fig 增量):
|
||||
- 纯增量埋点,不改任何既有接口的返回结构。
|
||||
- 向后兼容现有 `logging.getLogger(__name__)` 用法:trace_id 由 root logger 的
|
||||
- 向后兼容现有 \"logging.getLogger(__name__)\" 用法:trace_id 由 root logger 的
|
||||
TraceIdFilter 自动注入,调用方无需改动。
|
||||
- 不引入第三方依赖(structlog 未安装),自带可选的 JSON formatter。
|
||||
|
||||
@@ -58,7 +58,7 @@ def get_trace_id() -> str:
|
||||
class TraceIdFilter(logging.Filter):
|
||||
"""为每一条日志记录注入 trace_id 属性(不存在则写为 '-')。
|
||||
|
||||
挂载到 root logger 后,所有 `getLogger(__name__)` 的子 logger 都会继承,
|
||||
挂载到 root logger 后,所有 \"getLogger(__name__)\" 的子 logger 都会继承,
|
||||
从而让全平台既有日志自动带上链路 ID,零侵入。
|
||||
"""
|
||||
|
||||
|
||||
Reference in new issue
Block a user