2627 字
约 8 分钟
14
S20-X10 · 一次 RAG 请求的耗时拆解与优化

"RAG 太慢"这句话没有信息量。一次请求里有六段耗时,有的毫秒级、有的几秒级,优化错地方等于白改:把向量检索从 40ms 压到 20ms,用户完全感知不到;而把首 token 从 2.8s 降到 1.2s,体感是质的差别。

所以性能问题的第一件事不是优化,是分段计时。


1. 六段耗时与典型量级

段落 做什么 典型量级(自测,非基准) 占比感
查询改写 补全指代、扩写、抽取过滤条件 10–300ms(含小模型调用则更高) 小
embedding 查询向量化 5–50ms(API 更高) 小
向量检索 ANN + filter 5–100ms 小到中
rerank 交叉编码器重排 30–400ms(随候选数线性) 中
LLM 首 token 排队 + 预填充 200ms–3s 大
LLM 生成 逐 token 解码 由输出长度决定 最大

前四段加起来常常不到 500ms,而用户抱怨的"慢"几乎总在最后两段。优化资源应该按这个比例分配。

import time, functools

def timed(name, sink):
    def deco(fn):
        @functools.wraps(fn)
        def wrap(*a, **kw):
            t0 = time.perf_counter()
            try:
                return fn(*a, **kw)
            finally:
                sink[name] = round((time.perf_counter() - t0) * 1000, 1)
        return wrap
    return deco
SPANS = {}

@timed("rewrite", SPANS)
def rewrite(q): ...
@timed("embed", SPANS)
def embed(q): ...
@timed("search", SPANS)
def search(v): ...

def handle(q):
    q2 = rewrite(q)
    v = embed(q2)
    hits = search(v)
    SPANS["total"] = None
    return hits

典型输出(示意):

{'rewrite': 12.4, 'embed': 18.9, 'search': 41.2, 'rerank': 96.7,
 'llm_ttft': 810.0, 'llm_gen': 4260.0}

看到这张表就知道:rerank 的 96ms 值得看一眼,search 的 41ms 不值得。


2. 各段优化手段

段落 手段 收益 代价
查询改写 规则优先,小模型兜底;并发做 10–200ms 逻辑复杂
embedding 批处理、结果缓存、本地小模型 5–40ms 缓存失效管理
向量检索 调 ef_search、加 filter 下推 10–80ms 召回下降
rerank 候选 20–30 条,输入截断 50–300ms 精度略降
首 token 前缀缓存、缩短 prompt、并发排队 数百 ms–2s 需模型侧支持
生成 流式输出、控制输出长度 感知延迟大幅下降 无

3. embedding:批处理与缓存

单条编码的固定开销(网络、序列化、模型启动)远大于计算本身。所以在线只编一条是浪费,离线必须批量。

def embed_batched(texts, model, batch=64):
    out = []
    for i in range(0, len(texts), batch):
        chunk = texts[i:i + batch]
        out.extend(model.encode(chunk, normalize_embeddings=True))
    return out

_cache = {}

def embed_query(q, model):
    key = (model.version, q.strip())
    if key not in _cache:
        _cache[key] = model.encode([q], normalize_embeddings=True)[0]
    return _cache[key]
缓存对象 命中率 失效条件 建议
查询向量 高频问题高 模型版本变更 必做
整篇答案 中(FAQ 类高) 语料/模型变更 高风险场景慎用
rerank 结果 低 候选集变化即失效 一般不做
检索结果 中 索引更新 用短 TTL

注意 _cache 的 key 必须带模型版本——否则换模型后旧向量继续被复用,静默召回错乱(见语义漂移那篇)。


4. 向量检索:参数是第一杠杆

参数 影响延迟的方式 低延迟取值 代价
ef_search 候选池宽度 32–64 召回下降
num_candidates ES 的候选预算 2×k 起 过滤场景召回不足
索引类型 图 vs 倒排 HNSW 内存
filter 下推 是否在索引内过滤 尽量下推 依赖引擎
副本数 并发能力 按 QPS 定 内存与成本
def search_params(k, filtered: bool):
    return {
        "hnsw_ef": 128 if filtered else 64,     # 过滤场景给足
        "exact": False,
        "params": {"k": k},
    }

一个常被忽略的点:返回字段越少越快。只取 id / title / text,不要 SELECT * 把整条文档连同向量一起搬出来——序列化开销常常比检索本身还大。


5. rerank:候选数决定一切

交叉编码器的耗时近似正比于 候选数 × 输入长度。两个旋钮都能剪。

def rerank_budgeted(q, cands, reranker, max_chars=1200, top_n=8):
    inputs = [c["text"][:max_chars] for c in cands]
    scores = reranker.score(q, inputs)       # 批量调用,别逐条
    order = sorted(range(len(scores)), key=lambda i: -scores[i])
    return [cands[i] for i in order[:top_n]]
候选数 延迟(相对) 精度 建议
10 1× 一般(漏掉好答案) 小语料可用
20–30 2–3× 接近饱和 推荐区间
50 5× 饱和 只在离线评测用
100+ 10× 无提升 不要

