From 72bd2e1e02231ec63f0dd8017179f1e2e7c3a872 Mon Sep 17 00:00:00 2001
From: Nelson <1475262689@qq.com>
Date: Wed, 8 Jul 2026 17:20:29 +0800
Subject: [PATCH] Use async model streaming for chat
---
.../apps/backend/app/api/chat.py | 11 +-
.../app/services/chat_stream_service.py | 147 ++++++++++-
.../app/services/model_stream_service.py | 232 +++++++++++++++++-
...026-07-08-concurrency-optimization-plan.md | 31 +++
4 files changed, 415 insertions(+), 6 deletions(-)
diff --git a/ai_knowledge_base_v2/apps/backend/app/api/chat.py b/ai_knowledge_base_v2/apps/backend/app/api/chat.py
index 85378dc..c3aff5b 100644
--- a/ai_knowledge_base_v2/apps/backend/app/api/chat.py
+++ b/ai_knowledge_base_v2/apps/backend/app/api/chat.py
@@ -1,7 +1,8 @@
from __future__ import annotations
+import asyncio
import json
-from collections.abc import Iterator
+from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
@@ -87,7 +88,7 @@ def stop(
return api_success()
-def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user: User) -> Iterator[str]:
+async def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user: User) -> AsyncIterator[str]:
config = load_chat_queue_config(db)
queue_request = chat_queue_manager.request_slot(config)
acquired = queue_request.status == "acquired"
@@ -111,7 +112,7 @@ def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user: User
activeCount=queue_request.active_count,
waitingCount=queue_request.waiting_count,
)
- queue_request = chat_queue_manager.wait_for_slot(queued_token, config)
+ queue_request = await asyncio.to_thread(chat_queue_manager.wait_for_slot, queued_token, config)
acquired = queue_request.status == "acquired"
if queue_request.status == "timeout":
yield _sse_event("error", message="排队等待超时,请稍后再试。")
@@ -125,10 +126,12 @@ def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user: User
activeCount=queue_request.active_count,
waitingCount=queue_request.waiting_count,
)
- for chunk in ChatStreamService.stream_answer(db, current_user, payload.sessionId, payload.message):
+ async for chunk in ChatStreamService.stream_answer_async(db, current_user, payload.sessionId, payload.message):
yield _sse_event("content", content=chunk)
except HTTPException as exc:
yield _sse_event("error", message=str(exc.detail))
+ except asyncio.CancelledError:
+ raise
except Exception:
yield _sse_event("error", message="AI 回复生成失败,请稍后再试。")
finally:
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py b/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py
index febeff2..a9d7b98 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py
@@ -1,6 +1,7 @@
from __future__ import annotations
-from collections.abc import Iterator
+import asyncio
+from collections.abc import AsyncIterator, Iterator
from datetime import UTC, datetime
from inspect import signature
from time import perf_counter
@@ -138,6 +139,99 @@ class ChatStreamService:
)
db.commit()
+ @staticmethod
+ async def stream_answer_async(db: Session, user: User, session_id: int, question: str) -> AsyncIterator[str]:
+ session = ChatService._get_user_session(db, user, session_id)
+ ChatService._ensure_quota(user)
+
+ now = _now()
+ normalized_question = question.strip()
+ user_message = ChatMessage(
+ session_id=session.id,
+ user_id=user.id,
+ role="user",
+ content=normalized_question,
+ message_status="FINISHED",
+ created_at=now,
+ )
+ db.add(user_message)
+ db.flush()
+
+ history = list(
+ db.scalars(
+ select(ChatMessage)
+ .where(
+ ChatMessage.session_id == session.id,
+ ChatMessage.user_id == user.id,
+ ChatMessage.id < user_message.id,
+ )
+ .order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
+ )
+ )
+ _maybe_update_summary(db, session, history)
+
+ started_at = perf_counter()
+ rag_result = None
+ model_response = None
+ answer_parts: list[str] = []
+
+ try:
+ rag_result = _build_rag_result(db, user, normalized_question, history, getattr(session, "summary", None))
+ model_response = ModelStreamService.stream_async(db, rag_result)
+ async for chunk in model_response.chunks:
+ if chunk:
+ answer_parts.append(chunk)
+ yield chunk
+ except asyncio.CancelledError:
+ db.rollback()
+ raise
+ except ExternalServiceError as exc:
+ cost_ms = int((perf_counter() - started_at) * 1000)
+ AiRequestLogService.write_failed(
+ db,
+ session_id=session.id,
+ message_id=user_message.id,
+ user_id=user.id,
+ model_name=model_response.model_name if model_response is not None else None,
+ prompt=rag_result.prompt if rag_result is not None else normalized_question,
+ knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
+ retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
+ cost_ms=cost_ms,
+ error_message=str(exc),
+ )
+ db.commit()
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
+
+ answer = "".join(answer_parts)
+ if not answer.strip():
+ cost_ms = int((perf_counter() - started_at) * 1000)
+ AiRequestLogService.write_failed(
+ db,
+ session_id=session.id,
+ message_id=user_message.id,
+ user_id=user.id,
+ model_name=model_response.model_name if model_response is not None else None,
+ prompt=rag_result.prompt if rag_result is not None else normalized_question,
+ knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
+ retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
+ cost_ms=cost_ms,
+ error_message="模型未返回有效内容",
+ )
+ db.commit()
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容")
+
+ _write_success(
+ db,
+ user=user,
+ session=session,
+ question=normalized_question,
+ answer=answer,
+ rag_result=rag_result,
+ model_response=model_response,
+ started_at=started_at,
+ now=now,
+ )
+
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
@@ -147,6 +241,57 @@ def _rough_token_count(text: str) -> int:
return max(1, len(text.strip()) // 2)
+def _write_success(
+ db: Session,
+ *,
+ user: User,
+ session,
+ question: str,
+ answer: str,
+ rag_result,
+ model_response,
+ started_at: float,
+ now: datetime,
+) -> None:
+ cost_ms = int((perf_counter() - started_at) * 1000)
+ assistant_message = ChatMessage(
+ session_id=session.id,
+ user_id=user.id,
+ role="assistant",
+ content=answer,
+ message_status="FINISHED",
+ token_input=model_response.input_token if model_response is not None else None,
+ token_output=_rough_token_count(answer),
+ response_time_ms=cost_ms,
+ model_id=model_response.model_id if model_response is not None else None,
+ created_at=now,
+ )
+ db.add(assistant_message)
+ db.flush()
+
+ session.message_count += 2
+ session.last_message_at = now
+ if session.title == "新聊天":
+ session.title = _title_from_question(question)
+
+ user.daily_chat_used += 1
+ db.add_all([session, user])
+ AiRequestLogService.write_success(
+ db,
+ session_id=session.id,
+ message_id=assistant_message.id,
+ user_id=user.id,
+ model_name=model_response.model_name if model_response is not None else "unknown",
+ prompt=rag_result.prompt if rag_result is not None else question,
+ knowledge_ids=rag_result.knowledge_ids if rag_result is not None else "",
+ retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
+ input_token=model_response.input_token if model_response is not None else 0,
+ output_token=_rough_token_count(answer),
+ cost_ms=cost_ms,
+ )
+ db.commit()
+
+
def _build_rag_result(db: Session, user: User, question: str, history: list[ChatMessage], summary: str | None):
parameters = signature(RagService.build_result).parameters
if "history" in parameters:
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/model_stream_service.py b/ai_knowledge_base_v2/apps/backend/app/services/model_stream_service.py
index 683cdb6..36456dd 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/model_stream_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/model_stream_service.py
@@ -1,7 +1,8 @@
from __future__ import annotations
+import asyncio
import json
-from collections.abc import Iterator
+from collections.abc import AsyncIterator, Iterator
from dataclasses import dataclass
from typing import Any
@@ -36,6 +37,14 @@ class StreamingModelResponse:
chunks: Iterator[str]
+@dataclass(frozen=True)
+class AsyncStreamingModelResponse:
+ model_id: int | None
+ model_name: str
+ input_token: int
+ chunks: AsyncIterator[str]
+
+
class ModelStreamService:
@staticmethod
def stream(db: Session, rag_result: RagResult) -> StreamingModelResponse:
@@ -71,6 +80,40 @@ class ModelStreamService:
chunks=_stream_configured_model(model, rag_result),
)
+ @staticmethod
+ def stream_async(db: Session, rag_result: RagResult) -> AsyncStreamingModelResponse:
+ model = _get_enabled_model(db)
+ mock_model_enabled = _system_config_bool(db, "mock_model_enabled", get_settings().mock_model_enabled)
+
+ if mock_model_enabled:
+ model_name = model.model_name if model is not None else "mock-model"
+ return AsyncStreamingModelResponse(
+ model_id=model.id if model is not None else None,
+ model_name=model_name,
+ input_token=_rough_token_count(rag_result.prompt),
+ chunks=_async_chunk_text(_mock_answer(rag_result)),
+ )
+
+ if not rag_result.is_hit:
+ return AsyncStreamingModelResponse(
+ model_id=model.id if model is not None else None,
+ model_name=model.model_name if model is not None else "no-hit",
+ input_token=_rough_token_count(rag_result.prompt),
+ chunks=_async_chunk_text(NO_HIT_ANSWER),
+ )
+
+ if model is None:
+ raise ExternalServiceError("未启用可用模型,请先在模型管理中启用一个模型。", provider="model")
+ if not (model.api_url or model.base_url) or not model.api_key:
+ raise ExternalServiceError("模型 Base URL/API URL 或 API Key 未配置", provider="model")
+
+ return AsyncStreamingModelResponse(
+ model_id=model.id,
+ model_name=model.model_name,
+ input_token=_rough_token_count(rag_result.prompt),
+ chunks=_stream_configured_model_async(model, rag_result),
+ )
+
def _get_enabled_model(db: Session) -> ModelConfig | None:
return db.scalar(
@@ -92,6 +135,29 @@ def _stream_configured_model(model: ModelConfig, rag_result: RagResult) -> Itera
return _chunk_text(_call_configured_model(model, rag_result))
+async def _stream_configured_model_async(model: ModelConfig, rag_result: RagResult) -> AsyncIterator[str]:
+ api_type = model.api_type or "openai_compatible"
+ if model.stream_enabled != 1:
+ answer = await asyncio.to_thread(_call_configured_model, model, rag_result)
+ async for chunk in _async_chunk_text(answer):
+ yield chunk
+ return
+
+ if api_type == "anthropic_messages":
+ async for chunk in _stream_anthropic_messages_async(model, rag_result):
+ yield chunk
+ return
+
+ if api_type == "openai_compatible":
+ async for chunk in _stream_openai_compatible_model_async(model, rag_result):
+ yield chunk
+ return
+
+ answer = await asyncio.to_thread(_call_configured_model, model, rag_result)
+ async for chunk in _async_chunk_text(answer):
+ yield chunk
+
+
def _stream_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -> Iterator[str]:
payload: dict[str, Any] = {
"model": model.model_name,
@@ -124,6 +190,43 @@ def _stream_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -
raise ExternalServiceError(f"模型流式调用失败:{exc}", provider="model") from exc
+async def _stream_openai_compatible_model_async(model: ModelConfig, rag_result: RagResult) -> AsyncIterator[str]:
+ payload = _openai_stream_payload(model, rag_result)
+ try:
+ async with httpx.AsyncClient(timeout=model.timeout_second) as client:
+ async with client.stream(
+ "POST",
+ _resolve_openai_endpoint(model),
+ json=payload,
+ headers=_auth_headers(model),
+ ) as response:
+ response.raise_for_status()
+ async for chunk in _iter_openai_stream_async(response):
+ yield chunk
+ except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
+ raise ExternalServiceError(f"模型流式调用失败:{exc}", provider="model") from exc
+
+
+def _openai_stream_payload(model: ModelConfig, rag_result: RagResult) -> dict[str, Any]:
+ payload: dict[str, Any] = {
+ "model": model.model_name,
+ "messages": [
+ {"role": "system", "content": "你是企业知识库问答助手,只能基于已提供的知识片段回答。"},
+ {"role": "user", "content": rag_result.prompt},
+ ],
+ "max_tokens": model.max_token or 1024,
+ }
+ _put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
+ _put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p))
+ _put_if_not_none(payload, "presence_penalty", _decimal_to_float(model.presence_penalty))
+ _put_if_not_none(payload, "frequency_penalty", _decimal_to_float(model.frequency_penalty))
+ if model.response_format == "json_object":
+ payload["response_format"] = {"type": "json_object"}
+ payload.update(_load_extra_params(model.extra_params))
+ payload["stream"] = True
+ return payload
+
+
def _iter_openai_stream(response: httpx.Response) -> Iterator[str]:
in_reasoning = False
for data_line in _iter_sse_data(response):
@@ -167,6 +270,49 @@ def _iter_openai_stream(response: httpx.Response) -> Iterator[str]:
yield ""
+async def _iter_openai_stream_async(response: httpx.Response) -> AsyncIterator[str]:
+ in_reasoning = False
+ async for data_line in _iter_sse_data_async(response):
+ data = json.loads(data_line)
+ if isinstance(data, dict) and data.get("error"):
+ raise ValueError(str(data["error"]))
+
+ choices = data.get("choices")
+ if not isinstance(choices, list) or not choices:
+ continue
+ first_choice = choices[0]
+ if not isinstance(first_choice, dict):
+ continue
+
+ delta = first_choice.get("delta")
+ if isinstance(delta, dict):
+ reasoning = _string_or_empty(delta.get("reasoning_content") or delta.get("reasoning"))
+ if reasoning:
+ if not in_reasoning:
+ in_reasoning = True
+ yield ""
+ yield reasoning
+ continue
+
+ content = _string_or_empty(delta.get("content"))
+ if content:
+ if in_reasoning:
+ in_reasoning = False
+ yield ""
+ yield content
+ continue
+
+ text = _string_or_empty(first_choice.get("text"))
+ if text:
+ if in_reasoning:
+ in_reasoning = False
+ yield ""
+ yield text
+
+ if in_reasoning:
+ yield ""
+
+
def _stream_anthropic_messages(model: ModelConfig, rag_result: RagResult) -> Iterator[str]:
payload: dict[str, Any] = {
"model": model.model_name,
@@ -194,6 +340,38 @@ def _stream_anthropic_messages(model: ModelConfig, rag_result: RagResult) -> Ite
raise ExternalServiceError(f"模型流式调用失败:{exc}", provider="model") from exc
+async def _stream_anthropic_messages_async(model: ModelConfig, rag_result: RagResult) -> AsyncIterator[str]:
+ payload = _anthropic_stream_payload(model, rag_result)
+ try:
+ async with httpx.AsyncClient(timeout=model.timeout_second) as client:
+ async with client.stream(
+ "POST",
+ _resolve_anthropic_endpoint(model),
+ json=payload,
+ headers=_anthropic_headers(model),
+ ) as response:
+ response.raise_for_status()
+ async for chunk in _iter_anthropic_stream_async(response):
+ yield chunk
+ except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
+ raise ExternalServiceError(f"模型流式调用失败:{exc}", provider="model") from exc
+
+
+def _anthropic_stream_payload(model: ModelConfig, rag_result: RagResult) -> dict[str, Any]:
+ payload: dict[str, Any] = {
+ "model": model.model_name,
+ "max_tokens": model.max_token or 1024,
+ "system": "你是企业知识库问答助手,只能基于已提供的知识片段回答。",
+ "messages": [{"role": "user", "content": rag_result.prompt}],
+ }
+ _put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
+ _put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p))
+ _put_if_not_none(payload, "top_k", model.top_k)
+ payload.update(_load_extra_params(model.extra_params))
+ payload["stream"] = True
+ return payload
+
+
def _iter_anthropic_stream(response: httpx.Response) -> Iterator[str]:
in_reasoning = False
for data_line in _iter_sse_data(response):
@@ -227,6 +405,39 @@ def _iter_anthropic_stream(response: httpx.Response) -> Iterator[str]:
yield ""
+async def _iter_anthropic_stream_async(response: httpx.Response) -> AsyncIterator[str]:
+ in_reasoning = False
+ async for data_line in _iter_sse_data_async(response):
+ data = json.loads(data_line)
+ if isinstance(data, dict) and data.get("type") == "error":
+ raise ValueError(str(data.get("error", data)))
+
+ if not isinstance(data, dict) or data.get("type") != "content_block_delta":
+ continue
+ delta = data.get("delta")
+ if not isinstance(delta, dict):
+ continue
+
+ if delta.get("type") == "thinking_delta":
+ thinking = _string_or_empty(delta.get("thinking"))
+ if thinking:
+ if not in_reasoning:
+ in_reasoning = True
+ yield ""
+ yield thinking
+ continue
+
+ text = _string_or_empty(delta.get("text"))
+ if text:
+ if in_reasoning:
+ in_reasoning = False
+ yield ""
+ yield text
+
+ if in_reasoning:
+ yield ""
+
+
def _iter_sse_data(response: httpx.Response) -> Iterator[str]:
for line in response.iter_lines():
if not line:
@@ -243,10 +454,29 @@ def _iter_sse_data(response: httpx.Response) -> Iterator[str]:
yield data
+async def _iter_sse_data_async(response: httpx.Response) -> AsyncIterator[str]:
+ async for line in response.aiter_lines():
+ if not line:
+ continue
+ line = line.strip()
+ if not line.startswith("data:"):
+ continue
+ data = line[5:].strip()
+ if data == "[DONE]":
+ break
+ if data:
+ yield data
+
+
def _chunk_text(text: str, *, chunk_size: int = 12) -> Iterator[str]:
for index in range(0, len(text), chunk_size):
yield text[index : index + chunk_size]
+async def _async_chunk_text(text: str, *, chunk_size: int = 12) -> AsyncIterator[str]:
+ for index in range(0, len(text), chunk_size):
+ yield text[index : index + chunk_size]
+
+
def _string_or_empty(value: Any) -> str:
return value if isinstance(value, str) else ""
diff --git a/ai_knowledge_base_v2/development_records/2026-07-08-concurrency-optimization-plan.md b/ai_knowledge_base_v2/development_records/2026-07-08-concurrency-optimization-plan.md
index b263e79..dc4c5bc 100644
--- a/ai_knowledge_base_v2/development_records/2026-07-08-concurrency-optimization-plan.md
+++ b/ai_knowledge_base_v2/development_records/2026-07-08-concurrency-optimization-plan.md
@@ -247,3 +247,34 @@
2. 不修改数据库 schema。
3. 不把当前工作区已有的 5 个后端未提交文件混入阶段 3A 提交。
4. 如果 async 模型流式验证失败,保留同步流式入口,便于快速回退。
+
+## 阶段 3A 实施记录
+
+2026-07-08 完成模型长连接异步化第一版。
+
+已完成:
+
+1. `/chat/completions` 的 SSE 生成器已切换为 async 生成器。
+2. 排队等待改为 `asyncio.to_thread(...)`,避免排队中的请求阻塞事件循环。
+3. `ChatStreamService` 新增 `stream_answer_async`,保留原同步 `stream_answer` 作为兼容入口。
+4. `ModelStreamService` 新增 async 模型流式入口:
+ - OpenAI 兼容协议使用 `httpx.AsyncClient.stream`。
+ - Anthropic Messages 协议使用 `httpx.AsyncClient.stream`。
+5. mock、未命中知识库、关闭流式、MiniMax/Gemini 等路径保留安全降级;需要同步调用模型时使用 `asyncio.to_thread(...)` 放到线程中执行。
+6. SSE 事件协议未变化,用户端无需改动。
+
+验证记录:
+
+1. 本机 `compileall` 检查通过。
+2. 容器内 `compileall` 检查通过。
+3. OpenAI 兼容协议 async 模拟流解析通过,输出格式为 `思考答案`。
+4. Anthropic Messages 协议 async 模拟流解析通过,输出格式为 `想答`。
+5. 后端健康检查通过。
+6. 真实 `/chat/completions` 验证通过:首个事件为 `generating`,随后连续返回 `content`,最后返回 `[DONE]`。
+7. 真实会话历史验证通过:流式完成后用户消息和助手消息均已落库。
+
+阶段限制:
+
+1. 飞书检索、RAG 构建、数据库读写仍是同步逻辑;本阶段只优化模型长连接流式调用。
+2. 首字时间仍会受到飞书读取和 RAG 构建耗时影响。
+3. 队列仍是进程内队列,多 worker 全局并发控制需要阶段 4 Redis 队列。