03 流式输出原理

QuibblerAgentQuibblerAgent 2026-11-13 约 18 分钟 12 次阅读

所有正式的大模型产品都是逐字往外蹦回答的,这不是前端动画,而是协议层真实发生的事。本文是「从零手写 Agent」系列第 3 篇,拆开流式输出:请求体里一个字段的差别,如何让响应从"憋十五秒一次性给"变成"边生成边推送",以及工程师必须关心的指标——首字延迟。

第 2 篇的结尾留了个尾巴:对话记忆每轮全量重发,等待与账单都随轮次累积。账单的治理要等到第 7 篇,本篇先解决"等待"——它不是体验小事,而是长输出场景的工程门槛。本文按"为什么必须流式 → SSE 协议真面目 → SDK 的两层封装 → 首字延迟实测 → 总结"组织。

1、概述:为什么必须流式

流式输出解决两个问题,第二个比第一个重要。第一是体验:总耗时一样时,一秒出第一个字持续滚动,远好于干等十五秒看到全文。第二是工程:非流式请求要等整个回答生成完才返回 HTTP 响应,长输出很容易撞上链路上网关、代理、客户端的超时上限被拦腰掐断;流式连接持续有数据流动,不会被判超时。

超时掐断的机制值得展开一层。反向代理普遍有"读超时"——连续多久读不到数据就断开连接,Nginx 的默认值是 60 秒;而一次两千 token 的长回答生成两三分钟很常见。非流式响应在生成结束前一个字节都不会发出,链路上任何一环的读超时都能掐死它;流式连接一旦建立就开始吐数据,每一个 data: 行都刷新活跃时间,只要模型还在生成,连接就是热的。

所以流式不只是产品体验选择,而是长输出场景的工程必需。开启方式简单到惊讶——请求体加一个字段:

request_body = {
    "model": MODEL,
    "max_tokens": 256,
    "messages": [{"role": "user", "content": "从1数到10"}],
    "stream": True,    # 唯一的新字段:开启流式
}

解读:stream 设为 True 后,响应的性质完全改变:不再是一个完整 JSON,而是一条持续推送的事件流。协议本身是业界通用的 SSE,详见 MDN 的 SSE 文档。

两种模式的差异对比如下:

维度 非流式 流式
HTTP 响应 一次完整 JSON 持续推送的事件流
首个字节 全部生成结束后才发 思考一结束就到
超时风险 长输出易被网关掐断 数据流动连接保活
解析方式 json.loads 一次搞定 逐事件增量解析

一句话收束:非流式把"生成完"与"开始传"合并成一瞬,流式把它们拆开——拆开之后,传输被摊到了生成的全程。

2、SSE 协议真面目

用 httpx 直接读这条流,服务器推来的原始数据一行行长这样:

event: message_start
data: {"type": "message_start", "message": {"id": "msg_...", ...}}

event: content_block_start
data: {"type": "content_block_start", "index": 0,
       "content_block": {"type": "thinking", "thinking": ""}}

event: content_block_delta
data: {"type": "content_block_delta", "index": 0,
       "delta": {"type": "thinking_delta", "thinking": "The"}}

解读:每行以 data: 开头、内容是 JSON,空行分隔事件——这就是 SSE 的全部。模型的回答不是"一段文字",而是一串增量事件:思考内容以 thinking_delta 碎片抵达,正文以 text_delta 碎片抵达,客户端自己拼装。上面这份真实抓包还有个有趣细节:模型思考时用的是英文,回答时才切回中文,思考语言与输出语言是两回事。

事件流的收尾由 message_delta 携带 stop_reason 与用量信息,最后一条 message_stop 宣告结束。系列第 1 篇响应体里的三件宝,在流式里全部以事件形式重演一遍。

2.1、data 行解析

SSE 的语法比想象中简单,一共三条规则:每行都是字段名: 值的形式;一条事件由一到多行组成,空行是事件的分隔符;data: 行承载数据本体。Anthropic 协议里 event: 行的值与 data: 里 JSON 的 type 字段相同,裸解析只看 data: 行就够了。手写解析器如下:

import json

with httpx.stream("POST", url, headers=headers,
                  json=request_body, timeout=60) as resp:
    for line in resp.iter_lines():          # 逐行读 SSE 流
        if not line.startswith("data:"):
            continue                        # 跳过 event: 行与空行
        event = json.loads(line[5:].strip())
        if event["type"] == "content_block_delta":
            delta = event["delta"]
            print(delta.get("text", delta.get("thinking", "")),
                  end="", flush=True)
        if event["type"] == "message_stop":
            break                           # 官方收尾事件

