04 · 流式输出:接住 AI "一个字一个字" 想出来的过程
前三篇的调用都是"等半天,答案一次性蹦出来"。但你用 ChatGPT 时,字是一个一个出来的------这就是流式输出(streaming)。这一篇讲清它的原理和实现,它是第 20/21 篇"打字机聊天界面"的地基。
一、先理解:模型生来就是逐个 token 的
大模型生成文本的方式叫自回归(autoregressive):
输入: "今天天气真" 第1步: 预测下一个token → "不" 此时已生成: "今天天气真不" 第2步: 预测下一个token → "错" 此时已生成: "今天天气真不错" 第3步: 预测下一个token → "。" ......
模型一次只能产出一个 token,每个 token 都以前面所有 token 为条件。所谓"想好了再一次性输出"从来不存在------服务器本来就是一边生成一边可以往外发的。
非流式:服务器生成完所有 token,打包成一个大响应返回 → 你干等 20 秒,屏幕空白。 流式:每生成一个 token 就立刻发给你 → 1 秒内看到第一个字。
衡量体验的关键指标叫 TTFT(Time To First Token,首 token 时间)。流式不改变总生成时间,但把"20 秒后看到全部"变成"1 秒后开始逐字看",体感天壤之别。
二、代码:逐 token 接收
ini
from openai import OpenAI
client = OpenAI(api_key="sk-xxx", base_url="https://api.deepseek.com")
stream = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": "写一首四行诗,主题是debug"}],
stream=True, # ← 唯一的改动:打开流式开关
)
full_text = "" # 累积完整回答
for chunk in stream: # chunk = 一小片段,不是一个完整响应
delta = chunk.choices[0].delta # delta = "这次新增的内容"
if delta.content: # 有的chunk只有元信息,content为空,要判空
full_text += delta.content # 自己负责拼接
print(delta.content, end="", flush=True) # 立刻打印,不换行
print("\n--- 完整回答 ---")
print(full_text)
逐行解释关键处:
- for chunk in stream:普通调用返回一个响应对象;流式返回一个迭代器,每次迭代吐出一个 chunk
- chunk.choices0.delta.content:注意是 delta 不是 message。delta 里只有增量------第一个 chunk 可能是 "写",第二个是 "一",第三个是 "首"......
- if delta.content:流里混着一些"事件通知"类的 chunk(比如只带 finish_reason 的收尾 chunk),它们的 content 是 None,不判空会报错
- flush=True:让 print 立即刷新到屏幕(默认会缓冲,导致卡一下蹦一串)
- full_text += ...:流式只负责"传输切片",完整内容要自己拼。忘了拼,最后就只有一堆碎片
三、怎么知道结束了?
ini
for chunk in stream:
if chunk.choices[0].finish_reason == "stop":
print("\n[生成完毕]")
break
...
两种结束信号,用哪个看厂商:
1.最后一个 chunk 的 finish_reason 变成 "stop"(正常结束)或 "length"(被 max_tokens 截断) 2.迭代器自然耗尽(for 循环自己退出)
工程上稳妥的写法是两者都处理:循环内检查 finish_reason 做收尾动作(比如通知前端"完了",对应第 20 篇 SSE 的 DONE 哨兵),循环外做资源清理。
四、一个容易踩的坑:字典里的 chunk
有些场景(比如后面 FastAPI 转发)你会拿到 chunk.model_dump() 的字典形式,取值方式要变:
scss
d = chunk.model_dump() # 转成普通字典(方便序列化成JSON发给前端)
content = d["choices"][0]["delta"]["content"] # 用[]取键,不是 .属性
属性访问和字典访问别搞混:chunk.choices(对象)vs d"choices"(字典)。看到 AttributeError: 'dict' object has no attribute 'choices',就是这个问题。
五、流式和非流式怎么选
| 非流式(stream=False) | 流式(stream=True) |
|---|---|
| 程序内部调用:要完整结果做下一步处理 | 直接给用户看:聊天界面 |
| 简单,一行取结果 | 要处理 chunk 拼接、判空、结束信号 |
| 等待 | 打字机 |
注意一个重要认知:Agent 内部的 LLM 调用大多用非流式(Agent 循环需要完整回答来解析工具调用),只有"面向用户的最后一公里"才流式。第 20 篇我们会把两者接起来:后端 Agent 内部正常跑,输出环节用流式推给浏览器。
六、流式和钱的关系(提前埋个伏笔)
流式生成过程中,服务器已经算出的 KV(第 11 篇详解)自然构成下一轮的前缀 → 多轮对话 + 流式是天然缓存友好的组合。现在不理解没关系,第 11 篇讲 KV Cache 时回头来看这句,会有"原来如此"的感觉。
七、动手实验
实验一:跑通上面的流式代码,观察逐字输出。
实验二:给每个 chunk 加时间戳,亲眼看到"到达节奏":
python
import time
for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
print(f"[{time.time()%100:.2f}] {delta.content}")
你会发现 chunk 不是均匀到达的------模型"卡壳"(概率分布平坦、难以抉择)时会有明显停顿。这是观察模型"思考"的有趣窗口。
实验三:故意把 max_tokens 设小,观察流式里 finish_reason 什么时候变成 "length"。
八、小结
1.模型是自回归逐 token 生成的,流式只是"生成一个发一个",不是加速魔法 2.核心代码三件套:for chunk in stream、delta.content 判空、自己拼接全文 3.结束信号:finish_reason 或迭代器耗尽,两者都处理 4.程序内部用非流式,面向用户用流式------第 20 篇会把两者接起来 5.对象和字典两种取值方式,报 AttributeError 先想到这个
下一篇:05 什么是 Agent ------ 从"会聊天"到"会做事",差的就是一个循环。
写在最后
如果想系统、完整地吃透 Harness、Hermes 整套前沿智能体开发体系,完成从只会调模型到可控、高质量、可落地的 AI 工程交付进阶,可以关注慕课网近期上新的《Harness&Hermes 多智能体开发特训营》。