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); // 逐块输出}
两者的共同点:
设置 stream=True参数遍历返回的流对象 从每个 chunk 的 delta.content中提取文本增量立即输出到终端
四、流式 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 对象 | chunk | |
response.choices[0].message.content | chunk.choices[0].delta.content | |
注意:
message.contentvsdelta.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].deltaif 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="" | ||
flush=True |
什么是缓冲区(Buffer)?
出于性能考虑,程序通常不会每产生一个字符就写一次磁盘/网络,而是先把输出攒在内存的一块区域(缓冲区)里,攒够了再一次性输出。
flush=True就是告诉 Python:"别攒了,现在就输出"
6.2 delta.content 可能为 None
for chunk in stream:delta = chunk.choices[0].deltaif 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].deltaif 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 拥有记忆
夜雨聆风