AI 与大模型

大模型应用响应慢怎么优化

首字延迟高,往往不是模型不够快,而是你把三次模型调用串成了一条线。减少往返次数比换更快的模型有效得多。这篇拆解「响应慢」的六个方向和「状态混乱」的五个方向,并给出三个核心重构动作。

2026-06-04 约 17 分钟 6602 字 大模型 性能优化 SSE Agent

一、先算清楚慢在哪里

用户说「AI 回答要等六七秒」,这不是一个可以拿去优化的问题描述。得先把它拆成可度量的步骤。

一次 LLM 调用 = 一次完整的网络往返

① 客户端把 prompt 打包,HTTP 发给模型 API
② 模型开始逐 token 生成
③ 生成完(或到 max_tokens),整段结果返回

整个过程通常 1~3 秒,而用户在这期间什么都看不到。

那么「串行调多次」就等于把这些往返排队做,时间直接相加。看一个真实的售后场景:用户问「我买完就降价了,能赔吗?」

次序 这次调用在干什么 输出 耗时
第 1 次 把用户消息分类为导购 / 售后 / 闲聊 售后 1.2s
第 2 次 提取订单号和诉求,输出 JSON {"order_no": null, ...} 1.5s
第 3 次 根据判定结果生成回复 「请问方便提供订单号…」 2.8s
累计 5.5s

用户干等 5.5 秒才看到第一个字。

注意一个细节:第 1 次和第 2 次调用读的是同一句话。因为模型是无状态的,每次调用都要把上下文重新喂进去,重新理解一遍。这两次调用在信息上是高度重叠的。

用「医院排队」理解这件事

差的流程:
   去 1 号楼问「我该挂哪个科」   排队 20 分钟
   → 去 2 号楼填资料            排队 30 分钟
   → 回 1 号楼拿药              排队 15 分钟
   合计 65 分钟

好的流程:
   一个窗口把所有事办完,只排一次队

关键不在于每个窗口办得快不快,而在于你要排几次队。

「排队时间」= 一次 LLM 往返 + 模型生成时间。

这句话点出了优化的核心:单个窗口的速度你控制不了(模型就那个速度),但排队次数是你能控制的。

把三种手段的收益放在一起对比,优先级就很清楚了:

优化手段 收益
换更快的模型 每次调用快 20~30%,但调用次数不变
减少调用次数 3 次 → 2 次,直接省掉一整个往返
改流式输出 用户体感从「等完整生成」变成「等首 token」

减少往返次数和改流式,是这里性价比最高的两件事。

二、「响应慢」的六个方向

把慢的原因归类,常见的有六种。生产环境里通常同时存在两三种,但挑 2~3 个讲透比每个都提一句有用得多。

方向 现象 改法
1. 串行调 LLM 太多次 意图识别、信息提取、生成拆成三次独立调用,每次一个完整往返,串起来 4~6 秒 能合并的合并进一个 prompt
2. 没有流式输出 等整段生成完才返回,用户盯着空白等 3~5 秒 SSE 流式,首 token 就推给前端
3. 本该并行的串行做了 向量检索和 BM25 一前一后;重排等两路都完成才开始 两路召回并行,耗时取最慢那路
4. 小任务也用大模型 改写、意图识别这种任务用大模型,延迟差好几倍 按任务难度分配模型
5. 热点结果没缓存 同一个 query 反复算 embedding、反复检索 query→embedding / 结果做短时缓存
6. 没有超时降级 某个下游慢就拖死整条链路,表现为「偶尔特别慢」 每段设超时 + 失败降级

其中方向 1 和 2 最容易讲清楚,因为它们有明确的「之前 / 之后」对比,而且现象用户能直接感知——"要等六秒"、"盯着空白"。

优化后的时间线

步骤 做什么 耗时
第 1 次调用 合并「理解」类任务——意图识别 + 信息提取,一次输出结构化 JSON 0.8s
中间(不是 LLM) RAG 检索政策 + 规则引擎判断保价期 0.3s
第 2 次调用 生成回复,流式——首 token 出来就推给前端 0.9s(首 token)
用户看到第一个字 2.0s

首字延迟:5.5s → 2.0s。

中间那一步特别值得注意:检索和规则判定加起来 0.3 秒,而且完全不经过模型。把确定性工作从模型链路里摘出来,收益往往比压模型本身更大。

为什么不干脆全合并成一次

既然合并这么有效,那把所有调用合成一次不就完了?有三个代价:

代价 说明
① 任务性质不同 「理解」类要稳定、结构化输出;「生成」类要流式、自然语言。硬塞进一个 prompt 会互相干扰
② 中间要插检索 必须先知道意图,才能决定检索什么知识、调哪个工具 → 天然断点
③ 模型选型不同 理解类用小模型(快、便宜),生成类用大模型(质量高)。合并了就用不了两套

所以现实的做法是折中:性质相同的合并,性质不同的分开,生成阶段用流式把等待藏起来。

