大模型应用工程:响应慢?先找对瓶颈

点下“生成报告”,页面开始转圈。

五秒过去了,没有文字;十几秒后,答案突然整块出现。换一份材料,又变成等了很久才提示“请求超时”。你重新点一次,后台却出现了两份报告。

遇到这些情况,我们很容易把问题归结为“模型慢”。于是换模型、缩短提示词、把超时时间改大,再给页面加一个更漂亮的进度条。可是改完之后,有时只对一个请求有效,人一多又开始排队;有时前端看起来快了,结果保存和费用记录却更混乱。

一个大模型应用的速度,取决于整条请求链路。模型推理只是其中一段。

这篇文章以“上传资料后生成分析报告”为例,从 API 接入一路拆到流式传输、异步任务、日志、部署和成本。你可以把它替换成知识问答、智能客服、PPT 生成或文档分析,排查方法是相通的。

阅读说明:文中的架构图是设计示意,时间数字是解释方法用的假设案例。Python 示例仅使用标准库和模拟流,不调用真实模型,不产生模型费用,也不构成任何模型的性能测试。

一、用户说“慢”,可能在说三件不同的事

先别急着优化。把用户的抱怨换成可以记录的时间点,问题往往就清楚了一半。

第一种慢:很久看不到有用内容。 用户点下按钮,界面一直转圈。模型可能已经在生成,浏览器却迟迟没有显示;也可能模型根本还没开始,请求正在队列里等待。

第二种慢:文字出来很快,但完整结果很久才交付。 开头一两句立即出现,后面却持续生成几分钟。对于报告、PPT 或文件导出,模型输出结束后,还要经过格式校验、资源处理和文件保存。

第三种慢:大部分时候正常,偶尔特别慢。 这类问题容易被平均值掩盖。它可能与并发、长输入、重试、冷启动或某个外部依赖有关。

指标建议的测量口径能回答什么
前端首段可见时间用户提交到答案正文首次显示用户等了多久才看到内容?
后端首段文本时间应用开始处理到收到第一个非空文本增量正文何时到达后端?
排队时间进入待执行状态到拿到执行槽位是否还没开始就等了很久?
端到端完成时间提交到结果通过必要校验、保存并可获取用户什么时候真正拿到成品?
P50 / P95固定时间窗与同类请求下的耗时分位数典型体验和较慢请求怎样?
失败率 / 取消率明确分母、时间窗和错误类型速度改善是否以失败增加为代价?

“收到响应头”“收到心跳”“收到正文第一段”是不同事件。不要发一个 processing 就宣布首字延迟优化成功。对一些推理模型,内部计算阶段也未必立即产生可展示正文。

P95 可以粗略理解为:在所统计的样本中,约 95% 的请求耗时不超过这个值。它不是“所有慢请求的平均值”。样本很少时,不要拿一个看似精确的分位数下定论。超时请求也要单独计数,不能从图表里消失后,再宣称延迟全面下降。

二、沿请求链路找瓶颈,别把所有时间都算给模型

大模型请求链路与测量口径

一个常见的报告生成链路是:

用户提交
  → 鉴权、输入检查、额度检查
  → 等待执行槽位
  → 文档解析、检索、准备上下文
  → 请求模型、接收增量内容
  → 校验、排版、保存结果
  → 用户获得可查看或下载的成品

对于没有重叠的串行阶段,可以近似写成:

总耗时 = 接入耗时 + 排队耗时 + 准备耗时 + 生成耗时 + 交付耗时

如果检索和独立的数据查询并行进行,就不能再把它们简单相加,应看决定任务完成时间的关键路径。父阶段已经包含子阶段时,也不要重复累加。

假设一次任务总共 20 秒:接入与排队 3 秒、准备材料 5 秒、模型生成 10 秒、交付 2 秒。即使将模型部分缩短到 5 秒,总耗时也只是从 20 秒变成 15 秒。用户依然要等待,而且如果真正问题在高峰排队,换模型可能并没有解决根因。

