perf(agent): optimize web SSE streaming

This commit is contained in:
jxxghp
2026-08-07 12:44:28 +08:00
parent 759b9e47eb
commit 865635c59d
3 changed files with 437 additions and 36 deletions

View File

@@ -7,6 +7,7 @@ import shutil
import subprocess
import time
import uuid
from collections import deque
from queue import Empty, Queue
from pathlib import Path
from threading import Lock
@@ -50,6 +51,10 @@ WEB_AGENT_UPLOAD_CHUNK_SIZE = 1024 * 1024
WEB_AGENT_BROWSER_AUDIO_SUFFIXES = {".aac", ".m4a", ".mp3", ".mp4", ".wav", ".wave"}
WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS = 2.0
WEB_AGENT_TRADITIONAL_MAX_WAIT_SECONDS = 60.0
WEB_AGENT_STREAM_COALESCE_SECONDS = 0.03
WEB_AGENT_STREAM_COALESCE_MAX_CHARS = 256
WEB_AGENT_STREAM_HEARTBEAT_SECONDS = 15.0
WEB_AGENT_STREAM_QUEUE_MAX_SIZE = 64
_WEB_AGENT_FILE_REGISTRY: dict[str, dict[str, Any]] = {}
_WEB_AGENT_NOTICE_QUEUES: dict[str, list[Queue[schemas.Notification]]] = {}
_WEB_AGENT_NOTICE_LOCK = Lock()
@@ -57,6 +62,107 @@ _WEB_AGENT_NOTICE_LISTENER_REGISTERED = False
_WEB_AGENT_BACKGROUND_TASKS: set[asyncio.Task] = set()
class _WebAgentEventPublisher:
"""合并 WebAgent 文本增量,并通过有界队列向 SSE 消费者提供事件。"""
def __init__(self) -> None:
self._queue: asyncio.Queue[dict] = asyncio.Queue(
maxsize=WEB_AGENT_STREAM_QUEUE_MAX_SIZE
)
self._pending_events: deque[dict] = deque()
self._pending_signal = asyncio.Event()
self._pending_delta = ""
self._delta_timer: Optional[asyncio.TimerHandle] = None
self._disposed = False
self._max_depth = 0
self._last_logged_depth = 0
self._pump_task = asyncio.create_task(self._pump())
@property
def max_depth(self) -> int:
"""返回本轮发布器观测到的最大积压深度。"""
return self._max_depth
def publish(self, event: dict) -> None:
"""发布事件;相邻文本会按时间或长度边界合并。"""
if self._disposed:
return
if event.get("type") == "delta":
self._pending_delta += str(event.get("content") or "")
if len(self._pending_delta) >= WEB_AGENT_STREAM_COALESCE_MAX_CHARS:
self._flush_delta()
elif self._delta_timer is None:
loop = asyncio.get_running_loop()
self._delta_timer = loop.call_later(
WEB_AGENT_STREAM_COALESCE_SECONDS,
self._flush_delta,
)
return
self._flush_delta()
self._append_event(event)
async def get(self) -> dict:
"""等待并返回下一条已排序事件。"""
return await self._queue.get()
async def aclose(self) -> None:
"""停止发布器并释放等待中的泵任务。"""
if self._disposed:
return
self._disposed = True
self._cancel_delta_timer()
self._pending_delta = ""
self._pending_events.clear()
self._pump_task.cancel()
try:
await self._pump_task
except asyncio.CancelledError:
pass
def _cancel_delta_timer(self) -> None:
"""取消尚未触发的文本合并计时器。"""
if self._delta_timer is None:
return
self._delta_timer.cancel()
self._delta_timer = None
def _flush_delta(self) -> None:
"""把当前文本缓冲转换成一条增量事件。"""
self._cancel_delta_timer()
if not self._pending_delta or self._disposed:
return
content = self._pending_delta
self._pending_delta = ""
self._append_event({"type": "delta", "content": content})
def _append_event(self, event: dict) -> None:
"""追加待发布事件,相邻文本在出口阻塞时继续合并。"""
if (
event.get("type") == "delta"
and self._pending_events
and self._pending_events[-1].get("type") == "delta"
):
self._pending_events[-1]["content"] += str(event.get("content") or "")
else:
self._pending_events.append(event)
self._pending_signal.set()
depth = self._queue.qsize() + len(self._pending_events)
self._max_depth = max(self._max_depth, depth)
if depth >= WEB_AGENT_STREAM_QUEUE_MAX_SIZE // 2 and depth > self._last_logged_depth:
self._last_logged_depth = depth
logger.debug(f"WebAgent SSE事件积压深度: {depth}")
async def _pump(self) -> None:
"""按发布顺序把本地合并结果写入有界出口队列。"""
while True:
await self._pending_signal.wait()
while self._pending_events:
event = self._pending_events.popleft()
await self._queue.put(event)
self._pending_signal.clear()
def _ensure_superuser(user: User) -> None:
"""校验当前用户是否为超级管理员。"""
if not getattr(user, "is_superuser", False):
@@ -268,6 +374,18 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent):
"""文本输出交由 Web 流式处理器统一回调,避免重复增量。"""
self.stream_handler.emit(text)
def _emit_output(self, text: str) -> None:
"""保留完整输出状态,同时只把本次增量交给 Web SSE 回调。"""
if not text:
return
self._streamed_output += text
if not callable(self.output_callback):
return
try:
self.output_callback(text)
except Exception as e:
logger.debug(f"Web智能体输出回调失败: {e}")
def _build_web_agent_session_id(user: User, session_id: Optional[str]) -> str:
"""
@@ -1791,14 +1909,52 @@ async def web_agent_stream(
{"session_id": session_id},
locale=locale,
)
events = await _collect_web_agent_traditional_events(
text=prompt,
current_user=current_user,
original_message_id=payload.original_message_id,
original_chat_id=payload.original_chat_id,
collection_task = asyncio.create_task(
_collect_web_agent_traditional_events(
text=prompt,
current_user=current_user,
original_message_id=payload.original_message_id,
original_chat_id=payload.original_chat_id,
)
)
try:
while True:
try:
events = await asyncio.wait_for(
asyncio.shield(collection_task),
timeout=WEB_AGENT_STREAM_HEARTBEAT_SECONDS,
)
break
except asyncio.TimeoutError:
if await request.is_disconnected():
collection_task.cancel()
return
yield ": heartbeat\n\n"
except asyncio.CancelledError:
if not collection_task.done():
collection_task.cancel()
return
assistant_message = _build_web_agent_display_message_from_events(events)
display_messages.append(assistant_message)
async def save_display_snapshot() -> None:
"""后台保存传统消息展示快照,不阻塞 SSE 终态。"""
try:
await run_in_threadpool(
_save_web_agent_display_snapshot,
session_id=session_id,
current_user=current_user,
messages=display_messages,
client_session_id=payload.session_id or session_id,
)
except Exception as err:
logger.error(f"保存WebAgent传统消息快照失败: {str(err)}")
snapshot_task = asyncio.create_task(save_display_snapshot())
_WEB_AGENT_BACKGROUND_TASKS.add(snapshot_task)
snapshot_task.add_done_callback(_WEB_AGENT_BACKGROUND_TASKS.discard)
await asyncio.sleep(0)
for event in events:
event_payload = copy.deepcopy(event)
yield _build_web_agent_sse(
@@ -1807,21 +1963,14 @@ async def web_agent_stream(
locale=locale,
)
if await request.is_disconnected():
break
await run_in_threadpool(
_save_web_agent_display_snapshot,
session_id=session_id,
current_user=current_user,
messages=display_messages,
client_session_id=payload.session_id or session_id,
)
return
yield _build_web_agent_sse("done", {}, locale=locale)
return StreamingResponse(
traditional_event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
@@ -1868,8 +2017,7 @@ async def web_agent_stream(
session_id = _build_web_agent_session_id(current_user, payload.session_id)
MessageChain().bind_user_session(str(current_user.id), session_id)
event_queue: asyncio.Queue = asyncio.Queue()
last_output = ""
event_publisher = _WebAgentEventPublisher()
user_attachments = _build_web_agent_input_attachments(
images=payload.images or [],
files=[
@@ -1894,16 +2042,13 @@ async def web_agent_stream(
)
display_messages.append(assistant_display_message)
def output_callback(output: str) -> None:
def output_callback(delta: str) -> None:
"""
接收 Agent 累积输出并转成增量事件。
接收 Agent 文本增量并转换成 SSE 事件。
"""
nonlocal last_output
delta = output[len(last_output):] if output.startswith(last_output) else output
last_output = output
for item in _split_web_agent_output(delta):
_apply_web_agent_display_event(item, assistant_display_message)
event_queue.put_nowait(item)
event_publisher.publish(item)
def notification_callback(notification: schemas.Notification) -> None:
"""
@@ -1911,7 +2056,7 @@ async def web_agent_stream(
"""
for item in _build_web_agent_notification_events(notification):
_apply_web_agent_display_event(item, assistant_display_message)
event_queue.put_nowait(item)
event_publisher.publish(item)
async def event_generator() -> AsyncIterator[str]:
"""
@@ -1953,10 +2098,12 @@ async def web_agent_stream(
"message": f"智能助手执行失败: {str(err)}",
}
_apply_web_agent_display_event(error_event, assistant_display_message)
await event_queue.put(error_event)
event_publisher.publish(error_event)
finally:
done_event = {"type": "done"}
_apply_web_agent_display_event(done_event, assistant_display_message)
# 终态先进入 SSE 队列,避免展示快照落库延迟前端结束动画。
event_publisher.publish(done_event)
await run_in_threadpool(
_save_web_agent_display_snapshot,
session_id=session_id,
@@ -1964,40 +2111,51 @@ async def web_agent_stream(
messages=display_messages,
client_session_id=payload.session_id or session_id,
)
await event_queue.put(done_event)
task = asyncio.create_task(run_agent())
_WEB_AGENT_BACKGROUND_TASKS.add(task)
task.add_done_callback(_WEB_AGENT_BACKGROUND_TASKS.discard)
disconnected = False
terminal_sent = False
try:
yield _build_web_agent_sse(
"start",
{"session_id": session_id},
locale=locale,
)
disconnected = False
while not global_vars.is_system_stopped:
if await request.is_disconnected():
disconnected = True
break
event = await event_queue.get()
try:
event = await asyncio.wait_for(
event_publisher.get(),
timeout=WEB_AGENT_STREAM_HEARTBEAT_SECONDS,
)
except asyncio.TimeoutError:
yield ": heartbeat\n\n"
continue
event_type = str(event.get("type") or "")
if event_type == "done":
terminal_sent = True
yield _build_web_agent_sse(
event.pop("type"),
event,
event_type,
{key: value for key, value in event.items() if key != "type"},
locale=locale,
)
if task.done() and event_queue.empty():
if event_type == "done":
break
except asyncio.CancelledError:
disconnected = True
return
finally:
if not task.done() and not disconnected:
if not task.done() and not disconnected and not terminal_sent:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
await event_publisher.aclose()
# 客户端退到后台导致 SSE 断开时,保留后台 Agent 继续执行;完成后会保存展示快照,
# 前端恢复可见时可通过会话详情接口拉取最终状态。
@@ -2005,7 +2163,7 @@ async def web_agent_stream(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},