SSE流式推送与异步任务编排实战

SSE 流式推送 + asyncio 异步编排:翻译系统的实时性架构

LLM 翻译任务动辄耗时 30~120 秒,如果让用户干等一个 loading 动画,体验极差。本文详解"O小译"如何基于 Redis List + SSE + asyncio.create_task 构建一个非阻塞、实时反馈、优雅降级的翻译任务编排系统。


一、问题定义

翻译系统的时序特征:

用户提交 → [术语识别 5s] → [直译 10~30s] → [自审 5~10s] → [意译 10~30s] → [聚合 1s]
                                                                       
总耗时: 30~80 秒(取决于文本长度和模型响应速度)

如果用传统的"请求-响应"模式:

  • HTTP 请求超时(通常 30s)
  • 用户看不到任何进度
  • 网关/LB 可能主动断开长连接

解决方案:将翻译任务拆分为异步创建 + SSE 实时推送两个阶段。


二、架构全景

┌────────┐  POST /translate   ┌───────────┐  asyncio.create_task  ┌──────────────┐
│ Client │ ─────────────────→ │  FastAPI  │ ────────────────────→ │ LangGraph    │
│        │ ← session_id ──── │  Router   │                       │ Translation  │
│        │                    └─────┬─────┘                       │ Pipeline     │
│        │                          │                              └──────┬───────┘
│        │  GET /sse (长连接)  │                              │
│        │ ─────────────────→ │  SSE      │ ← BRPOP ────── ┌──────▼───────┐
│        │ ← data: {event} ── │  Endpoint │                │ Redis List   │
│        │ ← data: {event} ── │           │ ← LPUSH ────── │ sse:{sid}    │
│        │ ← data: {event} ── │           │                └──────────────┘
│        │ ← data: completed  │           │                              ▲
│        │                    └───────────┘                              │
│        │                                                       ┌──────┴───────┐
│        │                                                       │ 各节点 push  │
│        │                                                       │ SSE events   │
│        │                                                       └──────────────┘
└────────┘

关键设计:Redis List 充当了翻译管线和前端 SSE 之间的异步消息队列


三、任务创建:asyncio.create_task 实现"即返回"

async def create_translation_task(db: AsyncSession, request: TranslateRequest) -> str:
    session_id = f"sess_{uuid.uuid4().hex[:12]}"
    
    # 1. 写入数据库
    await session_repo.create_session(db, {
        "session_id": session_id,
        "source_text": request.source_text,
        "overall_status": "pending"
    })
    
    # 2. 初始化 State
    state: TranslateState = {
        "session_id": session_id,
        "source_text": request.source_text,
        "chunks": [],
        "global_terminology": {},
        "start_time": time.time(),
        ...
    }
    
    # 3. 推送初始事件
    await push_sse_event(session_id, {
        "type": "session_started",
        "payload": {"status": "pending"}
    })
    
    # 4. 后台启动翻译流程 ← 核心:不 await,而是 create_task
    task = asyncio.create_task(run_translation_graph(state))
    
    # 5. 异常回调
    task.add_done_callback(handle_task_exception)
    
    return session_id  # 立即返回,不等翻译完成

为什么用 asyncio.create_task 而不是 await

  • await run_translation_graph(state) 会阻塞当前请求,直到翻译完成(30~80s)
  • asyncio.create_task 将翻译任务放入事件循环,当前请求立即返回 session_id
  • 客户端拿到 session_id 后,通过 SSE 端点订阅进度

异常回调的必要性create_task 创建的后台任务如果抛出未捕获的异常,只会在任务完成时打印一个 Task exception was never retrieved 警告。通过 add_done_callback,我们确保每个异常都被记录到 Prometheus 指标中。


四、Redis List 作为消息队列

为什么选 Redis List 而不是 Pub/Sub?

特性 Redis List (LPUSH/BRPOP) Redis Pub/Sub
消息持久化 在 List 中保留直到消费 发后即忘,无持久化
晚连接 客户端晚连也能收到之前的消息 只能收到订阅后的消息
背压处理 自然堆积,不丢失 消息直接丢失
复杂度 极低 极低

翻译场景中,客户端可能在翻译已经开始后才建立 SSE 连接(网络延迟、用户操作延迟),List 的消息堆积能力确保不会丢失早期事件。

推送端

async def push_sse_event(session_id: str, event: dict):
    client = await get_redis_client()
    await client.lpush(f"sse:{session_id}", json.dumps(event, ensure_ascii=False))
    await client.expire(f"sse:{session_id}", 3600)  # 1 小时 TTL

每个 session 对应一个 Redis Key:sse:{session_id},用 LPUSH 往列表头部推入事件。

消费端

async def poll_sse_event(session_id: str, timeout: int = 30) -> dict:
    client = await get_redis_client()
    result = await client.brpop(f"sse:{session_id}", timeout=timeout)
    if result:
        _, data = result
        return json.loads(data)
    return None

BRPOP 是阻塞式弹出——如果 List 为空,它会阻塞等待直到有新元素或超时。但在 async Redis 中,这个"阻塞"是非阻塞的——它会挂起当前协程,让事件循环处理其他任务。


五、SSE 端点:StreamingResponse + 生成器