一个实用的排查动作,是找同一类请求的三份记录:一次正常、一次很慢、一次失败。沿同样的阶段比较,通常比盯着一张总体平均耗时图更有帮助。

记录时间还有一个细节:同一进程内测耗时,使用单调时钟;跨服务关联,依靠请求标识和链路追踪。 不要直接拿浏览器时钟减去另一台服务器的时钟,它们可能存在偏差。

三、流式输出:让内容更早可读,也要让结束状态可信

流式输出把逐步产生的内容及时送到用户面前。它主要改变“什么时候能开始阅读”,不保证模型生成速度或任务总耗时一定下降。

以 OpenAI Responses API 为例,流中存在不同类型的事件:文本增量、生命周期事件以及错误等。接入时需要按事件类型处理,不能把每条事件都当作答案正文。官方流式响应说明

应用层可以进一步转换成自己定义的事件协议,例如:

event: delta
data: {"task_id":"demo-42","seq":7,"text":"本节结论是……"}

event: completed
data: {"task_id":"demo-42","result_id":"result-42"}

这只是本文的应用协议示例,不是某个模型供应商的原始事件格式。completed 的业务含义也要写清楚:如果页面承诺的是“报告已生成”,就应该在结果完成必要校验与保存之后再发,而非收到模型结束事件后立即宣告交付。

SSE 使用 text/event-stream,事件通过空行分隔,可以携带事件名称、数据和事件 ID。心跳能帮助维持连接,但不代表业务取得进展。MDN 的 SSE 说明

实际接入时,值得专门检查四件事:

  1. 是否真的逐段透传。 后端如果先拼完整结果,再一次性返回,前端即使写了流式读取,也无法提前显示。
  2. 连接结束是否有明确结果。 网络断开、正常完成、模型失败、应用校验失败,需要不同状态。不能用“读到 EOF”直接判断成功。
  3. 部分内容怎么处理。 已显示了半篇报告后发生错误,应保留已收到内容并标记未完成。静默重跑并拼到原文后面,容易重复段落。
  4. 浏览器是否频繁重排。 每个很小的片段都触发全文 Markdown 解析和代码高亮,可能让页面越来越卡。可以合并短时间内的增量更新,结束后再做一次完整排版;具体间隔应实测。

如果使用浏览器原生 EventSource,还要考虑它的连接方式与鉴权能力;需要 POST 请求体或自定义请求头时,可以选择 fetch 流式读取等方案,并正确处理协议分帧。网络读取的一块字节,不一定恰好是一条完整事件,也不一定对应一个完整字符。

恢复连接也有边界:事件 ID 能帮助设计续传,但服务器仍需保存可回放事件或最终结果。浏览器重新连上,不等于任务自动具备断点恢复。

四、为什么本地能流式,上线却变成“一口气全出来”?

这类现象很容易误判成模型接口变化。实际上,内容从模型到浏览器要经过多个中间层:应用框架、反向代理、网关、CDN,再到前端解析。

先分别记录后端收到增量的时间和前端显示增量的时间。如果后端一直在收,前端长时间不更新,就沿传输与渲染链路继续定位。

Nginx 的响应缓冲是一个检查点。下面是一个局部配置示意,不是适用于所有站点的完整部署配置:

location /api/stream/ {
    proxy_pass http://127.0.0.1:8000;
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 90s;
}

proxy_buffering off 针对这个路径关闭响应缓冲。proxy_read_timeout 限制的是上游相邻读取操作之间的等待时间,不是整次请求的总时长;示例中的 90 秒只是占位配置,必须根据业务与整条代理链核对。Nginx 官方模块说明

不要看到卡顿就把所有超时都改成十分钟。这样可能只是让异常连接占用资源更久。应用仍需要独立的总截止时间、取消机制和无进展检测。

部署验证至少走两条路径:一次直连应用,一次经过用户实际访问的域名。只有第二条才能覆盖真实的代理、网关与浏览器行为。升级后也应核对运行版本,避免本地修复已经完成,线上进程仍在运行旧代码。

