本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段二 Go 调用大模型 · 第 1 课(08)
上一篇:模型幻觉、能力边界与成本控制
第一阶段,我们已经理解了 LLM、Token、Prompt 和结构化输出。现在开始解决一个更接近实际开发的问题:Go 后端如何与模型服务通信?
这一课不引入 Eino,也不依赖第三方 SDK。我们先使用 Go 标准库 net/http 理解模型 API 的通信原理,并在下一篇完成一次真实的 DeepSeek 调用。
一、本课学习目标
学完本课,你应该能够:
- 解释 Go 程序、HTTP 客户端和模型服务之间的关系。
- 说明一次模型调用从请求构造到结果解析的完整流程。
- 理解模型 API 为什么通常使用
POST + JSON。 - 分清 HTTP 请求头、请求体和响应体各自负责什么。
- 从模型响应中定位最终回答,并判断"HTTP 成功"是否等于"业务成功"。
- 使用分阶段排查的方法定位模型调用问题。
本课重点是通信与数据结构,下一课再编写完整的可运行客户端。
二、为什么模型调用应该放在 Go 后端
假设你的博客系统要增加一个"文章摘要"功能。用户提交文章 ID 后,系统读取正文、调用模型生成摘要,再把结果返回给前端。
一种常见但错误的做法,是让浏览器直接携带 API Key 调用模型服务:
text
浏览器 ──携带 API Key──> 模型服务
浏览器中的 JavaScript、网络请求和本地存储都难以真正保密,API Key 很容易泄露。更合理的链路是:
text
浏览器
↓ 文章 ID
Go 后端
├─ 鉴权与参数校验
├─ 查询文章正文
├─ 管理 API Key
├─ 调用模型服务
└─ 校验并记录结果
↓ 摘要
浏览器
模型服务只是调用链中的一个外部依赖。API Key 管理、用户权限、数据脱敏、超时控制、成本限制和日志审计,仍然应该由 Go 后端负责。
三、一次模型调用的完整流程
从通信角度看,模型 API 和常见的后端 HTTP 接口没有本质区别。一次典型调用包含以下步骤:
text
1. Go 程序组织 model、messages 等参数
↓
2. 将请求参数编码为 JSON
↓
3. 通过 HTTPS POST 发送到模型接口
↓
4. 服务端校验身份、配额和请求参数
↓
5. 模型生成结果
↓
6. 服务端返回 HTTP 状态码和 JSON 响应
↓
7. Go 程序检查状态码并解析业务字段
这里有三类错误需要分别处理:
- 请求没有发出去,例如参数编码失败、网络不可达或连接超时。
- 请求发出后,服务端返回了非成功状态码,例如认证失败或请求频率受限。
- HTTP 请求成功,但响应内容为空、结构异常或不符合业务要求。
如果把这三类问题混在一起,只记录一句"模型调用失败",后续排查会非常困难。
四、模型 API 的基本请求结构
DeepSeek 提供兼容常见 Chat Completions 风格的聊天接口。以下信息以官方文档和实际账号配置为准:
| 项目 | 典型约定 |
|---|---|
| 请求方法 | POST |
| API 基础地址 | https://api.deepseek.com |
| 聊天接口 | /chat/completions |
| 请求格式 | application/json |
| 身份认证 | Authorization: Bearer YOUR_API_KEY |
| 核心字段 | model、messages |
| 响应格式 | JSON |
一个抽象后的 HTTP 请求如下:
http
POST /chat/completions HTTP/1.1
Host: api.deepseek.com
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
请求体可以是:
json
{
"model": "YOUR_MODEL_ID",
"messages": [
{
"role": "user",
"content": "Go 语言中什么是 goroutine?"
}
]
}
其中:
- 请求头负责描述 HTTP 通信方式、数据格式和认证信息。
model指定要调用的模型标识。messages保存本轮请求需要发送给模型的消息。role表示消息角色,常见值有system、user和assistant。content保存消息正文。
YOUR_API_KEY 和 YOUR_MODEL_ID 都只是占位符。模型名称会随账号、服务版本和供应商配置变化。本系列后续代码统一使用 deepseek-flash 作为示例,实际运行时请替换为控制台当前可用的模型标识,不要仅凭旧文章猜测。
五、响应中的数据应该如何理解
一次成功响应的简化结构如下:
json
{
"id": "example-request-id",
"object": "chat.completion",
"model": "deepseek-flash",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Goroutine 是 Go 运行时管理的轻量级并发执行单元。"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 18,
"completion_tokens": 21,
"total_tokens": 39
}
}
以上只是示意数据,不是一次真实调用结果。
Go 后端最常读取的路径是:
text
响应 JSON
↓
choices 数组
↓
choices[0]
↓
message.content
也就是说,模型的最终文本通常位于 choices[0].message.content。不能把整个 HTTP 响应直接当成回答,因为其中还包含请求 ID、模型名称、结束原因和 Token 用量等字段。
这些字段对工程系统也有实际价值:
| 字段 | 工程用途 |
|---|---|
id |
关联请求日志和服务端问题排查 |
model |
确认实际响应的模型版本 |
finish_reason |
判断模型为何停止生成 |
usage |
统计 Token 消耗、成本和调用规模 |
需要特别检查 choices 是否为空,以及 message.content 是否为空。HTTP 返回成功,并不自动意味着模型给出了可用的业务结果。
六、Go 在请求构造阶段负责什么
Go 标准库已经覆盖了模型调用的基础能力:
| 类型或方法 | 作用 |
|---|---|
json.Marshal |
将 Go 数据结构编码为 JSON |
http.NewRequest |
创建 HTTP 请求 |
http.NewRequestWithContext |
创建可关联超时与取消信号的请求 |
http.Client.Do |
发送请求并读取响应 |
resp.StatusCode |
检查 HTTP 状态码 |
json.NewDecoder |
从响应流解析 JSON |
resp.Body.Close |
释放响应体相关资源 |
把请求构造与网络发送分开,是排查问题的有效方法。下面先只构造请求体,不真正访问网络:
go
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
}
func buildChatRequestBody(model, prompt string) ([]byte, error) {
payload := ChatRequest{
Model: model,
Messages: []Message{
{
Role: "user",
Content: prompt,
},
},
}
return json.Marshal(payload)
}
如果这一步失败,问题在数据结构或 JSON 编码;如果这一步成功,下一步才有必要检查 HTTP 请求、认证和网络。
这种拆分看起来只多做了一步,但出现问题时,可以把故障范围快速缩小到:
- 输入参数是否合法。
- JSON 是否按预期编码。
- HTTP 请求是否发送成功。
- 服务端是否返回成功状态。
- 响应字段是否能正确解析。
- 结果是否满足业务要求。
七、常见错误与工程注意事项
1. 把 HTTP 状态码当成全部结果
不同错误通常对应不同处理方式:
| 状态码 | 常见含义 | 优先检查 |
|---|---|---|
400 |
请求参数不合法 | JSON 字段、消息结构、模型标识 |
401 |
身份认证失败 | API Key 是否正确、是否包含 Bearer |
402 |
账户或余额相关问题 | 账户状态与服务端错误信息 |
429 |
频率或配额受限 | 请求频率、并发量、重试策略 |
5xx |
服务端错误或暂时不可用 | 服务状态、稍后重试与告警 |
具体含义要以模型服务当次返回的错误体为准,不能只根据状态码猜测原因。
2. 把模型输出直接当作可信业务数据
模型生成的摘要可以展示给用户,但如果响应内容被用于退款、授权、库存扣减等关键操作,Go 后端仍必须校验字段、权限和业务规则。
模型可以提出建议,不能成为权限系统的最终裁决者。
3. 把 API Key 写死在代码中
错误示范:
go
apiKey := "sk-把真实密钥写在这里"
正确方向是从环境变量或受控的配置系统读取密钥。下一篇会使用环境变量完成真实调用,第三课再进一步讨论密钥安全。
4. 忽略超时和响应体释放
真实网络调用必须设置合理的超时时间,并在读取响应后关闭 resp.Body。没有超时控制的请求可能长时间占用后端 goroutine;没有关闭响应体则会泄漏连接资源。
5. 在日志中泄露完整请求
排查问题时不要原样打印 Authorization 请求头,也不要把用户隐私内容长期写入日志。可以记录请求 ID、状态码、耗时和 Token 用量,并在记录正文前执行脱敏和截断。
八、本课总结
本课需要掌握的不是某个 SDK 的调用方式,而是模型 API 的基础通信模型:
- Go 后端是客户端,模型服务是服务端,双方通过 HTTPS 和 JSON 通信。
- 请求使用
POST,通过 Bearer Token 认证,通过messages描述输入。 - 最终回答通常位于
choices[0].message.content。 - HTTP 200 只表示这次 HTTP 请求成功,Go 后端仍需检查响应结构和业务结果。
- API Key、超时、日志、成本和业务权限都应由后端统一管理。
九、课后练习
练习 1:选择请求方式
向聊天模型发送消息时,以下哪种方式更合理?
A. 使用 GET,把 API Key 和问题都放在 URL 中
B. 使用 POST,通过请求头认证,通过 JSON 请求体提交消息
C. 使用普通 TCP 字符串,不需要 HTTP
参考答案:B。问题正文和结构化参数更适合放在 JSON 请求体中;API Key 应放在受 HTTPS 保护的认证请求头中,不应出现在 URL 和访问日志里。
练习 2:解析模型回答
按照本课的响应结构,模型的最终文本通常位于哪个字段?
A. model.content
B. choices[0].message.content
C. usage.total_tokens
参考答案 :B。
model是模型标识,usage.total_tokens是 Token 用量,它们都不能代替模型回答。
练习 3:处理异常响应
如果 DeepSeek 返回 HTTP 200,但 choices 是空数组,Go 程序应该如何处理?为什么不能直接返回空字符串?
参考思路:应把这种情况视为响应结构异常或业务失败,记录请求 ID、状态码和经过脱敏的响应摘要,再返回明确的错误。直接返回空字符串会把上游异常伪装成成功结果,前端无法区分"模型确实生成了空内容"和"调用链路存在问题",后续统计和重试也会失真。
下一篇:Go AI Agent 实战 09|用 net/http 真实调用 DeepSeek
专栏:Go 开发者学 AI Agent:从大模型到工程落地