Streaming

百科 · 模型与对话 · 边生成边吐

前面几次都是 "stream": false:服务器把全文攒完,一次性丢回一大团 JSON。改成 true,连接不关,按 SSE(Server-Sent Events)一块块往外推。还是同一支 /chat/completions,不是新接口。

一次流式调用

curl https://api.deepseek.com/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $DEEPSEEK_API_KEY" \
  -N \
  -d '{
    "model": "deepseek-chat",
    "messages": [
      {"role": "user", "content": "你是谁"}
    ],
    "stream": true
  }'

-N 关掉缓冲,否则 curl 可能把碎块攒成一团再给你看。响应头是 Content-Type: text/event-stream。正文长这样:

data: {"id":"chatcmpl-a1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"我"},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{"content":"是"},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{"content":" DeepSeek"},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]

翻译成人话:

  • 每一行 data: 后面是一小块 JSON,字段是 delta,不是整段 message
  • 把各块 delta.content 按顺序拼起来,才是「我是 DeepSeek……」
  • 最后一块带 finish_reason。为 stop 是它自己停的;为 length 仍是撞上 max_tokens
  • data: [DONE] 表示这条流结束。这是 DeepSeek / OpenAI 的接口约定,不是 SSE 规范里的事件

choices 仍然通常只有一项。流式不会变成多个回答,只是把一个回答切碎了送。

SSE 是什么

SSE 是一种单向推送:服务器往客户端写,客户端只读。不是 WebSocket,你不会在这条连接上把下一句用户话塞回去。

约定很短:HTTP 长连接,Content-Type: text/event-stream。一个事件是若干行字段,后面跟一个空行。客户端按空行切开。

几种事件类型

一条事件由这几类行组成。没写 event: 时,类型就是默认的 message

  • event:事件名。省略则是 message。同名的才进同一个回调
  • data:正文。可以连写多行,客户端用换行拼成一整段,再解析
  • id:这条事件的编号。断线重连时,客户端用请求头 Last-Event-ID 带回去
  • retry:建议的重连间隔,单位毫秒

: 开头的是注释,不算事件。

DeepSeek 的流几乎只写 data:,所以类型都是 message[DONE] 是 DeepSeek / OpenAI 放在 data 里的结束标记,不是另一种 event,SSE 规范里也没有它。

用 net/http 收

标准库没有 SSE 客户端。自己按行读 resp.Body 即可:

body := bytes.NewReader([]byte(`{
  "model": "deepseek-chat",
  "messages": [{"role": "user", "content": "你是谁"}],
  "stream": true
}`))
req, err := http.NewRequest(http.MethodPost, "https://api.deepseek.com/chat/completions", body)
if err != nil {
	return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+os.Getenv("DEEPSEEK_API_KEY"))

resp, err := http.DefaultClient.Do(req)
if err != nil {
	return err
}
defer resp.Body.Close()

sc := bufio.NewScanner(resp.Body)
for sc.Scan() {
	line := sc.Text()
	if !strings.HasPrefix(line, "data:") {
		continue
	}
	data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
	if data == "[DONE]" {
		break
	}
	fmt.Println(data)
}
return sc.Err()

空行、注释行直接跳过。只认 data:。拼 delta.content 仍是你自己的事,这里只把每一块 JSON 打出来。

这段只处理了 data。生产里还要断线重连、用 id 做断点续传、按自定义 event 分发、遵守 retry。这些不必手写,用社区里成熟的 SSE 客户端即可,例如 r3labs/sse

用 r3labs/sse 收

对接 chat completions 用 SubscribeRawSubscribe 会在 URL 上再拼一个 stream 查询参数,这支接口不认。

client := sse.NewClient("https://api.deepseek.com/chat/completions")
client.Method = http.MethodPost
client.Headers["Content-Type"] = "application/json"
client.Headers["Authorization"] = "Bearer " + os.Getenv("DEEPSEEK_API_KEY")
client.Body = strings.NewReader(`{
  "model": "deepseek-chat",
  "messages": [{"role": "user", "content": "你是谁"}],
  "stream": true
}`)

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

return client.SubscribeRawWithContext(ctx, func(msg *sse.Event) {
	if string(msg.Data) == "[DONE]" {
		cancel()
		return
	}
	fmt.Println(string(msg.Data))
})

msg.Event 是事件名(这里通常为空,当 message),msg.Data 是去掉 data: 之后的正文,msg.ID 对应 id。库替你切块,不替你拼 delta

总结

  • Streaming = 同一支补全接口,改用 SSE 边生成边吐
  • 事件行主要是 event / data / id / retry。没写 event 就是 message
  • delta,客户端负责拼接;[DONE] 是 DeepSeek / OpenAI 用来表示流结束的约定
  • 标准库按行读 data:r3labs/sseSubscribeRaw 收同一条流
  • 还是一问一答,答完就停。只是字先到,JSON 整包不到

参见