Agent 并发控制:多用户、多会话与资源隔离策略

摘要 :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。解法有三:

  1. 预估:用输入长度 + 历史平均输出长度估算(误差可接受)
  2. 事后记账:调用完成后按实际消耗扣减,透支则等待
  3. 混合:预估做准入、实际做校正
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:小团队需要四层控制吗?

不需要,两层就够 :会话串行 + 全局令牌桶。这两层覆盖了最关键的正确性 和真实瓶颈。用户级和租户层等出现了"某个客户拖垮其他人"的真实问题再加。


参考资料

  1. Google SRE Book --- Handling Overload (负载丢弃、排队与背压策略):https://sre.google/sre-book/handling-overload/
  2. 《Site Reliability Engineering》第 22 章 --- Addressing Cascading Failures:https://sre.google/sre-book/addressing-cascading-failures/
  3. Nygard, M. T. --- Release It! (舱壁隔离模式,本文资源分离设计的思想来源):https://pragprog.com/titles/mnee2/release-it-second-edition/
  4. Python 官方文档 --- asyncio 同步原语(Semaphore / Lock / Condition / Queue):https://docs.python.org/3/library/asyncio-sync.html
  5. Python 官方文档 --- contextvars 与异步上下文传递:https://docs.python.org/3/library/contextvars.html
  6. Kleppmann, M. --- Designing Data-Intensive Applications 第 8 章(分布式系统的瓶颈与延迟):https://dataintensive.net/
  7. OpenAI 官方文档 --- Rate limits 与 TPM/RPM 配额模型:https://platform.openai.com/docs/guides/rate-limits
  8. Anthropic 官方文档 --- Rate limits 与并发建议:https://docs.anthropic.com/en/api/rate-limits
  9. Redis 官方文档 --- 分布式锁的正确实现(Redlock 与其争议):https://redis.io/docs/latest/develop/use-cases/distributed-locks/
相关推荐
沉默王二1 小时前
Hy4正式发布一手实测,腾讯这波起飞了。
openai·agent·vibecoding
brave10253 小时前
LLMGuide 大模型面试指南
人工智能·ai·面试·ai编程
ZhengEnCi3 小时前
如何给 Agent 加一个 askQuestion 提问工具:从工具 Schema 到中断恢复的完整实现
agent
菩提小狗3 小时前
每日极客日报 · 2026年11月08日
ai·开源·极客日报·it热点·技术资讯
文谦南宁AI普工5 小时前
榜单上那个 90.4 分,可能连它自己的置信区间都撑不住
agent·ai编程·产品
Martina_03215 小时前
AI生成3D场景从白模换成精模后镜头全偏了?用6步检查单位、枢轴与层级替换
图像处理·人工智能·3d·ai·自然语言处理·材质·1024程序员节
10年前端老司机5 小时前
Milvus 8种检索能力实践
数据库·python·agent
小林ixn5 小时前
给 Agent 装上耳朵和嘴巴:ASR + TTS 双通道流式实战(腾讯云 × NestJS)
agent
壹米饭5 小时前
手搓 MCP 到最简 Agent,我都做了什么?
agent·ai编程·mcp