- Claude API 流式输出(SSE)实战:从打字机效果到工具调用全流程
接入 Claude API 的人多半都从 messages.create() 一把梭开始------发个请求,等几秒,整段返回。等到要做聊天框、做长文生成、做 Agent 工具调用,才发现这条路走不动:响应慢、用户看着白屏、长输出还会撞 524 超时。
这时候必须切到流式输出。Claude API 的流式协议走的是 SSE(Server-Sent Events),事件结构和 OpenAI 不一样,做错一步前端就会白屏或乱码。本文把 Python、Node.js、cURL 三套写法跑一遍,再覆盖 Tool Use 流式、断流重连、前端打字机效果和生产级容错。
读完你能拿到:
- 三种语言的最小可运行代码
- SSE 事件类型对照表(避免对着官方文档反复翻)
- Tool Use 流式输出的处理姿势(这是 Agent 项目最大的坑)
- 断流如何无损续传
- 前端 EventSource 实现打字机效果
一、为什么必须用流式:三个非用不可的场景
把流式当成"锦上添花"是最大的认知偏差。下面三个场景里,不开流式直接是工程缺陷。
场景 1:长上下文输出(>2K tokens)
Opus 4.7 生成一段 4K tokens 的长回复,从首 token 到最后一 token 普遍在 30 秒以上。非流式模式下,HTTP 请求要么白屏 30 秒,要么直接撞到反向代理或 CDN 的 60 秒超时上限(最常见的是 524)。流式模式下,首 token 通常 800ms 内就到,用户立刻看到内容。
场景 2:Agent 工具调用循环
Agent 一次推理可能产生 3-5 个 tool_use 块。非流式模式下你要等整段响应回来才能开始执行工具。流式模式下,第一个 tool_use 块完成时立刻就能开始调用工具,第二个块继续生成,整体延迟接近减半。
场景 3:聊天界面与 IDE 集成
Cursor、Claude Code、Cherry Studio 这类客户端能给出"丝滑打字机"体验,全靠 SSE。自己做内部 ChatBot 想达到一样的效果,前端必须用 EventSource 或 fetch + ReadableStream 接收。
二、SSE 事件结构:先把这张表背下来
Claude 的流式响应是一连串 SSE 事件,事件类型有 9 种。不理解事件结构直接写解析逻辑,必踩坑。
| 事件类型 | 触发时机 | 关键字段 | 你要做什么 |
|---|---|---|---|
message_start |
响应开始 | message.id, usage.input_tokens |
记录 message id;初始化输入 token 计数 |
content_block_start |
一个新的内容块开始 | index, content_block.type |
判断是 text、thinking 还是 tool_use |
content_block_delta |
增量内容 | delta.type, delta.text / delta.partial_json |
拼接到对应 index 的内容块 |
content_block_stop |
当前内容块结束 | index |
关闭该块(tool_use 完整后可以触发执行) |
message_delta |
消息级元数据更新 | delta.stop_reason, usage.output_tokens |
更新输出 token 计数与停止原因 |
message_stop |
整个响应结束 | --- | 收尾,关闭连接 |
ping |
保活心跳 | --- | 忽略即可 |
error |
服务端错误 | error.type, error.message |
立即终止;考虑重试 |
关键认知 :一次响应中可能有多个 content_block_* 序列,index 用来区分。text 块的增量在 delta.text,tool_use 块的增量在 delta.partial_json(注意是字符串增量,需要拼接后再 JSON.parse)。
三、最小可运行代码:三种语言
3.1 Python(Anthropic SDK,推荐)
官方 SDK 帮你封装好了 SSE 解析,最干净的写法是 messages.stream() 上下文管理器:
python
import anthropic
client = anthropic.Anthropic(
api_key="sk-你的密钥",
base_url="https://gw.claudeapi.com"
)
with client.messages.stream(
model="claude-opus-4-7",
max_tokens=2048,
messages=[{"role": "user", "content": "写一段关于流式输出的技术博客开头"}]
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
final_message = stream.get_final_message()
print(f"\n\n[输入 {final_message.usage.input_tokens} tokens, "
f"输出 {final_message.usage.output_tokens} tokens]")
stream.text_stream 只迭代文本增量,过滤掉了 thinking、tool_use 等其他块,做纯聊天最方便。
如果要拿到原始事件做更精细的控制(例如同时处理 thinking 和 text):
python
with client.messages.stream(
model="claude-opus-4-7",
max_tokens=2048,
messages=[{"role": "user", "content": "..."}]
) as stream:
for event in stream:
if event.type == "content_block_start":
print(f"\n[block #{event.index} start: {event.content_block.type}]")
elif event.type == "content_block_delta":
if event.delta.type == "text_delta":
print(event.delta.text, end="", flush=True)
elif event.type == "message_stop":
print("\n[done]")
3.2 Node.js / TypeScript
typescript
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
apiKey: "sk-你的密钥",
baseURL: "https://gw.claudeapi.com",
});
const stream = client.messages.stream({
model: "claude-opus-4-7",
max_tokens: 2048,
messages: [{ role: "user", content: "写一段关于流式输出的技术博客开头" }],
});
for await (const event of stream) {
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
process.stdout.write(event.delta.text);
}
}
const final = await stream.finalMessage();
console.log(`\n\n[input ${final.usage.input_tokens}, output ${final.usage.output_tokens}]`);
或者用更简洁的 .on("text") 事件订阅:
typescript
client.messages
.stream({
model: "claude-opus-4-7",
max_tokens: 2048,
messages: [{ role: "user", content: "..." }],
})
.on("text", (text) => process.stdout.write(text))
.on("finalMessage", (msg) => console.log("\ndone:", msg.usage));
3.3 cURL(原始 SSE,调试和反代必备)
排查问题时直接用 cURL 看原始事件流是最快的方式:
bash
curl -N https://gw.claudeapi.com/v1/messages \
-H "x-api-key: sk-你的密钥" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "claude-opus-4-7",
"max_tokens": 1024,
"stream": true,
"messages": [{"role": "user", "content": "Hello"}]
}'
注意 -N 关闭缓冲,否则 cURL 会卡到响应结束才一次性输出。返回会看到一串:
event: message_start
data: {"type":"message_start","message":{...}}
event: content_block_start
data: {"type":"content_block_start","index":0,...}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
...
四、Tool Use 流式:Agent 项目的高频踩坑点
工具调用块的增量字段是 delta.partial_json,是字符串拼接 后才能解析。直接对每个 delta 单独 JSON.parse 必然失败。
python
import json
tool_inputs: dict[int, str] = {} # index → accumulated json string
tool_meta: dict[int, dict] = {} # index → {"name": ..., "id": ...}
with client.messages.stream(
model="claude-opus-4-7",
max_tokens=2048,
tools=[{
"name": "get_weather",
"description": "查询某个城市的天气",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}
}],
messages=[{"role": "user", "content": "上海现在天气怎么样?"}]
) as stream:
for event in stream:
if event.type == "content_block_start":
block = event.content_block
if block.type == "tool_use":
tool_meta[event.index] = {"name": block.name, "id": block.id}
tool_inputs[event.index] = ""
elif event.type == "content_block_delta":
if event.delta.type == "input_json_delta":
tool_inputs[event.index] += event.delta.partial_json
elif event.type == "content_block_stop":
if event.index in tool_inputs:
args = json.loads(tool_inputs[event.index])
meta = tool_meta[event.index]
print(f"[tool call] {meta['name']}({args})")
# 这里立刻去执行工具,不必等整个响应结束
关键点:
content_block_start时拿到tool_use.name和tool_use.idinput_json_delta拼字符串content_block_stop时再json.loads
提早 parse 是最常见的错误,会抛 JSONDecodeError。
五、断流重连:生产环境必须做的事
国内网络抖动、移动端切换基站、反向代理重启,都会导致 SSE 半路中断。Claude API 不支持服务端续传,但可以通过记录已收到的内容做客户端侧降级。
健壮模式:
python
def stream_with_retry(messages, max_retries=2):
accumulated = ""
for attempt in range(max_retries + 1):
try:
with client.messages.stream(
model="claude-opus-4-7",
max_tokens=4096,
messages=messages
) as stream:
for text in stream.text_stream:
accumulated += text
yield text
return
except (anthropic.APIConnectionError, anthropic.APITimeoutError) as e:
if attempt == max_retries:
raise
# 把已生成内容拼到上下文,让模型续写
messages = messages + [
{"role": "assistant", "content": accumulated},
{"role": "user", "content": "请继续输出,不要重复已经写过的内容。"}
]
print(f"\n[stream broke at {len(accumulated)} chars, retrying...]")
要点:
- 只对网络层 异常(
APIConnectionError、APITimeoutError)重试,不要对 4xx 重试 - 重试时把已收到的内容塞进 assistant 消息,让模型续写而不是从头来
max_retries不要超过 3,否则用户会等到崩溃
六、前端打字机效果:浏览器侧实现
浏览器原生 EventSource 不支持自定义 header(API Key 没法塞),所以必须用 fetch + ReadableStream。中间最好加一层后端代理鉴权,避免把 Key 暴露到前端。
后端转发示例(Node.js / Express):
typescript
import express from "express";
import Anthropic from "@anthropic-ai/sdk";
const app = express();
app.use(express.json());
const client = new Anthropic({
apiKey: process.env.CLAUDE_API_KEY,
baseURL: "https://gw.claudeapi.com",
});
app.post("/api/chat", async (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const stream = client.messages.stream({
model: "claude-sonnet-4-6",
max_tokens: 2048,
messages: req.body.messages,
});
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
res.write(`data: ${JSON.stringify({ text: event.delta.text })}\n\n`);
}
}
res.write("data: [DONE]\n\n");
res.end();
});
前端消费:
typescript
async function chat(messages: any[], onText: (s: string) => void) {
const resp = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
});
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6);
if (data === "[DONE]") return;
const { text } = JSON.parse(data);
onText(text);
}
}
}
注意 buffer 拼接逻辑------SSE 的 \n\n 分隔可能跨 chunk,不缓冲会丢字。
七、踩坑清单
坑 1:Nginx 反代默认会缓冲 SSE,前端永远收不到首 token。 在反代配置里加 proxy_buffering off;,必要时再加 proxy_cache off; 与 proxy_read_timeout 600s;。Cloudflare 用户检查 "Caching" 是否被命中,必要时关掉。
坑 2:tool_use 块的 input 是 partial_json 不是 text。 不要在 text_delta 分支里找工具参数,会一直拿不到。事件结构记牢:text_delta 走 text 块,input_json_delta 走 tool_use 块。
坑 3:stream=True 与 count_tokens 不兼容。 想精确统计成本,就在流结束后从 final_message.usage 拿,或者请求前用 client.messages.count_tokens() 预估。
坑 4:移动端 4G/5G 切换时 keepalive 会断。 在 message_start 时记下 message id,断开后重试时把已收到的文本塞进 assistant 消息让模型续写(见第五节代码)。
坑 5:Extended Thinking 与流式同时开启时事件多了一类。 思考块走 thinking_delta 而非 text_delta,前端如果直接拿 text_delta 拼会丢掉思考内容。明确决定要不要展示思考过程,再写解析分支。
八、性能参考
以下是同样的 prompt(请生成一段 2K tokens 的技术文档)在国内网络下的实测对比:
| 模式 | 首 token 延迟 | 总耗时 | 用户感知 |
|---|---|---|---|
| 非流式 | --- | 28.4s | 白屏 28 秒 |
| 流式(Sonnet 4.6) | 720ms | 26.9s | 立即看到首字 |
| 流式(Opus 4.7) | 980ms | 35.2s | 立即看到首字 |
| 流式(Haiku 4.5) | 410ms | 11.8s | 极致顺滑 |
非流式 ≈ 流式总耗时,但用户体验差距巨大------首 token 是体验的分水岭。
小结
流式输出不是"可选优化",是 Claude API 进生产环境的必经之路。三个核心要点:
- 事件结构先理解再写代码 ------9 种事件、
text_deltavsinput_json_delta必须分清 - Tool Use 走 partial_json 拼接 ------
content_block_stop时再 parse - 生产环境必做断流重连------只对网络异常重试,重试时让模型续写