《AI Agent 高级编程》10.2 成本与延迟治理:路由/缓存/并发 + 评测兜底

路由、缓存、并发上限、降级——成本与延迟治理的四个旋钮,任何一个拧过头都会伤质量。本节用本地假模型真跑一条完整治理链:语义缓存命中即跳过、预算耗尽自动降级到小模型、Semaphore 把在飞请求压到上限内,再用评测集证明「降级后分数会掉」,并如实说明假模型为何量不出真实成本与延迟。

10.2 成本与延迟治理:路由/缓存/并发 + 评测兜底

上一节把 trace 归一了,现在你能看见「每个 run 花了多少 token、调了几次模型、哪次最贵」。看得见之后,第一件想做的事就是把它压下去:换小模型、加缓存、限并发。但治理有个绕不开的陷阱——每一个省钱的旋钮,都在偷偷动质量的账。缓存可能把过期答案发出去,降级可能把难题答错,限并发可能把请求排到超时。所以本节的主线不是「怎么省」,而是「怎么省的同时,还能证明质量没掉」。

本节把「成本治理」从「调参拍脑袋」推进到「有兜底的闭环」:用本地假模型真跑语义缓存 + 模型路由 + 预算降级 + 并发上限,再用一个评测集证明降级确实伤了分数,从而说明为什么「评测门禁」是治理链不可省的一环。

10.2.1 四个旋钮,四笔不同的账

先分清治理手段各自动的是哪笔账:

旋钮省的是可能伤到兜底手段
模型路由单次调用的单价难题的准确率按复杂度分流 + 评测分档
缓存重复请求的整次调用答案的新鲜度TTL + 缓存键含上下文版本
并发上限突发流量打垮后端排队延迟 / 超时队列 + 背压 + 超时降级
降级(小模型)预算耗尽时的成本输出质量评测门禁 + 自动回滚

四笔账的共同点是:省下的成本是即时的、可观测的,伤到的质量是延迟的、隐蔽的。这正是治理必须配评测的原因——不配评测,你根本不知道自己省掉了什么。

还有一层区分要说清楚:成本 = 单价 × 调用量,两个因子都值得动,但动法不同。缓存、路由动的是调用量(少调、调便宜的),降级动的是单价(同样调用、换便宜模型)。调用量类的优化通常更安全,因为它减少的是「不必要的工作」;单价类的优化更危险,因为它改变的是「工作本身的质量」。所以本节把缓存放在最前、降级放在最后,不是随意的排序。

10.2.2 真跑:语义缓存 + 模型路由 + 预算降级

下面这条链把前三个旋钮串起来。假模型只记录调用次数与合成成本单位(不是真实美元,见 10.2.6):

"""成本治理最小实测:语义缓存 + 复杂度路由 + 预算降级(本地假模型)。"""
from dataclasses import dataclass
import hashlib

@dataclass
class FakeModel:
    name: str
    unit_cost: int                        # 合成成本单位,非真实美元
    quality: dict[str, str]               # prompt -> answer(决定评测对错)
    calls: int = 0
    def complete(self, prompt: str) -> str:
        self.calls += 1
        return self.quality.get(prompt, "?")

class ResponseCache:
    def __init__(self):
        self.store: dict[str, str] = {}; self.hits = 0
    def key(self, prompt: str) -> str:
        return hashlib.sha256(prompt.encode()).hexdigest()[:10]
    def get(self, prompt):
        k = self.key(prompt)
        if k in self.store:
            self.hits += 1; return self.store[k]
        return None
    def put(self, prompt, ans):
        self.store[self.key(prompt)] = ans

@dataclass
class Router:
    big: FakeModel; small: FakeModel; budget: int
    spent: int = 0; degraded: int = 0
    def pick(self, prompt: str) -> FakeModel:
        complex_ = len(prompt) > 8
        if complex_ and self.spent + self.big.unit_cost <= self.budget:
            return self.big
        if complex_:
            self.degraded += 1            # 复杂请求但预算不够 -> 强制降级
        return self.small

cache = ResponseCache()
big = FakeModel("big", unit_cost=10,
                quality={"解释 CAP 定理": "cap", "总结这段长文本并给出要点": "sum"})
small = FakeModel("small", unit_cost=2,
                  quality={"解释 CAP 定理": "cap", "总结这段长文本并给出要点": "wrong"})
router = Router(big, small, budget=12)

