本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段二 Go 调用大模型 · 第 5 课(12)
前面几课已经完成了请求、响应和业务校验。但在真实环境里,调用失败不是例外,而是必须设计的正常分支:认证会失效、配额会耗尽、网络会中断、服务端也会暂时不可用。
这一课把"请求失败"拆成可分类、可记录、可处理的错误,并整理成可复用的 Go 客户端。
一、本课学习目标
学完本课,你应该能够:
- 区分请求构造错误、传输错误、HTTP 错误和业务错误。
- 根据状态码判断哪些问题可以重试。
- 解析服务端返回的 JSON 错误信息。
- 使用自定义
error保存状态码和错误类型。 - 使用
%w保留错误链。 - 设计不泄露密钥和敏感内容的诊断日志。
二、先建立错误分类
一条模型调用链可以分为:
text
构造请求
↓
发送 HTTP 请求
↓
检查 HTTP 状态码
├─ 非 2xx:读取有限长度的错误响应
└─ 2xx:解析正常响应
↓
校验 choices 与回答内容
对应错误类型如下:
| 错误类型 | 例子 | 处理方向 |
|---|---|---|
| 请求构造错误 | JSON 编码失败 | 修正代码或输入 |
| 网络传输错误 | DNS、TLS、连接超时 | 视错误类型决定是否重试 |
| HTTP 错误 | 401、429、503 | 结合状态码和错误体处理 |
| 解析错误 | 响应不是预期 JSON | 保留安全诊断信息 |
| 业务校验错误 | choices 为空 |
视为本次调用未成功 |
client.Do 返回的 err 和 resp.StatusCode 是两个不同层次:
err != nil:没有正常完成一次 HTTP 交互。err == nil:拿到了 HTTP 响应,但状态码仍可能是 401 或 429。
例如,服务端完全正确地返回了"认证失败"的 HTTP 401,因此传输层成功,业务调用失败。
三、常见状态码
| 状态码 | 常见含义 | 是否优先重试 |
|---|---|---|
200 |
请求成功 | 继续检查响应内容 |
400 |
请求格式或参数错误 | 否,先修请求 |
401 |
认证失败 | 否,先修密钥或权限 |
402 |
账户或余额相关问题 | 通常否 |
422 |
参数语义不合法 | 否,先修参数 |
429 |
频率或配额受限 | 视错误详情有限重试 |
500 |
服务端内部错误 | 可以有限重试 |
503 |
服务暂时不可用 | 可以有限重试 |
状态码是分类入口,不是唯一依据。429 可能来自短期限流,也可能意味着配额已经耗尽;具体还要看响应体和供应商文档。
四、解析 API 错误响应
很多 API 在非成功状态下仍会返回 JSON:
json
{
"error": {
"message": "Invalid API key",
"type": "authentication_error",
"param": null,
"code": "invalid_api_key"
}
}
这是用于说明结构的示例,字段和值应以实际响应为准。
可以设计如下结构:
go
type APIErrorResponse struct {
Error APIErrorDetail `json:"error"`
}
type APIErrorDetail struct {
Message string `json:"message"`
Type string `json:"type"`
Param *string `json:"param"`
Code string `json:"code"`
}
Param 使用指针,因为错误发生时这个字段可能为 null,也可能缺失。
再定义一个能保留状态码的业务错误:
go
type APIError struct {
StatusCode int
Type string
Code string
Message string
}
func (e *APIError) Error() string {
return fmt.Sprintf(
"API 请求失败:HTTP %d, code=%s, message=%s",
e.StatusCode,
e.Code,
e.Message,
)
}
上层可以用 errors.As 取出它:
go
var apiErr *APIError
if errors.As(err, &apiErr) {
switch apiErr.StatusCode {
case http.StatusUnauthorized:
// 认证配置错误
case http.StatusTooManyRequests:
// 限流或配额问题
}
}
五、完整客户端示例
下面的 main.go 将模型调用封装成一个返回 error 的函数。它不负责重试,但已经能把错误分类交给上层。
go
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
)
const (
apiURL = "https://api.deepseek.com/chat/completions"
defaultModel = "deepseek-flash"
)
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
}
type ChatResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []struct {
Message ChatMessage `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
type APIErrorResponse struct {
Error APIErrorDetail `json:"error"`
}
type APIErrorDetail struct {
Message string `json:"message"`
Type string `json:"type"`
Param *string `json:"param"`
Code string `json:"code"`
}
type APIError struct {
StatusCode int
Type string
Code string
Message string
}
func (e *APIError) Error() string {
return fmt.Sprintf(
"API 请求失败:HTTP %d, code=%s, message=%s",
e.StatusCode,
e.Code,
e.Message,
)
}
func callModel(apiKey, model, prompt string) (string, error) {
if strings.TrimSpace(apiKey) == "" {
return "", errors.New("DEEPSEEK_API_KEY 未设置")
}
requestData := ChatRequest{
Model: model,
Messages: []ChatMessage{
{Role: "user", Content: prompt},
},
}
body, err := json.Marshal(requestData)
if err != nil {
return "", fmt.Errorf("序列化请求失败: %w", err)
}
req, err := http.NewRequest(
http.MethodPost,
apiURL,
bytes.NewReader(body),
)
if err != 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}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
// 不记录 API Key 或完整请求头。
log.Printf(
"模型请求传输失败,耗时=%s,错误=%v",
time.Since(start),
err,
)
return "", fmt.Errorf("发送模型请求失败: %w", err)
}
defer resp.Body.Close()
log.Printf(
"模型请求收到响应,status=%d,耗时=%s",
resp.StatusCode,
time.Since(start),
)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
errorBody, readErr := io.ReadAll(
io.LimitReader(resp.Body, 1<<20),
)
if readErr != nil {
return "", fmt.Errorf(
"HTTP %d,读取错误响应失败: %w",
resp.StatusCode,
readErr,
)
}
var apiErrResp APIErrorResponse
if err := json.Unmarshal(errorBody, &apiErrResp); err == nil &&
apiErrResp.Error.Message != "" {
return "", &APIError{
StatusCode: resp.StatusCode,
Type: apiErrResp.Error.Type,
Code: apiErrResp.Error.Code,
Message: apiErrResp.Error.Message,
}
}
message := strings.TrimSpace(string(errorBody))
if message == "" {
message = "服务器未提供可读取的错误信息"
}
return "", &APIError{
StatusCode: resp.StatusCode,
Message: message,
}
}
var result ChatResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("解析模型响应失败: %w", err)
}
if len(result.Choices) == 0 {
return "", errors.New("模型响应异常:choices 为空")
}
answer := strings.TrimSpace(result.Choices[0].Message.Content)
if answer == "" {
return "", errors.New("模型响应异常:回答内容为空")
}
return answer, nil
}
func run() error {
apiKey := strings.TrimSpace(os.Getenv("DEEPSEEK_API_KEY"))
model := strings.TrimSpace(os.Getenv("DEEPSEEK_MODEL"))
if model == "" {
model = defaultModel
}
answer, err := callModel(
apiKey,
model,
"请用 Go 代码举例解释什么是 goroutine。",
)
if err != nil {
return err
}
fmt.Println("模型回答:")
fmt.Println(answer)
return nil
}
func main() {
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
if err := run(); err != nil {
log.Printf("调用失败:%v", err)
os.Exit(1)
}
}
运行:
powershell
$env:DEEPSEEK_API_KEY = "你的真实API_KEY"
go run .
成功时,日志中能看到状态码和耗时;失败时,错误会说明它发生在序列化、传输、HTTP 处理还是响应校验阶段。
六、几个关键设计
1. 用 %w 保留错误链
go
return "", fmt.Errorf("发送模型请求失败: %w", err)
%w 会包装原始错误,调用方能用 errors.Is 或 errors.As 判断底层原因。%v 主要只是把错误转成文本。
2. 底层函数返回错误,不直接退出进程
模型客户端未来可能运行在 Gin Handler、后台任务或消息消费者中。某次调用失败不应该让整个服务退出。
因此 callModel 只返回 error,由程序入口决定本次命令是否失败。
3. 限制错误体大小
go
io.LimitReader(resp.Body, 1<<20)
异常 HTML、代理页面或超长响应不应该无限制地进入内存。读取上限并不能解决所有安全问题,但它能提供基础资源保护。
4. 日志只记录必要字段
适合记录:
- 请求 ID。
- HTTP 状态码。
- 耗时。
- 模型标识。
- 错误类型和经过脱敏、截断的消息。
不应记录:
Authorization请求头。- API Key。
- 完整用户隐私数据。
- 未经审查的全部错误响应。
七、哪些错误适合重试
| 场景 | 建议 |
|---|---|
| 400、401、402、422 | 通常不重试,先修正配置或输入 |
| 429 | 遵守 Retry-After 或退避提示,限制次数 |
| 500、502、503、504 | 可以有限重试 |
| 连接失败 | 视具体网络错误决定 |
| 读取超时 | 谨慎,服务端可能已经执行 |
重试不是免费的。第一次请求可能已经生成内容,只是响应在返回途中丢失;再次调用可能产生重复费用。后续课程会实现有限重试和退避。
八、本课总结
- 模型调用错误要按构造、传输、HTTP、解析和业务校验分层处理。
err == nil不代表状态码成功,2xx 也不代表回答有效。- 自定义
APIError能保留状态码、错误类型和错误代码。 %w用于保留错误链,便于上层分类。- 底层客户端返回错误,入口统一决定退出。
- 日志要有用、有限、脱敏,不能以调试为理由泄露凭证。
九、课后练习
练习 1:网络错误还是 HTTP 错误
client.Do 返回 err == nil,但 resp.StatusCode == 401。这是哪类问题?
参考答案:这是 HTTP 层错误。客户端已经成功获得服务端响应,只是认证被拒绝。应检查密钥、认证头和账号状态,不应把它当作网络故障反复重试。
练习 2:%v 与 %w
两种写法有什么区别?
go
fmt.Errorf("请求失败: %v", err)
fmt.Errorf("请求失败: %w", err)
参考答案 :
%v主要生成文本;%w会包装原始错误,使上层可以使用errors.Is和errors.As检查错误链。
练习 3:如何设计日志
模型调用失败时,你会记录哪些字段?哪些字段不能记录?
参考思路:可以记录请求 ID、状态码、耗时、模型标识和脱敏后的错误类型。不能记录 API Key、Authorization 请求头或未经处理的隐私内容;错误体也要限制长度并评估是否包含敏感回显。
下一篇:Go AI Agent 实战 13|大模型流式输出 Streaming 的实现
专栏:Go 开发者学 AI Agent:从大模型到工程落地