大模型应用响应慢怎么优化
首字延迟高,往往不是模型不够快,而是你把三次模型调用串成了一条线。减少往返次数比换更快的模型有效得多。这篇拆解「响应慢」的六个方向和「状态混乱」的五个方向,并给出三个核心重构动作。
一、先算清楚慢在哪里
用户说「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_* 四个变量后自动开启。