五、async、并发和持久后台任务,是三个不同的问题

写了 async def,不意味着任务会自动变快,也不意味着刷新页面后任务不会丢。

异步代码适合让程序在等待网络等 I/O 时处理其他工作。它不会自动把耗 CPU 的文档转换变成并行计算,也无法阻止同步阻塞调用卡住事件循环。Python asyncio 官方说明

并发则回答“同一时间允许多少工作往前走”。有十个用户同时提交,不一定就该同时发十个模型请求。模型额度、连接池、CPU、内存、外部工具,都可能成为限制。

持久后台任务回答的是另一件事:页面离开、进程重启或 Worker 异常之后,任务还能不能被找回和处理。 进程内创建一个协程,不会凭空获得这些能力。

方案适合的情形需要注意
普通同步响应简短、耗时可控的小操作用户持续等待完整结果
流式响应用户需要尽早阅读的文本回答连接、分帧、终止状态与取消
进程内异步工作可以接受进程结束后丢失的轻量工作不等于持久任务系统
持久任务队列与 Worker报告、批处理、多步骤生成与导出状态持久化、重复投递、恢复与结果归属

流式和后台任务也可以组合:先创建持久任务,再订阅进度或查询状态。用户关掉页面,任务仍按产品定义继续或取消;用户回来后,通过同一个任务 ID 找回状态。

六、长任务要有状态机,别让“成功”只是一句提示

持久任务与结果交付状态示意

一个简化设计,可以让任务经历:

queued → running → finalizing → succeeded
              ↘ failed / cancelled
              ↘ reconciliation_required(需要核对外部执行结果)

这些状态是本文的设计建议,名称不必照抄。关键在于每次转换要有事实依据。

running 可以表示 Worker 已领取任务;finalizing 表示生成结束、正在校验与保存;succeeded 表示用户需要的结果已经准备好。报告文件写失败时,即使模型输出完整,也不能把业务任务标成成功。

还要防住几个常见缝隙:

任务写入数据库成功,但没有投递到队列。 可以采用事务性 Outbox 等设计,或提供可核对的补偿投递机制,让“有记录、无人执行”的任务不会一直挂着。

同一条任务被投递两次。 消费者要能识别重复执行,状态更新应有并发条件,输出写入也要有防重复机制。使用队列不等于自动实现“恰好执行一次”。Celery 文档同样强调任务幂等性与消息确认行为需要结合设计。Celery 任务说明

Worker 消失了,任务还显示执行中。 需要租约、心跳或其他失联识别机制。不过发现失联后,也不能一律重新发起外部有副作用的动作:外部操作可能已经成功,只是结果尚未写回来。

用户要求取消,但任务刚好完成。 取消是一个有竞争条件的过程。可以记录取消请求,再根据实际执行结果确定终态;不要仅因用户点了按钮,就宣告外部工作已经停止。

任务查询接口也必须校验归属。知道一个任务 ID,不应该就能读取别人的报告。事件回放、缓存和下载链接都要保持同样的权限边界。

七、API 接入:把超时、重试和幂等一起设计

接入模型 API 时,最容易写出的一行代码是“失败就重试三次”。真正困难的地方,是判断失败意味着什么。

情形更合适的处理方向容易踩的坑
参数不合法修正输入与接口适配原样重试不会自动变合法
认证失败核对服务端配置与权限无限重试、把密钥写进日志
临时限流或服务过载根据错误体和等待提示有限重试并发重试造成第二轮拥堵
已输出部分内容后断流标记未完成,核对任务与供应商状态静默重复生成并拼接
提交后等待超时将执行结果视为待确认,按接口能力查询把超时等同于未执行
用户取消向本地与上游传播取消,并保留结果记录认为取消必然立即停止计费

对于临时限流,OpenAI 文档建议结合错误类型与 Retry-After 等信息处理;并非所有 429 都能靠等待解决。重试应有次数和总时间上限,并考虑 SDK 本身是否已经自动重试。官方限流说明

