做后端和 AI 应用的朋友,应该都遇到过这种需求:让模型写一篇足够长的文章,又不能让用户等得想砸电脑。"Make It Long, Keep It Fast" 这句话,放在工程场景里就是一道经典难题——输出变长意味着计算时间变长,响应变快又要求延迟不能突破用户的心理阈值。我自己做长文生成工具时,这两个指标经常打架,后来把问题拆开看,才发现"长"和"快"其实各有各的瓶颈,也各有各的解法。
这篇文章就围绕"Make It Long, Keep It Fast"展开,聊一聊长文本生成场景下的速度优化方案。适用人群是正在做 AI 应用、聊天机器人、报告生成器,或者任何涉及大模型长输出的后端开发。内容包含从思路拆解到代码实现,再到稳定运行和质量评估的完整路径,算是给同样踩坑的同行一份可直接参考的工程笔记。
1. 先搞清楚 "Long" 和 "Fast" 到底指什么
很多方案一开始就做偏,是因为没把目标定义清楚。长不单指字数多,快也不单指返回快,先把维度拆出来,后面选型才不会左右摇摆。
1.1 "Long" 不是单维度的长
我在项目里把"长"拆成了三层:
- 输出长度:也就是模型生成的内容 token 数量。这是最直观的"长",通常由 max_tokens 控制。
- 上下文长度:输入侧塞给模型的 token 数量,包括历史对话、文档资料、系统提示词。上下文越长,模型推理阶段的计算量越大。
- 结构完整度:内容在业务语义上是否覆盖了大纲要求的全部要点。比如一篇行业分析报告,如果模型只写了背景和问题,缺了趋势预测和对策章节,哪怕字数到了 8000,它也不算"长",因为用户拿不到完整交付物。
这三层正好对应了三种不同的优化手段。输出长度可以靠分块生成、推理预算调控;上下文长度要靠摘要压缩、检索增强来降载;结构完整度则更多依赖提示词设计和大纲约束。
单追字数是最容易踩的坑。我在早期版本里让模型"写够 5000 字",结果它写出大量车轱辘话,而且后续章节明显信息密度下降。后来改成"按照 6 个章节展开,每章给出论点和数据",反而内容更扎实,总字数自然就上去了。所以"长"的正确打开方式,是先定结构再谈长度。
1.2 "Fast" 也不是单指标的快
速度这个事,外行看总耗时,内行看分段指标。一次大模型请求的时间可以拆成几个部分:
| 指标 | 含义 | 用户感知 |
|---|---|---|
| TTFT(首字延迟) | 从发起请求到收到第一个 token 的时间 | 决定用户是否觉得"响应快" |
| TPS(生成吞吐) | 每秒生成多少个 token | 决定后续内容刷新的顺滑度 |
| 端到端总耗时 | 从请求开始到最后一个 token 到达的时间 | 决定用户最终什么时候能拿到全文 |
这三个指标经常互相制约。TTFT 受 prefill 阶段影响最大,也就是模型处理输入 prompt 的时间;TPS 受模型大小、量化方式、推理后端影响;总耗时本质上是 TTFT 加上"生成 token 数除以 TPS"的结果。
最容易被忽视的是用户可感知速度。一次 5000 token 的生成长文请求,如果等全部生成完才一起返回,假设 TPS 是 50,用户要干等 100 秒。但如果用流式返回,用户 1.5 秒看到第一个字,接下来内容以稳定的速率滚动出现,哪怕总耗时同样是 100 秒,用户的耐心也会多很多。我实测下来,流式输出能把"用户主动中断"的比例降低一半以上。
所以"Make It Long, Keep It Fast"里的 Fast,优先优化 TTFT,其次是流式的持续输出稳定性,最后才是压 TPS。
2. 让文本更长:三条路线和取舍
要让模型输出更长,不是简单地把 max_tokens 调大就完事。调大 max_tokens 确实能放宽生成上限,但生成中途内容可能开始重复、跑偏,甚至截断在半句话上。我这里梳理了三条我在项目中实际用过的路线。
2.1 一次性生成 vs 分块生成
一次性生成长文,就是一句"帮我写一篇 3000 字的产品分析",然后把解析、排版、扩充的事全交给模型。优点是实现简单,接口调用一次就行;缺点是不受控——模型在长文本生成后段容易忘记前面的论点,出现事实前后矛盾,而且如果网络或推断服务刚好抖动,整篇内容都会丢掉。
我自己更推荐分块生成,特别是面对强结构化内容时。思路是这样的:
- 先让模型生成一份大纲,包含章节标题和每节的核心论点。
- 按章节拆成独立生成任务,每个任务只负责 500~800 字的内容。
- 把各章节的生成请求并发发出。
- 全部完成后按顺序合并成最终文本。
这样做的好处有两个:单个子任务短,生成速度快,失败成本低;各章节之间互不影响,一个章节生成失败只需要重试那一块,不需要整篇推倒重来。
下面是我在项目里用过的并行分块生成代码骨架,基于 Python 的 asyncio 和 OpenAI 兼容接口:
import asyncio from openai import AsyncOpenAI client = AsyncOpenAI() async def generate_section(title, point, previous_summary=""): prompt = f""" 请撰写文章章节,标题为「{title}」。 核心论点:{point} 上文背景:{previous_summary} 要求:内容 500-800 字,逻辑清晰,有数据或案例支撑。 """ resp = await client.chat.completions.create( model="qwen-plus", messages=[{"role": "user", "content": prompt}], temperature=0.7, max_tokens=1200, ) return resp.choices[0].message.content async def main(): outline = [ ("行业背景", "市场规模和增长驱动力"), ("核心挑战", "当前方案的主要瓶颈"), ("解决思路", "新架构如何绕过瓶颈"), ] # 并发生成所有章节 results = await asyncio.gather( *[generate_section(title, point) for title, point in outline] ) full_article = "\n\n".join(results) print(full_article) asyncio.run(main())注意一个细节:如果各章节之间有明显的逻辑依赖,比如第二章要引用第一章的结论,就不能完全并行。这种情况建议每章请求的 prompt 里附带前面章节的摘要,或者采用"先并行生成,再统一润色衔接"的两阶段方案。两阶段方案耗时会长一点,但对文章整体一致性帮助很大。
2.2 用推理预算让模型"自己变长"
除了工程上分块,还有一个让输出变长的思路是增加模型的推理预算。现在很多新模型支持思维链或者推理模式,比如通过 reasoning_effort 参数控制模型的思考深浅。当模型被要求"先思考再回答",它会在内部生成大量推理过程,然后输出更有条理的答案。这种情况下,最终答案的长度和深度都会提升。
我测试过同一家模型的普通模式和推理模式,在同样要求"分析新能源车市场"的情况下,推理模式输出的最终报告长度要多出 40% 左右,而且内容更结构化,小标题、分论点、数据引用都更完整。
使用的时候要注意 max_tokens 的分配。推理模式下模型先消耗 token 做内部思考,再消耗 token 输出最终结果,所以 max_tokens 要适当调大,否则可能出现正确答案想好了,但输出到一半被截断的情况。部分模型还支持把思维链过程单独返回,方便你决定是暴露给用户,还是过滤掉只保留正式内容。
另外,提示词里不要只写"写长一点",这种模糊指令效果不好。我常用的说法是"请从至少三个方面展开论述,每个方面包含背景介绍、案例分析和潜在风险",把"长"具象化成可执行的要求,模型才会真正照做。
2.3 上下文扩展与记忆压缩
长文本生成的另一个瓶颈是输入上下文太大。很多人以为把整本资料都塞进 prompt,模型就能写出长篇大论,结果发现请求速度一落千丈。原因在于 Transformer 的注意力计算复杂度是 O(L²) 级别的,上下文长度翻一倍,prefill 阶段的计算量约变成原来的 4 倍。上下文长了,TTFT 飙升,用户的第一个字迟迟等不到。
所以对输入侧的长文本,工程上要做减法而不是加法。我个人用的组合方案是这样的:
- 历史对话做滚动摘要,超过一定轮次就压缩成要点,而不是全量拼进 prompt。
- 参考资料用 RAG 检索,只把和当前话题最相关的 2~3 段塞进上下文。
- 超过 32k 上下文的场景,优先考虑支持长本文的模型,同时用分层摘要控制信息密度。
这三种方案可以混用。比如一个"产品周报生成器",用户上传了 50 页运营数据,我不会把 50 页全塞给模型,而是先提取关键指标表,再让模型基于指标表生成详细分析,实测 TTFT 下降了约 70%,生成的周报长度反而更稳定。
3. 保证"快":一个可落地的流式长文接口
到了具体实现环节。要让长文本接口在用户侧"看起来快",核心手段就是流式返回。这一节我会给出一个可以跑起来的 FastAPI 接口示例,并解释关键参数的设定原因。
3.1 核心思路:TTFT 拉低 + 总耗时可控
先看一组实际估算。假设目标输出 5000 token,模型生成速度是 50 TPS。非流式接口,用户提交请求后要等约 100 秒才能看到任何内容;流式接口,TTFT 按 1.5 秒算,用户 1.5 秒后就能看到第一个字,之后内容以每分钟约 3000 字的速率持续输出。区别就在这里:总耗时没有缩短,但"用户第一次看到内容的时间"从 100 秒变成了 1.5 秒。
实现流式接口通常使用 SSE(Server-Sent Events)或者 Chunked Transfer Encoding。服务端边生成边写入响应体,客户端边接收边渲染。对大多数文本生成场景,SSE 足够简单可靠。
下面是一个基于 FastAPI 和 OpenAI 异步客户端的流式接口示例:
from fastapi import FastAPI from fastapi.responses import StreamingResponse from openai import AsyncOpenAI app = FastAPI() client = AsyncOpenAI() async def generate_long_text(prompt: str): stream = await client.chat.completions.create( model="qwen-plus", messages=[{"role": "user", "content": prompt}], temperature=0.7, max_tokens=6000, stream=True, ) async for chunk in stream: if not chunk.choices: continue delta = chunk.choices[0].delta if delta and delta.content: yield delta.content @app.post("/v1/long-text") async def long_text(prompt: str): return StreamingResponse( generate_long_text(prompt), media_type="text/plain", )这个接口做的事情很简单:收到 prompt 后发起流式请求,把模型返回的每个 token 碎片通过 StreamingResponse 转发给客户端。客户端用 fetch 或者 axios 的流式读取模式,在页面上做打字机效果就可以了。
有几个容易被忽略的点:
- FastAPI 的 StreamingResponse 要求生成器是异步的,这样才不会阻塞事件循环。如果这里用了同步生成器,一旦高并发,服务端整体响应都会变慢。
- 如果推理服务返回的内容里带 reasoning_content,也就是思维链,记得单独处理。可以把思维链直接丢弃,只向前端输出正式内容;也可以把思维链放进单独的字段,让前端选择是否展示。
- 建议在响应头里加
Cache-Control: no-cache,避免中间代理层把流式响应缓存起来,导致内容要等全部生成完才到用户手上。
3.2 关键参数与实现细节
max_tokens 应该怎么定?我一般按照目标字数来估算:中文场景下,1 个汉字大约对应 1~1.5 个 token,所以目标 5000 字,max_tokens 至少要给 6500 到 7500。如果是推理模式,还要再往上加 20%~30%,因为内部思考链也要占用 token。给的太少,结果会在结尾被硬生生切断,这是长文生成最常见的问题之一。
temperature 和 top_p 也要注意。长文生成最怕内容失控,temperature 建议设在 0.6~0.8 之间,太高了内容天马行空,太低了语言变得干瘪。top_p 配合 temperature 一起控制采样范围,我通常保持默认或者设到 0.9 左右,不做过深调整。
如果服务端同时支持多种输出格式,这里推荐以纯文本流输出,前端自行按段落、标题渲染。虽然 JSON 格式方便结构化,但每个 chunk 都要带 JSON 包裹,数据量会膨胀,也会增加前端的解析成本。真要加 id 或者状态信息,可以用 SSE 事件格式,把元数据放在 event 行,内容放在 data 行。
客户端读取流时也要处理断线。网络抖动时,长文本生成到一半,客户端连接可能断开,服务端继续生成的内容就白费了。我的做法是在客户端记录已经收到的文本长度,断线后带上"续写点"重新请求,让模型从断点继续生成,而不是从头再来。
4. 稳定运行:超时、并发与性能优化
流式接口做出来以后,真正的考验在稳定运行。长文本请求的耗时动辄几十秒甚至上百秒,远超普通 HTTP 请求的合理范围,网关、代理、负载均衡都会成为压死会话的最后一根稻草。
4.1 长文请求常见故障与排查思路
我在调试过程中记录了几个高频故障,这里整理成速查表:
| 故障现象 | 常见原因 | 解决方案 |
|---|---|---|
| 请求生成到一半网关返回 504 | 网关或代理层的请求超时设得太短 | 拉长代理超时时间,或用异步任务绕过同步 HTTP 等待 |
| 接口明明用了流式,前端还是转圈 | Nginx 开启了 buffering,把流式响应缓存了 | 在 Nginx 配置中对该路由关闭 proxy_buffering |
| 返回了内容,但首字等了很久 | prefill 阶段输入 prompt 太长 | 压缩输入上下文、缩短系统提示词、用前缀缓存 |
| 流式输出断断续续,甚至中途停止 | 模型在后段开始输出无意义内容 | 降低 temperature,增加内容质量约束,或对输出做异常检测 |
关于 Nginx buffering,这里多说一句。默认情况下 Nginx 会缓冲上游响应,等拿到完整响应后再转发给客户端,这样即使上游是流式输出,用户侧也会变成一次性收到全量内容。解决方法是针对流式接口单独配置:
location /v1/long-text { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_send_timeout 300s; }proxy_buffering off是关键,它让 Nginx 边收边转,不再做全量缓冲。proxy_read_timeout也很重要,长文本接口的响应时间可能超过默认的 60 秒,不调大一样会断。
如果整个系统要承载大量长文本请求,我建议在普通同步接口之外再提供一套异步任务方案:请求进来先生成任务 ID,后台任务队列去调用模型,前端通过轮询或 WebSocket 获取进度和结果。这类方案牺牲了一点实时性,换来了极高的稳定性,特别适合报告生成、批量写作这类对实时性要求不高的业务。
4.2 并发上不去和成本控制
长文本生成对并发的影响很大。一个 1000 token 的请求可能只要 10 秒,但一个 6000 token 的请求要 60 秒,长时间占用推理服务的并发额度。我遇到过的情况是,几个用户在写长文,其他人的短请求全部排队,整体体验直线下降。
对这种问题,常见的优化手段有这么几个方向:
- 给长文本请求和短文本请求做服务拆分,长文任务走独立的队列和模型实例,避免互相挤兑。
- 推理服务本身做优化,比如用量化压缩模型体积、用投机采样加速 token 生成、开启前缀缓存复用公共的 prompt 部分。
- 请求入口做合并和排队,限制同一时刻的最大长文请求数,其余请求先排队,前端显示"前面还有 2 位等待"。
成本控制更直接。长文本生成是按 token 计费的,5000 字的文本加上推理链消耗,可能要用掉 8000~10000 个 token,成本是短文本请求的几十倍。我在项目里做三件事来控制成本:
- 第一步先让模型出大纲,确认无误后再逐节生成,避免生成完发现方向不对全量返工。
- 对模型的输出做长度兜底,超过业务需要的部分直接截断,不无谓地继续生成。
- 在提示词里明确要求输出格式,比如"每章 300 字以内,共 5 章",让模型在合理范围内收敛。
5. 更深一层:让"长"和"快"可量化、可度量
做工程优化最怕的就是没有度量。性能有没有变好,不能靠感觉,得靠数据。我给自己建了一套轻量级的评估流程,每次调整后都跑一遍基线,用数字说话。
5.1 快速建立基准测试
工欲善其事,必先利其器。我用一个简单的 Python 脚本收集三个指标:TTFT、TPS、总耗时。脚本逻辑不复杂,就是记录流式响应中第一个 token 到达的时间和全部 token 收完的时间,再统计 token 总数。
import time from openai import OpenAI client = OpenAI() prompt = "请撰写一篇关于智慧农业的详细分析报告,要求不少于3000字。" start = time.time() stream = client.chat.completions.create( model="qwen-plus", messages=[{"role": "user", "content": prompt}], max_tokens=5000, stream=True, ) first_token_time = None token_count = 0 for chunk in stream: if first_token_time is None: first_token_time = time.time() print(f"TTFT: {first_token_time - start:.2f}s") if chunk.choices and chunk.choices[0].delta.content: token_count += 1 total_time = time.time() - start print(f"总耗时: {total_time:.2f}s") print(f"Token数: {token_count}") print(f"TPS: {token_count / total_time:.2f}")跑几次取平均值,能很快发现改动前后的性能变化。我自己习惯每做一次优化,都跑一组"短文本(500 token)+ 中文本(2000 token)+ 长文本(5000 token)"的基准测试,覆盖不同压力场景。
需要注意的是,单次测试的波动可能比较大,特别是公共模型服务,白天晚上性能差距明显。测试尽量固定时间段、固定模型版本、固定 prompt 模板,并在报告里标注测试环境,这样数据才有可比性。
5.2 优化优先级建议
如果只让我给出三条优化建议,顺序是这样的:
第一,先把流式输出做出来。这一步对用户可感知速度的提升最明显,改动也相对小。就算后续所有性能优化都不做,流式至少能让用户安心等着,而不是频繁刷新页面。
第二,把长文本拆成分块生成。分块后单次请求耗时下降,超时率大幅降低,失败重试的成本也降下来了。同时配合大纲生成,能让长文内容更完整可控。
第三,再考虑推理加速和成本优化。量化、投机采样、前缀缓存这些手段,部署和调优成本都不低。只有当业务流量真的到了瓶颈,或者成本账单真的刺痛的时候,才值得投入精力去做。
从另一个角度看,业务产品设计也能帮大忙。与其把一个 5000 字的生成塞给用户干等,不如设计成"先生成大纲,用户确认后逐章生成"的交互。这样用户等待每一章的时间都很短,而且方案本身就把"长"切成了"快"的切片。
最后再分享一点个人体会
我在做长文本生成的这段时间,最大的感悟是:不要试图一上来就同时优化所有指标。把"Make It Long, Keep It Fast"当成一个系统问题,拆成"内容长度、结构完整度、TTFT、TPS、总耗时"几个变量,逐个击破,比盲目调参靠谱得多。先让用户快起来,再让内容长起来,顺序反了,优化效果会大打折扣。
另外一个小技巧:在长文请求的提示词里,我会要求模型"先把文章大纲列出来,再逐节展开,每节给出核心论点、案例和过渡句"。我发现这个大前提一旦立住,长文内容的质量和生成稳定性都会上一个台阶。前端再配合 Markdown 流式渲染,用户一边看着标题和段落框架陆续出现,一边等待详细内容,体验会好很多,哪怕总耗时没有缩短,用户的耐心也已经足够。