并行召回:省的是最慢那一路

方向 3 值得单独展开,因为它的收益是白捡的:

串行:
  向量召回 180ms → BM25 召回 120ms → RRF 融合 → Rerank 400ms
  合计 ≈ 700ms

并行:
  ┌─ 向量召回 180ms ─┐
  └─ BM25   120ms ─┘ → RRF 融合 → Rerank 400ms
  合计 ≈ 580ms(取最慢那路)
# 两路召回并行,再一起交给融合与重排
with ThreadPoolExecutor(max_workers=2) as pool:
    f_vec = pool.submit(vector_search, query, top_k=50)
    f_bm25 = pool.submit(bm25_search, query, top_k=50)
    vec_hits, bm25_hits = f_vec.result(), f_bm25.result()

# RRF 只用排名融合,绕开两路分数量纲不可比的问题
fused = rrf_fuse(vec_hits, bm25_hits)
final = rerank(query, fused)[:8]      # 精排取 top 5-8

这里的重排不能省。向量擅长语义但漏精确匹配(型号「H20」这类);BM25 擅长精确但不懂语义。家电、数码这类场景两者都需要,RRF 只用排名做融合,天然绕开了两路分数不可比的问题。

超时与降级:解决「偶尔特别慢」

方向 6 是最容易被忽略的一类。它的表现不是"一直慢",而是"大部分时候正常,偶尔卡住"——因为某次下游抖动把整条链路拖死了。

# 每一段都设超时,并定义降级路径
try:
    hits = vector_search(query, timeout=0.5)
except TimeoutError:
    hits = []                        # 向量库超时 → 只用 BM25 兜底
try:
    hits = hits or bm25_search(query, timeout=0.5)
except TimeoutError:
    hits = []                        # 两路都挂 → 降级为直接用工具查

reranked = rerank(query, hits, timeout=0.8) if hits else []
# 重排超时 → 直接用 RRF 融合后的顺序,不阻断问答

原则:每一段都要有超时,每一段都要有降级路径。

检索失败就退化成关键词检索,重排超时就退回融合顺序——宁可答得糙一点,也不能让整条链路卡死。

三、「状态混乱」的五个方向

响应慢是体验问题,状态混乱是正确性问题,后者更难查。

方向 具体表现
1. 状态分散在多个 Agent 里 每个 Agent 自己维护一份上下文,A 改了 B 不知道 → 同一件事被问两遍
2. 流程靠 Prompt 描述,是隐式的 提示词里写「你应该先确认订单号」,但模型不一定听话 → 同一输入跑出不同路径,线上无法复现
3. 状态在内存里 服务重启 / 用户离开就丢,用户回来要重说一遍
4. 没有明确的 State 定义 状态散落在各处变量里 → 加一个字段要改好几个地方
5. 多 Agent 互相调用成环 A 调 B,B 又调回 A → token 翻倍、延迟叠加

方向 2 最有杀伤力

用一句话实测:同一句「我买完就降价了」,早期实现跑 5 次出 5 条路径:

第 1 次:意图识别 → 判为「赔付」 → 询问订单号 → 规则判定 → 生成答复
第 2 次:意图识别 → 判为「闲聊」 → 推荐商品(跑偏了)
第 3 次:意图识别 → 判为「赔付」 → 跳过询问直接判定(缺订单号) → 判定失败
第 4 次:意图识别 → 判为「退货」 → 走退货流程(错的)
第 5 次:意图识别 → 判为「赔付」 → 询问订单号 → 又问了一遍 → … → 生成答复

最要命的不是某一条错了,而是线上出问题时无法复现:用户投诉「AI 乱答」,你在测试环境重跑一遍,可能完全正常。

根因在于:流程是隐式的,提示词是一段建议而不是约束。 大模型每次生成都是采样,把控制流交给它,路径不稳定就是必然的。

四、三个核心重构动作

# 动作 解决了什么
① 把隐式流程改成显式状态机 流程可以画出来给业务评审,每个节点能单独测
② 收敛成单一可信状态源 所有节点只读 State,只能通过 reducer 更新,不允许私存状态 → 直接解决「A 改了 B 不知道」
③ 状态持久化 checkpointer 存 Redis / MySQL,用 thread_id 关联会话 → 重启不丢、用户回来能接着聊

配套还有两个动作:

  • ④ 能合并的 Agent 合并掉——三个 Agent 各一次 LLM 往返,合并成一个 Agent + 多工具后,一次就够了
  • ⑤ 限制最大轮次——防止图跑飞(无限循环)

State 怎么定义

关键是分层,把控制流和内容分开:

class AfterSalesState(TypedDict):
    # 控制字段:决定往哪走,路由判断只读这里
    intent: Literal["after_sales", "shopping", "chitchat"] | None
    need_human: bool
    turn_count: int                  # 防止跑飞

    # 槽位字段:结构化的确定事实
    order_no: str | None
    issue_type: str | None
    missing_slots: list[str]

    # 结果字段
    rule_result: dict | None
    reply: str | None

