乐于分享
好东西不私藏

从零做编程助手:004 - 流式输出,让 AI 逐字显示

从零做编程助手:004 - 流式输出,让 AI 逐字显示

004 - 流式输出,让 AI 逐字显示

上一篇:003 - 系统提示词

phive,公众号:乐桐OPC003 - 系统提示词:给 AI 一个"人设"

一、这一篇要做什么?

目前我们的程序有一个明显的体验问题:用户发送消息后,要等 AI全部生成完才能看到回复。如果 AI 在生成一段长代码,用户可能要盯着空白的屏幕等好几秒甚至是几分钟。

本篇要实现流式输出(Streaming),让 AI 像 ChatGPT 那样逐字逐句是我显示回复。

# 现在(非流式):你: 写一个快排[等待 5 秒...]AI: def quicksort(arr):\n    if len(arr) <= 1:\n        return arr...  ← 一次性全部出现# 本篇之后(流式):你: 写一个快排AI: d→e→f→ →q→u→i→c→k→s→o→r→t→(...逐字显示,边生成边出现)

二、概念:什么是流式输出?

2.1 生活比喻

想象你去饭馆点了一份饺子:

  • 非流式(Non-streaming):饭馆在后厨给你煮完了之后,给你端来一盘给你(你等了很久才有得吃)
  • 流式(Streaming):饭馆不是等都煮好了一起上来,是每个每个饺子单独计时,煮熟一个上一个,直到给你上完一盘的量(类似于你守着锅吃)

流式输出就是让数据边产生边传输,而不是等全部生产完再传输。

