本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段二 Go 调用大模型 · 第 2 课(09)
上一篇:HTTP 模型 API 的工作原理与请求流程
上一课已经讲清了模型 API 的请求与响应结构。这一课把骨架补成完整程序:使用 Go 标准库net/http读取环境变量、发送真实请求、解析响应,并把失败原因转换成可处理的错误。
课程仍然不依赖第三方 SDK。你会看到 HTTP 客户端如何一步步完成模型调用,也会知道为什么不能把“拿到了一个 HTTP 响应”直接当成调用成功。
一、本课学习目标
学完本课,你应该能够:
- 使用
http.NewRequest和http.Client.Do完成一次模型调用。 - 使用 Go 结构体描述请求和响应。
- 通过环境变量读取 API Key 与模型标识。
- 检查 HTTP 状态码并限制错误响应体的读取大小。
- 校验
choices和回答内容,避免把异常响应当成成功。 - 用可复用的函数组织模型调用,而不是把所有逻辑塞进
main。
二、从接口约定到 Go 程序
一次最小模型调用可以对应五个阶段:
读取配置 ↓ 构造请求 JSON ↓ 创建并发送 HTTP 请求 ↓ 检查状态码并解析响应 ↓ 校验最终回答每个阶段都可能失败。例如:
- 没有配置 API Key,程序不应该继续发送请求。
- JSON 编码失败,说明本地数据结构有问题。
client.Do返回错误,通常意味着请求没有正常完成。- 服务端返回 401 或 429,说明请求已经到达服务端,但被拒绝。
- HTTP 返回成功,响应缺少
choices,仍然不能视为业务成功。
把这些阶段拆开,报错时才能快速定位问题。
三、准备项目
在 PowerShell 中执行:
mkdir go-deepseek-demoSet-Locationgo-deepseek-demo go mod init go-deepseek-demo本课只使用 Go 标准库,不需要额外安装依赖。
四、完整示例
新建main.go,写入以下代码:
packagemainimport("bytes""encoding/json""errors""fmt""io""net/http""os""strings""time")const(apiURL="https://api.deepseek.com/chat/completions"defaultModel="deepseek-flash")typeChatMessagestruct{Rolestring`json:"role"`Contentstring`json:"content"`}typeChatRequeststruct{Modelstring`json:"model"`Messages[]ChatMessage`json:"messages"`}typeChatResponsestruct{IDstring`json:"id"`Modelstring`json:"model"`Choices[]struct{Message ChatMessage`json:"message"`FinishReasonstring`json:"finish_reason"`}`json:"choices"`}funccallModel(apiKey,model,promptstring)(string,error){ifstrings.TrimSpace(apiKey)==""{return"",errors.New("DEEPSEEK_API_KEY 未设置")}requestData:=ChatRequest{Model:model,Messages:[]ChatMessage{{Role:"user",Content:prompt,},},}body,err:=json.Marshal(requestData)iferr!=nil{return"",fmt.Errorf("序列化请求失败: %w",err)}req,err:=http.NewRequest(http.MethodPost,apiURL,bytes.NewReader(body),)iferr!=nil{return"",fmt.Errorf("创建 HTTP 请求失败: %w",err)}req.Header.Set("Authorization","Bearer "+apiKey)req.Header.Set("Content-Type","application/json")client:=&http.Client{Timeout:90*time.Second,}resp,err:=client.Do(req)iferr!=nil{return"",fmt.Errorf("发送模型请求失败: %w",err)}deferresp.Body.Close()ifresp.StatusCode<200||resp.StatusCode>=300{errorBody,readErr:=io.ReadAll(io.LimitReader(resp.Body,1<<20),)ifreadErr!=nil{return"",fmt.Errorf("HTTP %d,读取错误响应失败: %w",resp.StatusCode,readErr,)}return"",fmt.Errorf("模型 API 返回 HTTP %d: %s",resp.StatusCode,strings.TrimSpace(string(errorBody)),)}varresult ChatResponseiferr:=json.NewDecoder(resp.Body).Decode(&result);err!=nil{return"",fmt.Errorf("解析模型响应失败: %w",err)}iflen(result.Choices)==0{return"",errors.New("模型响应中 choices 为空")}answer:=strings.TrimSpace(result.Choices[0].Message.Content)ifanswer==""{return"",errors.New("模型返回了空回答")}returnanswer,nil}funcrun()error{apiKey:=strings.TrimSpace(os.Getenv("DEEPSEEK_API_KEY"))model:=strings.TrimSpace(os.Getenv("DEEPSEEK_MODEL"))ifmodel==""{model=defaultModel}answer,err:=callModel(apiKey,model,"请用 Go 代码举例解释什么是 goroutine。",)iferr!=nil{returnerr}fmt.Println("模型回答:")fmt.Println(answer)returnnil}funcmain(){iferr:=run();err!=nil{fmt.Fprintln(os.Stderr,"调用失败:",err)os.Exit(1)}}deepseek-flash是系列代码中的默认示例。模型名称可能随账号和供应商配置变化,可以通过DEEPSEEK_MODEL覆盖,也可以直接修改适配器的默认值。
五、配置并运行
在 PowerShell 中设置当前会话的密钥:
$env:DEEPSEEK_API_KEY ="你的真实API_KEY"如果账号使用的模型标识与示例不同,再设置:
$env:DEEPSEEK_MODEL ="控制台中的模型ID"然后运行:
go run.成功时,终端会输出一段模型回答。请求 ID、耗时、Token 用量等诊断信息还没有在这里打印,后续课程会逐步加入。
这类环境变量通常只对当前 PowerShell 会话及其子进程有效。关闭终端后可能需要重新设置。不要把真实密钥写进截图、聊天消息或 Git 仓库。
六、逐段理解关键代码
1. 用结构体表达协议
typeChatMessagestruct{Rolestring`json:"role"`Contentstring`json:"content"`}JSON 标签把 Go 字段名和 API 字段名连接起来:
- Go 中的
Role序列化为 JSON 中的role。 - Go 中的
Content序列化为 JSON 中的content。
直接拼接 JSON 字符串容易漏掉转义、逗号或引号。结构体加encoding/json的做法更可靠,也方便后续加入多轮消息、temperature和stream等字段。
2.json.Marshal只负责编码
body,err:=json.Marshal(requestData)它把 Go 值转换成[]byte,不会自动发送网络请求。
如果更新到json.Marshal这一步就失败,问题范围通常已经缩小到输入数据或字段定义。
3.NewRequest与Client.Do分工不同
req,err:=http.NewRequest(http.MethodPost,apiURL,bytes.NewReader(body))NewRequest只是创建请求对象。真正建立网络连接并等待响应的是:
resp,err:=client.Do(req)也要区分两个错误来源:
err != nil:HTTP 请求没有正常完成,例如 DNS、TLS、连接失败或超时。err == nil:拿到了 HTTP 响应,但响应仍然可能是 401、429 或 500。
4.defer resp.Body.Close()释放连接资源
读取响应体后必须关闭resp.Body。否则在持续发送请求的服务中,底层连接和相关资源可能无法及时释放。
示例把主逻辑放进run(),由main()统一处理退出状态。这能保证函数返回时先执行defer,再决定是否结束进程。
5. 限制错误响应的读取量
io.LimitReader(resp.Body,1<<20)1 << 20等于 1 MiB。异常服务或代理可能返回很大的 HTML 错误页,直接读入内存并不安全。
生产日志仍需要对错误内容做脱敏和截断,不能因为加了读取上限就无差别记录全部响应。
6. 解析成功不代表业务成功
iflen(result.Choices)==0{return"",errors.New("模型响应中 choices 为空")}空切片不包含索引0。先检查长度,再读取result.Choices[0],可以避免运行时 panic。
之后还要检查回答去掉空白后是否为空。只有协议解析和业务校验都通过,调用才算真正成功。
七、常见失败如何排查
| 现象 | 优先检查 |
|---|---|
| 提示 API Key 未设置 | 是否在同一个终端窗口中设置环境变量 |
| 401 | API Key 是否有效,Bearer请求头是否完整 |
| 400 或 422 | 模型标识、消息结构和请求 JSON 是否符合要求 |
| 429 | 请求频率、并发量和账号配额 |
Client.Do返回错误 | DNS、网络、TLS、代理和超时设置 |
| JSON 解析失败 | 响应是否真的是 JSON,结构是否与代码一致 |
choices为空 | 服务端是否返回了异常或非预期的成功响应 |
排查时优先保留状态码、请求 ID、错误类型和安全截断后的错误消息,不要打印完整Authorization请求头。
八、本课总结
- 一次模型调用需要依次完成配置读取、请求编码、HTTP 发送、状态检查、JSON 解析和业务校验。
NewRequest构造请求,Client.Do才真正发送请求。err == nil只表示拿到了 HTTP 响应,不代表状态码成功。- HTTP 成功也不代表回答字段一定有效。
- 环境变量可以把代码与密钥分离,但不能代替权限控制和日志脱敏。
- 可复用函数应返回
error,由程序入口统一决定如何退出。
九、课后练习
练习 1:请求为什么使用 POST
为什么不把问题、API Key 和模型参数全部拼到 URL 中?
参考答案:聊天接口使用 POST 接收结构化 JSON 请求体,更适合传输长文本和复杂参数。API Key 应放在 HTTPS 认证请求头中,放进 URL 容易出现在访问日志、代理日志和浏览器历史里,增加泄露风险。
练习 2:为什么先检查choices
为什么不能直接访问result.Choices[0].Message.Content?
参考答案:
choices可能是空数组。直接访问索引0会触发 panic。应先检查长度,再检查回答内容是否为空,并把异常响应转换为普通错误。
练习 3:什么情况下不应继续重试
如果程序收到 HTTP 401,是否应该立即再次发送相同请求?
参考思路:通常不应该。401 表示认证失败,重复同一个无效密钥不会修复问题。应先检查密钥、请求头和账号状态。只有明确属于暂时性故障的错误,才适合在限制次数的前提下重试。
下一篇:Go AI Agent 实战 10|环境变量、API Key 与密钥安全
专栏:Go 开发者学 AI Agent:从大模型到工程落地