Claude API 流式输出(SSE)实战:从打字机效果到工具调用全流程

  • 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 判断是 textthinking 还是 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})")
                # 这里立刻去执行工具,不必等整个响应结束

关键点:

  1. content_block_start 时拿到 tool_use.nametool_use.id
  2. input_json_delta 拼字符串
  3. 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...]")

要点:

  • 只对网络层 异常(APIConnectionErrorAPITimeoutError)重试,不要对 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_deltatext 块,input_json_deltatool_use 块。

坑 3:stream=Truecount_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 进生产环境的必经之路。三个核心要点:

  1. 事件结构先理解再写代码 ------9 种事件、text_delta vs input_json_delta 必须分清
  2. Tool Use 走 partial_json 拼接 ------content_block_stop 时再 parse
  3. 生产环境必做断流重连------只对网络异常重试,重试时让模型续写
相关推荐
砍材农夫3 小时前
物联网 基于netty构建mqtt协议规范(三种 QoS 等级)
java·开发语言·物联网
Wanderer X3 小时前
【LLM】AR/Diffusion
人工智能
甲方大人请饶命3 小时前
Java-IO流
java·开发语言
青春喂了后端3 小时前
IntelliGit 前端入口与开发测试面板边界重构
前端·重构
SimonKing3 小时前
别再死磕 Elasticsearch 了,这个轻量级搜索引擎更香
java·后端·程序员
网安侠士3 小时前
网安进入“AI自动攻击时代”,如何用AI守住AI?
人工智能
asdfg12589633 小时前
一文理解“工程化思维”
java·编程思想
穗余3 小时前
2026 AI x Web3 School共学营笔记-Day3
人工智能·笔记·web3
光锥智能3 小时前
面向Agentic时代,阿里云重构“芯-云-模型-推理”全栈技术体系
人工智能