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 安全纵深:注入/越权/供应链审计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。