def handle(prompt: str) -> str:
    hit = cache.get(prompt)
    if hit is not None:
        return hit                        # 命中:不路由、不调用模型
    m = router.pick(prompt)
    ans = m.complete(prompt); cache.put(prompt, ans); router.spent += m.unit_cost
    return ans

REQS = ["解释 CAP 定理", "总结这段长文本并给出要点", "解释 CAP 定理",
        "总结这段长文本并给出要点", "短问题"]
for p in REQS:
    handle(p)

本机实测:

缓存命中次数: 2
big 调用: 1  small 调用: 2
累计合成成本单位: 14 / 预算 12
预算不足触发降级次数: 1

把这条链拆开看,每个数字都能对账:

  • 缓存命中 2 次:第 3、4 个请求是第 1、2 个的原文重复,键相同,直接返回,连路由都跳过。5 个请求里只有 3 个真正到了模型。
  • big 调用 1 次:第 1 个请求是复杂请求且预算够(0 + 10 ≤ 12),走了大模型,花掉 10 个单位。
  • small 调用 2 次:第 2 个请求也是复杂请求,但 10 + 10 > 12,预算不够——降级到小模型(degraded 加 1);第 5 个是短请求,本来就该走小模型。
  • 累计 14 > 预算 12:注意这里超了。预算检查是「发起前看够不够」,一旦降级走小模型,小模型的 2 个单位照样计费,于是最终会略超预算。真实系统里要允许这种小溢出,或者把预算设成软阈值。

还有一个顺序陷阱值得点出:代码里 cache.get 在 router.pick 之前。如果反过来先路由再查缓存,那么「同一个 prompt 因为预算变化被路由到不同模型」会导致缓存键不同、缓存失效——第 3 个请求本该命中却会落空。缓存键要么只含 prompt(语义缓存),要么把「决定答案内容的所有变量」都塞进去,别让它跟一个会变的治理状态绑在一起。

另外,本节这个缓存故意没写 TTL,只用来演示「命中即跳过」的机制。真实缓存必须有过期策略:一个只增不减的字典会把内存吃光,且陈旧答案会一直发出去。生产里至少要有两样东西——TTL(time.time() - put_at > ttl 就失效)和版本号(系统提示或检索库变了,整批缓存作废)。缓存命中率越高,这两样越不能省。

10.2.3 真跑:并发上限把在飞请求压住

第四个旋钮是并发上限。它不省成本,但保护延迟——不让突发流量把所有请求一起拖慢。asyncio.Semaphore 就是最直接的实现:

"""并发上限:Semaphore 把在飞请求数压到上限之内。"""
import asyncio

class FakeModel:
    def __init__(self):
        self.calls = 0
    async def acomplete(self, prompt: str) -> str:
        self.calls += 1
        await asyncio.sleep(0.01)          # 模拟一次模型往返
        return prompt.upper()

async def main():
    model = FakeModel(); limit = 3
    sem = asyncio.Semaphore(limit)
    in_flight = 0; peak = 0
    async def guarded(p):
        nonlocal in_flight, peak
        async with sem:                    # 超过 limit 的请求在此排队
            in_flight += 1; peak = max(peak, in_flight)
            try:
                return await model.acomplete(p)
            finally:
                in_flight -= 1
    reqs = [f"q{i}" for i in range(10)]
    await asyncio.gather(*(guarded(r) for r in reqs))
    print("完成请求数:", 10)
    print("模型调用次数:", model.calls)
    print(f"并发上限 limit={limit},实测峰值在飞数: {peak}")

asyncio.run(main())

本机实测:

完成请求数: 10
模型调用次数: 10
并发上限 limit=3,实测峰值在飞数: 3

10 个请求全完成、模型被调 10 次,但同时在飞的峰值恰好等于 limit=3。这就是信号量给的东西:一个「同时在途」的硬上限。它不改变总量,只改变到达后端时的并发形状——把「10 个一起冲」变成「3 个一组、排 4 轮」。

要警惕的是:限并发只对「你能控制调用点」的场景有效。如果并发来自多个进程、多个副本,进程内的 Semaphore 管不了全局,得靠分布式限流(Redis 令牌桶之类)。这一点本卷不再展开,站内 Redis 专题 有实现细节。

10.2.4 评测兜底:降级后分数不能掉

前三个旋钮都跑通了,但真正的问题是:降级到底伤了多少质量? 没有评测,你只能靠感觉。补一个最小评测集就能量化:

EVAL = {"解释 CAP 定理": "cap", "总结这段长文本并给出要点": "sum"}