批量调用与逐条调用的差距常在 3–10 倍,因为每条都要付一次请求开销。这是最容易拿到的性能优化。


6. 首 token:前缀缓存与 prompt 结构

预填充(prefill)的成本正比于输入 token 数,而输入里最大的一块通常是固定的系统提示 + 检索上下文。

手段 机制 效果
前缀缓存 相同前缀的 KV 复用,跳过重复预填充 首 token 明显下降(按所用推理框架是否支持核对)
缩短系统提示 直接减少 token 线性下降
上下文裁剪 少放无关 chunk 线性下降 + 质量提升
并发排队优化 减少等待 看排队分布

前缀缓存生效的前提是稳定前缀:系统提示、few-shot 示例必须逐字节一致,且放在最前面。把动态内容(时间戳、用户 ID)插在系统提示里,缓存就永远命不中。

SYSTEM = """你是资料检索助手。规则固定如下:
1) 只使用编号资料中的事实;2) 每个事实标注 [n];3) 资料不足时明确说明。"""

def build_prompt(q, ctx):
    # 动态内容一律放在稳定前缀之后
    blocks = [f"[{i}] {c['text']}" for i, c in enumerate(ctx, 1)]
    return f"{SYSTEM}\n\n资料:\n" + "\n".join(blocks) + f"\n\n问题:{q}"

7. 流式输出:把感知延迟和真实延迟分开

指标 定义 优化目标
真实延迟 从请求到最后一字 总成本
感知延迟 从请求到第一字出现 首 token
完成率 用户读到结尾的比例 内容质量

用户对"1.5 秒开始出字、8 秒写完"的容忍度远高于"8 秒后一次性出现"。所以即使总耗时不变,流式也能明显改善体感。

def stream_answer(prompt, llm):
    first = None
    t0 = time.perf_counter()
    for delta in llm.stream(prompt):
        if first is None:
            first = time.perf_counter() - t0
            yield {"event": "ttft", "ms": round(first * 1000, 1)}
        yield {"event": "delta", "text": delta}
    yield {"event": "done", "ms": round((time.perf_counter() - t0) * 1000, 1)}

配套的产品侧动作:首字之前给进度提示("正在检索 3 个文档"),否则流式的优势会被空白等待抵消。


8. 并发下的资源竞争

单请求快不等于系统快。并发上来后,瓶颈会从"计算"变成"排队"。

资源 竞争表现 监控 处理
GPU 显存 批大小受限、OOM 显存占用、批队列长度 限制并发、动态批
模型连接数 排队、超时 p95 排队时长 连接池 + 信号量
向量库连接 连接耗尽、握手开销 活跃连接数 连接池复用
外部 API 配额 限流 429 计数 退避 + 本地缓存
线程/GIL(Python) CPU 段串行 CPU 利用率 异步 IO、进程池
import asyncio

SEM = asyncio.Semaphore(8)          # 保护下游模型服务

async def call_llm(prompt):
    async with SEM:
        return await client.generate(prompt)

信号量的大小要按下游能承受的并发定,不是按"我们希望多快"定。设太大只会把排队从本地搬到下游,超时反而更多。


9. 监控埋点:埋哪几段

埋点 类型 用途
每段耗时 histogram 定位瓶颈
首 token 时间 histogram 感知延迟
检索命中数 gauge 关联质量与延迟
rerank 候选数 gauge 解释延迟波动
拒答率 counter 与阈值联动
端到端 p50/p95/p99 histogram SLO

必须用 p95/p99,不看均值。 均值 800ms 可能对应 p99 是 6s,而用户记住的是那 1% 的糟糕体验。

def observe(spans, sink_hist):
    for name, ms in spans.items():
        if isinstance(ms, (int, float)):
            sink_hist.observe(name, ms / 1000)

10. 排错:五种"改了不觉得快"

现象 原因 处理
优化检索后体感无变化 检索只占 10% 先看分段表,改首 token
首 token 没降 系统提示里含动态内容,前缀缓存未命中 把动态内容后移
并发一上来全慢 下游连接数打满 信号量 + 连接池
rerank 越调越慢 候选数被悄悄放大 固定候选数并监控
p50 好看但投诉多 长尾没管 看 p99,限制最大输出长度

11. 边界

场景 说明
离线批处理 关注吞吐,不是延迟,可大 batch
嵌入式/端侧 显存与内存是硬约束,模型要换小
强实时(语音) 首 token 是唯一指标,宁可少检索
高并发低价值查询 加缓存层,别硬优化链路
多轮对话 历史增长会推高预填充,必须做窗口裁剪

12. 自测

  1. 一次 RAG 请求的六段耗时里,哪两段通常占绝对多数?
  2. 为什么"返回字段只取需要的"能明显提速?
  3. rerank 的候选数为什么建议在 20–30,而不是 50?
  4. 前缀缓存生效的前提是什么?把时间戳放进系统提示会怎样?
  5. 流式输出降低了真实延迟吗?它改善了什么?
  6. 为什么监控必须看 p99 而不是均值?
S20-X10 · 一次 RAG 请求的耗时拆解与优化
https://www.clxhxhhr.top/posts/5685/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。