
当你调一次 LLM API 要等 30 秒到 5 分钟,同步接口就扛不住了。本文分享我在生产环境从同步改异步、从轮询改 Webhook 的完整演进过程,附完整 Python + FastAPI + Redis 实现。
一、让我意识到"同步不行了"的场景
先上代码。这是大多数 LLM 项目最初的实现:
python
# 最 naive 的实现------同步等待
from openai import OpenAI
import time
client = OpenAI(api_key="sk-xxx", base_url="https://api.example.com/v1")
def analyze_document(doc_text: str) -> str:
"""分析一份文档,返回结构化摘要"""
start = time.time()
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "你是文档分析专家,返回 JSON 格式摘要"},
{"role": "user", "content": f"分析以下文档:\n\n{doc_text[:8000]}"}
],
max_tokens=4096
)
elapsed = time.time() - start
print(f"请求耗时: {elapsed:.2f}s")
return response.choices[0].message.content
这段代码跑单条文档的时候一点问题没有------200ms 到 5s 之间的延迟,HTTP 调用天然就该同步。
问题出在 1 变 N 的时候。
第一个客户要求 批量分析 200 篇文档。我当时想,"简单,for 循环跑就行"------
python
results = []
for doc in documents[:200]:
result = analyze_document(doc)
results.append(result)
print(f"已完成 {len(results)}/200")
理想情况:每篇 3 秒,200 篇 = 10 分钟。
实际情况:第 47 篇挂住了。OpenAI API 返回 500,重试 3 次又花了 15 秒,第 83 篇返回了一次 10 秒的慢响应,第 129 篇因为达到每分钟 5000 RPM 的 Rate Limit 直接 429。
最终耗时 37 分钟,其中 12 分钟在等。HTTP 连接池占满,其他业务请求全部排队。
这不是最糟糕的。更糟的是------用户浏览器等超时了。前端配置了 30 秒超时,第 39 秒(第三次重试时)前端已经弹了 "操作失败",但后端还在傻跑。用户刷了几下页面,我们启动了 3 个相同的分析流程,直接打爆了 Rate Limit。
这就是 LLM 应用从"玩具"到"生产"必经的阵痛。当 LLM 调用的响应时间从"秒级"跨越到"分钟级"(长文档分析、批量翻译、Agent 多步推理),同步架构就彻底失效了。
二、异步任务队列的两种架构模式
从同步走向异步,本质上就两件事:
- 不阻塞调用方------任务丢进队列,立刻返回 task_id
- 通知结果------调用方通过轮询或 Webhook 拿到结果
模式一:短任务(请求 < 30 秒)
python
# 模式一:短任务走异步但可以等------用 asyncio 就够了
import asyncio
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key="sk-xxx", base_url="https://api.example.com/v1")
async def analyze_document_async(doc_text: str) -> str:
response = await client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": doc_text[:8000]}]
)
return response.choices[0].message.content
async def batch_analyze(docs: list[str], concurrency: int = 3):
"""用信号量控制并发,比 for 循环快 N 倍"""
semaphore = asyncio.Semaphore(concurrency)
async def worker(doc):
async with semaphore:
return await analyze_document_async(doc)
tasks = [worker(doc) for doc in docs]
return await asyncio.gather(*tasks)
适用场景:单个 LLM 调用 < 30 秒、无需进度反馈、调用方可以等。
模式二:长任务(请求 > 30 秒)
当任务时长不可控时(Agent 多步推理可能 2 分钟,也可能 15 分钟),必须上真正的异步队列:
客户端 → 创建任务 → Redis/RabbitMQ
↕
Worker 进程
↕
LLM API (可能耗时 1-15 分钟)
↕
回调:轮询 OR Webhook → 客户端
这是生产级别的通用模式。下面的实现围绕这个架构展开。
三、完整实现:Redis + FastAPI + 后台 Worker
3.1 数据结构设计
python
# models.py --- 任务模型
from pydantic import BaseModel
from enum import Enum
from datetime import datetime
import uuid
class TaskStatus(str, Enum):
PENDING = "pending" # 等待处理
PROCESSING = "processing" # 正在处理
COMPLETED = "completed" # 已完成
FAILED = "failed" # 失败
CANCELLED = "cancelled" # 取消
class TaskRequest(BaseModel):
"""创建任务的请求体"""
prompt: str
model: str = "gpt-4o"
max_tokens: int = 4096
temperature: float = 0.7
webhook_url: str | None = None # 回调地址
metadata: dict = {} # 透传元数据
class Task(BaseModel):
"""完整的任务对象"""
task_id: str = ""
status: TaskStatus = TaskStatus.PENDING
prompt: str
model: str
max_tokens: int
temperature: float
webhook_url: str | None = None
metadata: dict = {}
result: str | None = None
error: str | None = None
created_at: datetime = datetime.now()
updated_at: datetime = datetime.now()
completed_at: datetime | None = None
retry_count: int = 0
def __init__(self, **data):
if "task_id" not in data or not data["task_id"]:
data["task_id"] = f"task_{uuid.uuid4().hex[:12]}"
super().__init__(**data)
class TaskStatusResponse(BaseModel):
"""轮询接口的响应"""
task_id: str
status: TaskStatus
result: str | None = None
error: str | None = None
elapsed_seconds: float | None = None
3.2 Redis 存储层
用 Redis Hash 存任务,List 做队列。简单可靠,零依赖第三方队列系统。
python
# storage.py --- Redis 存储层
import json
import redis.asyncio as aioredis
from typing import Optional
class RedisTaskStore:
"""用 Redis Hash + List 实现的任务存储和队列"""
TASK_PREFIX = "task:"
QUEUE_KEY = "task_queue"
RETRY_QUEUE = "task_queue:retry"
def __init__(self, redis_url: str = "redis://localhost:6379/0"):
self.redis = aioredis.from_url(redis_url, decode_responses=True)
async def create_task(self, task: Task) -> Task:
"""创建任务并入队"""
key = f"{self.TASK_PREFIX}{task.task_id}"
# 存为 Hash
await self.redis.hset(key, mapping={
"task_id": task.task_id,
"status": task.status.value,
"prompt": task.prompt,
"model": task.model,
"max_tokens": str(task.max_tokens),
"temperature": str(task.temperature),
"webhook_url": task.webhook_url or "",
"metadata": json.dumps(task.metadata),
"created_at": task.created_at.isoformat(),
"updated_at": task.updated_at.isoformat(),
"retry_count": str(task.retry_count),
})
# 设置 TTL:7 天后自动清理
await self.redis.expire(key, 604800)
# 入队
await self.redis.rpush(self.QUEUE_KEY, task.task_id)
return task
async def get_task(self, task_id: str) -> Optional[Task]:
"""获取任务"""
key = f"{self.TASK_PREFIX}{task_id}"
data = await self.redis.hgetall(key)
if not data:
return None
return Task(
task_id=data["task_id"],
status=TaskStatus(data["status"]),
prompt=data["prompt"],
model=data["model"],
max_tokens=int(data.get("max_tokens", 4096)),
temperature=float(data.get("temperature", 0.7)),
webhook_url=data.get("webhook_url") or None,
metadata=json.loads(data.get("metadata", "{}")),
created_at=datetime.fromisoformat(data["created_at"]),
updated_at=datetime.fromisoformat(data["updated_at"]),
completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None,
result=data.get("result"),
error=data.get("error"),
retry_count=int(data.get("retry_count", 0)),
)
async def update_status(self, task_id: str, status: TaskStatus,
result: str = None, error: str = None) -> None:
"""更新任务状态和结果"""
key = f"{self.TASK_PREFIX}{task_id}"
now = datetime.now()
updates = {
"status": status.value,
"updated_at": now.isoformat(),
}
if status in (TaskStatus.COMPLETED, TaskStatus.FAILED):
updates["completed_at"] = now.isoformat()
if result:
updates["result"] = result
if error:
updates["error"] = error
await self.redis.hset(key, mapping=updates)
async def pop_task(self, timeout: int = 5) -> Optional[str]:
"""非阻塞弹出任务(BRPOP)"""
result = await self.redis.blpop(self.QUEUE_KEY, timeout=timeout)
if result:
return result[1] # (queue_name, task_id)
return None
async def requeue(self, task_id: str) -> None:
"""重试------重新入队"""
await self.redis.rpush(self.RETRY_QUEUE, task_id)
3.3 FastAPI 任务 API
python
# api.py --- FastAPI 任务接口
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
import httpx
app = FastAPI(title="LLM Async Task Queue")
# 全局存储实例
store = RedisTaskStore()
@app.post("/tasks", status_code=202)
async def create_task(req: TaskRequest) -> dict:
"""创建异步任务------立刻返回 task_id"""
task = Task(
prompt=req.prompt,
model=req.model,
max_tokens=req.max_tokens,
temperature=req.temperature,
webhook_url=req.webhook_url,
metadata=req.metadata,
)
created = await store.create_task(task)
return {
"task_id": created.task_id,
"status": created.status.value,
"created_at": created.created_at.isoformat(),
"_links": {
"status": f"/tasks/{created.task_id}",
"result": f"/tasks/{created.task_id}/result",
}
}
@app.get("/tasks/{task_id}")
async def get_task(task_id: str) -> TaskStatusResponse:
"""获取任务状态(轮询专用)"""
task = await store.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
elapsed = None
if task.completed_at:
elapsed = (task.completed_at - task.created_at).total_seconds()
return TaskStatusResponse(
task_id=task.task_id,
status=task.status,
result=task.result if task.status == TaskStatus.COMPLETED else None,
error=task.error if task.status == TaskStatus.FAILED else None,
elapsed_seconds=elapsed,
)
@app.post("/tasks/{task_id}/cancel")
async def cancel_task(task_id: str) -> dict:
"""取消任务"""
task = await store.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.status not in (TaskStatus.PENDING, TaskStatus.PROCESSING):
raise HTTPException(status_code=400, detail="只能取消等待中或处理中的任务")
await store.update_status(task_id, TaskStatus.CANCELLED)
return {"task_id": task_id, "status": TaskStatus.CANCELLED.value}
3.4 Worker------真正的"干活"模块
python
# worker.py --- 后台工作进程
import asyncio
import httpx
from openai import AsyncOpenAI
import logging
logger = logging.getLogger("llm_worker")
class LLMTaskWorker:
"""工作进程:从队列取任务 -> 调 LLM -> 存结果 -> 发回调"""
def __init__(self, store: RedisTaskStore, openai_key: str,
base_url: str = "https://api.openai.com/v1",
max_retries: int = 3):
self.store = store
self.client = AsyncOpenAI(api_key=openai_key, base_url=base_url)
self.max_retries = max_retries
self.running = False
async def start(self):
"""启动 worker 循环"""
self.running = True
logger.info("Worker 启动,等待任务...")
while self.running:
try:
task_id = await self.store.pop_task(timeout=10)
if task_id:
await self.process_task(task_id)
except Exception as e:
logger.error(f"Worker 循环异常: {e}", exc_info=True)
await asyncio.sleep(3)
def stop(self):
self.running = False
async def process_task(self, task_id: str):
"""处理单个任务"""
task = await self.store.get_task(task_id)
if not task:
logger.warning(f"任务 {task_id} 不存在,跳过")
return
# 标记为处理中
await self.store.update_status(task_id, TaskStatus.PROCESSING)
logger.info(f"开始处理任务 {task_id} | 模型: {task.model}")
for attempt in range(1, self.max_retries + 1):
try:
response = await self.client.chat.completions.create(
model=task.model,
messages=[{"role": "user", "content": task.prompt}],
max_tokens=task.max_tokens,
temperature=task.temperature,
timeout=300, # 5 分钟超时
)
result = response.choices[0].message.content
# 保存结果
await self.store.update_status(
task_id, TaskStatus.COMPLETED, result=result
)
logger.info(f"任务 {task_id} 完成 | 尝试 {attempt} 次")
# 发 Webhook 回调
if task.webhook_url:
await self.send_webhook(task_id, task.webhook_url,
status="completed", result=result)
return
except Exception as e:
logger.warning(f"任务 {task_id} 第 {attempt} 次尝试失败: {e}")
# 更新重试次数
task.retry_count = attempt
await self.store.update_status(
task_id, TaskStatus.PENDING
)
if attempt < self.max_retries:
# 指数退避:1s, 2s, 4s
await asyncio.sleep(2 ** (attempt - 1))
else:
# 全部重试失败
error_msg = f"重试 {self.max_retries} 次后失败: {str(e)}"
await self.store.update_status(
task_id, TaskStatus.FAILED, error=error_msg
)
if task.webhook_url:
await self.send_webhook(task_id, task.webhook_url,
status="failed", error=error_msg)
async def send_webhook(self, task_id: str, url: str,
status: str, result: str = None,
error: str = None):
"""发送 Webhook 回调"""
payload = {
"task_id": task_id,
"status": status,
"result": result,
"error": error,
"timestamp": datetime.now().isoformat(),
}
async with httpx.AsyncClient(timeout=10) as client:
try:
resp = await client.post(url, json=payload)
logger.info(f"Webhook 发送到 {url}: {resp.status_code}")
except Exception as e:
logger.error(f"Webhook 发送失败 {url}: {e}")
3.5 启动服务
python
# main.py --- 入口
import uvicorn
import asyncio
from contextlib import asynccontextmanager
store = RedisTaskStore()
worker = LLMTaskWorker(store, openai_key="sk-xxx")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用启动时启动 worker,关闭时停止"""
worker_task = asyncio.create_task(worker.start())
yield
worker.stop()
worker_task.cancel()
app = FastAPI(lifespan=lifespan, ...)
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000)
四、从轮询到 Webhook:两种客户端模式
有了任务队列之后,客户端就有两种拿结果的方式了。
模式 A:轮询
前端简单,不需要后端配置回调地址。缺点:浪费带宽,延迟取决于轮询间隔。
javascript
// 客户端 --- 轮询
async function pollTask(taskId, maxAttempts = 60, intervalMs = 2000) {
for (let i = 0; i < maxAttempts; i++) {
const resp = await fetch(`/api/tasks/${taskId}`);
const task = await resp.json();
switch (task.status) {
case 'completed':
return task.result;
case 'failed':
throw new Error(task.error);
case 'cancelled':
throw new Error('任务已取消');
default:
// pending/processing---继续等
await new Promise(r => setTimeout(r, intervalMs));
}
}
throw new Error('轮询超时');
}
// 使用
const task = await fetch('/api/tasks', { method: 'POST', body: JSON.stringify({ prompt }) })
.then(r => r.json());
const result = await pollTask(task.task_id);
适合:Web 前端、短任务(< 2 分钟)、用户盯着进度条的场景。
模式 B:Webhook
服务端主动推送,零浪费。缺点:需要客户端暴露一个公网可访问的 URL。
python
# 客户端注册 webhook 地址
task_request = TaskRequest(
prompt="分析这份合同...",
webhook_url="https://myapp.com/webhooks/llm-result",
metadata={"user_id": 1001, "doc_id": "doc_abc123"}
)
对于无法暴露公网地址的场景,可以做一个中间层:
python
# 客户端配合 SSE 实现"Webhook + 实时推送"
from fastapi import FastAPI, Request
from sse_starlette.sse import EventSourceResponse
import asyncio
# 存 SSE 连接
sse_connections: dict[str, list] = {}
@app.post("/webhook/internal")
async def internal_webhook(payload: dict):
"""Webhook 收到结果后,推给 SSE 连接"""
task_id = payload["task_id"]
if task_id in sse_connections:
for queue in sse_connections[task_id]:
await queue.put(payload)
return {"ok": True}
@app.get("/tasks/{task_id}/stream")
async def stream_result(task_id: str, request: Request):
"""客户端连 SSE,等结果推过来"""
queue: asyncio.Queue = asyncio.Queue()
if task_id not in sse_connections:
sse_connections[task_id] = []
sse_connections[task_id].append(queue)
async def event_generator():
try:
while True:
if await request.is_disconnected():
break
data = await asyncio.wait_for(queue.get(), timeout=30)
yield {"event": "result", "data": json.dumps(data)}
break
except asyncio.TimeoutError:
yield {"event": "timeout", "data": "等待超时"}
finally:
sse_connections[task_id].remove(queue)
return EventSourceResponse(event_generator())
这样 Client 端只需要:
javascript
// 客户端 --- SSE 等待结果
const evtSource = new EventSource(`/api/tasks/${taskId}/stream`);
evtSource.addEventListener('result', (e) => {
const result = JSON.parse(e.data);
console.log('收到结果:', result);
evtSource.close();
});
不需要暴露公网地址,不需要轮询,实时推送。
五、生产环境必须处理的 4 个工程坑
坑 1:Worker 崩溃导致任务丢失
如果 Worker 在处理任务中途挂了,Redis 里的任务会永远卡在 processing 状态。
解法:心跳 + 超时重置
python
# 在 process_task 开始和过程中周期更新心跳
class LLMTaskWorker:
async def process_task(self, task_id: str):
# 标记处理中,记录开始时间
await self.redis.hset(f"task:{task_id}", "started_at", time.time())
# 每 15 秒更新心跳
heartbeat_task = asyncio.create_task(self._heartbeat(task_id))
try:
# ...实际 LLM 调用...
pass
finally:
heartbeat_task.cancel()
async def _heartbeat(self, task_id: str):
while True:
await asyncio.sleep(15)
await self.redis.expire(f"task:{task_id}", 120) # 延长 TTL
# 恢复脚本(定时跑)
async def recover_stale_tasks(self, timeout: int = 600):
"""恢复超时未完成的任务"""
async for key in self.redis.scan_iter("task:*"):
task = await self.store.get_task(key.split(":")[1])
if task and task.status == TaskStatus.PROCESSING:
if task.updated_at.timestamp() < time.time() - timeout:
await self.store.update_status(
task.task_id, TaskStatus.PENDING
)
await self.store.requeue(task.task_id)
logger.warning(f"恢复过期任务 {task.task_id}")
坑 2:幂等性------同一个任务被 Worker 取了两次
BRPOP 在多 Worker 下是安全的(原子弹出),但如果 Worker 在获取任务到标记 processing 之间崩溃,另一个 Worker 永远不会看到这个任务(因为已经弹出了)。
解法:先标记再弹出 + 重试队列
python
# 更严格的做法:2 阶段队列
async def create_task_safe(self, task: Task) -> Task:
"""1. 先存 Redis Hash + 加到 '待确认' 队列"""
await self.redis.hset(f"task:{task.task_id}", ...)
await self.redis.rpush("task_queue_pending", task.task_id)
return task
# Worker 取任务
async def claim_task(self) -> Optional[str]:
"""2. 移到 '处理中' 队列"""
task_id = await self.redis.brpoplpush(
"task_queue_pending",
"task_queue_processing",
timeout=5
)
return task_id
# 完成后
async def complete_task(self, task_id: str):
"""3. 从 '处理中' 移除"""
await self.redis.lrem("task_queue_processing", 0, task_id)
可以理解为 BRPOPLPUSH 。Redis 原生支持这个操作,它把元素从 A List 原子地弹出并推到 B List。如果 Worker 崩溃了,任务还在 task_queue_processing 里,恢复脚本可以扫描它。
坑 3:Webhook 超时/失败导致丢通知
Webhook 调用是副作用。如果对方服务挂了,通知就丢了。
解法:离线通知------Webhook 重试 + 死信队列
python
# Webhook 重试器
class WebhookRetrier:
def __init__(self, store: RedisTaskStore):
self.store = store
async def enqueue_retry(self, task_id: str, webhook_url: str,
payload: dict, attempt: int = 1):
"""Webhook 发送失败时入重试队列"""
retry_data = json.dumps({
"task_id": task_id,
"url": webhook_url,
"payload": payload,
"attempt": attempt,
"next_retry": (datetime.now() + timedelta(seconds=30 * attempt)).isoformat()
})
await self.redis.zadd("webhook_retry", {retry_data: time.time()})
async def retry_loop(self):
"""单独的 retry worker"""
while True:
now = time.time()
# 获取到期的重试任务
items = await self.redis.zrangebyscore(
"webhook_retry", 0, now, start=0, num=10
)
for item in items:
data = json.loads(item)
try:
async with httpx.AsyncClient() as client:
resp = await client.post(data["url"], json=data["payload"])
if resp.status_code < 500:
await self.redis.zrem("webhook_retry", item) # 成功则移除
else:
await self._schedule_next(data)
except:
await self._schedule_next(data)
await asyncio.sleep(10)
async def _schedule_next(self, data: dict):
attempt = data["attempt"] + 1
if attempt > 5:
# 超过 5 次放弃------移入死信队列
await self.redis.rpush("webhook_dead_letter", json.dumps(data))
await self.redis.zrem("webhook_retry", json.dumps(data))
else:
await self.enqueue_retry(
data["task_id"], data["url"], data["payload"], attempt
)
坑 4:任务积压下的调度策略
当 1000 个任务同时入队,Worker 只有 5 个,怎么调度?
python
class PriorityWorker(LLMTaskWorker):
"""带优先级的 Worker"""
QUEUES = {
"high": "task_queue:high",
"normal": "task_queue:normal",
"low": "task_queue:low",
}
async def get_next_task(self) -> Optional[str]:
"""优先级调度:先取高优先级队列"""
for priority in ["high", "normal", "low"]:
result = await self.redis.blpop(
self.QUEUES[priority], timeout=1
)
if result:
return result[1]
return None
生产建议的配置:
| 场景 | 队列 | 优先级 | 最大并发 |
|---|---|---|---|
| 实时对话 | 高优 | 立即处理 | 3 |
| 文档分析 | 普通 | 30 秒内 | 10 |
| 批量迁移 | 低优 | 后台跑 | 20 |
| 重试任务 | 重试 | 低优,指数退避 | 2 |
六、实测对比:同步 vs 异步
我用上面这套代码跑了 100 篇文档在同一台机器上测试,结果如下:
| 指标 | 同步 for 循环 | 异步 asyncio (5 并发) | 队列 + 3 Worker |
|---|---|---|---|
| 总耗时 | 342 秒 | 71 秒 | 28 秒 |
| API 429 次数 | 12 次 | 5 次 | 0 次 |
| 超时任务数 | 3 个 | 1 个 | 0 个 |
| 失败重试次数 | 9 次 | 4 次 | 3 次 |
| 客户端体验 | 页面卡死 6 分钟 | 页面卡死 1 分钟 | 立刻返回 + 逐步推送 |
| 可控并发 | ❌ 只能串行 | ✅ 有限制 | ✅ 精确控制 |
| 容错能力 | ❌ 需要手动恢复 | ⚠️ 失败任务丢失 | ✅ 自动重试 + 恢复 |
关键结论:
- 异步队列比同步循环快 10 倍以上
- 配合 Webhook/SSE,用户感知延迟从"几分钟"降到"< 500ms"(提交后立即反馈编号,结果逐步到)
- 生产环境必须上幂等性和恢复机制------没有的话,Worker 崩溃等于丢任务
常见问题
Q: 为什么不直接用 Celery?
A: Celery 非常成熟,但为了这个场景引入 RabbitMQ + Celery + Flower 太重了。Redis 自带的 List + Hash 完全可以胜任,而且我们在同一个 Redis 实例上做缓存,运维成本极低。当然,如果你的并发量超过每秒 1000 个任务,该上 RabbitMQ / Kafka 还是得上。
Q: Webhook 和 SSE 哪个更适合前端?
A: 前端拉取(SSE)更适合浏览器场景------浏览器不需要暴露端口。Webhook 适合后端到后端的通信。我们在实际项目中两种都用:后端服务之间走 Webhook,前端页面走 SSE,中间用内部的 Webhook 入口把结果转成 SSE 事件。
Q: 长任务如何给用户展示进度?
A: 最简单的做法是在任务处理过程中周期写入 progress 字段(百分比或阶段描述),轮询接口返回它。更高级的做法是把进度标记也做成事件流,和 SSE 一起推。用户界面至少显示"正在分析第 X/100 段"。
总结
从同步调到异步队列,LLM 应用才算真的"上线"了。核心就三件事:
- 队列 + 存储分离 ------ 任务是数据,队列是调度,不要混在一个表里
- 至少实现一种通知机制 ------ 轮询能接 Webhook,SSE 成本最低
- 幂等性和恢复是安全网 ------ 不想凌晨 3 点被叫起来修任务,就把它做好
这套代码已经在我两个生产项目里跑了半年,处理了 10 万 + 个任务。架构本身不是什么新东西------队列和 Worker 的模式在 2000 年代就有了。但 LLM 的特殊之处在于:它把响应时间从"毫秒级"拉到了"1-15 分钟",让同步架构的所有问题一次性暴露出来。解决好这一层,剩下的就是业务逻辑了。