def score(model) -> float:
    return sum(1 for q, g in EVAL.items() if model.quality.get(q) == g) / len(EVAL)

print(f"big 评测通过率: {score(big):.0%}   small 评测通过率: {score(small):.0%}")

实测输出:

big 评测通过率: 100%   small 评测通过率: 50%
=> 降级把合成通过率从 100% 拉到 50%,必须靠评测门禁兜底

这个结果把治理链的最后一环补上了:降级不是免费的。在这个两题评测集里,大模型全对、小模型错一半。真实系统里,评测集不会只有两题,但逻辑一样——每次要上线一条降级策略,先跑评测集,分数掉了超过阈值就不许上。

评测兜底通常做在三处:

位置做什么触发时机
上线前门禁在固定评测集上比 big / small 的通过率CI 里、合并前
在线灰度一小部分流量走降级,比对两边指标策略上线后
自动回滚在线质量指标跌破阈值就切回大模型运行时

第一处是底线,第二、三处是保险。本卷第 4.3 节的三框架同题对照,本质上就是「上线前门禁」的一次实例——用同一份评测集给不同方案打分。评测集的构造与判分口径,见站内 测试专题 。

10.2.5 治理旋钮对照表

把四个旋钮放在一起,看它们各自的适用边界:

旋钮最适场景不适用场景主要风险
模型路由请求复杂度差异大请求同质、都很难分流阈值拍错,难题被降级
缓存重复问题多、答案时效要求低答案随上下文实时变发过期答案、缓存污染
并发上限后端有明确的并发承受力全局并发来自多副本进程内限流管不住全局
降级预算硬约束、可容忍质量下滑错误代价极高(医疗/金融)静默降质,用户无感知

一条经验法则:能省在「不调用」上的钱(缓存),比省在「调用便宜模型」上的钱(降级)更安全。缓存命中的请求根本没经过模型,质量不会变;降级则是在同样的请求上换了个更弱的模型,质量必然波动。所以治理的优先级通常是:先缓存 → 再路由 → 再限并发 → 最后才降级。

10.2.6 为什么假模型量不出真实成本与延迟

必须坦白一件事:本节所有「成本单位」和「调用次数」都是合成的,不是真实成本。

  • unit_cost=10 / 2 是我手编的相对值,用来演示预算逻辑;它跟任何真实模型的定价都没有关系。
  • 假模型 complete() 不产生任何 token,也不花时间,所以本节没有、也无法给出真实的 token 数与延迟数字。
  • 「评测通过率 100% / 50%」是假模型按 quality 字典查表得来的合成结果,不是真实模型的准确率。

为什么不能拿假模型测真实成本/延迟?因为成本和延迟是「模型 + 输入长度 + 并发 + 网络」的函数,而假模型把这四个变量全抽掉了:它没有 token 计量、没有网络往返、没有排队。你只能在假模型上验证「治理逻辑对不对」(缓存是否命中、降级是否触发、并发是否受限),不能验证「治理省了多少钱、快了多少毫秒」。

要拿到真实成本与延迟,只有一条路:用真实 API 跑,并且从响应里读 usage 字段。这件事本卷只在 11.2 真实 API 冒烟 做一次,且要先取得明确同意。在那之前,本节给出的所有数字都请当作逻辑演示,而不是性能结论。

10.2.7 这套治理替你做了什么、你又让渡了什么

治理链替你做的:把「重复请求跳过」「按复杂度分流」「预算耗尽降级」「并发封顶」这四件事从散落在各处的 if-else,收成一条可观测、可对账的流水线;配合上一节的统一 trace,每个请求走了哪个档位、省了多少、有没有降级,全都留痕。

你让渡的:四个旋钮各自引入了新的状态与失败模式——缓存有失效与污染、路由有误判、限流有排队、降级有静默降质。框架不会替你保证这些状态是对的;预算超支(本节实测 14 > 12)、缓存键设计错误、全局限流失效,这些坑都要你自己填。

最该记住的一句是:治理的目标不是「成本最低」,而是「在质量可证明不降的前提下,成本最低」。少了后半句,你省下的钱迟早会以事故的形式还回去。所以评测门禁不是治理的附属品,而是它的前提——先有评测,再谈降级。

想回看统一 trace 是怎么为成本统计提供口径的,见 10.1 统一可观测 ;下一步的安全纵深见 10.3 安全纵深 。

阅读导航:上一节:10.1 统一可观测:三框架 trace 归一 OTel · 下一节:10.3 安全纵深:注入/越权/供应链审计 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距