2.2 技术本质:SSE(Server-Sent Events,服务器推送事件

在 HTTP 协议层面,流式输出使用的技术是SSE

普通 HTTP 请求:客户端 ──请求──→ 服务器客户端 ←──完整响应── 服务器  (一次返回所有数据,连接关闭)SSE 流式请求:客户端 ──请求──→ 服务器客户端 ←──数据块1── 服务器客户端 ←──数据块2── 服务器   (连接保持打开,数据一块一块地来)客户端 ←──数据块3── 服务器...客户端 ←──[完成]── 服务器   (最后关闭连接)

SSE vs WebSocket:SSE 是单向的(服务器→客户端),WebSocket 是双向的。对于 LLM 流式输出,SSE 更简单且足够——我们只需要服务器推送数据,不需要客户端发给服务器。

2.3 大模型是如何"流式输出"的?

LLM 生成文本的过程本身就是逐个生成的(Token-by-Token Generation,逐令牌生成):

输入: ”写一个冒泡排序”生成过程:  Token 1: "def"      → 立即发送  Token 2: " bubble"  → 立即发送  Token 3: "_sort"    → 立即发送  Token 4: "(arr"     → 立即发送  ...  Token N: <结束>     → 发送完成信号

流式输出所做的,就是把每个 Token 生成后立即通过网络发送给客户端,而不是攒起来等全部完成再发送。


三、参考产品是怎么做流式输出的?

3.1 Claude Code

Claude Code 使用 Anthropic 的 Stream API。Anthropic 支持两种流式事件类型:

事件类型
含义
content_block_delta
文本增量(一个新生成的词/字符)
message_stop
生成完成

Claude Code 在收到每个 content_block_delta 后,立即将其渲染到终端。

3.2 Codex CLI

Codex 使用 OpenAI 的 Stream API,参数设 stream: true。在 TypeScript 中遍历异步迭代器(Async Iterator):

const stream = await openai.chat.completions.create({    model: "deepseek-v4-flash",    messages: [...],    stream: true,  // ← 开启流式});forawait (const chunk of stream) {    const content = chunk.choices[0]?.delta?.content || "";    process.stdout.write(content);  // 逐块输出}

两者的共同点:

  1. 设置 stream=True 参数
  2. 遍历返回的流对象
  3. 从每个 chunk 的 delta.content 中提取文本增量
  4. 立即输出到终端

四、流式 vs 非流式:API 层面的区别

4.1 非流式(当前实现)

# 非流式:等全部生成完,返回一个完整的 response 对象response = client.chat.completions.create(    model="deepseek-v4-flash",    messages=[...],    stream=False# 默认值,不开启流式)print(response.choices[0].message.content) # 完整的回复文本

4.2 流式(本篇实现)

# 流式:返回一个迭代器,逐个产生"数据块"(chunk)stream = client.chat.completions.create(    model="deepseek-v4-flash",    messages=[...],    stream=True,# 开启流式)# 遍历 stream,每个 chunk 包含一小段文本for chunk in stream:    if chunk.choices[0].delta.content: # 文本增量        print(chunk.choices[0].delta.content, end="", flush=True)

区别:

非流式 
流式
返回类型
完整的 response 对象
迭代器(Iterator),产生多个 chunk
文本位置
response.choices[0].message.contentchunk.choices[0].delta.content
获取时机
全部生成完后一次性获取
边生成边获取
首字延迟
高(用户等待时间长)
低(用户几乎能立刻看到第一个字)

注意message.content vs delta.content

  • message
     是完整的消息对象(非流式)
  • delta
     是增量(Delta),表示这一块新增了哪些内容(流式)

五、完整代码

5.1 修改后的 main.py

与第 3 步相比,核心变化只有 API 调用部分

"""NorAI Code - 第4步:加入流式输出(Streaming)让 AI 像 ChatGPT 一样逐字显示回复,提升交互体验。"""import osfrom openai import OpenAI# ──────────────────────────────────────────────# 1. 初始化 OpenAI 客户端# ──────────────────────────────────────────────base_url="https://api.deepseek.com"api_key = os.getenv(”DEEPSEEK_API_KEY”)if not api_key:    print("错误:请设置环境变量 DEEPSEEK_API_KEY")    print("  Windows CMD:  set DEEPSEEK_API_KEY=sk-xxxxx")    print("  Windows PS:   $env:DEEPSEEK_API_KEY='sk-xxxxx'")    print("  macOS/Linux:  export DEEPSEEK_API_KEY=sk-xxxxx")    exit(1)client = OpenAI(api_key=api_key,base_url=base_url)# ──────────────────────────────────────────────# 2. 配置模型参数 + 系统提示词# ──────────────────────────────────────────────MODEL = "deepseek-v4-flash"TEMPERATURE = 0MAX_TOKENS = 64000SYSTEM_PROMPT = """你是一个专业的 AI 编程助手。你的职责是帮助用户解决编程相关的问题。行为准则:1. 用中文回复,但代码中的变量名、函数名保持英文2. 给出的代码要包含必要的注释,帮助用户理解3. 如果用户的问题与编程无关,礼貌地引导用户回到编程话题4. 回答应该简洁、直接,展示代码而非冗长的解释5. 如果用户问”你是谁”,回答你是一个 AI 编程助手,专注于帮助编程任务"""# ──────────────────────────────────────────────# 3. Agent 主循环(Agent Loop)# ──────────────────────────────────────────────print("=" * 50)print("  NorAI Code - 最简 Agent")print("  输入 'exit' 或 'quit' 退出")print("=" * 50)while True:     # ── 步骤 1:读取用户输入 ──    try:        user_input = input("\n你: ").strip()    except (EOFError, KeyboardInterrupt):        # 用户按了 Ctrl+C 或 Ctrl+D,优雅退出        print("\n再见")        break    # 检查是否退出    if user_input.lower() in ("exit""quit""q"):        print("再见")        break    # 跳过空输入    if not user_input:        continue # ── 步骤 2:调用 LLM API(流式输出)──# ★ 关键变化:stream=True,返回一个迭代器而非完整响应    try:        stream = client.chat.completions.create(            model=MODEL,            messages=[                {"role""system""content": SYSTEM_PROMPT},                {"role""user",   "content": user_input},            ],            temperature=TEMPERATURE,            max_tokens=MAX_TOKENS,            stream=True# 开启流式输出        )    except Exception as e:        print(f"API 调用失败: {e}")        continue    # ── 步骤 3:逐块接收并显示 AI 的回复 ──     # stream 是一个迭代器,每次迭代产生一个 chunk(数据块)    # 每个 chunk 包含一小段新生成的文本(可能是一个词、几个字、甚至一个标点)    # end="" 表示打印后不换行    # flush=True 表示立即输出到终端(不等待缓冲区填满)    print("\nAI: ", end="", flush=True)    # 先打印前缀,不换行,立即刷新    for chunk in stream:        # 注意:有些 chunk 可能没有 content(比如第一个 chunk 通常是空的)        delta = chunk.choices[0].delta        if delta.content:            print(delta.content, end="", flush=True)    print() # 流式输出完成后,打印一个换行

5.2 变化对比

第 3 步(非流式):┌───────────────────────────────────────────────┐│ response = client.chat.completions.create(    ││     ...                                       ││     stream=False,                             ││ )                                             ││                                               ││ ai_reply = response.choices[0].message.content│ 一次性获取全部文本│ print(f"\nAI: {ai_reply}")                    │ 一次性打印└───────────────────────────────────────────────┘第 4 步(流式):┌──────────────────────────────────────────────────┐│ stream = client.chat.completions.create(         ││     ...                                          ││     stream=True,                                 ││ )                                                ││                                                  ││ print("\nAI: ", end="", flush=True)              │ 先打印前缀│ for chunk in stream:                             │ 遍历数据块│     delta = chunk.choices[0].delta               ││     if delta.content:                            ││         print(delta.content, end="", flush=True) │ 逐块打印│ print()                                          │ 最后换行└──────────────────────────────────────────────────┘

六、细节说明

6.1 end="" 和 flush=True

print("Hello", end="", flush=True)

Python 的 print() 函数默认会在末尾添加换行符(\n),并且使用行缓冲,也就是攒够一行内容才输出。

对于流式输出,我们需要:

参数
作用
为什么需要
end=""
不自动添加换行符
因为我们要把多个 chunk 拼接在同一行显示
flush=True
立即刷新输出缓冲区
否则 Python 会攒够一定量的内容才输出,流式就没意义了

什么是缓冲区(Buffer)?

出于性能考虑,程序通常不会每产生一个字符就写一次磁盘/网络,而是先把输出攒在内存的一块区域(缓冲区)里,攒够了再一次性输出。flush=True 就是告诉 Python:"别攒了,现在就输出"

6.2 delta.content 可能为 None

for chunk in stream:    delta = chunk.choices[0].delta    if delta.content: # 这个检查很重要        print(delta.content, end="", flush=True)

在 OpenAI 的流式响应中,不是每个 chunk 都包含文本内容:

  • 第一个 chunk 通常只有 role: "assistant",没有 content
  • 最后一个 chunk 可能包含 finish_reason(结束原因),也没有 content
  • 某些 chunk 可能只包含其他元数据

所以if delta.content这个判断是有必要的,否则会打印出 None。

6.3 Ctrl+C 中断流式输出

在流式输出过程中,用户可能有时需要中断(比如你发现 AI 理解错了)。

try:    for chunk in stream:        delta = chunk.choices[0].delta        if delta.content:            print(delta.content, end="", flush=True)except KeyboardInterrupt: # 用户按了 Ctrl+C,中断流式输出    print("\n[已中断]")    continue # 回到主循环,等待下一次输入

这个细节很有用,在后续加入工具调用后,中断能力就更重要了。


七、运行演示

==================================================  NorAI Code - AI 编程助手  输入 'exit' 或 'quit' 退出==================================================你: 用 Python 一行代码写个 HTTP 服务器AI: 当然可以!Python 内置的 http.server 模块可以一行启动:```pythonpython -m http.server 8000# 在 Python 3 中,直接用命令行即可

如果你想在代码中实现(一行):

exec("import http.server;http.server.HTTPServer(('',8000),http.server.SimpleHTTPRequestHandler).serve_forever()")

这行代码会:

  • 导入 http.server 模块
  • 创建一个 HTTP 服务器监听 8000 端口
  • 将当前目录作为静态文件根目录

运行后访问 http://localhost:8000 就能看到效果

八、小结

变化 

说明
新增

stream=True 参数,开启流式模式

修改

API 调用从获取完整 response 变为遍历 stream 迭代器

修改

文本提取从 message.content 变为 delta.content

新增

end="" 和 flush=True 实现逐字实时打印

效果

用户几乎立刻就能看到 AI 开始回复,提示使用体验

收获: 

  • 流式输出的本质是:设置 stream=True,然后遍历服务端推送的数据块(chunk),从每个 chunk 的 delta.content 中提取文本增量,用 flush=True 立即显示。

  • 从 message.content 到 delta.content,一字之差,体验天差地别。

九、下一步

到目前为止,我们的程序每次都是”一问一答,答完就忘”,因为 AI 不记得上一轮说了什么。在下一篇,我们将加入对话历史(Conversation History),让 AI 拥有”记忆”,支持多轮连续对话。

```python# 现在(无历史):messages = [    {"role""system""content": SYSTEM_PROMPT},    {"role""user""content": user_input}, # 只有当前这一轮]# 下一篇文章后(有历史):messages = [    {"role""system""content": SYSTEM_PROMPT}, # 系统提示词    {"role""user""content""写个快排"}, # 第1轮用户    {"role""assistant""content""def quick..."}, # 第1轮 AI    {"role""user""content""能加注释吗?"}, # 第2轮用户(引用上一轮)    ]

下一篇:005 - 对话历史:让 AI 拥有记忆