四、《从零手撸 Agent》 — 流式输出:接住 AI “一个字一个字” 想出来的过程

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 多智能体开发特训营》

相关推荐
是吕先森1 小时前
【python】selenium实现web自动化测试
前端·python·selenium
卷无止境1 小时前
从脚本到程序:Windows平台上的Python打包全景图
后端·python
my05921 小时前
市场学习,不止看资讯,数据查阅与思维练习同样重要
python·学习
黑科技工坊1 小时前
2026年口碑载道:铝面板定制供应商优选指南
大数据·人工智能·python
ai小陈1 小时前
PyTorch实验可复现实战:随机种子、依赖锁定与配置归档
人工智能·pytorch·python·深度学习·ai·gpu算力
郝学胜-神的一滴1 小时前
C++11 工程级应用 08:Lambda表达式与Tuple元组
开发语言·jvm·c++·python·程序人生·开源
岁月宁静1 小时前
三、《从零手撸 Agent》 · system prompt 与核心参数:调好你的旋钮
后端·python·agent
卷无止境1 小时前
除了写代码,AI智能体还能帮开发者做什么
人工智能·python