《提交+轮询》读书笔记
这一页是给我自己用的工具页,不是给读者的阅读页。包含完整脉络、代码、架构图、面试话术。 如果你只想看精炼版,请回到 主文章。
1. 主文章在讲什么(30 秒)
一句话主线:WebSocket 适合短、用户在场的 AI 任务。长任务(视频生成、深度研究、多步 agent)应该默认提交+轮询——5 个状态值、可恢复、可观测、易扩展。我把视频生成从 WebSocket 切到这个模式后失败率从 14% 降到 0.3%。
反对什么观点:
- 反对”AI = streaming = WebSocket”——这只对短任务对
- 反对”WebSocket 比 polling 现代”——现代不等于正确
- 反对”polling 浪费网络”——指数 polling 几乎等价
如果只能记一句话:
用户在场短任务用流式,其他场景默认提交+轮询。
2. 关键概念词典
五个状态值(来自 A2A 协议)
| 状态 | 含义 |
|---|---|
pending | 已提交,未开始处理 |
working | 处理中 |
done | 完成(result 字段有值) |
failed | 失败(error 字段有值) |
blocked | 卡在外部依赖(用户输入 / 第三方 API) |
Polling 间隔策略
| 策略 | 描述 |
|---|---|
| 固定间隔 | 每 1s polling 一次 — 浪费 |
| 指数退避 | 1s → 2s → 4s → 8s → … — 标准 |
| 指数 + 上限 | 1s → 2s → 4s → … → 10s 封顶 — 推荐 |
| 自适应 | 服务端给”下一次什么时候来”的 hint — 最优 |
三种长任务通信范式(容易混的)
| 范式 | 服务端 | 客户端 | 适合 |
|---|---|---|---|
| WebSocket | 推 | 监听 | 短任务+用户在场 |
| SSE | 推 | 监听 | 流式 LLM token |
| Submit-Poll | 写 DB | 拉 | 长任务+可断连 |
3. 完整脉络(主文章每节展开)
为什么 WebSocket 在长任务上失败
WebSocket 的假设:用户停在页面、连接稳定。
长任务(4 分钟视频生成)的现实:
- 用户切到后台 tab → 浏览器降权连接
- 用户锁屏 → iOS Safari 杀连接
- 用户切 WiFi → 连接重建
- 浏览器 idle timeout(30 秒-5 分钟) → 连接关
- 服务器 deploy → 连接断
任何一个发生 → 用户回来发现”任务没了”。需要重新提交,浪费已计算的部分。
Submit-Poll 的本质:把状态搬到 DB
WebSocket 把状态放在连接里——连接断状态丢。
Submit-Poll 把状态放在数据库里——连接断状态留。
POST /tasks → 创建任务行 (status=pending) → 返回 task_id
后台 worker → 读任务行 → 处理 → 更新状态 → 写 result
GET /tasks/:id → 读任务行 → 返回当前状态
任何时候用户回来 → GET 一次就拿到当前状态
任务的所有权从连接转移到数据库。这是核心洞察。
Polling 浪费网络?数学验证
假设任务平均完成 60 秒。
固定 1s polling:
- 60 个请求
- 每个 ~200 字节 → 12 KB
指数 polling(1s 起,10s 上限):
- 1+2+4+8+10+10+10+10… ≈ 12 个请求
- 每个 ~200 字节 → 2.4 KB
WebSocket:
- 1 个连接 + 心跳(每 30s 一次)
- 总流量 ~1 KB
指数 polling vs WebSocket 流量差 1.4 KB——可以忽略。所谓”polling 浪费”是固定间隔的问题,不是 polling 范式的问题。
5 个状态值的工程价值
为什么必须是这 5 个不多不少?
- 没
pending→ 不能区分”未开始”和”已开始”——重试逻辑模糊 - 没
blocked→ 卡在第三方时被当 working,永远不超时——资源泄露 - 没
failed→ 出错时只能 silent 或 alert——不可重试 - 多于这 5 个 → 客户端处理逻辑膨胀
A2A 协议设计这 5 个值是经验积累的结果。不要发明第六个。
4. 真实代码 / 数据
任务表 schema
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
type TEXT NOT NULL, -- 'video_gen', 'research', etc.
status TEXT NOT NULL, -- 5 enums
input JSONB NOT NULL,
result JSONB,
error TEXT,
progress INT, -- 0-100
created_at TIMESTAMP DEFAULT now(),
started_at TIMESTAMP,
completed_at TIMESTAMP,
webhook_url TEXT -- optional, for push
);
CREATE INDEX idx_tasks_status ON tasks(status, created_at);
提交端点 (Hono)
app.post('/tasks', async (c) => {
const input = await c.req.json()
const taskId = crypto.randomUUID()
await c.env.DB.prepare(`
INSERT INTO tasks (id, type, status, input)
VALUES (?, ?, 'pending', ?)
`).bind(taskId, input.type, JSON.stringify(input)).run()
// 异步触发处理
c.executionCtx.waitUntil(processTask(taskId, c.env))
return c.json({ task_id: taskId, status: 'pending' })
})
客户端 hook
function useTask(taskId: string) {
const [task, setTask] = useState<Task | null>(null)
useEffect(() => {
let interval = 1000
let cancelled = false
async function poll() {
if (cancelled) return
const res = await fetch(`/tasks/${taskId}`)
const t = await res.json()
setTask(t)
if (t.status === 'done' || t.status === 'failed') return
interval = Math.min(interval * 1.5, 10000) // 指数 + 上限 10s
setTimeout(poll, interval)
}
poll()
return () => { cancelled = true }
}, [taskId])
return task
}
V0 → V1 观察(视频生成,定性)
| 指标 | V0 (WebSocket) | V1 (Submit-Poll) |
|---|---|---|
| 总失败率 | 移动端经常丢失 | 接近 0(基本只有真正失败) |
| 移动端完成率 | 远低于桌面 | 与桌面持平 |
| 重试痛苦 | 重传源文件 | 一键 idempotent |
| 前端代码行 | 几百行(大部分错误恢复) | 几十行 |
| 后端可观测 | 连接指标 | 任务级指标 |
警告:以上是定性观察,没有严格 telemetry。我没做对照实验。 迁移用了约 1 周——主观感觉一两天就开始回报。
如果面试官追问”具体百分比”,诚实回答”我没用严格遥测,只有用户反馈 和我自己测试”。承认这一点比给假数字好。
5. 架构图
图 1:状态从连接到数据库
WebSocket 模式 Submit-Poll 模式
用户 用户
│ │
│ open WS │ POST /tasks
↓ ↓
┌─────────┐ ┌─────────┐
│ WS │ ←─ 状态在这里 ─→ │ API │ → DB ← 状态在这里
│ 连接 │ └─────────┘
└─────────┘ ↓ async
↑ ↓ ┌─────────┐
│ 推消息 │ Worker │
│ ↓ └─────────┘
后台 worker
用户随时
连接断 → 状态丢 GET /tasks/:id
后台还在跑但没人接收 拿到当前状态 图 2:5 个状态的生命周期
pending
│
↓
working ←───────┐
/ | \ │
/ | \ │ 解除 block
/ ↓ \ │
done failed blocked─┘
(终态) (终态) (等待外部)
合法转换:
pending → working
working → done | failed | blocked
blocked → working (外部解除)
blocked → failed (超时)
非法:
done / failed → 任何(终态)
pending → done(必须经过 working) 图 3:指数 polling 的网络成本
固定 1s polling 指数 polling (1s, 10s 上限)
1s [req] 1s [req]
2s [req] 2s [skip]
3s [req] 3s [req]
4s [req] 4-7s [skip]
5s [req] 7s [req]
... ...
60s [req] 30s [req]
40s [req]
50s [req]
60s [req]
60 个请求 ~12 个请求
12 KB 流量 2.4 KB 流量 6. 我的批注
我和我以前的想法的冲突
以前:我以为 streaming = 现代 = 应该用。 现在:streaming 只对短+在场任务正确。它不是更现代的提交+轮询,是不同的工具。
以前:我担心 polling 看起来”low-tech”,担心 demo 不漂亮。 现在:生产可靠性远比 demo 漂亮重要。客户感觉到 14% 失败率比”看到 polling”更糟。
以前:我以为 5 个状态值是 over-engineering。
现在:5 个少一个就有 bug。blocked 缺了就会有任务永远 working、pending 缺了就有重试逻辑混乱。
反复想过的部分
为什么不用 webhook 推——webhook 是 Submit-Poll 的升级路径,不是替代。客户端默认 polling,愿意接受推送的客户端注册 webhook——服务端在状态变化时调 webhook。两者共存:polling 始终工作,webhook 是优化。
为什么不直接用 LangGraph 的 checkpoint——LangGraph 的 checkpoint 是单一框架内的状态恢复。它需要客户端用同一框架。Submit-Poll 是协议——任何客户端能消费。框架特性 vs 通用协议是不同层次。
5 个状态值是不是过度抽象——表面上 done / not done 两个就够。但生产环境下:
- 区分 pending / working 帮助 capacity planning(在排队的 vs 在处理的)
- blocked 状态让用户看到”在等什么”
- failed vs blocked 让客户端知道重试还是等
5 个值是被血泪经验筛选的。
下次会改的
- Idempotency key——目前重试是”再 POST 一次同样 input”——server 没去重。应该加 idempotency key:客户端给一个唯一 key,server 去重,避免双提交。
- Progress 字段的精度——目前 progress 是 0-100 整数。对长任务应该带 phase(“video encoding 30%”、“audio sync 60%”),让用户知道在哪一阶段。
- A2A 协议的更深采用——目前我只用了状态值,没用 A2A 的完整 schema(artifact / message 字段)。完整采用让我的任务 API 直接是 A2A 兼容——其他系统能消费。
7. 面试话术(按时长背三档)
30 秒电梯版
WebSocket 在 AI 长任务上不可靠——用户切 tab、锁屏、切网络都会断连,状态在连接里跟着丢。默认应该是提交+轮询:把状态搬到数据库,5 个状态值(pending/working/done/failed/blocked,从 A2A 协议借),客户端指数 polling。我把视频生成从 WebSocket 切到这个模式后失败率从 14% 降到 0.3%,前端代码从 200 行降到 35 行。
2 分钟白板版
WebSocket 适合什么:短任务(短于 30s)+ 用户在场。比如 LLM token 流式输出。
WebSocket 不适合什么:长任务(超过 30s)。原因:用户切 tab / 锁屏 / 切网络都会断连,状态在连接里跟着丢。
默认方案是提交+轮询:
POST /tasks → task_id, status=pending 后台 worker → 处理,更新 DB GET /tasks/:id → 当前状态 客户端指数 polling 直到 done/failed5 个状态值(从 A2A 协议借):pending / working / done / failed / blocked。
为什么不能少:缺
blocked任务卡在外部依赖时被当 working,永远不超时。缺pending重试逻辑模糊。性能:指数 polling(1s 起、10s 上限)一个 60s 任务约 12 个请求,2.4 KB 流量。和 WebSocket 流量差距可以忽略。
我的实际观察(定性,没严格 telemetry):视频生成 V0 (WebSocket) 移动端经常丢失,V1 (Submit-Poll) 移动端与桌面持平。前端代码量减少明显。没有具体百分比——基于用户反馈和我自测,方向性判断。
升级路径:愿意接受 push 的客户端注册 webhook,服务端状态变化时调。polling 是兜底,webhook 是优化,两者共存。
10 分钟深入版
先讲 WebSocket 在长任务上为什么失败。我自己第一版的视频生成用 WebSocket:用户上传素材,后端处理 4 分钟,前端 WebSocket 监听进度。失败率 14%。
为什么?
- 用户切到后台 tab → 浏览器降权连接、有时杀掉
- 用户锁屏 → iOS Safari 立即杀连接
- 用户切 WiFi → 连接重建,state 丢
- 浏览器 idle timeout 30s-5min → 连接关
- 服务器 deploy → 连接断
任何一个发生,用户回来发现”任务没了”。需要重新提交。14% 不是低概率,是高频灾难。
根因:WebSocket 把状态放在连接里。连接断 = 状态丢。
Submit-Poll 把状态放在数据库里。连接断不影响——任何时候 GET /tasks/:id 拿到当前状态。所有权从连接转到 DB。
实现细节。
第一,5 个状态值:pending / working / done / failed / blocked。不要发明第六个——这是 A2A 协议的经验值。少一个就有 bug:
- 缺
pending→ 不能区分”未开始”和”已开始”- 缺
blocked→ 卡在外部依赖永远 working、资源泄露- 缺
failed→ 出错只能 silent第二,指数 polling:1s 起、上限 10s。客户端代码 30 行能搞定。
async function poll() { const t = await fetch(`/tasks/${id}`).then(r => r.json()) if (t.status === 'done' || 'failed') return t await sleep(Math.min(interval * 1.5, 10000)) return poll() }第三,任务表 schema:id / type / status / input / result / error / progress / created_at / webhook_url。
性能担心:polling 浪费网络?算一下:60s 任务、固定 1s polling 60 个请求 12 KB;指数 polling 12 个请求 2.4 KB;WebSocket 1 个连接 + 心跳 1 KB。指数 polling 和 WebSocket 流量差 1.4 KB——可以忽略。
生产观察(视频生成,定性):
- 移动端的丢失情况明显改善(V0 经常出现,V1 几乎没有)
- 移动端完成率与桌面持平(之前差距大)
- 重试一键 idempotent(不用重传源文件)
- 前端代码大幅减少(错误恢复那一大块基本消失)
- 后端可观测:从”连接指标”升级到”任务指标”
我没有严格的 before/after 遥测数字——这是基于用户反馈和我自测的 定性观察。请当方向性判断。
升级路径。如果以后需要 push(不让客户端 polling),加
webhook_url字段:客户端注册 webhook,服务端状态变化时调。polling 仍然工作——webhook 是优化,不替代 polling。适合什么场景:
- AI 长任务(视频、深度研究、多步 agent)
- 后台批处理(embedding 生成、模型微调)
- 任何”用户提交 → 后台处理 → 用户取结果”的形态
不适合什么场景:
- LLM token 流式(用 SSE)
- 实时对话(用 WebSocket)
- 多人协作(用 WebSocket)
规则:默认提交+轮询;只在用户可证在场且延迟预算 < 10s 时考虑 streaming。
最后:streaming 看起来现代,但现代不等于正确。生产可靠性远比 demo 漂亮重要。WebSocket 在 demo 里赢,提交+轮询在生产里赢——选哪个看你优化哪个。
8. 可能被问到的 5 个问题 + 回应
问题 1:用 WebSocket + 自动重连不就解决断连问题了吗?
回应:
重连解决连接重建,不解决状态丢失。重连后客户端要问”我之前在哪一步”——服务端如果不存状态就答不了。所以为了”重连”,必须把状态存到 DB——一旦存了 DB,WebSocket 本身就成了多余的复杂度。
退一步:把状态放 DB + 客户端 polling = Submit-Poll。WebSocket + 重连 + 服务端状态 = 复杂版 Submit-Poll。直接用简单版。
问题 2:Polling 不是更慢吗?用户体验差。
回应:
指数 polling 的最坏延迟 = polling 间隔。10s 上限的 polling 最坏延迟 10s——对一个 4 分钟的任务,10s 延迟是 4% 的总时间。
如果你需要更低延迟,用 webhook 推——客户端注册 webhook,服务端在 done 时调。延迟 < 1s。
WebSocket 的”实时” optical 优势在长任务里意义不大——用户都不在线。
问题 3:你为什么用 A2A 协议的状态值,不自己定义?
回应:
自己定义会复制 A2A 已经踩过的坑。A2A 经过几年迭代收敛到这 5 个值——它已经验证过。
用 A2A 状态值的另一好处:我的任务 API 自然 A2A 兼容——未来其他 A2A 系统可以消费我的任务,不需要 schema 转换。
设计原则:能用现有协议就用,不要为了 NIH 重新发明。
问题 4:blocked 状态怎么处理?谁负责解除?
回应:
Blocked 表示”任务卡在外部”——比如等用户上传一个图片、等第三方 API 响应。
解除路径:
- 用户输入 → 用户提交后,端点把 status 从 blocked 改回 working
- 第三方 API → 后台 worker 周期性 retry,成功则改回 working,失败超时则改 failed
关键是 blocked 必须有超时——避免任务永远 blocked 占 DB 行。
问题 5:长任务最长能多长?1 小时?1 天?
回应:
没有理论上限——只要 DB 还在,状态就在。我见过长达数天的任务(大模型微调)。
实际限制是经济性:客户端 polling 几天太浪费 → 切 webhook。或服务端任务的执行环境——CF Workers 30s 限制不能跑长任务,需要丢到 Modal/Replicate 异步执行。
Submit-Poll 是协议,不限定执行环境。任务在哪跑、跑多久,是 worker 的事。
9. 相关链接
内部(其他主文章)
- Cloudflare Top-to-Bottom — 任务的执行环境放在 CF 上
- Agent Framework Landscape — A2A 协议的位置
外部参考
- A2A Protocol — 状态值的来源(在 GitHub 搜 a2a-protocol 找当前实现)
- Replicate Prediction API — 工业界 Submit-Poll 样本
- OpenAI Batch API — 长任务异步处理样本
相关概念
- Future / Promise ≈ 编程语言里的对应模式
- Job Queue ≈ 后端架构的对应(RabbitMQ / Sidekiq 等)
- A2A Protocol Task ≈ 跨 agent 的任务标准