Go 手写 HTTP 调用 DeepSeek API:从非流式到流式输出

无需 SDK,只用 Go 标准库,搞清楚 LLM API 通信的全部细节。


为什么要手写 HTTP?

现在 Go 生态里调用 LLM API 有很多现成的 SDK,比如 go-openai、字节的 Eino,一个比一个方便。

但 SDK 把底层细节全藏起来了——请求头怎么拼?流式数据 SSE 怎么解析?stream: truestream: false 返回的 JSON 结构到底差在哪?

这篇文章就是要把这些都拆开看,用 Go 标准库从零写一个 DeepSeek API 的流式聊天客户端。


项目结构

1
2
3
4
api_study/
├── body.go # 请求体 & 数据结构定义
├── bearer.go # API 认证 Header & 端点
└── main.go # 主逻辑

三个文件,不到 120 行代码,拒绝过度设计。


第一步:定义数据结构(body.go)

DeepSeek API 是 OpenAI 兼容的,请求体格式长这样:

1
2
3
4
5
6
7
8
9
{
"model": "deepseek-chat",
"messages": [
{"role": "user", "content": "你好"}
],
"temperature": 0.7,
"max_tokens": 2048,
"stream": true
}

Go 结构体就是 JSON 的镜像:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}

type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream"`
}

func BuildRequest(prompt string) *ChatRequest {
return &ChatRequest{
Model: "deepseek-chat",
Messages: []Message{{Role: "user", Content: prompt}},
Stream: true, // 关键:开启流式
}
}

知识点json:"stream" 这个字段名是 API 协议约定的,DeepSeek 服务端读到 stream: true 就会切到流式模式返回 SSE。你写的结构体字段名可以随便起,但 json tag 必须和 API 文档一致。


第二步:认证 & 端点(bearer.go)

1
2
3
4
5
6
7
8
9
const apiEndpoint = "https://api.deepseek.com/v1/chat/completions"

func CreateAuthHeader() http.Header {
apiKey := os.Getenv("DEEPSEEK_API_KEY")
return http.Header{
"Authorization": []string{"Bearer " + apiKey},
"Content-Type": []string{"application/json"},
}
}

血的教训:API Key 永远用环境变量,绝对不要硬编码。我一开始图省事写在 const 里,结果代码截图里直接暴露了。发现后马上去 DeepSeek 后台吊销,重新生成了一个。


第三步:非流式调用(回顾)

先看非流式。请求发出去后,DeepSeek 返回一个完整的大 JSON

1
2
3
4
5
6
7
8
9
10
{
"choices": [
{
"message": {
"role": "assistant",
"content": "你好!有什么可以帮你的吗?"
}
}
]
}

Go 处理起来很简单——一次性解码:

1
2
3
4
5
6
7
8
9
type ChatResponse struct {
Choices []struct {
Message Message `json:"message"`
} `json:"choices"`
}

var response ChatResponse
json.NewDecoder(resp.Body).Decode(&response)
fmt.Println(response.Choices[0].Message.Content)

问题很明显:用户要等模型全部生成完才能看到结果。聊天场景里,体验很差——你以为程序卡死了,实际上只是模型还在生成。


第四步:改为流式

请求里加上 "stream": true,DeepSeek 返回的就完全不一样了,是一行一行的 SSE(Server-Sent Events)

1
2
3
4
5
6
7
data: {"choices":[{"delta":{"content":"你"}}]}

data: {"choices":[{"delta":{"content":"好"}}]}

data: {"choices":[{"delta":{"content":"!"}}]}

data: [DONE]

注意两个细节:

  1. 字段名变了:非流式是 message.content,流式是 delta.content
  2. 结束标志data: [DONE] 表示流结束了

Go 用 bufio.Scanner 逐行读取,边读边打印:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()

// 只处理 data 行
if !strings.HasPrefix(line, "data: ") {
continue
}

payload := strings.TrimPrefix(line, "data: ")

// 流结束
if payload == "[DONE]" {
break
}

// 解析 delta.content
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}

if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
continue
}

if len(chunk.Choices) > 0 {
fmt.Print(chunk.Choices[0].Delta.Content) // 不换行,逐字吐出
}
}
fmt.Println() // 流结束,补一个换行