@router.get("/translate/{session_id}/sse")
async def translation_sse(session_id: str):
    async def event_generator():
        # 连接确认
        yield f"data: {json.dumps({'type': 'connected'})}\n\n"
        
        while True:
            event = await poll_sse_event(session_id, timeout=30)
            
            if event is None:
                yield ":heartbeat\n\n"  # SSE 心跳,防止代理断开
                continue
            
            yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
            
            if event.get("type") in ["completed", "error"]:
                break  # 翻译完成或出错,关闭流
    
    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no"  # 告诉 Nginx 不要缓冲
        }
    )

关键细节

  1. 心跳机制:每 30 秒无事件时发送 :heartbeat\n\n(SSE 注释格式),防止 Nginx/CDN 因空闲超时断开连接
  2. X-Accel-Buffering: no:Nginx 默认会缓冲 SSE 响应,这个头告诉 Nginx 立即转发每个 chunk
  3. 终态判断:只有 completederror 事件才关闭流,中间状态(step_progressstep_retry 等)保持流开启

六、事件类型设计

翻译流程中的事件按语义分为三类:

会话级事件

{"type": "session_started",  "payload": {"status": "pending"}}
{"type": "session_running",  "payload": {"status": "running", "progress": 0}}
{"type": "completed",        "payload": {"status": "completed", "progress": 100, "final_translation": "..."}}
{"type": "error",            "payload": {"status": "failed", "error_code": "TRANSLATION_FAILED"}}

步骤级事件

{"type": "step_progress",  "payload": {"step": "terminology", "message": "正在提取全局术语表...", "progress": 20}}
{"type": "step_started",   "payload": {"step": "literal", "status": "running"}}
{"type": "step_completed", "payload": {"step": "review", "overall_score": 8.5}}
{"type": "step_retry",     "payload": {"step": "literal", "message": "失败,正在重试 (2/3)...", "attempt": 2}}

系统级事件

{"type": "model_fallback", "payload": {"original_model": "deepseek", "fallback_model": "bailian"}}

前端可以根据 type 字段做差异化渲染

  • step_progress → 更新进度条 + 显示 message
  • step_retry → 显示黄色警告提示
  • model_fallback → 显示模型切换通知
  • completed → 显示最终译文

七、翻译管线中的 SSE 埋点

每个翻译节点都内嵌了 SSE 推送,形成全链路进度反馈

# 术语识别节点
await push_sse_event(session_id, {
    "type": "step_progress",
    "payload": {
        "step": "terminology",
        "message": f"全局术语提取完成,共 {term_count} 个术语",
        "progress": 50
    }
})

# 直译节点
await push_sse_event(session_id, {
    "type": "step_progress",
    "payload": {
        "step": "literal",
        "message": f"正在翻译 {total_chunks} 个文本片段...",
        "progress": 20
    }
})

进度百分比设计:不是基于时间的精确计算(不可能精确),而是基于步骤的粗略划分:

阶段 进度范围
会话创建 0%
术语识别 10% ~ 25%
直译 25% ~ 55%
自审 55% ~ 75%
意译 75% ~ 95%
聚合+完成 95% ~ 100%

这比"精确到秒的 ETA"更可靠——LLM 调用延迟的方差太大,精确 ETA 反而会误导用户。


八、Redis 连接池的异步化

async def get_redis_pool() -> aioredis.ConnectionPool:
    global _redis_pool
    if _redis_pool is None:
        _redis_pool = aioredis.ConnectionPool.from_url(
            settings.REDIS_URL,
            max_connections=100,
            decode_responses=True
        )
    return _redis_pool

async def get_redis_client() -> aioredis.Redis:
    global _redis_client
    if _redis_client is None:
        _redis_client = aioredis.Redis(connection_pool=await get_redis_pool())
    return _redis_client

踩坑记录:早期版本使用了同步 Redis 客户端(redis.Redis),在 asyncio 事件循环中执行 BRPOP阻塞整个事件循环——导致所有并发请求都卡住。切换为 redis.asyncio 后,BRPOP 变成了协程友好的非阻塞等待。

连接池 max_connections=100 的考量:每个 SSE 长连接在轮询期间会占用一个连接,100 个连接支持约 100 个并发 SSE 用户。对于翻译系统这个量级已经足够。


九、异常安全与资源清理

async def run_translation_graph(state: TranslateState):
    try:
        active_sessions.inc()  # 活跃会话 +1
        result = await graph.ainvoke(state)
        # 更新数据库 + 推送完成事件
    except Exception as e:
        # 推送错误事件 + 更新数据库
        state["overall_status"] = "failed"
    finally:
        active_sessions.dec()  # 无论如何都要 -1

finally 中的资源清理是防止指标泄漏的关键——如果翻译任务因异常终止但没有 dec()active_sessions Gauge 会永远偏高,导致监控告警误报。


十、总结

整个实时架构可以用一句话概括:

asyncio.create_task 实现任务异步化,Redis List 实现跨进程消息传递,SSE 实现前端实时推送,三者组合形成了一个非阻塞、可观测、可恢复的翻译任务编排系统。

这种"异步任务 + 消息队列 + SSE"的模式,适用于所有需要长时间后台处理且需要实时反馈的场景:AI 图像生成、数据导入导出、视频转码、批量邮件发送等。

posted @ 2026-06-10 12:25  黄忠  阅读(23)  评论(0)    收藏  举报