在产品自己的“创建任务”接口中,可以引入幂等键:同一个用户、同一次业务提交,重复请求应返回同一任务,而不是再创建一份。还应校验请求内容摘要:同一个键带来不同内容时,不能悄悄复用旧任务。

但自己的任务幂等,不等于供应商的生成请求也幂等。要确认外部接口是否支持幂等键、任务查询或恢复;如果都不支持,未知执行结果就必须保留为未知,而不能凭猜测补成“未执行”。

可以把一次业务任务与多次尝试分开记录:task_id 代表用户要做的事,attempt_id 代表某一次执行尝试。这样才能回答“这份报告到底重试了几次”“哪次请求收到了部分输出”。

八、日志要能串起来,还要避免越记越乱

只打印“开始调用模型”和“调用失败”,很难支持真实排查。更实用的是,为关键阶段记录结构化字段,并用请求或任务标识关联。

字段组示例字段排查用途
关联关系task_id、request_id、attempt_id、trace_id把同一次业务的多段操作串起来
执行阶段stage、status、error_type确认卡在哪一步
时间queue_ms、first_text_ms、duration_ms分离排队、首段与总耗时
输入输出规模input_tokens、output_tokens、cached_tokens对比同类请求与费用来源
运行版本model、prompt_version、app_version判断模型、提示词或部署变更影响
结果与费用result_id、cost_status、usage_source分开核对交付和计费

这些字段并不需要全部作为监控指标的标签。request_id 等高基数字段更适合日志与追踪;直接放进时序指标标签,可能导致监控存储迅速膨胀。

OpenTelemetry 使用 Trace 串联相关操作,Span 表示其中的工作单元。可以把接入、排队、检索、模型调用、文件保存拆成不同 Span,跨服务传播关联上下文。OpenTelemetry Traces 说明

注意,供应商返回的 Token 用量可能缺失。客户端断流时也可能没收到最终 usage。此时保留空值和来源说明,比填一个 0 更可靠。没有文本也不意味着一定没有产生推理或其他可计费工作。

默认日志不必保存用户完整资料、密钥和整段提示词。可以记录经过授权的摘要、长度、版本和错误分类。需要排查具体内容时,再使用有限访问、明确保留期的诊断记录,避免为了调试把整个知识库复制进日志系统。

九、动手:写一个能区分“慢”和“没完成”的离线观察器

下面的代码可以保存为 latency_demo.py,用 Python 3.10 及以上版本运行,不需要安装第三方库:

python latency_demo.py

它故意模拟五种情况:正常完成、中途报错、流结束但没有完成事件、只有心跳没有正文、长时间停滞。并发上限设为 2,整个任务的截止时间包含排队过程。

"""Offline teaching demo: simulated chunks, not a model or HTTP benchmark."""
import asyncio
import json
import time


async def fake_stream(mode="ok"):
    await asyncio.sleep(0.01)
    yield "heartbeat", ""  # Heartbeats are not answer text.
    if mode == "stall":
        await asyncio.sleep(60)
    if mode != "empty":
        for text in ("先定位", "耗时阶段", ",再优化。"):
            await asyncio.sleep(0.01)
            yield "delta", text
            if mode == "error":
                raise ConnectionError("simulated disconnect")
    if mode != "truncated":
        yield "completed", ""