这样用户在终端里看到的就是一个字一个字往外蹦,跟网页版聊天一模一样。


第五步:主函数串联

1
2
3
4
5
6
7
8
9
func main() {
fmt.Println("请输入你想询问的问题:")
var prompt string
fmt.Scanln(&prompt)

if err := callDeepseekAPI(prompt); err != nil {
log.Fatalf("API调用失败: %v", err)
}
}

跑起来:

1
2
3
4
5
$ DEEPSEEK_API_KEY="sk-xxx" go run .

请输入你想询问的问题:
你的名字叫什么
我是 DeepSeek Chat,由深度求索公司创造的大型语言模型...

完整代码

完整代码见 GitHub Gist(链接),三个核心文件的完整版上面都贴了。


流式 vs 非流式:什么时候用哪个?

非流式 流式
用户体验 干等,一次全出来 打字效果,互动感强
处理逻辑 json.Decode 一行搞定 bufio.Scanner + 逐行 Unmarshal
超时 生成时间长,易超时 持续有数据,不容易断
适合场景 后台摘要、分类、翻译(需要完整结果) 聊天、写作、代码生成(希望即时反馈)

实际项目里,聊天类产品几乎全是流式。非流式更多用在流水线里的一个环节——比如自动给文章打标签,不需要给人看过程。


今天踩过的坑

  1. 环境变量名不一致:代码里 os.Getenv("DEEPSEEK_API_KEY"),终端 export API_KEY=xxx,死活读不到
  2. **chunk 结构体多套了一层 []**:SSE 每行是单个对象,不是数组,json.Unmarshal 静默失败,啥也打不出来
  3. 流式 JSON 字段名和非流式不同message.contentdelta.content,一开始没注意文档
  4. io 包忘 import:用了 io.ReadAll 但没加 import,Go 编译器直接报错

总结

手写 HTTP 调用 LLM API 其实不复杂,核心就是:

  1. 按 API 文档拼好请求 JSON
  2. stream: false → 一次解码;stream: true → 逐行解析 SSE
  3. API Key 用环境变量,别作死硬编码

SDK 好用,但先把底层跑通,理解 HTTP → JSON → SSE 这条链路,后面换什么框架都是降维打击。

今日学习总结:Go 调用 LLM API — 非流式 vs 流式

项目结构

1
2
3
4
api_study/
├── body.go # 请求/响应数据结构
├── bearer.go # API 认证 & 端点配置
└── main.go # 主逻辑:HTTP 调用 & 流式解析

两种模式对比

维度 非流式 (Non-Stream) 流式 (Stream)
请求参数 Stream: false Stream: true
响应格式 一个完整 JSON 对象 多行 SSE (Server-Sent Events)
响应示例 {"choices":[{"message":{"content":"你好!"}}]} data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]
读取方式 json.Decoder 一次解码 bufio.Scanner 逐行扫描
用户体感 干等 → 一次性全出来 逐字打字效果
适合场景 后台批处理、需要完整结果再处理 聊天、实时交互

核心知识点

1. SSE 事件流格式

1
data: <json payload>\n\n
  • 每行以 data: 开头
  • 结束标志是 data: [DONE]
  • 非流式用 message.content,流式用 delta.content(字段名不同!)

2. 流式解析流程

1
2
3
4
5
bufio.NewScanner(resp.Body)
→ 逐行 scanner.Scan()
→ 过滤 "data: " 前缀
→ json.Unmarshal 解析 chunk
→ 立刻 fmt.Print(content)

3. 安全实践

  • API Key 绝不硬编码 → 用 os.Getenv() 从环境变量读取
  • 代码里有 Key 历史 → 立刻去后台吊销,生成新的

今日走过的大坑

原因 解决
Authentication Fails 环境变量没设 / 变量名不一致 export 前检查 os.Getenv 里的名字
****2297 is invalid Key 被吊销了 去 DeepSeek 后台重新生成
chunk 定义多了 [] 外层多套了一个切片,Unmarshal 失败 SSE 每行是单个对象,不是数组
undefined: io import 缺了 io 加上 "io"