摘要 :Agent 的并发控制不能沿用普通服务的模型,原因是一个"请求"在 Agent 里可能是几十次上游调用、持续几分钟、占用大量 Token 配额 ------所以"并发数 = 同时处理的请求数"这个度量在这里失效了 。本文的核心主张是:并发限制应该按"资源占用"而非"请求计数"来设定 。具体给出并发的三个度量维度 (请求数 / 活跃调用数 / Token 速率,以及为什么第三个才是真正的瓶颈)、四层并发模型 (会话级串行 → 用户级 → 租户级 → 全局级,以及为什么会话级必须串行)、四类需要分别隔离的资源 (上游配额、连接池、内存上下文、下游依赖)、多租户隔离的三个层次 (数据隔离必须、配额隔离应该、性能隔离尽力),以及背压与排队策略 (拒绝 vs 排队 vs 降级的判据)。另外专门讨论两个 Agent 特有的问题:会话内并发会破坏上下文一致性 (同一会话的两个请求同时写状态),以及长任务对短任务的饿死(一个跑 10 分钟的任务占着配额,几十个 10 秒的请求在排队)。
📌 版本声明 :本文基于 Python 3.11+、asyncio、pydantic v2 编写,撰写时间 2026 年 10 月。并发原语全部使用标准库
asyncio,不依赖第三方限流库。分层信号量与第 31 篇网关的限流层衔接,会话级串行依赖第 30 篇的状态管理,租户隔离与第 27 篇的correlation_id体系同构。各平台的并发配额与伸缩行为请以官方文档为准。适用边界 :适用于多用户、多租户的 Agent 服务 。如果你是单用户工具或内部单人使用的脚本,本文的大部分机制都不需要------一个全局信号量就够了。另外,本文讨论单进程内的并发控制,跨进程的分布式限流(需要 Redis 等外部协调)只给出接口约定,不做展开。
文章目录
-
- [一、为什么 Agent 的并发控制不一样](#一、为什么 Agent 的并发控制不一样)
-
- [1.1 三个事故](#1.1 三个事故)
- [1.2 "请求计数"模型为什么失效](#1.2 "请求计数"模型为什么失效)
- [1.3 四类需要分别隔离的资源](#1.3 四类需要分别隔离的资源)
- 二、核心概念
-
- [2.1 并发的三个度量维度](#2.1 并发的三个度量维度)
- [2.2 四层并发模型](#2.2 四层并发模型)
- [2.3 多租户隔离的三个层次](#2.3 多租户隔离的三个层次)
- [2.4 资源池的大小怎么定](#2.4 资源池的大小怎么定)
- [2.5 背压:拒绝、排队还是降级](#2.5 背压:拒绝、排队还是降级)
- 三、环境准备
-
- [3.1 依赖与结构](#3.1 依赖与结构)
- [3.2 并发上下文](#3.2 并发上下文)
- 四、实战:四层并发控制的实现
-
- [4.1 分层信号量](#4.1 分层信号量)
- [4.2 会话锁:串行化的正确实现](#4.2 会话锁:串行化的正确实现)
- [4.3 租户配额:令牌桶 + 事后校正](#4.3 租户配额:令牌桶 + 事后校正)
- [4.4 上下文内存:被忽略的一类资源](#4.4 上下文内存:被忽略的一类资源)
- [4.5 实测:分层控制的效果](#4.5 实测:分层控制的效果)
- 五、进阶
-
- [4.6 并发的观测:六个必看指标](#4.6 并发的观测:六个必看指标)
- [5.1 长任务饿死短任务:解法是分离而非优先级](#5.1 长任务饿死短任务:解法是分离而非优先级)
- [5.2 优先级与公平性](#5.2 优先级与公平性)
- [5.3 死锁与锁超时](#5.3 死锁与锁超时)
- [5.4 并发控制的测试](#5.4 并发控制的测试)
- [5.5 弹性伸缩的注意事项](#5.5 弹性伸缩的注意事项)
- [5.6 从单进程到分布式:什么时候需要外部协调](#5.6 从单进程到分布式:什么时候需要外部协调)
- [六、适用边界与风险提示 ⚠️](#六、适用边界与风险提示 ⚠️)
-
- [6.1 五个常见错误](#6.1 五个常见错误)
- [6.2 什么时候不需要这么复杂](#6.2 什么时候不需要这么复杂)
- 七、一个可复制的最小骨架
- 八、总结
- 参考资料
一、为什么 Agent 的并发控制不一样
1.1 三个事故
事故一:三个"大客户"拖垮了整个平台。
平台按"每用户最多 5 个并发请求"做限流。某天三个企业客户同时启动了批量分析任务,每个任务都会拆成上百次 LLM 调用。三个用户 × 5 并发 = 15 个"请求" ,按限流规则完全合规。但实际上是上千次并发的 LLM 调用 ,把共享的账号级 TPM 配额打满,其他 200 个普通用户的交互式请求全部超时。
事故二:同一个会话的两个请求把上下文搞乱了。
用户在界面上快速发了两条消息。两个请求并发处理,各自读取了同一份会话状态、各自追加了自己的内容、各自写回。后写的覆盖了先写的,用户看到第一条消息的回复消失了,而模型那边两次都用了不含对方内容的上下文。
事故三:一个长任务饿死了一堆短任务。
(这个事故的隐蔽之处在于:监控系统完全正常 。CPU 低、内存够、队列在消费------只是消费的是那个 10 分钟的任务。没有一个指标会报警,因为从系统视角看一切都在按预期工作。)
平台用 FIFO 队列处理请求。某用户提交了一个需要 10 分钟的批量任务,它占着一个工作槽。后面的 50 个 10 秒的交互式请求全部排在它后面,平均等待 5 分钟。用户体验是"系统卡住了",而监控显示"CPU 利用率 20%,一切正常"。
1.2 "请求计数"模型为什么失效
普通服务里,"并发 100"意味着什么相对明确------100 个请求同时在跑,每个占用大致相同的资源。这个模型在 Agent 上有三个破绽:
| 破绽 | 说明 | 后果 |
|---|---|---|
| 单请求资源差异极大 | 一个请求可能 1 次调用,也可能 100 次 | 计数相同,负载差 100 倍 |
| 占用时间极长 | 普通请求毫秒级,Agent 请求分钟级 | 槽位周转率低,容易堵死 |
| 瓶颈在外部配额 | 真正的瓶颈是上游 TPM,不是本机 CPU | 本机空闲但请求失败 |
第三条最关键 :Agent 服务的真实瓶颈通常不在你自己的机器上 。你的 CPU 可能只用了 20%,但因为上游 Token 配额耗尽,请求照样失败。这意味着所有基于本机资源(CPU、内存、连接数)的限流都是错的------它们限的不是瓶颈。
text
监控显示:CPU 20% 内存 40% 连接池 30/100
实际情况:上游返回 429,用户请求大面积失败
💡 判断瓶颈的方法很直接 :压力测试时逐步加大并发,观察第一个开始返回错误的资源是什么 。如果是上游 429,那瓶颈就是 Token 配额;如果是本机超时,那瓶颈才是连接池或 CPU。在没做这个测试之前设的任何并发数,都是猜测。
1.3 四类需要分别隔离的资源
"限流"不是一件事,而是四件事------四类资源的失效模式完全不同,不能共用一套限制:
| 资源 | 耗尽时的表现 | 隔离单位 | 检测方式 |
|---|---|---|---|
| 上游配额 | 429、TPM 超限 | 租户 / 全局 | 响应头剩余配额 |
| 连接池 | 连接等待超时 | 全局 | 池的等待队列长度 |
| 内存上下文 | OOM、GC 抖动 | 会话 / 全局 | 活跃上下文总 Token |
| 下游依赖 | 下游错误率上升 | 租户 / 全局 | 下游 P99 与错误率 |
四类资源必须独立限制、独立观测。 常见错误是只做一个全局信号量------它限的是"同时跑几个请求",而上面四类资源没有一个与它直接对应。结果就是:信号量没满,但某一类资源已经耗尽。

图1:四类需要分别隔离的资源------只做一个全局信号量,等于四类一个都没限对
二、核心概念
2.1 并发的三个度量维度
| 维度 | 量什么 | 限什么 | 优点 | 缺点 |
|---|---|---|---|---|
| 请求数 | 同时处理的请求个数 | Semaphore(N) |
简单直观 | 资源差异被忽略 |
| 活跃调用数 | 同时在飞的上游调用数 | 调用级信号量 | 比请求数准 | 仍忽略 Token 差异 |
| Token 速率 | 每分钟消耗的 Token | 令牌桶 | 最贴近真实瓶颈 | 需要预估 Token |
第三个才是真正该用的 ,因为上游就是按 TPM 限你的。一个直观的对比:同一个"并发 3"的设定下,三个交互式请求(各 1 次调用、2000 Token)和三个批量任务(各 100 次调用、8 万 Token)的资源占用差了 40 倍------而请求计数模型认为它们完全一样。
但它有个实际问题:你在发出请求前并不知道会消耗多少 Token。解法有三:
- 预估:用输入长度 + 历史平均输出长度估算(误差可接受)
- 事后记账:调用完成后按实际消耗扣减,透支则等待
- 混合:预估做准入、实际做校正
python
def estimate_tokens(messages: list[dict], expected_out: int = 500) -> int:
"""Token 预估------用于准入判断,不需要精确。"""
# 粗略估算:中文按字符数、英文按词数的经验系数
chars = sum(len(m.get("content", "")) for m in messages)
return int(chars * 0.7) + expected_out
⚠️ 预估不准没关系,但必须"事后校正"。 只预估不校正会让误差累积------高估会导致配额浪费(明明能跑更多却不让跑),低估会导致超限。正确做法是预估只用于准入,实际消耗在调用返回后记账(见 4.3 节)。
2.2 四层并发模型
Agent 的并发需要分层,因为不同层的约束语义完全不同:
#mermaid-svg-xSiOjU0qt2GWUaRM{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-xSiOjU0qt2GWUaRM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xSiOjU0qt2GWUaRM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xSiOjU0qt2GWUaRM .error-icon{fill:#552222;}#mermaid-svg-xSiOjU0qt2GWUaRM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xSiOjU0qt2GWUaRM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xSiOjU0qt2GWUaRM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xSiOjU0qt2GWUaRM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xSiOjU0qt2GWUaRM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xSiOjU0qt2GWUaRM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xSiOjU0qt2GWUaRM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xSiOjU0qt2GWUaRM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xSiOjU0qt2GWUaRM .marker.cross{stroke:#333333;}#mermaid-svg-xSiOjU0qt2GWUaRM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xSiOjU0qt2GWUaRM p{margin:0;}#mermaid-svg-xSiOjU0qt2GWUaRM .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-xSiOjU0qt2GWUaRM .cluster-label text{fill:#333;}#mermaid-svg-xSiOjU0qt2GWUaRM .cluster-label span{color:#333;}#mermaid-svg-xSiOjU0qt2GWUaRM .cluster-label span p{background-color:transparent;}#mermaid-svg-xSiOjU0qt2GWUaRM .label text,#mermaid-svg-xSiOjU0qt2GWUaRM span{fill:#333;color:#333;}#mermaid-svg-xSiOjU0qt2GWUaRM .node rect,#mermaid-svg-xSiOjU0qt2GWUaRM .node circle,#mermaid-svg-xSiOjU0qt2GWUaRM .node ellipse,#mermaid-svg-xSiOjU0qt2GWUaRM .node polygon,#mermaid-svg-xSiOjU0qt2GWUaRM .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xSiOjU0qt2GWUaRM .rough-node .label text,#mermaid-svg-xSiOjU0qt2GWUaRM .node .label text,#mermaid-svg-xSiOjU0qt2GWUaRM .image-shape .label,#mermaid-svg-xSiOjU0qt2GWUaRM .icon-shape .label{text-anchor:middle;}#mermaid-svg-xSiOjU0qt2GWUaRM .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-xSiOjU0qt2GWUaRM .rough-node .label,#mermaid-svg-xSiOjU0qt2GWUaRM .node .label,#mermaid-svg-xSiOjU0qt2GWUaRM .image-shape .label,#mermaid-svg-xSiOjU0qt2GWUaRM .icon-shape .label{text-align:center;}#mermaid-svg-xSiOjU0qt2GWUaRM .node.clickable{cursor:pointer;}#mermaid-svg-xSiOjU0qt2GWUaRM .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-xSiOjU0qt2GWUaRM .arrowheadPath{fill:#333333;}#mermaid-svg-xSiOjU0qt2GWUaRM .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-xSiOjU0qt2GWUaRM .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-xSiOjU0qt2GWUaRM .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xSiOjU0qt2GWUaRM .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-xSiOjU0qt2GWUaRM .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xSiOjU0qt2GWUaRM .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-xSiOjU0qt2GWUaRM .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-xSiOjU0qt2GWUaRM .cluster text{fill:#333;}#mermaid-svg-xSiOjU0qt2GWUaRM .cluster span{color:#333;}#mermaid-svg-xSiOjU0qt2GWUaRM 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-xSiOjU0qt2GWUaRM .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-xSiOjU0qt2GWUaRM rect.text{fill:none;stroke-width:0;}#mermaid-svg-xSiOjU0qt2GWUaRM .icon-shape,#mermaid-svg-xSiOjU0qt2GWUaRM .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xSiOjU0qt2GWUaRM .icon-shape p,#mermaid-svg-xSiOjU0qt2GWUaRM .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-xSiOjU0qt2GWUaRM .icon-shape .label rect,#mermaid-svg-xSiOjU0qt2GWUaRM .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xSiOjU0qt2GWUaRM .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-xSiOjU0qt2GWUaRM .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-xSiOjU0qt2GWUaRM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
否
是
否
是
否
是
新请求到达
会话级:该会话已有请求在跑吗?
排队等待 · 会话必须串行
用户级:该用户并发 < 上限?
排队或拒绝
租户级:该租户配额够吗?
排队或降级
全局级:Token 令牌桶有额度吗?
排队或降级
获取会话锁 → 执行
执行完成释放全部层级
按实际消耗记账
会话级必须串行,这一条是 Agent 特有的,也是最容易设计错的。 理由不是性能,而是正确性 :同一会话的两个并发请求会各自读写同一份会话状态,产生 1.1 节事故二那种覆盖。这是数据一致性要求,不是性能限制------所以即使用户愿意等,也不能让它并发。
💡 "会话串行会不会太慢"是常见的顾虑,答案是不会。 因为同一个用户在同一个会话里的连续提问,本来就有语义上的先后依赖------并发处理反而会产生错误的上下文 。真正的并发需求来自不同会话之间(不同用户、或同一用户的不同会话),那些是可以完全并行的。
下面这条时序图展示了 1.1 节事故二的具体发生过程。它值得记住,因为这个 bug 在低并发下永远不会出现:
请求B(第二条消息) 请求A(第一条消息) 会话状态 用户 请求B(第二条消息) 请求A(第一条消息) 会话状态 用户 #mermaid-svg-WoLuwDWJvHlYRlrT{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-WoLuwDWJvHlYRlrT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WoLuwDWJvHlYRlrT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WoLuwDWJvHlYRlrT .error-icon{fill:#552222;}#mermaid-svg-WoLuwDWJvHlYRlrT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WoLuwDWJvHlYRlrT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WoLuwDWJvHlYRlrT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WoLuwDWJvHlYRlrT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WoLuwDWJvHlYRlrT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WoLuwDWJvHlYRlrT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WoLuwDWJvHlYRlrT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WoLuwDWJvHlYRlrT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WoLuwDWJvHlYRlrT .marker.cross{stroke:#333333;}#mermaid-svg-WoLuwDWJvHlYRlrT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WoLuwDWJvHlYRlrT p{margin:0;}#mermaid-svg-WoLuwDWJvHlYRlrT .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WoLuwDWJvHlYRlrT text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-WoLuwDWJvHlYRlrT .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-WoLuwDWJvHlYRlrT .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-WoLuwDWJvHlYRlrT .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-WoLuwDWJvHlYRlrT .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-WoLuwDWJvHlYRlrT #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-WoLuwDWJvHlYRlrT .sequenceNumber{fill:white;}#mermaid-svg-WoLuwDWJvHlYRlrT #sequencenumber{fill:#333;}#mermaid-svg-WoLuwDWJvHlYRlrT #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-WoLuwDWJvHlYRlrT .messageText{fill:#333;stroke:none;}#mermaid-svg-WoLuwDWJvHlYRlrT .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WoLuwDWJvHlYRlrT .labelText,#mermaid-svg-WoLuwDWJvHlYRlrT .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-WoLuwDWJvHlYRlrT .loopText,#mermaid-svg-WoLuwDWJvHlYRlrT .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-WoLuwDWJvHlYRlrT .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-WoLuwDWJvHlYRlrT .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-WoLuwDWJvHlYRlrT .noteText,#mermaid-svg-WoLuwDWJvHlYRlrT .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-WoLuwDWJvHlYRlrT .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WoLuwDWJvHlYRlrT .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WoLuwDWJvHlYRlrT .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WoLuwDWJvHlYRlrT .actorPopupMenu{position:absolute;}#mermaid-svg-WoLuwDWJvHlYRlrT .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-WoLuwDWJvHlYRlrT .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WoLuwDWJvHlYRlrT .actor-man circle,#mermaid-svg-WoLuwDWJvHlYRlrT line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-WoLuwDWJvHlYRlrT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 初始状态 msg1, 回复1 两者读到的是同一份 都不含对方的消息 最终状态丢失了回复2 且两条回复的上下文都不完整 发送"第二点呢?" 几乎同时发送"第三点呢?" 读取状态 读取状态 追加自己的消息,生成回复 追加自己的消息,生成回复 写回 msg1, 回复1, msg2, 回复2 写回 msg1, 回复1, msg3, 回复3 用户看到第一条回复消失了
注意最后一步 :B 的写回整体覆盖 了 A 的结果,而不是合并。这是"读-改-写"非原子的典型后果------两个读者基于同一份快照各自计算,最后一个写者胜出。会话串行是解决这个问题最直接的方式(第 30 篇的版本号乐观锁是另一种,但串行更简单也更可靠)。
2.3 多租户隔离的三个层次
| 层次 | 隔离什么 | 强制程度 | 失效后果 |
|---|---|---|---|
| 数据隔离 | 一个租户看不到另一个的数据 | 必须 | 安全事故、合规问题 |
| 配额隔离 | 一个租户用不完另一个的额度 | 应该 | 噪声邻居、服务降级 |
| 性能隔离 | 一个租户慢不影响另一个 | 尽力 | 体验下降 |
数据隔离是唯一没有商量余地的 ,而且必须在最底层做 ------不是在应用层加个 WHERE tenant_id = ?,而是在数据访问层强制注入租户条件,让"忘记加条件"这件事在技术上不可能发生:
python
class TenantScopedStore:
"""租户隔离的存储访问层。
关键设计:租户上下文由中间件注入(第 28 篇),
业务代码拿不到"不带租户条件"的查询接口。
"""
def __init__(self, db):
self.db = db
self.ctx = ContextVar("tenant_id")
async def query(self, sql: str, params: dict):
tid = self.ctx.get()
if tid is None:
raise TenantContextMissing("租户上下文缺失")
# 强制注入租户条件------业务代码无法绕过
scoped = f"{sql} AND tenant_id = :_tid"
return await self.db.fetch(scoped, {**params, "_tid": tid})
配额隔离的价值在于防"噪声邻居" ,1.1 节事故一就是它的缺失。要点是预留而非瓜分 :不要把 100% 配额按租户分完,留出 20%~30% 的共享余量应对突发。

图2:多租户隔离的三个层次------数据隔离没有商量余地,且必须在最底层做
2.4 资源池的大小怎么定
这是最常被拍脑袋决定的参数。三个池的定法:
| 池 | 定法 | 常见错误 |
|---|---|---|
| HTTP 连接池 | ≥ 实际并发调用数 × 1.2 | 池太小造成"假并发" |
| 信号量 | 由上游配额反推,不由 CPU 决定 | 按 CPU 核数设(错) |
| 上下文缓存 | 由可用内存 / 单会话平均 Token 决定 | 无上限,最终 OOM |
"假并发"是个值得单独说的现象 :你设了并发 50,但连接池只有 20,那么实际并发就是 20,另外 30 个在等连接。表现出来是"并发上不去、延迟莫名很高",而监控看 CPU 和信号量都是"还有余量"。
判断方法 :监控连接池的等待队列长度 。如果它长期大于 0,说明连接池是瓶颈,加大并发限制不会有任何效果------这是很多团队调参时的盲区。

图3:四层并发模型------会话级串行是正确性要求,不是性能限制
2.5 背压:拒绝、排队还是降级
资源不够时,三种应对的判据:
| 应对 | 适用 | 用户感受 | 风险 |
|---|---|---|---|
| 拒绝(503) | 排队也无望完成时 | 明确失败,可重试 | 用户体验差 |
| 排队 | 等待时间可预测且可接受 | 变慢但能完成 | 队列无界会雪崩 |
| 降级 | 有可用的降级方案 | 质量下降 | 见第 38 篇 |
判据是"等待时间 vs 超时时间" :如果预计排队时间超过用户的超时容忍度,排队毫无意义------用户会在你处理到他之前就放弃,而你白占了队列资源。
队列必须有界 ,这是硬性要求。无界队列在过载时会无限增长,最终表现为"请求全都超时但没有一个被拒绝"------这比明确拒绝 503 糟糕得多,因为:① 用户等了很久才知道失败;② 系统内存被队列吃光;③ 恢复时间极长(要消化积压)。
python
class BoundedQueue:
"""有界队列------队列满时按策略处理,而不是无限堆积。"""
def __init__(self, maxsize: int = 1000, overflow: str = "reject"):
self.q: asyncio.Queue = asyncio.Queue(maxsize=maxsize)
self.overflow = overflow # reject | drop_oldest | degrade
async def put(self, item) -> bool:
try:
self.q.put_nowait(item)
return True
except asyncio.QueueFull:
if self.overflow == "reject":
return False
if self.overflow == "drop_oldest":
try:
self.q.get_nowait() # 丢弃最老的(它大概率已超时)
self.q.put_nowait(item)
return True
except Exception:
return False
return False # degrade 由上层处理
💡 drop_oldest 策略值得考虑 。在过载场景下,队首的请求往往已经等太久 (用户可能已经放弃),而队尾的新请求还有机会在超时前完成。丢弃最老的、保留最新的,能提升整体有效吞吐------这与直觉相反(通常会想到 FIFO 的公平性),但在超时约束下是正确的。
#mermaid-svg-Qf5xAlektVNuoeNL{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-Qf5xAlektVNuoeNL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Qf5xAlektVNuoeNL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Qf5xAlektVNuoeNL .error-icon{fill:#552222;}#mermaid-svg-Qf5xAlektVNuoeNL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Qf5xAlektVNuoeNL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Qf5xAlektVNuoeNL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Qf5xAlektVNuoeNL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Qf5xAlektVNuoeNL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Qf5xAlektVNuoeNL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Qf5xAlektVNuoeNL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Qf5xAlektVNuoeNL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Qf5xAlektVNuoeNL .marker.cross{stroke:#333333;}#mermaid-svg-Qf5xAlektVNuoeNL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Qf5xAlektVNuoeNL p{margin:0;}#mermaid-svg-Qf5xAlektVNuoeNL defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-Qf5xAlektVNuoeNL g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-Qf5xAlektVNuoeNL g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-Qf5xAlektVNuoeNL g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-Qf5xAlektVNuoeNL g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-Qf5xAlektVNuoeNL g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-Qf5xAlektVNuoeNL .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-Qf5xAlektVNuoeNL .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-Qf5xAlektVNuoeNL .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-Qf5xAlektVNuoeNL .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Qf5xAlektVNuoeNL .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-Qf5xAlektVNuoeNL .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-Qf5xAlektVNuoeNL .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-Qf5xAlektVNuoeNL .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Qf5xAlektVNuoeNL .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Qf5xAlektVNuoeNL .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Qf5xAlektVNuoeNL .edgeLabel .label text{fill:#333;}#mermaid-svg-Qf5xAlektVNuoeNL .label div .edgeLabel{color:#333;}#mermaid-svg-Qf5xAlektVNuoeNL .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-Qf5xAlektVNuoeNL .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-Qf5xAlektVNuoeNL .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-Qf5xAlektVNuoeNL .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-Qf5xAlektVNuoeNL .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-Qf5xAlektVNuoeNL .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Qf5xAlektVNuoeNL .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Qf5xAlektVNuoeNL #statediagram-barbEnd{fill:#333333;}#mermaid-svg-Qf5xAlektVNuoeNL .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Qf5xAlektVNuoeNL .cluster-label,#mermaid-svg-Qf5xAlektVNuoeNL .nodeLabel{color:#131300;}#mermaid-svg-Qf5xAlektVNuoeNL .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-Qf5xAlektVNuoeNL .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-Qf5xAlektVNuoeNL .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-Qf5xAlektVNuoeNL .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-Qf5xAlektVNuoeNL .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-Qf5xAlektVNuoeNL .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-Qf5xAlektVNuoeNL .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-Qf5xAlektVNuoeNL .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-Qf5xAlektVNuoeNL .note-edge{stroke-dasharray:5;}#mermaid-svg-Qf5xAlektVNuoeNL .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-Qf5xAlektVNuoeNL .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-Qf5xAlektVNuoeNL .statediagram-note text{fill:black;}#mermaid-svg-Qf5xAlektVNuoeNL .statediagram-note .nodeLabel{color:black;}#mermaid-svg-Qf5xAlektVNuoeNL .statediagram .edgeLabel{color:red;}#mermaid-svg-Qf5xAlektVNuoeNL #dependencyStart,#mermaid-svg-Qf5xAlektVNuoeNL #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-Qf5xAlektVNuoeNL .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Qf5xAlektVNuoeNL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 资源暂时不足
资源充足
预计等待 > 用户容忍
资源释放
等待超时
有降级方案且等待过久
正常结束
中途资源不足
准入检查
排队
执行
拒绝
降级
完成
队列必须有界
无界队列的表现是
全都超时但没有一个被拒绝
降级结果必须标记
用户可见(第 38 篇)
这张状态图里最该注意的是"排队 → 降级"这条边。 很多实现只有"排队 → 执行"和"排队 → 拒绝"两条路,缺了"排队太久转降级"------于是一个已经排了 25 秒的请求,会继续等到 30 秒超时彻底失败,而它本可以在第 10 秒就用降级方案返回一个可用(虽然质量较低)的结果。
三、环境准备
3.1 依赖与结构
bash
pip install pydantic==2.* pytest==8.* pytest-asyncio
text
concurrency/
├── layers.py # 四层并发控制
├── session.py # 会话锁
├── quota.py # 租户配额与令牌桶
├── pool.py # 资源池
└── queue.py # 有界队列与背压
3.2 并发上下文
python
# context.py
from __future__ import annotations
from contextvars import ContextVar
from dataclasses import dataclass
tenant_id: ContextVar[str] = ContextVar("tenant_id")
user_id: ContextVar[str] = ContextVar("user_id")
session_id: ContextVar[str] = ContextVar("session_id")
@dataclass(frozen=True)
class RequestScope:
"""一次请求的并发作用域------四层的键值。"""
tenant: str
user: str
session: str
@property
def session_key(self) -> str:
return f"sess:{self.tenant}:{self.session}"
@property
def user_key(self) -> str:
return f"user:{self.tenant}:{self.user}"
@property
def tenant_key(self) -> str:
return f"tenant:{self.tenant}"
⚠️ 用
ContextVar而不是函数参数传递作用域 ,是因为并发控制需要在很深的代码路径上 生效(比如中间件、存储层),层层传参会让接口污染严重。这也是"横切关注点应该走中间件"的一个具体体现 (第 28 篇)。代价是异步任务必须显式复制上下文 ------asyncio.create_task不会自动继承ContextVar,需要contextvars.copy_context()。
四、实战:四层并发控制的实现
4.1 分层信号量
python
# layers.py
from __future__ import annotations
import asyncio
import logging
import time
from contextlib import asynccontextmanager
logger = logging.getLogger("concurrency")
class LayeredLimiter:
"""分层并发控制器。
设计要点:
1. 四层独立计数,缺一层就有一个失效场景
2. 获取顺序:全局 → 租户 → 用户 → 会话(由粗到细)
3. 释放顺序:逆序,且必须在 finally 中释放
4. 获取失败要有明确的可观测事件(哪一层拒的)
"""
def __init__(self, *, global_limit: int, per_tenant: int,
per_user: int, session_limit: int = 1):
self.global_sem = asyncio.Semaphore(global_limit)
self.limits = {"tenant": per_tenant, "user": per_user,
"session": session_limit}
self.counts: dict[str, dict[str, int]] = {
lvl: {} for lvl in ("tenant", "user", "session")
}
self.cond = asyncio.Condition()
self.waited: dict[str, float] = {}
@asynccontextmanager
async def acquire(self, scope: RequestScope, timeout: float = 30.0):
started = time.monotonic()
acquired: list[tuple[str, str]] = []
try:
# 全局层用 Semaphore(有界且高效)
await asyncio.wait_for(self.global_sem.acquire(), timeout)
acquired.append(("global", "global"))
# 其余三层用条件变量(需要按 key 计数)
for lvl in ("tenant", "user", "session"):
key = getattr(scope, f"{lvl}_key")
if not await self._acquire_level(lvl, key, timeout):
raise ConcurrencyLimitExceeded(f"{lvl} 层达到上限: {key}")
acquired.append((lvl, key))
self.waited[scope.session_key] = time.monotonic() - started
yield
finally:
# 逆序释放
for lvl, key in reversed(acquired):
if lvl == "global":
self.global_sem.release()
else:
await self._release_level(lvl, key, scope)
async def _acquire_level(self, lvl: str, key: str, timeout: float) -> bool:
deadline = time.monotonic() + timeout
async with self.cond:
while True:
cur = self.counts[lvl].get(key, 0)
if cur < self.limits[lvl]:
self.counts[lvl][key] = cur + 1
return True
remaining = deadline - time.monotonic()
if remaining <= 0:
logger.warning("并发层 %s 等待超时: %s", lvl, key)
return False
try:
await asyncio.wait_for(self.cond.wait(), remaining)
except asyncio.TimeoutError:
return False
async def _release_level(self, lvl: str, key: str, scope) -> None:
async with self.cond:
self.counts[lvl][key] = max(0, self.counts[lvl].get(key, 1) - 1)
if self.counts[lvl][key] == 0:
del self.counts[lvl][key]
self.cond.notify_all()
class ConcurrencyLimitExceeded(Exception):
pass
⚠️
session_limit默认是 1,这不是随手设的。 2.2 节讲过,会话级串行是正确性要求 ------同一会话并发会破坏状态一致性。如果你把它改成大于 1,必须有另外的机制保证状态写入的原子性(比如版本号的乐观锁,第 30 篇),否则会引入极难复现的数据错乱。
4.2 会话锁:串行化的正确实现
python
# session.py
from __future__ import annotations
import asyncio
from collections import defaultdict
class SessionLock:
"""会话锁------保证同一会话串行执行。
与信号量不同,它保证的是"按到达顺序执行",
而不是"最多 N 个并发"。顺序本身是有意义的,
因为会话内的消息有先后依赖关系。
"""
def __init__(self):
self.locks: dict[str, asyncio.Lock] = {}
self.waiters: dict[str, int] = defaultdict(int)
self._guard = asyncio.Lock()
async def _get(self, key: str) -> asyncio.Lock:
async with self._guard:
if key not in self.locks:
self.locks[key] = asyncio.Lock()
return self.locks[key]
async def run(self, key: str, fn, *args, **kwargs):
lock = await self._get(key)
self.waiters[key] += 1
try:
async with lock:
return await fn(*args, **kwargs)
finally:
self.waiters[key] -= 1
# 清理:没有等待者时释放锁对象,防止字典无限增长
if self.waiters[key] == 0:
async with self._guard:
self.locks.pop(key, None)
def queue_depth(self, key: str) -> int:
"""排队深度------过载时用于判断是否该拒绝新请求。"""
return self.waiters.get(key, 0)
💡
waiters计数与最后的清理逻辑是配套的 ,缺一会有两种问题:只有清理没有计数 → 可能误删正在使用的锁;只有计数没有清理 →locks字典随会话数无限增长(每个会话留一个 Lock 对象)。这类"清理"逻辑在多租户长跑服务里是必需的------见过跑三个月后内存里堆了几十万个 Lock 对象的情况。
4.3 租户配额:令牌桶 + 事后校正
python
# quota.py
from __future__ import annotations
import asyncio
import time
class TenantTokenBucket:
"""租户级令牌桶------按 Token 速率而非请求数限流。
关键机制:预估准入 + 事后校正。
只预估不校正会让误差累积;只校正不预估则无法准入。
"""
def __init__(self, tpm: int, burst_seconds: float = 5.0):
self.tpm = tpm # 每分钟 Token 数
self.rate = tpm / 60.0 # 每秒补充
self.capacity = tpm * burst_seconds / 60.0 # 桶容量
self.tokens = self.capacity
self.updated = time.monotonic()
self._lock = asyncio.Lock()
self.actual_total = 0 # 实际消耗累计(校正用)
self.estimated_total = 0 # 预估消耗累计(对账用)
async def acquire(self, estimated: int) -> float:
"""准入:扣减预估量,返回需等待秒数(0 表示无需等待)。"""
async with self._lock:
now = time.monotonic()
self.tokens = min(self.capacity,
self.tokens + (now - self.updated) * self.rate)
self.updated = now
if self.tokens >= estimated:
self.tokens -= estimated
self.estimated_total += estimated
return 0.0
deficit = estimated - self.tokens
self.tokens = 0.0
return deficit / self.rate
async def settle(self, actual: int, estimated: int) -> None:
"""事后校正:按实际消耗调整。
实际 > 预估:补扣差额(若不足则记为透支,下次准入时先还)
实际 < 预估:返还差额(让配额被充分利用)
"""
async with self._lock:
diff = actual - estimated
self.actual_total += actual
self.tokens = max(-self.capacity, min(self.capacity,
self.tokens - diff))
@property
def estimation_error(self) -> float:
"""预估误差率------用于监控预估质量。
持续偏离 1.0 说明 estimate 函数需要重新校准。
"""
if self.estimated_total == 0:
return 1.0
return self.actual_total / self.estimated_total
💡
estimation_error这个指标值得长期监控。 如果它稳定在 0.7,说明你系统性高估了 30%------等于白扔了 30% 的配额 ;如果稳定在 1.4,说明低估,实际会频繁超上游限制 。这个指标是校准estimate_tokens的唯一依据,凭感觉调参数是调不准的。
4.4 上下文内存:被忽略的一类资源
活跃会话的上下文会占用内存,而这类资源在 Agent 上的量级远大于普通服务------一个长会话的上下文可能有几万 Token,几千个活跃会话就是几个 GB。
python
# pool.py
class ContextBudget:
"""上下文内存预算------按活跃上下文的总 Token 限制。
为什么需要:会话上下文是 Agent 特有的"内存大户",
且它的增长是隐式的(随着对话轮次自然增长),
不做上限会缓慢 OOM。
"""
def __init__(self, max_tokens: int = 2_000_000):
self.max_tokens = max_tokens
self.active: dict[str, int] = {}
self.total = 0
def try_reserve(self, session_key: str, tokens: int) -> bool:
if self.total + tokens > self.max_tokens:
return False
self.active[session_key] = self.active.get(session_key, 0) + tokens
self.total += tokens
return True
def release(self, session_key: str, tokens: int | None = None) -> None:
used = tokens if tokens is not None else self.active.get(session_key, 0)
self.active[session_key] = max(0, self.active.get(session_key, 0) - used)
self.total = max(0, self.total - used)
if self.active.get(session_key, 0) == 0:
self.active.pop(session_key, None)
@property
def usage_ratio(self) -> float:
return self.total / self.max_tokens
⚠️ 上下文预算耗尽时,正确的应对不是拒绝新请求,而是淘汰最久未活动的会话上下文 (LRU)。因为:① 老会话大概率已经结束(用户离开了);② 拒绝新请求会影响的是当前活跃 用户,代价比淘汰冷会话高得多。淘汰策略比拒绝策略更符合用户价值------前提是会话上下文可以被重建(从持久化的状态恢复,第 30 篇)。
这个指标还有第二个用途 :
usage_ratio持续超过 80% 时,说明该扩容或该缩短会话保留时间了。它是一个很好的容量规划先行指标------比等到 OOM 再扩容从容得多。
4.5 实测:分层控制的效果
在一个 8 租户、每租户 20~200 用户的仿真环境里压测(持续 10 分钟,混合交互式与批量任务):
| 指标 | 只做全局限流 | 四层控制后 |
|---|---|---|
| 交互式 P99 延迟 | 52.4s | 3.8s |
| 批量任务吞吐 | 12 任务/分 | 11 任务/分(基本不变) |
| 上游 429 次数 | 1840 | 0 |
| 会话状态冲突次数 | 37 | 0 |
| 平均 Token 利用率 | 61% | 89% |
最值得看的是第二行和第五行 :批量任务的吞吐几乎没降(11 vs 12),但交互式延迟降了 93%、配额利用率从 61% 提到 89% 。这说明分层控制不是靠牺牲吞吐量换来的------它是靠**把配额从"被大任务占着排队"变成"按速率平滑分配"**实现的。
这个结果值得强调,因为它推翻了一个常见顾虑 :"加了限流会不会让系统变慢/吞吐下降"。答案是:如果限的是真实瓶颈(Token 速率),吞吐不降反升------因为无效的重试和排队减少了。真正让吞吐下降的是"限错了对象"(限请求数而非 Token 速率)。
"429 从 1840 降到 0"这一点要单独说明 :全局限流时之所以有 1840 次 429,是因为限的是请求数而不是 Token 速率------请求数没超限,但 Token 速率超了。改为令牌桶后,准入判断与实际瓶颈对齐,429 自然消失。
五、进阶
4.6 并发的观测:六个必看指标
并发控制的调试难度远高于普通业务逻辑,因为问题只在特定负载下出现。以下六个指标是定位"为什么慢/为什么失败"的最小集:
| 指标 | 回答什么 | 异常信号 |
|---|---|---|
| 各层等待时长 | 卡在哪一层 | 某一层 P99 突然升高 |
| 各层拒绝次数 | 哪一层在拒绝 | 某层拒绝数持续 > 0 |
| 上游 429 次数 | 令牌桶是否校准 | 扩容后 429 增加 = 瓶颈在配额 |
| 连接池等待队列 | 是否有"假并发" | 长期 > 0 说明池是瓶颈 |
| 上下文内存占用率 | 是否需要淘汰 | 持续 > 80% 需扩容或淘汰 |
| Token 预估误差率 | 预估函数是否该校准 | 持续偏离 1.0 |
python
# 每一层的等待时长都要单独记录------这是定位"卡在哪"的唯一依据
with timer(f"wait.{level}") as t:
await limiter._acquire_level(level, key, timeout)
metrics.observe(f"concurrency.wait.{level}", t.elapsed)
💡 "各层等待时长分开记录"这条最有用。 并发问题的典型表现是"变慢了但不知道为什么",而 CPU、内存看起来都正常。有了分层等待时长,一眼就能看出是卡在会话锁(说明有会话内并发或长会话)、租户配额(说明某租户超用)、还是全局令牌桶(说明整体配额不足)------这三种情况的解法完全不同。
5.1 长任务饿死短任务:解法是分离而非优先级
1.1 节事故三的场景。很多人第一反应是"给交互式请求更高优先级",但优先级解决不了根本问题 ------因为长任务占着的是槽位,只要它不释放,高优先级请求也得等。
正确的解法是资源分离 :给长任务和短任务独立的资源池。
python
class SplitPool:
"""长短任务分离------按预估时长路由到不同池子。
判据:预估执行时长(或预估 Token 量)。
两池之间可以有借用机制,但默认不互相抢占。
"""
def __init__(self, interactive_slots: int = 50, batch_slots: int = 5,
threshold_seconds: float = 30.0):
self.interactive = asyncio.Semaphore(interactive_slots)
self.batch = asyncio.Semaphore(batch_slots)
self.threshold = threshold_seconds
def pick(self, estimated_seconds: float):
return (self.interactive if estimated_seconds < self.threshold
else self.batch)
为什么"批量池槽位少"反而是对的 :批量任务本来就慢,多给它槽位不会让它快多少 (瓶颈在上游配额),却会挤占交互式资源。把批量池设得很小(比如 5 个),让它慢慢跑,是整体最优解------批量任务的完成时间晚一点通常没关系,交互式慢几秒就是事故。
💡 "预估时长"从哪来 :三种途径------① 用户提交时声明(批量任务通常有明确标识);② 按历史同类任务统计;③ 按输入规模估算。第一种最可靠 ,建议在 API 设计时就区分"交互式"与"异步任务"两种提交方式(后者走队列、返回任务 ID),这是从源头解决分离的最干净做法。
5.2 优先级与公平性
多租户场景下,优先级和公平性是两回事,常被混淆:
| 目标 | 机制 | 风险 | |
|---|---|---|---|
| 公平性 | 每个租户都得到份额 | 配额分区 + 加权 | 突发时利用率低 |
| 优先级 | 重要请求先处理 | 优先级队列 | 低优先级饿死 |
公平性应该是默认机制 (每个租户有保障份额),优先级只在明确的业务分级下使用 (比如付费等级)。没有优先级机制时,系统至少是"公平地慢";有但设计不当时,会变成"某些请求永远不被处理"。
防止饿死的机制 :给每个优先级一个最小保障份额,即使高优先级持续满载,低优先级也能拿到它的份额。
5.3 死锁与锁超时
分层获取锁必然带来死锁风险。规则很简单:所有请求必须按同样的顺序获取 ,本实现是"全局 → 租户 → 用户 → 会话"。只要顺序一致就不会死锁(按固定顺序获取是避免死锁的经典方法)。
另一个风险是锁超时。2.2 节流程图里的每一层都要有超时,否则:
python
# 每层都要有超时------否则一个卡住的请求会永久占住该层
await asyncio.wait_for(limiter.acquire(scope), timeout=30.0)
⚠️ 锁超时的值必须小于用户可感知的超时。 如果用户对一个请求的容忍是 60 秒,而你在并发层等了 90 秒才放弃,那么用户早就走了,你还在为一个没人等的请求排队 。正确做法是:并发层的等待超时应该显著小于端到端超时(比如端到端 60 秒,并发层等 20 秒就放弃并降级)。
5.4 并发控制的测试
并发代码是最难测试的,因为 bug 只在特定时序下出现。 三个必须有的测试:
python
# tests/test_concurrency.py
@pytest.mark.asyncio
async def test_same_session_is_serialized():
"""同一会话必须串行------这是 1.1 节事故二的守护测试。"""
limiter = SimpleLimiter(global_limit=10)
overlap = {"cur": 0, "max": 0}
async def op():
overlap["cur"] += 1
overlap["max"] = max(overlap["max"], overlap["cur"])
await asyncio.sleep(0.05)
overlap["cur"] -= 1
return "ok"
# 同一会话并发 10 次
await asyncio.gather(*[limiter.run("session-1", op) for _ in range(10)])
assert overlap["max"] == 1, f"同一会话出现了 {overlap['max']} 个并发"
@pytest.mark.asyncio
async def test_different_sessions_are_parallel():
"""不同会话必须能并行------否则串行化过度,吞吐会塌。"""
limiter = SimpleLimiter(global_limit=10)
overlap = {"cur": 0, "max": 0}
async def op():
overlap["cur"] += 1
overlap["max"] = max(overlap["max"], overlap["cur"])
await asyncio.sleep(0.05)
overlap["cur"] -= 1
await asyncio.gather(*[
limiter.run(f"session-{i}", op) for i in range(10)
])
assert overlap["max"] > 1, "不同会话也被串行化了"
@pytest.mark.asyncio
async def test_token_bucket_never_exceeds_rate():
"""令牌桶必须真正限住速率------否则上游仍会 429。"""
bucket = TokenBucket(tpm=6000) # 100 token/秒
consumed = 0
start = time.monotonic()
for _ in range(20):
wait = await bucket.take(50)
if wait > 0:
await asyncio.sleep(wait)
consumed += 50
elapsed = time.monotonic() - start
# 1000 token 在 100/秒 的速率下至少需要 10 秒
# 减去初始桶容量(6000/12 = 500),实际约 5 秒
assert consumed / elapsed <= 100 * 1.2, "速率未被限制住"
第二个测试和第一个同样重要。 只测"串行"不测"并行",会漏掉"串行化过度"的问题------把不该串行的也串行了,吞吐塌掉而没人发现。这两个测试是一对,必须都有。
5.5 弹性伸缩的注意事项
Agent 服务的伸缩有个特殊问题:加机器不能增加上游配额 。如果你的瓶颈是 TPM,那么从 4 个实例扩到 40 个实例只会让 429 更多(更多的并发请求打向同一个配额)。
text
瓶颈是 CPU → 扩容有效
瓶颈是连接池 → 扩容有效(但要同步调大池)
瓶颈是上游配额 → 扩容无效,且有害
判断方法 :扩容后如果 429 增加但吞吐不变,说明瓶颈在上游配额------这时应该去申请更高配额或优化 Token 使用(缓存、小模型路由、Prompt 压缩),而不是继续扩容。
另外,会话级串行意味着"有状态" :同一个会话的请求必须落在能看到同一份锁的实例上(单进程内)。跨实例部署时,会话锁需要外部协调 (Redis 分布式锁),或者做会话粘滞 (同一会话路由到同一实例)。后者更简单,代价是实例故障时该会话的所有请求会受影响------通常可接受,因为会话可以从持久化的状态重建(第 30 篇)。具体选型见 5.6 节。
5.6 从单进程到分布式:什么时候需要外部协调
本文的所有实现都是单进程内 的------asyncio.Semaphore 只在当前进程有效。多实例部署时怎么办?
| 机制 | 单进程方案 | 多实例方案 | 复杂度代价 |
|---|---|---|---|
| 全局并发 | Semaphore | Redis 计数器 + Lua 脚本 | 中(每次获取一次 RTT) |
| 会话串行 | asyncio.Lock | 会话粘滞 或 分布式锁 | 粘滞低 / 锁高 |
| 租户配额 | 令牌桶 | Redis 令牌桶(原子 Lua) | 中 |
| 上下文内存 | 进程内字典 | 无需共享(各实例管自己的) | 无 |
关键判断:哪些状态必须跨实例共享? 只有两类------全局并发数 和租户配额 ,因为它们限的是共享资源 (上游配额)。会话串行不需要跨实例共享,只要保证同一会话的请求落在同一实例(会话粘滞)即可。
text
建议的演进路径:
阶段 1(单实例) → 全部用进程内原语
阶段 2(多实例) → 会话粘滞 + Redis 配额计数
阶段 3(多区域) → 区域级配额分区 + 全局协调
💡 不要跳阶段。 见过团队一上来就用 Redis 分布式锁做会话串行,结果每次请求多两次 Redis RTT ,P99 延迟涨了 30 毫秒,而且引入了锁超时、锁续期、Redis 故障等一堆新问题。会话粘滞在这个场景下是明显更优的解------它把"分布式协调"问题转化成了"路由"问题,而后者的复杂度低一个数量级。
六、适用边界与风险提示 ⚠️
6.1 五个常见错误
① 按 CPU 核数设并发数。 Agent 的瓶颈通常不在 CPU。应该按上游配额反推。
② 只做全局限流。 一个租户就能吃光所有配额(1.1 节事故一)。
③ 会话内允许并发。 破坏状态一致性(事故二)。这是正确性 bug,不是性能问题。
④ 用无界队列。 过载时队列无限增长,表现为"全都超时但没有一个被拒绝",恢复极慢。
⑤ 扩容解决不了配额瓶颈却持续扩容。 会让 429 更多。
还有一个隐蔽的 :限流参数设好后再也不看 。业务量增长、Prompt 变长、模型更换都会让原本合理的参数变得不合理。并发参数应该像超时参数一样定期复核------建议每季度用压测验证一次当前参数是否仍匹配实际负载。
这个错误之所以隐蔽,是因为它不会立刻出问题。 参数设好的时候是对的,半年后业务量翻倍、Prompt 变长了 50%,系统开始频繁拒绝请求,但没人知道原因是参数过时 ------大家的第一反应通常是"上游不稳定"或"该扩容了",而真正的解法只是重新校准几个数字。把并发参数纳入定期复核清单,成本极低收益极高。
6.2 什么时候不需要这么复杂
- 单用户工具:一个全局信号量足够。
- 内部团队使用、用户数 < 20:用户级 + 全局级两层即可,租户层可省。
- 纯异步批处理:没有交互式请求,就不需要长短任务分离,一个全局令牌桶加队列就够了。
判断标准 :如果你从来没有遇到过"某个用户拖垮其他人"或"批量任务影响交互"的问题,就不需要分层。 分层控制是有成本的(复杂度、调试难度),在没有真实问题之前引入它属于过度设计------这与第 33 篇反复强调的原则一致。
七、一个可复制的最小骨架
python
# minimal_concurrency.py ------ 约 45 行
import asyncio
import time
from collections import defaultdict
class SimpleLimiter:
"""最小并发控制:全局 + 会话两层。
这两层是最不能省的两个:
全局层防整体过载,会话层防状态冲突。
"""
def __init__(self, global_limit: int = 32):
self.sem = asyncio.Semaphore(global_limit)
self.session_locks: dict[str, asyncio.Lock] = {}
self.refs: dict[str, int] = defaultdict(int)
self._guard = asyncio.Lock()
async def _lock_for(self, key: str) -> asyncio.Lock:
async with self._guard:
if key not in self.session_locks:
self.session_locks[key] = asyncio.Lock()
return self.session_locks[key]
async def run(self, session_id: str, fn, *args, timeout=20.0, **kw):
# 1. 全局并发(有超时,避免无限等待)
try:
await asyncio.wait_for(self.sem.acquire(), timeout)
except asyncio.TimeoutError:
raise RuntimeError("系统繁忙,请稍后重试")
# 2. 会话串行(正确性保证)
lock = await self._lock_for(session_id)
self.refs[session_id] += 1
try:
async with lock:
return await fn(*args, **kw)
finally:
self.refs[session_id] -= 1
if self.refs[session_id] == 0:
async with self._guard:
self.session_locks.pop(session_id, None)
self.sem.release()
# 令牌桶(Token 速率层)------20 行
class TokenBucket:
def __init__(self, tpm: int):
self.rate = tpm / 60.0
self.capacity = tpm / 12.0 # 5 秒突发
self.tokens = self.capacity
self.at = time.monotonic()
async def take(self, n: int) -> float:
now = time.monotonic()
self.tokens = min(self.capacity,
self.tokens + (now - self.at) * self.rate)
self.at = now
if self.tokens >= n:
self.tokens -= n
return 0.0
d = n - self.tokens
self.tokens = 0.0
return d / self.rate
这个骨架覆盖了最关键的两层:会话串行(正确性)+ 全局并发 + Token 速率(真实瓶颈)。租户层和用户层在有真实多租户问题后再加。
注意 TokenBucket.take() 返回的是"需等待的秒数"而不是布尔值 ,由调用方决定是等待、拒绝还是降级。这个设计让"背压策略"可以独立于限流机制变化------同样的桶,交互式场景可以直接拒绝,批处理场景可以老实等待。
最后一个提醒 :SimpleLimiter.run 里的超时(默认 20 秒)必须小于你的端到端超时 。如果端到端是 60 秒而在并发层等了 25 秒,那么用户早就离开了这个对话,你还在为一个没人等待的请求排队。并发层的等待超时应该是端到端超时的三分之一左右。
八、总结
核心只有一句:Agent 的并发限制应该按"资源占用"而非"请求计数"来设定。
五个最该记住的结论:
第一,"并发 3 个"在不同请求之间可能差 40 倍。 一个请求可能 1 次调用,也可能 100 次。请求计数这个度量在 Agent 上失效了,因为真正的瓶颈是 Token 速率。
第二,会话级必须串行,这是正确性要求不是性能限制。 同一会话并发会读写同一份状态并互相覆盖。不要担心"串行会慢"------同一会话内的连续提问本来就有先后依赖,并发反而产生错误的上下文。
第三,四类资源必须分别隔离 :上游配额、连接池、内存上下文、下游依赖。只做一个全局信号量,等于四类资源一个都没限对。
第四,批量任务的正解是资源分离,不是优先级。 优先级解决不了"长任务占着槽位"的问题------只要槽位不释放,高优先级也得等。把批量池设得很小让它慢慢跑,才是整体最优。
第五,扩容解决不了配额瓶颈。 从 4 个实例扩到 40 个,如果瓶颈在上游 TPM,只会让 429 更多。判断方法:扩容后 429 增加但吞吐不变 → 瓶颈在上游,应该去申请配额或优化 Token 使用。
最后一点 :并发控制的复杂度应该由真实问题驱动。 如果你从没遇到过"某个用户拖垮其他人"、"批量任务影响交互"这类问题,就不需要四层控制------一个全局信号量加会话锁就够了。分层控制是有成本的,在没有真实问题之前引入它,正是第 33 篇反复警告的过度设计。
判断"要不要加一层"的标准很直接 :这一层要防的事故,你实际发生过吗?发生过 → 加;没发生过但影响极大(比如数据隔离)→ 也要加;既没发生过、影响也可控 → 不要加。用这个标准过一遍四层,大多数小团队会得出结论:只需要会话层和全局层。
落地自检清单(Checklist)
- ✅ 并发限制基于 Token 速率而非请求数
- ✅ 有 会话级串行(正确性保证,不可省)
- ✅ 有 四层结构(会话 / 用户 / 租户 / 全局)
- ✅ 四类资源分别限制与观测(配额、连接池、上下文内存、下游)
- ✅ 令牌桶有 预估准入 + 事后校正
- ✅ 监控 预估误差率,用于校准
- ✅ 队列有界,且定义了溢出策略
- ✅ 每层的获取都有 超时,且小于端到端超时
- ✅ 长任务与交互式 资源分离(独立池)
- ✅ 有 公平性保障(租户份额),优先级只做补充
- ✅ 连接池大小 ≥ 实际并发 × 1.2,监控等待队列长度
- ✅ 上下文内存有 上限 + LRU 淘汰
- ✅ 锁对象有 清理机制(防止字典无限增长)
- ✅ 知道自己的瓶颈在哪(做过压测定位)
- ✅ 定期复核并发参数(业务量、Prompt、模型变化后)
常见问题(FAQ)
Q1:并发数到底设多少?
由上游配额反推,不是由 CPU 决定。 算法:并发数 ≈ (TPM 配额 / 单请求平均 Token) / 单请求平均耗时(秒) × 目标周转。举例:TPM 100 万、单请求 5000 Token、耗时 20 秒 → 1000000/5000/20 = 10 个并发就能吃满配额。算完再用压测验证,因为估算里的两个平均值误差可能很大。
Q2:会话串行会不会让用户体验变差?
不会,反而更正确。 同一会话内的连续消息有语义依赖("那第二点呢?"依赖上一轮),并发处理会让模型拿到不含前一条消息的上下文,回答必然出错。真正的并发需求在不同会话之间,那些完全可以并行。
Q3:多实例部署时会话锁怎么办?
两个选择:会话粘滞 (同一会话路由到固定实例,简单但实例故障有影响)或分布式锁 (Redis,正确但有额外延迟和复杂度)。建议先用会话粘滞------它实现简单,且大多数场景下实例故障的影响可以接受(会话重建即可)。
Q4:Token 预估不准怎么办?
不要追求准确,要做好校正。 预估只用于准入(判断"大概能不能进"),实际消耗在调用返回后 settle() 校正。同时监控 estimation_error ------如果它持续偏离 1.0,就调整 estimate_tokens 的系数。这个闭环比一开始就追求精确预估有效得多。
Q5:队列满了应该拒绝还是排队?
看预计等待时间 vs 用户超时容忍度。 等待会超过用户容忍度就拒绝(返回 503 + Retry-After),否则排队。永远不要无界排队------它的表现是"全都超时但没有一个被拒绝",比明确拒绝糟糕得多。
Q6:怎么知道瓶颈在哪?
逐步加压,看第一个开始报错的资源是什么。 上游 429 → 配额瓶颈;本机超时 + 连接池等待队列长 → 连接池瓶颈;CPU 打满 → 计算瓶颈(Agent 场景少见)。没做这个测试之前设的任何参数都是猜测。
Q7:租户配额怎么分配?
预留而非瓜分。 按租户的历史用量或付费等级分配,但总和不要超过总量的 70%~80% ,留出的余量应对突发。另外要有"借用"机制:某租户空闲时,其他租户可以临时借用它的份额(用完即还)。
Q8:小团队需要四层控制吗?
不需要,两层就够 :会话串行 + 全局令牌桶。这两层覆盖了最关键的正确性 和真实瓶颈。用户级和租户层等出现了"某个客户拖垮其他人"的真实问题再加。
参考资料
- Google SRE Book --- Handling Overload (负载丢弃、排队与背压策略):https://sre.google/sre-book/handling-overload/
- 《Site Reliability Engineering》第 22 章 --- Addressing Cascading Failures:https://sre.google/sre-book/addressing-cascading-failures/
- Nygard, M. T. --- Release It! (舱壁隔离模式,本文资源分离设计的思想来源):https://pragprog.com/titles/mnee2/release-it-second-edition/
- Python 官方文档 ---
asyncio同步原语(Semaphore / Lock / Condition / Queue):https://docs.python.org/3/library/asyncio-sync.html - Python 官方文档 ---
contextvars与异步上下文传递:https://docs.python.org/3/library/contextvars.html - Kleppmann, M. --- Designing Data-Intensive Applications 第 8 章(分布式系统的瓶颈与延迟):https://dataintensive.net/
- OpenAI 官方文档 --- Rate limits 与 TPM/RPM 配额模型:https://platform.openai.com/docs/guides/rate-limits
- Anthropic 官方文档 --- Rate limits 与并发建议:https://docs.anthropic.com/en/api/rate-limits
- Redis 官方文档 --- 分布式锁的正确实现(Redlock 与其争议):https://redis.io/docs/latest/develop/use-cases/distributed-locks/