解读:十几行就是全部协议处理。httpx.stream 返回的响应对象用 iter_lines() 逐行迭代;line[5:] 去掉 data: 前缀,剩下就是纯 JSON;正文与思考碎片在同一分支里用 get 兜底,命中哪个打哪个。响应头里的内容类型是 text/event-stream,看到它就知道对面在说 SSE。httpx 的流式用法细节见 httpx 官方文档。

2.2、事件类型速查

一次完整请求会收到的事件就六种,职责清晰。各事件的说明如下:

事件 携带内容 出现次数
message_start 消息骨架:id、模型名、用量占位 一次
content_block_start 某个内容块开张:索引与块类型 每块一次
content_block_delta 增量碎片:正文、思考或工具参数 每块多次
content_block_stop 某个内容块收工 每块一次
message_delta stop_reason 与最终用量 一次
message_stop 全部结束 一次

另有一类 ping 心跳事件,在长思考的间隙插入,解析器直接忽略即可。把六种事件按时间排开,一次"思考加正文"的短回答全貌如下:

message_start              消息骨架登场
content_block_start        块 0 开张:thinking
content_block_delta ×N     thinking_delta 碎片流
content_block_stop         块 0 收工
content_block_start        块 1 开张:text
content_block_delta ×N     text_delta 碎片流
content_block_stop         块 1 收工
message_delta              stop_reason 加最终用量
message_stop               全剧终

解读:结构是两级嵌套——消息级事件管头尾,块级事件管中间;一条消息由若干内容块串行组成,每个块内用增量事件推碎片。content_block_delta 里的 delta 有三种面孔:text_delta 带正文、thinking_delta 带思考、input_json_delta 带工具参数碎片——第三种要等系列第 4 篇的工具调用才会登场,此处先按下不表。

3、SDK 的两层封装

手工拼事件流很啰嗦,官方 SDK 的 stream() 提供两层 API。核心用法如下:

with client.messages.stream(
    model=MODEL, max_tokens=1024, messages=history,
) as stream:
    for text in stream.text_stream:        # 第一层:合并好的正文增量
        print(text, end="", flush=True)
    final = stream.get_final_message()     # 第二层:拼装回完整响应对象

解读:text_stream 是生成器,把所有正文碎片合并好,模型每吐几个字循环就转一圈,打字机效果只需 end="" 加 flush=True 两个打印参数——忘了 flush 输出会被缓冲,全文最后一起蹦出来。get_final_message() 在流结束后把增量拼装成与非流式 create() 完全相同的响应对象,content、stop_reason、usage 都在,上层代码可以零改动。

若要看事件全貌,把 text_stream 换成直接遍历 for event in stream:,按 event.delta.type 分流思考与正文——用这个手法可以把模型的思考过程实时灰色打印出来,做一个"能看穿内心"的聊天机器人。

3.1、渲染思考过程

"能看穿内心"落到代码只要十来行。实现如下:

GREY, RESET = "\033[90m", "\033[0m"         # ANSI 灰色与复位

with client.messages.stream(
    model=MODEL, max_tokens=1024, messages=history,
) as stream:
    for event in stream:                    # 第二层:原始事件
        if event.type != "content_block_delta":
            continue
        d = event.delta
        if d.type == "thinking_delta":
            print(GREY + d.thinking + RESET, end="")
        elif d.type == "text_delta":
            print(d.text, end="", flush=True)

解读:思考碎片用 ANSI 灰色转义包住、正文原色输出,终端里就是"灰色的内心独白滚过、正色的回答压轴"。Windows 10 之后的终端原生支持 ANSI,配上第 1 篇修 GBK 编码那招就能直接跑。产品上这个手法价值不小:推理模型的思考阶段动辄几秒到几十秒,把思考过程灰显出来,用户看到的是"它在干活"而不是"它死了"——体感等待被真实工作量替代。

3.2、断流的善后

流式把一次响应拆成几百个事件,也就把"断"的机会放大了几百倍。网络抖动、服务端过载都可能让流中途断开:此时 text_stream 吐出的是不完整文本,get_final_message() 会因收尾事件缺失而抛错。生产代码要在流外面包一层捕获——能重试就带上已有内容续问,不能就明确提示"回答被中断",而不是把半截话当完整答案。

一个实用的纪律是把流式响应当事务对待:只有 message_stop 抵达才算完成,没抵达的内容一律视为不完整。系列第 6 篇解剖的"静默失败"事故里,会再次见到这条纪律的变体——工具返回的信息与已知事实对不上时,先怀疑管道。

4、首字延迟实测

流式时代的核心性能指标是 TTFT(Time To First Token,首字延迟)。一次真实测量的数据如下:

你: 用一句话介绍流式输出
(正文流出)
[首个字 10.2s | 全程 10.6s | 输出 362 token]

