Agent 的并发与调度
单个 Agent 跑得通,不等于能服务多用户、多任务。本文讲清 Agent 服务的并发模型(每请求一 Agent vs Agent 池)、资源管理(模型 API 并发限/工具并发)、调度(优先级/队列/限流)、多用户状态隔离,把 Agent 从"单线程 demo"推到"能扛流量的服务"。
本文要点
- 主题聚焦:AI Agent、DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
把一个 Agent 跑通,在今天已经不难。随便一个 LangGraph、OpenAI Agents SDK 的 demo,本地 loop 一遍,工具调通了,看着就像"能上线"。
但 demo 和服务之间,隔着一座山:并发。
单个 Agent 是个长任务——动辄几十秒、几十步,占着模型连接、占着工具、占着内存里的 KV cache 和会话状态。一旦同时来了 100 个用户,模型 API 的 RPM/TPM 撞顶、数据库连接池打满、队列堵死、付费用户的请求排在一个写垃圾 prompt 的免费用户后面……这些问题 demo 永远遇不到,生产环境里每天都会要你的命。
这篇文章讲清楚:Agent 服务到底怎么扛流量。
一、先看清 Agent 并发的"难"在哪
普通 Web 请求是无状态的、毫秒级的、一次性的。Agent 完全相反:
- 长生命周期:一次对话可能跑 30 秒到 5 分钟,中间多轮模型调用 + 多次工具调用。
- 有状态:Agent 持有会话记忆、工具句柄、中间 plan,不能像 HTTP 一样"用完即弃"。
- 重资源:占着一条到模型 API 的并发通道、占着工具连接(数据库、浏览器、Sandbox)、占着 GPU/KV cache(自托管模型时)。
- 不可中断的中间态:工具调用到一半被 kill,可能留下脏数据、半完成的副作用。
所以"并发"这件事在 Agent 服务里,本质上是在多个维度上同时抢资源:模型 API 的并发额度、工具侧的并发额度、进程/内存。任何一维打满,服务就崩。
一个残酷的事实:你买的是模型 API 的 1000 RPM,不是 1000 个 Agent 并发。一个 Agent 一次任务可能打 8 次模型 API,所以你的真实并发 Agent 数大概是 RPM / 平均调用轮数 / 用户峰值倍数。算明白这一笔账,后面所有的设计才有的放矢。
二、并发模型:每请求一 Agent vs Agent 池
两种主流模型,各自有坑。
模型 A:每请求创建一个 Agent(Per-request Agent)
每次请求 new Agent(),跑完销毁。最简单、最不容易出错,状态天然隔离。
async def handle_turn(session_id: str, user_msg: str):
agent = Agent(
llm=build_llm(),
tools=build_tools(),
memory=await load_memory(session_id),
)
try:
return await agent.run(user_msg)
finally:
await agent.close() # 释放工具连接、刷盘记忆
适用:会话短、工具轻、无重预热成本的场景(客服、问答、单轮 RAG)。缺点是每次都要重建工具连接、重载系统提示,冷启动成本高。
模型 B:预热 Agent 池(Agent Pool)
预先 warm 一批 Agent 常驻,请求来了从池子里借一个用完还回去。适合工具重(浏览器、Sandbox、数据库长连接)、系统提示长(重建一次要几万 token)的场景。
class AgentPool {
private idle: AsyncQueue<Agent> = new AsyncQueue();
private inUse = new Set<Agent>();
constructor(size: number) {
for (let i = 0; i < size; i++) {
this.idle.push(warmAgent()); // 预热:加载工具、建立长连接
}
}
async acquire(userId: string, timeout = 30_000): Promise<Agent> {
const agent = await this.idle.pop(timeout); // 满了就排队/拒绝
if (!agent) throw new PoolExhaustedError();
this.inUse.add(agent);
return agent.bindContext(userId); // 关键:每次绑定用户上下文
}
release(agent: Agent) {
agent.resetContext(); // 关键:用完必须清状态
this.inUse.delete(agent);
this.idle.push(agent);
}
}
池化模型最大的坑,是状态串台。 一个 Agent 上一个用户刚问完"我的订单",下一个用户接手,如果 resetContext 没做干净,下一个用户会看到上一个用户的订单。这是 Agent 池唯一的、也是最致命的 bug 来源。我的建议:只要团队规模允许,先上 Per-request 模型,等真出现冷启动瓶颈(监控能看到)再切池化,而且池化一定要配上完备的 reset 测试。
池子大小怎么定?取 min(模型API并发额度, 工具并发额度, 内存预算) 再打个 0.8 的安全系数。不是拍脑袋。
三、资源管理:三个独立的水龙头
Agent 服务的资源要分成三个独立的水龙头去管,任何混用都会失控。
水龙头 1:模型 API 的并发与速率
模型厂商给你的限制是分层的:RPM(requests/min)、TPM(tokens/min)、并发连接数。三者都可能成为瓶颈,而且不同模型/不同 tier 额度不同。一个常见错误:你同时调了 GPT-4o 和 Claude,以为"加起来 2000 RPM",结果 Claude 那边单独打爆了。
工程上必须有一个全局的、按 provider 维度独立的令牌桶 + 并发 semaphore:
const providerLimits = {
openai: { rpm: 1000, tpm: 2_000_000, maxConcurrent: 50 },
anthropic: { rpm: 500, tpm: 800_000, maxConcurrent: 30 },
};
// 每 provider 一个 semaphore
const llmSem: Record<string, Semaphore> = Object.fromEntries(
Object.entries(providerLimits).map(([k, v]) => [k, new Semaphore(v.maxConcurrent)])
);
水龙头 2:工具的并发
工具资源比模型更容易被忽略。你的 Agent 调数据库——连接池是 20;调浏览器自动化——Sandbox 实例上限是 10;调第三方 API——对方限 5 QPS。这些必须每个工具一个独立的并发限制,不能和模型共用 semaphore。
const toolLimits = {
postgres: new Semaphore(20),
browser_sandbox: new Semaphore(10),
search_api: new RateLimiter({ qps: 5 }),
};
async function callTool(name: keyof typeof toolLimits, input: unknown) {
const guard = name === 'search_api'
? await (toolLimits[name] as RateLimiter).acquire()
: await (toolLimits[name] as Semaphore).acquire();
try {
return await tools[name].run(input);
} finally {
guard.release();
}
}
水龙头 3:内存与 KV cache
自托管模型时,KV cache 是稀缺资源——一个长会话动辄吃几百 MB 显存。Agent 服务要么在 prompt 超长时主动 truncate/compress history,要么用 prefix cache 复用公共部分。托管 API 用户也别掉以轻心:会话越长,延迟和成本都线性涨,要做 context window 预算管理。
四、调度:优先级、队列、限流、回收
资源就这么多,谁来先用?这就是调度。
优先级:付费用户优先于免费、交互式优先于后台批处理。优先级不是嘴上说,是要在队列里用 priority queue 实现的:
const queue = new PriorityQueue<Job>((a, b) => a.priority - b.priority);
// 优先级:付费实时(0) > 免费实时(10) > 后台任务(100)
队列削峰:模型 API 的额度是刚性的,但用户请求是脉冲的。队列的职责是把脉冲摊平到时间上,而不是无脑堆积。队列长度必须有上限——超过上限直接拒绝(返回 429 + Retry-After),而不是让用户等 10 分钟最后超时。让用户等 = 让用户骂街;让用户立刻知道"现在忙,稍后再试" = 还能挽留。
限流(防滥用):按 user_id 维度的并发上限 + QPS 上限,防止一个用户(或一个爬虫)把整个池子吃光。
const perUserConcurrent = new Map<string, Semaphore>();
function getUserGate(userId: string, max = 2): Semaphore {
if (!perUserConcurrent.has(userId)) {
perUserConcurrent.set(userId, new Semaphore(max));
}
return perUserConcurrent.get(userId)!;
}
超时与回收:这是 Agent 服务稳定性的最后一道闸。每个 Agent 必须有硬超时(比如 5 分钟),超时后强制 cancel——不只是取消 LLM 调用,还要回滚/标记它正在执行的工具副作用。否则一个卡死的 Agent 会一直占着池子,直到把你榨干。
async def run_with_reclaim(agent, task, hard_timeout=300):
try:
return await asyncio.wait_for(agent.run(task), timeout=hard_timeout)
except asyncio.TimeoutError:
await agent.cancel() # 中断 LLM 流
await agent.mark_orphaned() # 标记可能未完成的工具副作用
raise ServiceUnavailable()
五、多用户状态隔离:不能串,一串就是事故
这是 Agent 服务最容易出 P0 事故的地方,值得单独一节。
记忆、会话历史、缓存,一切按 user_id + session_id 严格分区。落到数据库就是强制的复合主键;落到 Redis 就是 key 前缀;落到内存 Map 就是从不跨用户读取。
# 正确:所有记忆查询都强制带 user_id
async def load_memory(user_id: str, session_id: str) -> Memory:
return await db.fetch_one(
"SELECT * FROM memories WHERE user_id=$1 AND session_id=$2",
[user_id, session_id],
)
# 错误(会串):只按 session_id 查
# 如果两个用户巧合或攻击构造了相同的 session_id,就串了
几个实战铁律:
- Key 永远是
(user_id, session_id),不是session_id单独。session_id 可伪造、可碰撞,只信 user_id 是不行的,user_id + session_id 才是安全边界。 - 池化 Agent 换用户时,所有上下文变量、工具内的连接句柄、缓存,必须全部 reset。养成"reset 是一等方法"的代码习惯,而不是散落各处的赋值。
- 工具返回里禁止出现其他用户的数据——例如一个 list_orders 工具,query 必须强制注入
WHERE user_id = current_user,不能信任 LLM 传上来的 user_id。 - 日志和 trace 里也别打别人的 user_id——隔离意识要贯彻到可观测层。
把状态串台当 P0 来设计,而不是 P0 来修。
六、长任务:不要让 HTTP 连接陪你等到天荒地老
Agent 任务经常超过 30 秒,而 HTTP/网关层的超时往往就是 30 秒。让用户一直 hold 着连接等结果,是反模式——连接会被网关掐断、体验极差、也无法做进度反馈。
正确的架构是:请求即入队 + 后台 Agent 执行 + 状态查询 + 进度推送。
Client ──POST /jobs──> [优先级队列] ──> [Worker: Agent]
│ │
└──返回 job_id ├──写状态到 Redis/DB
│
Client <───GET /jobs/{id} (轮询) │
Client <───SSE/WebSocket (推送进度) <──────────┘
// 入队
app.post('/jobs', auth, async (req, res) => {
const job = await queue.enqueue({
userId: req.user.id,
prompt: req.body.prompt,
priority: req.user.tier === 'paid' ? 0 : 10,
});
res.status(202).json({ jobId: job.id, status: 'queued' });
});
// 状态查询
app.get('/jobs/:id', auth, async (req, res) => {
const job = await jobs.get(req.params.id, req.user.id); // 强制带 user_id
res.json({ status: job.status, progress: job.progress, result: job.result });
});
// 进度推送
app.get('/jobs/:id/stream', auth, (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
jobs.subscribe(req.params.id, req.user.id, (evt) => {
res.write(`data: ${JSON.stringify(evt)}\n\n`);
});
});
后台 Worker 是长期运行的进程,从队列里取 job、起 Agent、把 agent.run() 里的中间事件(step / tool_call / token)写回 Redis,前端轮询或 SSE 拿。这个架构让你同时获得了削峰、可观测、可恢复(worker 挂了 job 还在队列里)。
七、可观测:并发数、排队时长、失败率
线上没有可观测的 Agent 服务,就是在裸奔。你至少要看清这几组指标:
- 并发指标:当前活跃 Agent 数、每 provider 的 LLM 在途请求数、每工具的在途调用数、队列深度。这些是容量规划的依据。
- 延迟拆解:排队时长(从入队到 Agent 开始执行)、Agent 执行时长、模型调用 P95、工具调用 P95。排队时长是用户体验的第一杀手——它单独成段,不要平均进总延迟里。
- 失败与回收:Agent 失败率(按阶段分:LLM 失败 / 工具失败 / 超时)、硬超时触发次数、429 拒绝率、池耗尽次数。
- 成本:每次任务的 token 消耗、调用轮数分布,按 user/tier 聚合。这个直接关系到账单能不能撑住。
每条 Agent 任务必须有一个 trace ID,把 LLM 调用、工具调用、队列等待串起来。出问题时能在一屏里看到一个 Agent 到底卡在哪一步、等了多久、调了什么工具。Langfuse、OpenTelemetry、Arize 这类工具能省你大量排障时间。
一个被低估的实践:给每个 Agent 任务打 tag(plan 轮数、工具调用数、最终是否成功),长期下来你会看到哪类 prompt 最烧钱、哪类最容易超时——这些是产品迭代的弹药。
小结
- Agent 是长任务、重资源、有状态——它不是 HTTP 请求,不能用 Web 那套"来一个处理一个"的思路硬扛,必须从模型 API、工具、内存三个维度独立管资源。
- 并发模型先选 Per-request,后选池化。池化的唯一致命问题是状态串台,只有在冷启动真的成为瓶颈时才上,且必须配齐 reset 测试。
- 调度四件套:优先级、队列、限流、回收。队列要有上限、Agent 要有硬超时、用户要有并发上限——这三道闸任何一道缺了都会出生产事故。
- 状态隔离按
(user_id, session_id)强制分区,贯穿到数据库 key、工具 SQL、日志 tag。串台是 P0,设计阶段就要堵死。 - 长任务走"入队 + 后台 + 推送",别让 HTTP 连接陪 Agent 一起等。这套架构顺带送你削峰和可恢复。
金句:单个 Agent 跑通是 demo,一组 Agent 调度得起来才是服务。把资源、调度、隔离、可观测这四件事做扎实,你的 Agent 才有资格谈"扛流量"。
Related