第二部 原理篇:规则驱动的智能体设计

第 17 章:并发与性能:正确性是性能的前提

17.1 工程问题的本质:快的前提是没错

并发与性能优化有两个目标,但顺序不可颠倒:先正确,后快速。并发下的错误(身份串扰、数据竞争、状态覆盖)比单线程错误更隐蔽、更难复现;性能优化的手段(缓存、并行、预检)一旦破坏正确性,优化的收益全部化为故障成本。

设计原理: 并发与性能的工程形态是"正确性结构 + 延迟预算":先通过结构设计保证并发正确(线程本地、加锁、隔离),再在正确性的约束下做延迟优化(预检、分流、复用)。优化的每一步都必须回答"它破坏了什么正确性前提"。

17.2 并发正确性:共享状态的三种防护

并行路由(多输入并发 + 异常隔离)要求共享状态安全。三种防护手段:

原理: 三者的分工对应三类并发风险的来源:可变实例状态(线程本地)、共享单例(加锁)、全局上下文(contextvars 隔离)。并发正确性的本质是"状态的可见域"——每个状态都要明确"谁能看到、何时看到",并发错误几乎都是可见域失控[38]。

17.3 延迟优化:把模型调用从热路径上拿下来

延迟优化的主线是"减少模型调用、复用已有结果":

原理: 延迟优化的本质是把概率性、高成本的模型调用从高频确定路径上拿下来。规则预检、补充信息收敛、预识别复用,本质都是"确定性手段先行"(与第 9 章识别链路同一原则)。当确定手段能裁决时,模型调用就不该发生——这既是性能原则,也是成本原则。

17.4 并发模型选择与延迟预算方法

17.4.1 Python 并发模型:线程池 vs 协程

框架的并行路由基于 ThreadPoolExecutor。理解这一选择需要回到 Python 的并发约束:GIL 使纯计算型线程无法并行,但 IO 型任务(网络调用、数据库、模型请求)会释放 GIL,线程池在 IO 密集场景仍能并行提速。

表 17-1 线程池与协程的对比

维度 线程池(框架所选) 协程 / asyncio
模型 线程封装同步代码 事件循环 + 异步语法
侵入性 低(业务代码保持同步) 高(全链路 async 化)
调试 常规 async/await 传播难排查
适用 IO 密集、调用栈深、混合同步依赖 高并发短任务、无阻塞依赖

原理: 框架业务链路包含大量同步组件(规则引擎、审批流、数据库),全部协程化的改造面与调试成本极高。线程池以"低侵入"为代价接受"线程开销",换取业务代码保持同步可调试。并发正确性(线程本地、加锁、隔离,见 17.2 节)正是为这一选择补上的安全网[38]。并发模型的选择取决于调用栈的形态,而非并发的上限。

17.4.2 延迟预算:从 SLO 倒推设计

性能优化的工程方法是延迟预算(latency budget):从用户可感知的端到端 SLO 出发,逐环节分解,使每个环节的优化目标可度量、可问责。

图 17-1 从用户 SLO 倒推的延迟预算分配

graph LR
    SL["用户 SLO<br/>对话回复 P95 ≤ 5s"] --> E1["入口与安全检测 ≤ 20ms<br/>(规则匹配,无模型调用)"]
    E1 --> E2["意图识别 ≤ 1s<br/>(规则预检命中即 0ms;模型兜底 ≤ 1s)"]
    E2 --> E3["路由与上下文 ≤ 10ms"]
    E3 --> E4["业务处理(Agent) ≤ 3s<br/>(规则裁决 + 模型生成)"]
    E4 --> E5["返回与流式 ≤ 500ms<br/>(SSE 分块)"]

原理: 预算的价值在"优化有目标、回归可发现"——每个环节的实测值对照预算,超预算即报警,而非等到端到端劣化才察觉。预算还揭示"瓶颈在哪里":若识别强模型兜底占去预算大头,优化优先级就应落在"提高规则预检命中率"上,而非盲目并发。

17.5 失败模式与权衡

表 17-2 并发与性能的失败模式与权衡