解读:首字等了 10.2 秒,但全文只用了 10.6 秒——正文 0.4 秒就飞完了,首字延迟的大头是模型的思考时间,不是网络。对推理模型,TTFT 约等于网络往返加读输入加思考;总时长约等于 TTFT 加输出 token 除以生成速度。由此得出两条调优直觉:让回答更早出现的最有效手段是减少思考,而让总耗时下降靠的是压输出长度,两者互不替代。

另一个实测细节是计费:流式与不流式的 token 账单一分不差,流式改变的只是"你什么时候看到",不改变"生成多少"。

4.1、计时方法与口径

上面的数字不是拍脑袋,测量代码二十行以内。计时器实现如下:

import time

t0 = time.perf_counter()
first = None
with client.messages.stream(
    model=MODEL, max_tokens=1024,
    messages=[{"role": "user", "content": "用一句话介绍流式输出"}],
) as stream:
    for text in stream.text_stream:
        if first is None:
            first = time.perf_counter()     # 第一个正文碎片抵达
        print(text, end="", flush=True)
print(f"\n[首字 {first - t0:.1f}s | 全程 {time.perf_counter() - t0:.1f}s]")

解读:口径必须说清,否则数字没有意义——首字取第一个 text_delta 抵达的时刻而非 message_start,用户看见的第一个字才是体感口径,思考碎片的抵达不算"出字";全程从发起请求到流关闭。本次实测在系列第 1 篇的同一兼容端点上、同一天内连问三次取中位数,家用宽带网络。换端点、换时段,绝对值会变,但"首字大头是思考"这个结构性结论不会变。

4.2、首字与滚动的分工

把等待拆成两段,两个产品指标各管一段:TTFT 管第一段,用户从按下回车到看见第一个字;生成速度管第二段,字与字之间的滚动是否流畅。两者的主导因素不同——TTFT 由网络往返、输入长度、思考深度决定;滚动速度由模型的 token 生成速率决定,客户端基本无能为力。

第 4 节解读里的两条调优直觉放进这个框架更清晰:减少思考压的是 TTFT,压输出长度压的是滚动段——两味药治两种病,别开错方子。客户端真正能做的三件事是:让首字前的输入处理更快(第 2 篇提过的 prompt caching 命中历史前缀)、给思考过程做灰显兜住体感、在等待期渲染进度而不是空白。第三件事属于交互设计,却常常是性价比最高的一件。

5、总结

流式输出的全部秘密是一个字段加一条事件流:stream: true 让服务器改成逐行推送 data: 事件,六种事件类型两级嵌套拼出整条消息,解析器十几行就能手写;SDK 用 text_stream 与 get_final_message() 把拼装活干完,事件层还能把思考实时灰显;TTFT 的主导因素在推理模型上是思考而非网络,测它必须盯住第一个正文碎片。对于要动手的读者,建议抓一次原始 SSE 流并数一数思考碎片与正文碎片的数量对比,体感会非常直观。下一篇进入全系列的转折点:工具调用——模型的嘴连上手的瞬间。流式接口细节见 Anthropic 流式文档。

相关推荐

获取系统SDK版本、判断手机ROM
精选Code

获取系统SDK版本、判断手机ROM

Build获取系统SDK版本Android中部分API的使用,需要在特定的SDK版本之后才能使用,因此在兼容老版本SDK的时候,经常需要判断API的版本。各种Android版本的对应关系参考《Android各版本对应的SDK版本》判断手机ROM有时候需要判断手机系统的ROM,检测ROM是MIUI、EMUI还是Flyme,可以使用getprop命令,去系统build.prop文件(关于build.p

4.3k
AndroidStudio中各种中文乱码问题
精选Code

AndroidStudio中各种中文乱码问题

AndroidStudio中各种中文乱码问题1、编译Java错误信息乱码在出现这个Annotation processors must be explicitly declared now...问题的时候,正好也发现AndroidStudio Build Output错误信息都乱码。经常遇到各种问题,习惯了,现在遇到的问题,都是以后的答案。 1.1、修改项目build.gradle(无效)在整个p

2.8k
Multi-Agent 详解:多智能体协作架构与实践
精选Agent

Multi-Agent 详解:多智能体协作架构与实践

一个智能体配上一套工具,已经能完成相当多任务。但让它同时“当产品经理、写后端、跑测试、做代码评审”,很快就会露馅:上下文里塞满了互相干扰的指令,十几件工具的清单来回切换,任务做到一半忘了开头。解法看起来很自然——像人类团队一样分工:让多个智能体各司其职,由一个协调者调度,这就是 Multi-Agent(多智能体)系统。它也是 2026 年智能体工程里讨论度最高、误用率也最高的架构。本文按“架构模式…

144