5.2 自建缓存网关:为多模型路由加缓存层


文档摘要

5.2 自建缓存网关:为多模型路由加缓存层 本节摘要:引擎层的前缀缓存只管"相同前缀",管不了"相同语义的不同表述",也管不了多模型路由。本节用约百行 FastAPI 代码搭一个缓存网关,把请求哈希键、L1 精确命中、未命中转发、响应回写跑通,并讲清键设计的两个要点与流式响应下的特殊处理。 5.1 我们在单个 vLLM 实例上验证了前缀缓存,省下了重复 prefill。但当你的流量要同时打到多个模型、或者你想把"省了多少"统一记一笔账时,引擎层的缓存就够不着了——它分散在每个实例里,跨模型不会共享。这一节我们在业务和模型之间插一层网关,把缓存和计量收拢到一处,也为 5.3 的 Agent 优化准备好统一的拦截点。 为什么要在业务和模型之间插一层 先说清楚动机,不然容易为了加层而加层。

5.2 自建缓存网关:为多模型路由加缓存层

本节摘要:引擎层的前缀缓存只管"相同前缀",管不了"相同语义的不同表述",也管不了多模型路由。本节用约百行 FastAPI 代码搭一个缓存网关,把请求哈希键、L1 精确命中、未命中转发、响应回写跑通,并讲清键设计的两个要点与流式响应下的特殊处理。

5.1 我们在单个 vLLM 实例上验证了前缀缓存,省下了重复 prefill。但当你的流量要同时打到多个模型、或者你想把"省了多少"统一记一笔账时,引擎层的缓存就够不着了——它分散在每个实例里,跨模型不会共享。这一节我们在业务和模型之间插一层网关,把缓存和计量收拢到一处,也为 5.3 的 Agent 优化准备好统一的拦截点。

为什么要在业务和模型之间插一层

先说清楚动机,不然容易为了加层而加层。业务直接调模型,问题有三:第一,计量散落,每个模型实例各自报数,算总账要对半天;第二,降本手段单一,前缀缓存只认字节级相同,语义相同但表述略异的请求照样全价计算;第三,多模型路由时,同样的请求可能落到不同模型,缓存无法复用。

网关这一层把这三件事一并兜住:所有流量先过网关,网关做统一哈希、查缓存、记日志、再决定转发到哪个模型。它不改变模型能力,只改变"请求如何被复用"和"成本如何被看见"。

图 5-2:缓存网关在请求链路中的位置与数据流

图 5-2:缓存网关在请求链路中的位置与数据流

核心骨架:约百行网关

读代码前先建立心智模型:网关是无状态的中转,它不持有模型权重,只持有一张"键到响应"的映射表。请求进来,算键;键在表里就直接返回,不在就转发并把结果记进表。整段逻辑的核心复杂度,几乎全部集中在 make_key 这一行——它决定了"什么算相同请求"。

下面这段是可直接运行的骨架(生产环境请把进程内字典换成 Redis,并加上超时与限流)。它实现了完整闭环:算键 → 查 L1 → 命中即返 → 未命中转发 → 回写。

import hashlib, json, time, uuid from fastapi import FastAPI, Request, Response from openai import OpenAI app = FastAPI() # L1 缓存:进程内字典,仅作演示;生产用 Redis 并设 TTL CACHE = {} # 上游模型客户端;多模型时按名字路由 client = OpenAI(base_url="http://upstream-model:8000/v1", api_key="EMPTY") # 命中与未命中的计数,供 5.4 仪表盘取数 STATS = {"hit": 0, "miss": 0} def make_key(model: str, messages: list) -> str: """键设计核心:模型名 + 消息规范化序列化。 只保留 role 与 content,剥离推理字段、顺序抖动和空格差异, 保证'语义等价的请求'落到同一个键上。""" norm = [{"role": m["role"], "content": m["content"]} for m in messages] payload = json.dumps( {"model": model, "messages": norm}, ensure_ascii=False, sort_keys=True, ) return hashlib.sha256(payload.encode("utf-8")).hexdigest() @app.post("/v1/chat/completions") async def chat(req: Request): body = await req.json() model = body.get("model", "default") messages = body.get("messages", []) key = make_key(model, messages) if key in CACHE: STATS["hit"] += 1 # 精确命中:直接回放缓存的响应体 return Response(content=CACHE[key], media_type="application/json") STATS["miss"] += 1 # 未命中:剥离网关自用字段后转发上游 forward = {k: v for k, v in body.items() if k not in ("model", "messages")} upstream = client.chat.completions.create( model=model, messages=messages, **forward, ) raw = upstream.model_dump_json() CACHE[key] = raw # 回写 L1 return Response(content=raw, media_type="application/json") @app.get("/cache/stats") async def stats(): total = STATS["hit"] + STATS["miss"] rate = STATS["hit"] / total if total else 0 return {"hit": STATS["hit"], "miss": STATS["miss"], "hit_rate": rate}