async def observe(stream_factory, gate, timeout_s=1.0):
    started = time.perf_counter()
    report = {"status": "pending", "queue_ms": None,
              "first_text_ms": None, "total_ms": None,
              "text": "", "error_type": None}

    def elapsed():
        return round((time.perf_counter() - started) * 1000, 2)

    async def consume():
        async with gate:
            report["queue_ms"] = elapsed()
            report["status"] = "running"
            source = stream_factory()
            try:
                async for kind, text in source:
                    if kind == "delta" and text:
                        if report["first_text_ms"] is None:
                            report["first_text_ms"] = elapsed()
                        report["text"] += text
                    elif kind == "completed":
                        report["status"] = (
                            "completed" if report["text"] else "empty_output")
                        return
                report["status"] = "incomplete"  # EOF is not success.
            finally:
                await source.aclose()

    try:
        # The deadline covers both queue waiting and stream consumption.
        await asyncio.wait_for(consume(), timeout=timeout_s)
    except asyncio.TimeoutError:
        report["status"] = "timeout"
        report["error_type"] = "TimeoutError"
    except asyncio.CancelledError:
        report["status"] = "cancelled"
        raise  # Let the owner observe cancellation.
    except Exception as exc:
        report["status"] = "failed"
        report["error_type"] = type(exc).__name__
    finally:
        report["total_ms"] = elapsed()
    return report


async def main():
    gate = asyncio.Semaphore(2)
    modes = ("ok", "error", "truncated", "empty", "stall")
    reports = await asyncio.gather(*(
        observe(lambda mode=mode: fake_stream(mode), gate, timeout_s=0.3)
        for mode in modes
    ))
    for mode, report in zip(modes, reports):
        print(json.dumps({"scenario": mode, **report}, ensure_ascii=False))


if __name__ == "__main__":
    asyncio.run(main())

运行时,每行输出是一种场景的观察结果。预期状态分别是 completed、failed、incomplete、empty_output 和 timeout。具体毫秒值受电脑负载和调度影响,不应拿来评价任何模型或供应商。

这段代码有几个值得保留的习惯:

  • first_text_ms 只在收到非空正文时设置,心跳不会冒充首段输出。
  • wait_for 包住排队与消费过程,避免排队时间悄悄逃出总预算。
  • 发生中途错误时保留部分文本,不自动重发请求。
  • 没有明确完成事件,即使收到了完整样子的句子,也标记为 incomplete。
  • 使用 finally 关闭模拟流,取消异常继续向上传播。

本文配套离线检查覆盖了上述边界,以及并发上限、取消传播、超时后释放槽位等,共 8 项。这些检查验证的是模拟观察逻辑,不证明真实 SSE 网关、模型接口或持久任务系统已经通过验收。

同样,这里收集的是“本地观察器收到文本”的时间,不是用户屏幕真正显示内容的时间;它也没有 HTTP 解析、鉴权、重试、账单核对、持久化和生产级背压控制。接入真实服务时,需要把供应商事件映射到明确的业务事件,再逐项补齐。

还有一个边界:asyncio 的取消属于协作式机制。真实底层代码如果阻塞事件循环或不及时响应取消,截止时间并不等于强制终止所有远端工作。示例中的状态是本地观察结果,不能直接拿来确认供应商是否停止执行。

十、优化顺序:先减少无效等待,再讨论换模型

定位以后,可以按瓶颈选择动作,而不是同时改十个参数。

如果主要慢在排队,先看并发槽位、队列长度、连接池和流量分布。短问答与长报告共享同一个小池子时,长任务可能挤占短任务体验。可以在配额允许的范围内设置不同任务通道,同时保留整体上限,避免拆成多个队列后总并发反而失控。

如果主要慢在准备阶段,检查是否重复解析同一文件、重复生成向量或检索过多无关资料。对有明确输入版本的中间结果做缓存,往往比盲目增加机器更直接。缓存键应考虑用户或租户、权限、文档版本与处理规则,不能只用一个文件名。

如果主要慢在生成阶段,再考虑更合适的模型、输出长度、请求次数与可并行步骤。减少不必要输出和串行调用,是官方延迟优化指南讨论的方向;具体能改善多少,需要在自己的任务集上验证。官方延迟优化指南

“输出越短越好”也不成立。把必须的依据删掉,可能换来更快但不可用的回答。更合理的目标,是在满足业务质量的前提下减少无效工作。例如先返回结论与关键依据,详细附件在用户确实需要时再生成。

如果主要慢在交付阶段,就去看 Markdown 渲染、图片处理、文件写入、对象存储和下载权限。此时继续调提示词,通常离瓶颈很远。