控制字段和内容字段分开的好处是:路由判断永远不依赖对自然语言的解析。if state["need_human"] 是一个布尔判断,不是一次模型调用——路径因此变得确定。

状态更新只走 reducer

def rule_check(state: AfterSalesState) -> dict:
    """节点只返回自己要改的字段,不碰其他部分"""
    order = order_mapper.selectByNo(state["order_no"])
    if order is None:
        return {"need_human": True, "rule_result": {"reason": "订单不存在"}}

    days = (order.getPayTime().date() - today()).days
    if days < -PRICE_PROTECT_DAYS:
        return {"need_human": True, "rule_result": {"reason": "超出保价期"}}
    return {"need_human": False, "rule_result": {"within_period": True}}

这条约束看起来只是编码规范,实际解决的是方向 1:只要不允许节点私存状态,"A 改了 B 不知道"这件事在结构上就不可能发生。

重构后的效果

同一句话跑 5 次,路径完全一致。 问题从"偶发玄学"变成可复现、可定位,每个节点也能单独写测试。

这里最重要的一点:重心不是「用了 LangGraph」,而是「之前那样为什么不行」。

框架只是工具,真正解决问题的是把控制流从模型手里拿回代码这个决定。

五、把等待藏起来:SSE 流式输出

前面说「改流式」能把体感从"等完整生成"变成"等首 token"。具体怎么做?

为什么选 SSE 而不是 WebSocket

方案 适合场景 为什么不选
SSE 服务端单向推流 ✅ 浏览器原生 EventSource 支持,自带重连,格式标准
WebSocket 双向实时通信 只需单向推,却要额外承担心跳、重连、代理兼容成本
HTTP 轮询 兼容性最好的兜底 延迟高、请求量大
chunked 传输 与 SSE 接近 没有标准事件格式和自动重连

结论很清晰:要的只是服务端单向推 token,没必要为单向流付 WebSocket 的双向连接成本。

四个必须处理的异常

流式输出不是"把响应改成 chunked"就完了,下面四种情况都要考虑:

情况 处理方式
连接断开 EventSource 自动重连 + 传 lastEventId 续传
生成中断 前端保留已收到内容并提示可重试,不要清空
长时间无输出 发心跳事件保活,避免网关 / Nginx 超时断连
Nginx 缓冲导致「不流式」 关闭 proxy_buffering

最后一条是排查成本最高的坑:后端明明在逐 token 写,前端却一次性收到整段。原因是 Nginx 默认会缓冲上游响应。对应的配置是:

location /chat {
    proxy_pass http://backend;
    proxy_buffering off;          # 关键:不缓冲,边收边转发
    proxy_cache off;
    proxy_read_timeout 300s;      # 流式连接会持续较久
    chunked_transfer_encoding on;
}

后端侧的写法是持续写 data 事件:

async def event_stream(user_msg: str):
    yield "event: start\ndata: {}\n\n"
    async for token in llm.astream(build_prompt(user_msg)):
        yield f"data: {json.dumps({'t': token}, ensure_ascii=False)}\n\n"
    # 收尾工作交给 MQ,不阻塞这里的响应
    mq.publish("chat.log", collect_metrics())
    yield "event: done\ndata: {}\n\n"

注意倒数第二行的 mq.publish:对话日志落库、消息通知、评测埋点这类「必须做但不影响用户看到回答」的操作,不能阻塞主对话链路——否则首 token 延迟又白优化了。

六、小结

  • 一次 LLM 调用就是一次完整往返(1~3 秒),串行调用时间直接相加;优化的核心是减少排队次数,不是换更快的模型
  • 「响应慢」的六个方向:串行调用过多、没有流式、该并行没并行、小任务用大模型、热点无缓存、没有超时降级
  • 「理解」类任务可以合并成一次调用;不变的是中间要插检索,这是天然断点,所以不是全部都能合
  • 召回两路并行,耗时取最慢那路;每一段设超时,每一段有降级路径
  • 「状态混乱」的五个方向里,流程隐式化最难查:同一输入跑出不同路径,线上还无法复现
  • 三个核心动作:显式状态机、单一可信状态源、状态持久化;配套合并 Agent 和限制最大轮次
  • State 要分层,控制字段必须与内容字段分开——路由判断只读结构化字段
  • 流式输出选 SSE:单向、原生支持、自动重连;四种异常(断连、中断、无输出、Nginx 缓冲)都要处理
  • 日志落库、通知、埋点走 MQ 异步,不要阻塞主对话链路

这类优化最有意思的地方在于:它的瓶颈几乎从不在模型本身。 上游看起来很"AI"的问题——回答慢、前后不一致——最后都是用最传统的工程手段解决的:合并调用、并行 IO、加缓存、设超时、显式状态机、异步化。把模型当成一个又贵又慢的下游依赖来对待,该有的那套东西一个都不能少。

留下点什么

评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。