文章目录
-
- [📌 技术名片](#📌 技术名片)
- [💡 一个简单比喻](#💡 一个简单比喻)
- [一、AI 最容易犯的错误:用高频轮询模拟实时](#一、AI 最容易犯的错误:用高频轮询模拟实时)
- 二、架构规范
-
- [1. 任务和流式传输要解耦](#1. 任务和流式传输要解耦)
- [2. 统一事件格式](#2. 统一事件格式)
- [3. SSE 生命周期必须完整](#3. SSE 生命周期必须完整)
- [4. 关闭缓存和代理缓冲](#4. 关闭缓存和代理缓冲)
- [5. 任务执行和任务观察要分离](#5. 任务执行和任务观察要分离)
- [6. 为什么不要滥用 WebSocket](#6. 为什么不要滥用 WebSocket)
- 三、长连接与流式推送有什么好处?
-
- [1. 减少无效请求](#1. 减少无效请求)
- [2. 降低感知延迟](#2. 降低感知延迟)
- [3. 非常适合 AI 系统](#3. 非常适合 AI 系统)
- [四、提示词落地:直接约束 AI](#四、提示词落地:直接约束 AI)
- [五、正面产出:miniagent(https://github.com/liupras/miniagent) 的真实 SSE 实现](#五、正面产出:miniagent 的真实 SSE 实现)
-
- [1. 每个任务拥有独立事件队列](#1. 每个任务拥有独立事件队列)
- [2. 业务层只负责发布事件](#2. 业务层只负责发布事件)
- [3. SSE Endpoint 持续消费事件](#3. SSE Endpoint 持续消费事件)
- [4. 30 秒无事件时发送 Keepalive](#4. 30 秒无事件时发送 Keepalive)
- [5. 完成或失败后自动关闭并清理](#5. 完成或失败后自动关闭并清理)
- [6. 禁止缓存与代理缓冲](#6. 禁止缓存与代理缓冲)
- [六、miniagent(https://github.com/liupras/miniagent) 的 SSE 架构](#六、miniagent 的 SSE 架构)
- [七、给 AI 的检查清单](#七、给 AI 的检查清单)
- 总结
- 开源代码
在 Web 系统里,有一类需求特别容易被 AI 写成"能跑,但很笨"的实现:
text
前端:
每 1 秒问一次服务器
"任务完成了吗?"
服务器:
"没有。"
1 秒后再问:
"完成了吗?"
这种方式叫:
Polling:轮询
偶尔查询一次状态没有问题,但如果是 AI 流式回答、OCR 进度、文档解析、知识库构建、后台任务状态等实时场景,高频轮询会产生大量无效请求。
更合适的方案通常是:
SSE(Server-Sent Events,服务器发送事件)
或者:
WebSocket:Web 套接字,全双工长连接通信协议
核心思想只有一句:
不要让客户端不停问"有消息了吗",而是让服务器有消息时主动推送。
📌 技术名片
Long-lived Connection:长连接
客户端和服务器建立连接后,不立即关闭,而是在一段时间内持续保持连接,用于后续数据传输。
常见方案主要有两种。
SSE
SSE(Server-Sent Events,服务器发送事件) 基于 HTTP,主要用于:
text
Server
↓
Client
适合:
- AI Token 流式输出;
- 后台任务进度;
- 日志流;
- 状态通知。
WebSocket
WebSocket:Web 套接字协议 支持:
text
Client
↕
Server
也就是:
Full-Duplex Communication:全双工通信
更适合:
- 在线聊天;
- 多人协作;
- 实时游戏;
- 双向实时控制。
简单判断:
只需要服务器推给客户端,优先 SSE;双方都要高频主动发消息,再考虑 WebSocket。
💡 一个简单比喻
轮询像不停给维修店打电话:
text
你:修好了吗?
店员:没有。
1 分钟后:
你:修好了吗?
店员:还没有。
而服务器推送更像:
text
你:修好了通知我。
......
店员:修好了。
所以可以记住:
text
Polling
客户端不断问
SSE
服务器有消息就推送
WebSocket
双方随时都能讲话
一、AI 最容易犯的错误:用高频轮询模拟实时
假设让 AI 实现:
上传 PDF 后实时显示处理进度。
它很容易生成:
http
POST /tasks
GET /tasks/{task_id}/status
然后前端:
javascript
setInterval(async () => {
const result = await getTaskStatus(taskId)
}, 1000)
如果 100 个用户每秒查询一次,服务器每秒就多出:
text
100 HTTP Requests
一个任务持续 60 秒:
text
100 × 60 = 6000 次请求
但真正发生状态变化的次数可能只有几十次。
因此:
不要用高频轮询模拟实时推送。
二、架构规范
1. 任务和流式传输要解耦
不推荐:
text
DocumentService
↓
直接控制 StreamingResponse
↓
直接 yield SSE
更合理的是:
text
Business Task
↓
Progress Event
↓
Async Queue
↓
SSE Endpoint
↓
Browser
也就是说:
任务负责产生事件,SSE 接口负责发送事件。
这样业务逻辑不需要知道 HTTP 协议细节。
2. 统一事件格式
不同任务不要各自定义:
json
{"percent": 30}
或者:
json
{"status": "almost_done"}
推荐统一成:
json
{
"stage": "embed",
"message": "Embedding chunks...",
"progress": 72.5,
"done": false,
"error": false,
"ts": "2026-08-17T10:00:00"
}
含义:
text
stage
当前阶段
message
状态说明
progress
进度 0~100
done
是否完成
error
是否失败
ts
事件时间
这样前端可以复用同一套进度组件。
3. SSE 生命周期必须完整
一个可靠的 SSE 实现至少要考虑四件事:
text
创建 Queue
↓
持续发送 Event
↓
Keepalive 保活
↓
done / error 后关闭并清理
Keepalive:保活
长时间没有数据时,中间代理可能误以为连接失效。
因此可以周期性发送:
text
: keepalive
明确结束条件
任务完成:
text
done = true
任务失败:
text
error = true
都应该结束流。
清理资源
连接关闭后要移除任务队列,否则长期运行可能造成内存积累。
队列必须有上限
例如:
python
asyncio.Queue(maxsize=200)
防止客户端消费过慢时事件无限堆积。
这与:
Backpressure:背压
机制有关,即消费者跟不上生产者时,系统要有办法限制积压。
4. 关闭缓存和代理缓冲
SSE 常见问题之一是:
服务器已经持续:
python
yield event
浏览器却迟迟收不到,最后一次性收到很多条。
通常是代理缓冲造成的,因此常见响应配置是:
http
Cache-Control: no-cache
X-Accel-Buffering: no
目的很简单:
事件产生后尽快送到客户端,而不是积攒后再发送。
5. 任务执行和任务观察要分离
对于 OCR、文档处理、Embedding 等长任务,更合理的结构是:
text
Client
↓
Start Task
↓
task_id
Background Task
↓
持续产生 Progress Event
Client
↓
SSE /tasks/{task_id}/progress
即:
任务执行负责做事,SSE 负责观察进度。
两者通过 task_id 关联。
6. 为什么不要滥用 WebSocket
WebSocket 功能更强,但也意味着需要额外处理:
- 心跳;
- 重连;
- 身份认证;
- 连接管理;
- 多实例路由;
- 消息顺序;
- 广播;
- 断线清理。
如果业务只是:
text
20%
40%
80%
100%
这种单向进度推送,SSE 往往更简单。
所以:
架构不是 WebSocket 越多越先进,而是协议与通信模式匹配。
三、长连接与流式推送有什么好处?
1. 减少无效请求
轮询:
text
Client → Server
Client → Server
Client → Server
SSE:
text
Client ───────── Server
↓
20%
↓
50%
↓
100%
只有真正有变化时才推送业务事件。
2. 降低感知延迟
Perceived Latency:感知延迟
AI 一次回答即使总耗时仍是 10 秒:
传统模式:
text
等待 10 秒
↓
完整答案突然出现
流式模式:
text
第 1 秒开始看到内容
↓
持续输出
↓
第 10 秒结束
用户会明显感觉系统更快。
3. 非常适合 AI 系统
AI 系统天然包含:
text
LLM Token Stream
文档处理进度
Embedding 进度
Web Search 状态
工具执行过程
Agent 执行状态
所以流式推送通常是 Agent 系统的重要基础能力。
四、提示词落地:直接约束 AI
可以把以下规则写进:
Project Rules:项目级规则
text
## 流式传输与长连接规则
1. 当服务器推送更合适时,请勿使用高频轮询来获取实时进度。
2. 优先使用 SSE 进行服务器到客户端的流式传输:
- AI 令牌流式传输
- 后台任务进度
- OCR/PDF 处理进度
- 日志
- 通知
3. 仅在需要频繁双向通信时才使用 WebSocket
4. 将任务执行与流式传输分离。
推荐架构:任务 → 进度事件 → 异步队列 → SSE 端点 → 客户端
5. 使用统一的事件模式:
{
stage,
message,
progress,
done,
error,
ts
}
6. SSE 必须支持 keepalive。
7. 当 done=true 或 error=true 时关闭流。
8. 终止后清理队列和资源。
9. 事件队列必须具有有限的容量。
10. 必要时禁用 SSE 的响应缓冲。
11. 在生成轮询代码之前,请检查 SSE或 WebSocket 哪种方式更合适。
这比一句:
"帮我实现实时进度。"
更能避免 AI 下意识生成 setInterval()。
五、正面产出:miniagent 的真实 SSE 实现
miniagent 已经实现了一套完整的任务进度 SSE 架构:
text
Task
↓
ProgressTracker
↓
asyncio.Queue
↓
SSE Endpoint
↓
Browser
1. 每个任务拥有独立事件队列
miniagent 的 ProgressTracker 使用:
python
class ProgressTracker:
_queues: dict[str, asyncio.Queue] = {}
@classmethod
def create(cls, task_id: str) -> asyncio.Queue:
q: asyncio.Queue = asyncio.Queue(maxsize=200)
cls._queues[task_id] = q
return q
每个任务对应自己的 Queue:
text
task_001 → Queue A
task_002 → Queue B
task_003 → Queue C
并通过:
python
maxsize=200
限制事件积压。
2. 业务层只负责发布事件
miniagent 通过:
python
await ProgressTracker.emit(
task_id,
stage="embed",
message="Embedding chunks...",
progress=72.5,
)
发布统一结构:
python
{
"stage": stage,
"message": message,
"progress": round(progress, 1),
"done": done,
"error": error,
"ts": datetime.now().isoformat(),
}
任务本身不需要操作 StreamingResponse,实现了业务逻辑与 SSE 传输层解耦。
3. SSE Endpoint 持续消费事件
miniagent 提供:
http
GET /{task_id}/progress
核心逻辑:
python
queue = ProgressTracker.get(task_id)
event = await asyncio.wait_for(
queue.get(),
timeout=30.0,
)
有新进度时,Queue 立即把事件交给 SSE Endpoint。
4. 30 秒无事件时发送 Keepalive
miniagent 在超时时:
python
except asyncio.TimeoutError:
yield ": keepalive\n\n"
让长连接在任务暂时没有新状态时仍保持活跃。
5. 完成或失败后自动关闭并清理
发送事件:
python
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
然后判断:
python
if event.get("done") or event.get("error"):
break
最后:
python
finally:
ProgressTracker.remove(task_id)
也就是说完整生命周期是:
text
Create Queue
↓
Emit Progress
↓
SSE Push
↓
done / error
↓
Close Stream
↓
Remove Queue
6. 禁止缓存与代理缓冲
最终 miniagent 返回:
python
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
其中:
text
text/event-stream
表示 SSE 数据流。
而:
text
Cache-Control: no-cache
X-Accel-Buffering: no
用于减少缓存和反向代理缓冲对实时性的影响。
六、miniagent 的 SSE 架构
#mermaid-svg-DeVumR8OzPbpVXv1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DeVumR8OzPbpVXv1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DeVumR8OzPbpVXv1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DeVumR8OzPbpVXv1 .error-icon{fill:#552222;}#mermaid-svg-DeVumR8OzPbpVXv1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DeVumR8OzPbpVXv1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DeVumR8OzPbpVXv1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DeVumR8OzPbpVXv1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DeVumR8OzPbpVXv1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DeVumR8OzPbpVXv1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DeVumR8OzPbpVXv1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DeVumR8OzPbpVXv1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DeVumR8OzPbpVXv1 .marker.cross{stroke:#333333;}#mermaid-svg-DeVumR8OzPbpVXv1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DeVumR8OzPbpVXv1 p{margin:0;}#mermaid-svg-DeVumR8OzPbpVXv1 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DeVumR8OzPbpVXv1 .cluster-label text{fill:#333;}#mermaid-svg-DeVumR8OzPbpVXv1 .cluster-label span{color:#333;}#mermaid-svg-DeVumR8OzPbpVXv1 .cluster-label span p{background-color:transparent;}#mermaid-svg-DeVumR8OzPbpVXv1 .label text,#mermaid-svg-DeVumR8OzPbpVXv1 span{fill:#333;color:#333;}#mermaid-svg-DeVumR8OzPbpVXv1 .node rect,#mermaid-svg-DeVumR8OzPbpVXv1 .node circle,#mermaid-svg-DeVumR8OzPbpVXv1 .node ellipse,#mermaid-svg-DeVumR8OzPbpVXv1 .node polygon,#mermaid-svg-DeVumR8OzPbpVXv1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DeVumR8OzPbpVXv1 .rough-node .label text,#mermaid-svg-DeVumR8OzPbpVXv1 .node .label text,#mermaid-svg-DeVumR8OzPbpVXv1 .image-shape .label,#mermaid-svg-DeVumR8OzPbpVXv1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-DeVumR8OzPbpVXv1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DeVumR8OzPbpVXv1 .rough-node .label,#mermaid-svg-DeVumR8OzPbpVXv1 .node .label,#mermaid-svg-DeVumR8OzPbpVXv1 .image-shape .label,#mermaid-svg-DeVumR8OzPbpVXv1 .icon-shape .label{text-align:center;}#mermaid-svg-DeVumR8OzPbpVXv1 .node.clickable{cursor:pointer;}#mermaid-svg-DeVumR8OzPbpVXv1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DeVumR8OzPbpVXv1 .arrowheadPath{fill:#333333;}#mermaid-svg-DeVumR8OzPbpVXv1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DeVumR8OzPbpVXv1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DeVumR8OzPbpVXv1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DeVumR8OzPbpVXv1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DeVumR8OzPbpVXv1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DeVumR8OzPbpVXv1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DeVumR8OzPbpVXv1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DeVumR8OzPbpVXv1 .cluster text{fill:#333;}#mermaid-svg-DeVumR8OzPbpVXv1 .cluster span{color:#333;}#mermaid-svg-DeVumR8OzPbpVXv1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DeVumR8OzPbpVXv1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DeVumR8OzPbpVXv1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-DeVumR8OzPbpVXv1 .icon-shape,#mermaid-svg-DeVumR8OzPbpVXv1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DeVumR8OzPbpVXv1 .icon-shape p,#mermaid-svg-DeVumR8OzPbpVXv1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DeVumR8OzPbpVXv1 .icon-shape .label rect,#mermaid-svg-DeVumR8OzPbpVXv1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DeVumR8OzPbpVXv1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DeVumR8OzPbpVXv1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DeVumR8OzPbpVXv1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} emit()
GET /tasks/{id}/progress
await queue.get()
data: JSON
30s timeout
done / error
Document / OCR / KB Task
ProgressTracker
asyncio.Queue
maxsize = 200
Browser
FastAPI SSE Endpoint
keepalive
Close Stream
Remove Queue
这套结构最重要的边界是:
text
任务生产者
↓
事件队列
↓
SSE 传输层
↓
客户端
而不是让任务代码直接控制 HTTP Response。
下面是更详细的SSE实现数据流图:
#mermaid-svg-aToAUULvjB2gUwr1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-aToAUULvjB2gUwr1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aToAUULvjB2gUwr1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aToAUULvjB2gUwr1 .error-icon{fill:#552222;}#mermaid-svg-aToAUULvjB2gUwr1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aToAUULvjB2gUwr1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aToAUULvjB2gUwr1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aToAUULvjB2gUwr1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aToAUULvjB2gUwr1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aToAUULvjB2gUwr1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aToAUULvjB2gUwr1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aToAUULvjB2gUwr1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aToAUULvjB2gUwr1 .marker.cross{stroke:#333333;}#mermaid-svg-aToAUULvjB2gUwr1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aToAUULvjB2gUwr1 p{margin:0;}#mermaid-svg-aToAUULvjB2gUwr1 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-aToAUULvjB2gUwr1 .cluster-label text{fill:#333;}#mermaid-svg-aToAUULvjB2gUwr1 .cluster-label span{color:#333;}#mermaid-svg-aToAUULvjB2gUwr1 .cluster-label span p{background-color:transparent;}#mermaid-svg-aToAUULvjB2gUwr1 .label text,#mermaid-svg-aToAUULvjB2gUwr1 span{fill:#333;color:#333;}#mermaid-svg-aToAUULvjB2gUwr1 .node rect,#mermaid-svg-aToAUULvjB2gUwr1 .node circle,#mermaid-svg-aToAUULvjB2gUwr1 .node ellipse,#mermaid-svg-aToAUULvjB2gUwr1 .node polygon,#mermaid-svg-aToAUULvjB2gUwr1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-aToAUULvjB2gUwr1 .rough-node .label text,#mermaid-svg-aToAUULvjB2gUwr1 .node .label text,#mermaid-svg-aToAUULvjB2gUwr1 .image-shape .label,#mermaid-svg-aToAUULvjB2gUwr1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-aToAUULvjB2gUwr1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-aToAUULvjB2gUwr1 .rough-node .label,#mermaid-svg-aToAUULvjB2gUwr1 .node .label,#mermaid-svg-aToAUULvjB2gUwr1 .image-shape .label,#mermaid-svg-aToAUULvjB2gUwr1 .icon-shape .label{text-align:center;}#mermaid-svg-aToAUULvjB2gUwr1 .node.clickable{cursor:pointer;}#mermaid-svg-aToAUULvjB2gUwr1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-aToAUULvjB2gUwr1 .arrowheadPath{fill:#333333;}#mermaid-svg-aToAUULvjB2gUwr1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-aToAUULvjB2gUwr1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-aToAUULvjB2gUwr1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aToAUULvjB2gUwr1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-aToAUULvjB2gUwr1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aToAUULvjB2gUwr1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-aToAUULvjB2gUwr1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-aToAUULvjB2gUwr1 .cluster text{fill:#333;}#mermaid-svg-aToAUULvjB2gUwr1 .cluster span{color:#333;}#mermaid-svg-aToAUULvjB2gUwr1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-aToAUULvjB2gUwr1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-aToAUULvjB2gUwr1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-aToAUULvjB2gUwr1 .icon-shape,#mermaid-svg-aToAUULvjB2gUwr1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aToAUULvjB2gUwr1 .icon-shape p,#mermaid-svg-aToAUULvjB2gUwr1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-aToAUULvjB2gUwr1 .icon-shape .label rect,#mermaid-svg-aToAUULvjB2gUwr1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aToAUULvjB2gUwr1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-aToAUULvjB2gUwr1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-aToAUULvjB2gUwr1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1. Start Task
2. return task_id
3. create task
4. emit()
5. GET /{task_id}/progress
6. await queue.get()
7. data: JSON
30s timeout
progress / stage / message
done / error
8. done / error
Browser / Frontend
FastAPI Task API
Document / OCR / KB Task
ProgressTracker
asyncio.Queue
task_id → Queue
maxsize=200
FastAPI SSE Endpoint
Keepalive
Close Stream
9. Remove Queue
七、给 AI 的检查清单
以后让 AI 实现实时功能时,可以要求它检查:
text
Streaming Architecture Checklist
□ 这里真的需要轮询吗?
□ 如果只是 Server → Client,是否优先使用 SSE?
□ 是否真的需要双向 WebSocket?
□ 任务执行是否与 Streaming 解耦?
□ 是否使用统一事件格式?
□ Queue 是否有容量限制?
□ SSE 是否有 keepalive?
□ done / error 后是否结束?
□ 结束后是否清理资源?
□ 是否禁用了缓存和代理缓冲?
总结
AI 很容易生成:
text
setInterval
↓
GET /status
↓
没完成
↓
再请求
对于实时进度、AI 输出和长任务,这往往会产生大量无效请求。
更加合理的设计是:
text
后台任务
↓
Progress Event
↓
Async Queue
↓
SSE
↓
Browser
只有真正需要频繁双向通信时,再使用:
text
Client
↕
WebSocket
↕
Server
miniagent 当前已经通过 ProgressTracker、有界 asyncio.Queue、FastAPI StreamingResponse、30 秒 Keepalive、done/error 终止以及 Queue 清理,形成了一套完整的 SSE 任务进度推送链路。
对于 AI 编程,我们要避免的不只是错误代码,还包括这种架构浪费:
明明服务器可以主动告诉你结果,却让客户端不停地问:"好了吗?"
这正是长连接与流式推送存在的价值。
开源代码
🪐祝您好运🪐