3535
3636from ._http import close_shared_clients
3737from ._runtime import close_blocking_executor , submit_blocking
38+ from ._streaming import StreamEventQueue
3839from ._version import __version__
3940from .chart import build_chart
4041
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
0 commit comments