Skip to content

Commit c6ca44a

Browse files
feat: bound fusion stream lifecycle
1 parent d223ddb commit c6ca44a

3 files changed

Lines changed: 192 additions & 51 deletions

File tree

scripts/bazi_engine/_streaming.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Small, bounded bridge for sending worker events to an async SSE generator."""
2+
3+
import asyncio
4+
import logging
5+
import os
6+
import threading
7+
from typing import Any
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
class StreamEventQueue:
13+
"""Bound worker-to-event-loop events and stop accepting them after cancellation."""
14+
15+
def __init__(self, loop: asyncio.AbstractEventLoop, *, name: str) -> None:
16+
self._loop = loop
17+
self._name = name
18+
self._queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue(
19+
maxsize=max(1, int(os.getenv("BAZI_STREAM_EVENT_QUEUE_MAX", "128")))
20+
)
21+
self._closed = threading.Event()
22+
self._overflowed = threading.Event()
23+
24+
@property
25+
def closed(self) -> bool:
26+
return self._closed.is_set()
27+
28+
@property
29+
def overflowed(self) -> bool:
30+
return self._overflowed.is_set()
31+
32+
def publish(self, event_type: str, payload: Any) -> None:
33+
"""Schedule a non-blocking event offer from a synchronous worker callback."""
34+
if not self._closed.is_set():
35+
self._loop.call_soon_threadsafe(self._offer, event_type, payload)
36+
37+
def _offer(self, event_type: str, payload: Any) -> None:
38+
if self._closed.is_set():
39+
return
40+
try:
41+
self._queue.put_nowait((event_type, payload))
42+
except asyncio.QueueFull:
43+
self._overflowed.set()
44+
self._closed.set()
45+
logger.warning("SSE event queue overflowed stream=%s", self._name)
46+
47+
async def get(self, timeout: float) -> tuple[str, Any]:
48+
return await asyncio.wait_for(self._queue.get(), timeout=timeout)
49+
50+
def close(self) -> None:
51+
self._closed.set()

scripts/bazi_engine/api.py

Lines changed: 106 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
from ._http import close_shared_clients
3737
from ._runtime import close_blocking_executor, submit_blocking
38+
from ._streaming import StreamEventQueue
3839
from ._version import __version__
3940
from .chart import build_chart
4041

@@ -52,6 +53,9 @@
5253
}
5354
_MAX_AI_STREAMS = int(os.getenv("BAZI_MAX_AI_STREAMS", "3"))
5455
_AI_STREAM_SLOTS = threading.BoundedSemaphore(max(1, _MAX_AI_STREAMS))
56+
_FUSION_STREAM_TOTAL_TIMEOUT = float(os.getenv("BAZI_FUSION_STREAM_TOTAL_TIMEOUT", "150"))
57+
_FUSION_STREAM_IDLE_TIMEOUT = float(os.getenv("BAZI_FUSION_STREAM_IDLE_TIMEOUT", "45"))
58+
_STREAM_HEARTBEAT_INTERVAL = float(os.getenv("BAZI_STREAM_HEARTBEAT_INTERVAL", "15"))
5559
_CORS_ORIGINS = [
5660
origin.strip()
5761
for origin in os.getenv("BAZI_CORS_ORIGINS", "").split(",")
@@ -324,39 +328,47 @@ def run_build():
324328
chart_data.get("life_stage", ""),
325329
age_info=age_info,
326330
)
327-
fusion_queue: asyncio.Queue = asyncio.Queue()
331+
fusion_events = StreamEventQueue(loop, name="chart-fusion")
328332

329-
def on_token(tok: str, _fusion_queue=fusion_queue):
330-
loop.call_soon_threadsafe(
331-
_fusion_queue.put_nowait, ("token", tok))
333+
def on_token(tok: str, _fusion_events=fusion_events):
334+
_fusion_events.publish("token", tok)
332335

333336
fusion_done_flag = {"done": False}
334337

335338
def run_fusion(
336339
_fusion_key=fusion_key,
337-
_fusion_queue=fusion_queue,
340+
_fusion_events=fusion_events,
338341
_pkg=pkg,
339342
_fusion_done_flag=fusion_done_flag,
340343
_fusion_meta=fusion_meta,
341344
_fusion_started_at=fusion_started_at,
342345
):
343346
try:
344347
if not _fusion_key:
345-
loop.call_soon_threadsafe(_fusion_queue.put_nowait, ("fusion_error", "DEEPSEEK_API_KEY未设置"))
348+
_fusion_events.publish("fusion_error", "DEEPSEEK_API_KEY未设置")
346349
return
347350
full = generate_fusion_report(
348351
_pkg,
349352
on_chunk=on_token,
350353
result_metadata=_fusion_meta,
351354
)
355+
if _fusion_events.closed:
356+
_record_fusion_generation(
357+
_fusion_meta["generation_id"],
358+
_fusion_started_at,
359+
"cancelled",
360+
_fusion_meta,
361+
"stream_closed",
362+
)
363+
return
352364
if full:
353365
_record_fusion_generation(
354366
_fusion_meta["generation_id"],
355367
_fusion_started_at,
356368
"success",
357369
_fusion_meta,
358370
)
359-
loop.call_soon_threadsafe(_fusion_queue.put_nowait, ("fusion_done", full))
371+
_fusion_events.publish("fusion_done", full)
360372
else:
361373
_record_fusion_generation(
362374
_fusion_meta["generation_id"],
@@ -365,10 +377,7 @@ def run_fusion(
365377
_fusion_meta,
366378
"empty_response",
367379
)
368-
loop.call_soon_threadsafe(
369-
_fusion_queue.put_nowait,
370-
("fusion_error", "融合报告暂时不可用,请稍后重试"),
371-
)
380+
_fusion_events.publish("fusion_error", "融合报告暂时不可用,请稍后重试")
372381
_fusion_done_flag["done"] = True
373382
except Exception as error:
374383
error_class = _fusion_error_class(error)
@@ -383,36 +392,41 @@ def run_fusion(
383392
"fusion generation failed id=%s class=%s type=%s",
384393
_fusion_meta["generation_id"], error_class, type(error).__name__,
385394
)
386-
loop.call_soon_threadsafe(
387-
_fusion_queue.put_nowait,
388-
("fusion_error", "融合报告暂时不可用,请稍后重试"),
389-
)
395+
_fusion_events.publish("fusion_error", "融合报告暂时不可用,请稍后重试")
390396
_fusion_done_flag["done"] = True
391397

392398
submit_blocking(loop, run_fusion)
393399

394-
_fusion_start = loop.time()
400+
_fusion_last_activity_at = loop.time()
395401
while True:
396402
try:
397-
ft, fd = await asyncio.wait_for(fusion_queue.get(), timeout=15.0)
403+
ft, fd = await fusion_events.get(_STREAM_HEARTBEAT_INTERVAL)
398404
except TimeoutError:
399405
if fusion_done_flag["done"]:
400406
break # thread finished but queue empty
401-
elapsed = loop.time() - _fusion_start
402-
if elapsed > 90:
403-
yield f"data: {json.dumps({'phase': 'personality_error', 'message': f'融合超时({elapsed:.0f}s)'})}\n\n"
407+
total_elapsed = loop.time() - fusion_started_at
408+
idle_elapsed = loop.time() - _fusion_last_activity_at
409+
if total_elapsed > _FUSION_STREAM_TOTAL_TIMEOUT:
410+
fusion_events.close()
411+
yield f"data: {json.dumps({'phase': 'personality_error', 'message': '融合报告超时,请稍后重试'})}\n\n"
412+
break
413+
if idle_elapsed > _FUSION_STREAM_IDLE_TIMEOUT or fusion_events.overflowed:
414+
fusion_events.close()
415+
yield f"data: {json.dumps({'phase': 'personality_error', 'message': '融合报告响应超时,请稍后重试'})}\n\n"
404416
break
417+
yield f"data: {json.dumps({'phase': 'heartbeat'})}\n\n"
405418
continue
406419
if ft == "token":
407420
yield f"data: {json.dumps({'phase': 'personality_token', 'token': fd})}\n\n"
408-
_fusion_start = loop.time() # reset timeout on activity
421+
_fusion_last_activity_at = loop.time()
409422
elif ft == "fusion_done":
410423
if fd:
411424
yield f"data: {json.dumps({'phase': 'personality_done', 'full': fd, 'meta': fusion_meta})}\n\n"
412425
break
413426
elif ft == "fusion_error":
414427
yield f"data: {json.dumps({'phase': 'personality_error', 'message': fd})}\n\n"
415428
break
429+
fusion_events.close()
416430
except Exception as error:
417431
error_class = _fusion_error_class(error)
418432
_record_fusion_generation(
@@ -876,14 +890,25 @@ async def err_gen():
876890

877891
# SSE 生成器 — 用 asyncio.Queue 桥接同步 LLM 流,实现真正的逐 token 推送
878892
async def stream_fusion():
879-
queue: asyncio.Queue = asyncio.Queue()
880893
loop = asyncio.get_event_loop()
894+
events = StreamEventQueue(loop, name="fusion-direct")
881895
fusion_meta: dict = {"generation_id": uuid.uuid4().hex}
882896
fusion_started_at = time.monotonic()
897+
generation_recorded = threading.Event()
898+
899+
def record_once(
900+
outcome: Literal["success", "failure", "cancelled"],
901+
error_class: str | None = None,
902+
) -> None:
903+
if not generation_recorded.is_set():
904+
generation_recorded.set()
905+
_record_fusion_generation(
906+
fusion_meta["generation_id"], fusion_started_at, outcome, fusion_meta, error_class,
907+
)
883908

884909
def on_token(token: str):
885910
"""LLM 每吐一个 token,立刻推入 queue"""
886-
loop.call_soon_threadsafe(queue.put_nowait, ("token", token))
911+
events.publish("token", token)
887912

888913
def run_llm():
889914
"""在线程中跑同步流式 LLM 调用"""
@@ -893,45 +918,75 @@ def run_llm():
893918
on_chunk=on_token,
894919
result_metadata=fusion_meta,
895920
)
921+
if events.closed:
922+
record_once("cancelled", "client_disconnected")
923+
return
896924
if full:
897-
_record_fusion_generation(
898-
fusion_meta["generation_id"], fusion_started_at, "success", fusion_meta,
899-
)
900-
loop.call_soon_threadsafe(queue.put_nowait, ("done", full))
925+
record_once("success")
926+
events.publish("done", full)
901927
else:
902-
_record_fusion_generation(
903-
fusion_meta["generation_id"], fusion_started_at, "failure", fusion_meta,
904-
"empty_response",
905-
)
906-
loop.call_soon_threadsafe(queue.put_nowait, ("error", "融合报告暂时不可用,请稍后重试"))
928+
record_once("failure", "empty_response")
929+
events.publish("error", "融合报告暂时不可用,请稍后重试")
907930
except Exception as error:
908931
error_class = _fusion_error_class(error)
909-
_record_fusion_generation(
910-
fusion_meta["generation_id"], fusion_started_at, "failure", fusion_meta, error_class,
911-
)
932+
record_once("failure", error_class)
912933
logger.warning(
913934
"fusion generation failed id=%s class=%s type=%s",
914935
fusion_meta["generation_id"], error_class, type(error).__name__,
915936
)
916-
loop.call_soon_threadsafe(queue.put_nowait, ("error", "融合报告暂时不可用,请稍后重试"))
937+
events.publish("error", "融合报告暂时不可用,请稍后重试")
917938

918939
submit_blocking(loop, run_llm)
919940

920-
while True:
921-
msg_type, msg_data = await queue.get()
922-
if msg_type == "token":
923-
yield f"data: {json.dumps({'token': msg_data})}\n\n"
924-
elif msg_type == "done":
925-
if msg_data:
926-
yield f"data: {json.dumps({'done': True, 'length': len(msg_data), 'full': msg_data, 'meta': fusion_meta})}\n\n"
927-
else:
928-
yield f"data: {json.dumps({'error': 'LLM 调用失败,请稍后重试'})}\n\n"
929-
yield "data: [DONE]\n\n"
930-
return
931-
elif msg_type == "error":
932-
yield f"data: {json.dumps({'error': msg_data})}\n\n"
933-
yield "data: [DONE]\n\n"
934-
return
941+
last_event_at = loop.time()
942+
try:
943+
while True:
944+
elapsed = loop.time() - fusion_started_at
945+
idle = loop.time() - last_event_at
946+
if elapsed >= _FUSION_STREAM_TOTAL_TIMEOUT:
947+
events.close()
948+
record_once("failure", "total_timeout")
949+
yield f"data: {json.dumps({'error': '融合报告超时,请稍后重试'})}\n\n"
950+
yield "data: [DONE]\n\n"
951+
return
952+
if idle >= _FUSION_STREAM_IDLE_TIMEOUT:
953+
events.close()
954+
record_once("failure", "idle_timeout")
955+
yield f"data: {json.dumps({'error': '融合报告响应超时,请稍后重试'})}\n\n"
956+
yield "data: [DONE]\n\n"
957+
return
958+
timeout = min(
959+
_STREAM_HEARTBEAT_INTERVAL,
960+
_FUSION_STREAM_TOTAL_TIMEOUT - elapsed,
961+
_FUSION_STREAM_IDLE_TIMEOUT - idle,
962+
)
963+
try:
964+
msg_type, msg_data = await events.get(max(0.01, timeout))
965+
except TimeoutError:
966+
if events.overflowed:
967+
events.close()
968+
record_once("failure", "queue_overflow")
969+
yield f"data: {json.dumps({'error': '融合报告处理繁忙,请稍后重试'})}\n\n"
970+
yield "data: [DONE]\n\n"
971+
return
972+
yield f"data: {json.dumps({'heartbeat': True})}\n\n"
973+
continue
974+
last_event_at = loop.time()
975+
if msg_type == "token":
976+
yield f"data: {json.dumps({'token': msg_data})}\n\n"
977+
elif msg_type == "done":
978+
if msg_data:
979+
yield f"data: {json.dumps({'done': True, 'length': len(msg_data), 'full': msg_data, 'meta': fusion_meta})}\n\n"
980+
else:
981+
yield f"data: {json.dumps({'error': 'LLM 调用失败,请稍后重试'})}\n\n"
982+
yield "data: [DONE]\n\n"
983+
return
984+
elif msg_type == "error":
985+
yield f"data: {json.dumps({'error': msg_data})}\n\n"
986+
yield "data: [DONE]\n\n"
987+
return
988+
finally:
989+
events.close()
935990

936991
return _limited_stream_response(stream_fusion())
937992

scripts/tests/test_api.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import importlib
55
import json
66
import sys
7+
import time
78
from concurrent.futures import ThreadPoolExecutor
89
from contextlib import asynccontextmanager
910
from datetime import date
@@ -288,6 +289,40 @@ def fake_generate(*_args, **_kwargs):
288289
assert generation["error_class"] == "provider_rejected"
289290

290291

292+
def test_fusion_stream_stops_after_idle_timeout(monkeypatch, tmp_path):
293+
import bazi_engine.api as api_module
294+
import bazi_engine.personality_fusion as fusion_module
295+
from bazi_engine.api import app
296+
297+
monkeypatch.setenv("BAZI_FUSION_ENGINE", "1")
298+
monkeypatch.setenv("DEEPSEEK_API_KEY", "test-key")
299+
monkeypatch.setattr(api_module, "_GENERATION_DIR", tmp_path)
300+
monkeypatch.setattr(api_module, "_FUSION_STREAM_IDLE_TIMEOUT", 0.01)
301+
monkeypatch.setattr(api_module, "_FUSION_STREAM_TOTAL_TIMEOUT", 0.5)
302+
monkeypatch.setattr(api_module, "_STREAM_HEARTBEAT_INTERVAL", 0.005)
303+
304+
def fake_generate(*_args, **_kwargs):
305+
time.sleep(0.05)
306+
return "late report"
307+
308+
monkeypatch.setattr(fusion_module, "generate_fusion_report", fake_generate)
309+
with TestClient(app).stream(
310+
"POST",
311+
"/api/personality/fusion/stream",
312+
json={"personality": {"traits": {"社交": "内敛"}}},
313+
) as response:
314+
body = "".join(response.iter_text())
315+
316+
assert response.status_code == 200
317+
events = [
318+
json.loads(line[6:])
319+
for line in body.splitlines()
320+
if line.startswith("data: {")
321+
]
322+
assert any(event.get("error") == "融合报告响应超时,请稍后重试" for event in events)
323+
assert "late report" not in body
324+
325+
291326
def test_fusion_feedback_saves_metadata_without_report_or_birth_data(monkeypatch, tmp_path):
292327
"""融合反馈只保存分析元数据和报告哈希,不落报告正文或出生资料。"""
293328
import bazi_engine.api as api_module

0 commit comments

Comments
 (0)