失败形态 根因 设计应对
并发身份串扰 实例可变状态共享 线程本地存储
全局锁拖垮并行 类级锁互斥 实例粒度锁
缓存导致脏读 缓存无代次校验 进程级缓存 + 代次一致性
优化破坏正确性 缓存绕过校验 校验在优化之前

权衡: 预检优化以"规则层先命中"为代价——若规则误命中,会绕过模型的正确判断。因此预检守卫(动作类命中且句首为查询动词时跳过,保留主谓宾歧义给模型)是必要的:优化的前提是它不会把"该由模型裁决"的输入抢走。

17.6 实现剖析:缓存的击穿、穿透与雪崩三防

本节从 runtime/cache.py(496 行)摘取两段,印证 17.2 节的共享状态防护与 17.3 节的热路径优化。

17.6.1 击穿防护:per-key 单飞锁 + double-check

def _get_build_lock(self, key, namespace):
    """获取 per-key 单飞锁(进程内按完整 key 复用,避免重建同一 key 时并发放大)"""
    full_key = self._make_key(key, namespace)
    with self._build_locks_guard:
        lock = self._build_locks.get(full_key)
        if lock is None:
            lock = threading.Lock()
            self._build_locks[full_key] = lock
        return lock


def get_or_build(self, key, builder, ttl=None, namespace="cache"):
    """缓存击穿防护(singleflight 简化版)"""
    # 1) 常规命中直接返回(含空标记 → 返回 None)
    value = self.get(key, namespace=namespace)
    if value is not None:
        return None if _is_empty_marker(value) else value

    # 2) 未命中:per-key 单飞锁内重建
    lock = self._get_build_lock(key, namespace)
    with lock:
        value = self.get(key, namespace=namespace)     # ★ double-check
        if value is not None:                          # 等待期间他人可能已完成
            return None if _is_empty_marker(value) else value
        try:
            value = builder()
        except Exception as e:
            # 回源异常不缓存(避免把临时故障固化),向上抛由调用方处理
            logger.exception("缓存重建失败 key=%s: %s", key, e)
            raise
        if value is None:
            self.set_empty(key, ttl, namespace=namespace)   # 防穿透:缓存空标记
            return None
        self.set(key, value, ttl=self._jitter_ttl(ttl), namespace=namespace)
        return value

这段代码印证 17.2 节:并发正确性的核心是"让同一份工作只做一次"。热点 key 失效瞬间,若无防护,N 个并发请求会同时回源(缓存击穿),把瞬时压力放大 N 倍。_get_build_lock 为每个 key 分配一把进程内锁,with lock 内先 double-check(等待期间他人可能已重建完成),确保只有第一个请求执行 builder(),其余复用结果。

注意"回源异常不缓存、直接上抛"——若把异常当结果缓存,一次瞬时故障会被固化,反而制造更长的事故窗口。锁按完整 key 复用而非每次新建,是为了让等待者能命中同一把锁。

17.6.2 穿透与雪崩:空标记 + TTL 抖动

def set_empty(self, key, ttl=None, namespace="cache"):
    """空对象缓存(防穿透):builder 返回 None 时缓存空标记"""
    return self.set(key, _EMPTY_MARKER, ttl=ttl, namespace=namespace)


@staticmethod
def _jitter_ttl(ttl):
    """TTL 随机抖动(±10%)——同一批 key 的过期时刻错开,防缓存雪崩"""
    if ttl is None:
        return None
    try:
        return ttl * random.uniform(0.9, 1.1)
    except (TypeError, ValueError):
        return ttl

这段代码印证 17.3 节:"防穿透"与"防雪崩"针对的是两种不同的失效模式。穿透是"查了也不存在"——攻击或高频请求反复查询不存在的键,每次都回源;解法是把"空"也当结果缓存(_EMPTY_MARKER),命中空标记直接返回 None。

雪崩是"同一时刻大批 key 集体过期"——若所有 key 的 TTL 都是 3600,它们会在同一秒集中失效;解法是给每个 TTL 乘一个 ±10% 的随机因子,把过期时刻打散。

两个机制都是"用极小的代价换掉一类系统性风险",符合 17.3 节"把模型调用从热路径上拿下来"的整体思路:能靠缓存结构解决的,就不要留给下游扛。

17.7 本章小结