启动后,POST /v1/chat/completions 行为与直连模型一致,但相同的请求第二次起会走缓存分支。访问 /cache/stats 能看到实时命中率——这正是 5.4 要接走的原料。

键设计的两个要点

键怎么算,直接决定缓存是"精准省钱"还是"误伤正确性与命中率"。两个要点:

要点 做法 不做的后果
模型名入键 不同模型输出分布不同,必须分桶 跨模型串缓存,返回错误模型的回答
消息规范化 仅保留 role/content,排序后序列化 字段顺序、空格差异导致键漂移,命中率暴跌

把模型名放进键,是因为"同一句话问两个模型"本就该是两次不同计算;把消息规范化,是因为 JSON 序列化时字段顺序、空白、无关的 metadata 都会让哈希值漂移,明明相同的请求却被判成不同。

网关缓存与引擎缓存不是替代关系

容易混淆的一点是:网关这层缓存和 5.1 的引擎前缀缓存是不是二选一?不是。它们是两道不同维度的复用:引擎层复用的是"字节级相同的前缀",省的是 prefill 算力;网关层复用的是"完全相同的一次请求",连生成都省了。一个请求如果前缀相同但问题不同,引擎层命中、网关层不命中;一个请求如果整体相同,两层都命中,网关层直接短路,引擎层根本不会被调到。所以生产里通常是网关做第一道精确拦截,引擎做第二道前缀复用,两层叠加才把回收率拉满。

流式响应的特殊处理

上面骨架是一次性返回。真实业务大量使用流式(stream=True),token 边生成边吐。流式下缓存不能直接缓存"响应体",因为响应是按块推送的。思路有两种:

其一,对"完全命中"的流式请求,网关自己按缓存好的完整文本切分后模拟流式逐块下发,对外表现与上游一致;其二,对未命中请求,网关边转发上游的流边把块拼回完整文本,流结束后再回写完整体到缓存,下一次同样的请求就能走其一的模拟流式。

无论哪种,网关都要在流结束时才确定"该回写什么",所以不能像非流式那样拿到响应就立刻写。这是流式场景最容易被忽略的一处:缓存写入点必须从"收到响应"后移到"流关闭后"。还有一个细节:流式命中时,网关模拟下发的"块"节奏最好接近上游,避免前端因为节奏突变而超时或重连。这不是正确性问题,是体验问题,但在长文本场景会被用户明显感知。

上线前要想清楚的几件事

演示骨架能跑通逻辑,但真上线还有几道必须过的关。

第一,失效与版本。上游模型升级、系统提示微调之后,旧缓存的响应就过时了,却可能因为键没变而一直被命中。对策是把提示词版本号或模型版本号打进键,或者给缓存设 TTL。否则你改了提示词,用户却还在收旧回答。

第二,缓存中毒防护。未命中时网关把请求转发上游,如果上游那一刻被投毒或抽风,那条坏响应会被写进缓存、被后续命中反复放大。回写前对响应做基础校验(非空、格式合法、长度合理)是低成本高收益的兜底。

第三,多实例一致性。演示用进程内字典,多副本部署时每个实例各存各的,命中率会被严重稀释。生产必须换 Redis 这类共享存储,让所有副本查同一份缓存。

第四,容量与逐出。L1 不能无限增长,要设上限加 LRU 这类逐出策略,否则内存随请求数线性膨胀,反而拖垮网关本身。

怎么验证这层真的有用

上线不是终点,验证才是。网关自带统计接口,但它只给总量。真正该看的是"接入网关前后,同一批流量的整体命中率变化":挑一条高重复的业务线,接网关跑一天,把命中率曲线和接入前对比。如果曲线没抬升,先别怀疑网关,去查键——多半是消息规范化没做干净,导致相同请求算出不同键。网关的价值不在"它存在",而在"它让命中率可被人为抬高"。

这一层给后面留的接口

网关把所有流量收拢到一处,意味着 5.3 的 Agent 优化和 5.4 的埋点统计,都能在这个拦截点统一做:在 make_key 之前插入"消息规范化策略",就能专门照顾 Agent 的追加式前缀;在命中/未命中分支里补一行日志,5.4 的三张仪表盘就有了数据源。插这一层,短期看是多写了百行代码,长期看是把"降本"和"看得见"两件事一次性买断了。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U