提示缓存和业务结果缓存也要分开。前者与模型输入中的可复用前缀有关,不是直接返回一份旧答案;后者复用业务结果,更依赖权限和数据新鲜度。供应商的缓存条件、有效期和计费规则应按所用服务核对。OpenAI 提示缓存说明

十一、成本控制:算“有效交付”,别只看单次请求价格

一个调用价格很低的方案,如果经常失败、重试或产出不可用结果,整体成本未必更低。

可以在明确统计口径后观察:

单位有效交付成本
= 同一统计范围内的相关总成本 ÷ 通过验收并交付的任务数

分子可以分别列模型费用、工具费用、存储和计算费用,避免把不同来源混成一个无法追溯的数字。分母也要写清楚:是生成成功,还是用户真正收到并且通过质量检查的成品?

预算控制需要区分三个阶段:

阶段表示什么不能直接等同于什么
提交前预估与预留为任务保留可使用额度已发生的真实费用
执行中的用量记录已观察到的请求、尝试与供应商 usage已完整核对的账单
任务后的对账按来源确认费用并处理预留差额所有未知费用都可以归零

预留额度时也要考虑并发。若两个请求同时读到相同余额,再各自批准执行,合计就可能超预算。检查与预留应当具备原子性;结束后释放或结算时,也要防止重复处理。

对一次超时请求,可以先记录“费用待核对”,保留供应商请求标识和尝试信息,再根据 usage、后台任务状态或账单确认。不要因为本地没有拿到结果就退款式归零,也不要因为预留了预算就认定已经全部花掉。

实际的成本优化经常很朴素:限制意外超长输出、减少无意义重试、避免重复提交、复用允许缓存的中间结果、对不同难度任务选择合适模型。涉及质量取舍时,应当用自己的验收数据做决定。

十二、最后用一张表,验证优化是不是落到了用户体验上

做一次变更,只测“页面能打开”是不够的。准备固定的输入集合,记录输入规模、模型配置、版本、并发与缓存条件,再比较改动前后。

验证场景要观察什么通过标准应包含什么
一条普通问答首段、总耗时、答案质量内容正确且显示及时
一份长资料报告准备、生成、保存分别耗时结果完整、可获取、归属正确
并发提交排队、限流、P95、失败率资源受控,无无限堆积
中途断网或刷新重连、查询、部分内容状态可解释,不静默重复创建
上游临时失败重试次数、等待与总截止时间有限恢复,不制造重试风暴
Worker 或应用重启租约、补偿、结果核对不永久卡住,不重复副作用
取消与预算耗尽实际停止状态、预留与费用不把请求取消冒充确认停费

如果想快速开始,可以只做三件事:

  1. 给一条真实请求加上贯穿全流程的标识。
  2. 记录排队、首段文本、生成结束、结果就绪四个关键时间点。
  3. 挑一个最明显的瓶颈,只改一处,再用同样条件复测。

优化大模型应用,最有价值的变化往往是:用户能更早看到有用内容;长任务离开页面后仍能找回;失败时知道停在哪里;每次重试与费用都有依据。

这些能力连在一起,才能把一个“能调用模型的页面”,变成一个值得持续使用的应用。


参考资料与延伸阅读

以下为撰文时核对的官方资料,具体接口、事件与参数应以实际使用版本为准:

  1. OpenAI:延迟优化
  2. OpenAI:流式响应
  3. OpenAI:限流与错误恢复
  4. OpenAI:提示缓存
  5. MDN:使用 Server-Sent Events
  6. Nginx:HTTP 代理模块
  7. Python:协程与任务
  8. Celery:任务设计
  9. OpenTelemetry:Traces

系列前文:Agent 工具调用与执行闭环 · Agent 记忆与上下文管理

本文含 AI 辅助创作与生成封面;架构图为自绘示意。教学代码仅做离线验证,真实生产环境的模型效果、吞吐、计费与恢复能力需要独立测试。

Logo

更多推荐