本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段二 Go 调用大模型 · 第 8 课(15)
上一篇:多轮对话与上下文历史管理
一、本课学习目标
学完本课,你应该能够:
- 理解
context.Context的取消和截止时间机制。 - 使用
context.WithTimeout限制模型调用时间。 - 使用
context.WithCancel主动取消任务。 - 使用
http.NewRequestWithContext绑定请求。 - 区分 Context 超时、HTTP 客户端超时和主动取消。
- 在 Gin Handler 中正确传递
c.Request.Context()。
二、没有 Context 会怎样
早期代码直接使用:
go
req, err := http.NewRequest(http.MethodPost, apiURL, body)
http.Client.Timeout 可以限制整个 HTTP 交互,但它是适配器内部的固定预算,不能自动表达"上游请求已经被取消"。
例如,一个 Gin 请求的预算是 10 秒,用户提前关闭连接。如果模型客户端仍按自己的 90 秒预算继续执行,资源可能被无效占用。
Context 要解决的是控制信号传播:
text
上游请求 Context
↓
业务层
↓
模型客户端
↓
http.NewRequestWithContext
↓
HTTP 传输可以响应取消
Context 不会强行杀死任意 Go 函数。下游操作必须支持 Context,或者主动检查 ctx.Done(),取消才真正有效。
三、三个核心 API
1. context.WithTimeout
go
ctx, cancel := context.WithTimeout(
context.Background(),
30*time.Second,
)
defer cancel()
它创建带截止时间的子 Context。到期后,ctx.Done() 关闭,ctx.Err() 返回 context.DeadlineExceeded。
即使操作提前完成,也应该调用 cancel(),及时释放计时器等资源。
2. context.WithCancel
go
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 不再需要这项工作时
cancel()
它没有自动到期时间,适合由业务事件主动触发取消,例如用户点击"停止生成"。
3. NewRequestWithContext
go
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
apiURL,
body,
)
创建 Context 还不够。必须把它绑定到实际发起的 HTTP 请求,取消信号才能传给 HTTP 传输层。
四、完整示例
下面的程序使用 30 秒调用预算,并把错误区分为超时、主动取消、普通传输失败和业务异常:
go
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"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 {
Choices []struct {
Message ChatMessage `json:"message"`
} `json:"choices"`
}
func callModel(
ctx context.Context,
client *http.Client,
apiKey string,
model string,
messages []ChatMessage,
) (string, error) {
if err := ctx.Err(); err != nil {
return "", fmt.Errorf("调用开始前已取消: %w", err)
}
body, err := json.Marshal(ChatRequest{
Model: model,
Messages: messages,
})
if err != nil {
return "", fmt.Errorf("序列化请求失败: %w", err)
}
req, err := http.NewRequestWithContext(
ctx,
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")
resp, err := client.Do(req)
if err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return "", fmt.Errorf(
"模型请求超过截止时间: %w",
context.DeadlineExceeded,
)
}
if errors.Is(ctx.Err(), context.Canceled) {
return "", fmt.Errorf(
"模型请求已取消: %w",
context.Canceled,
)
}
return "", fmt.Errorf("发送模型请求失败: %w", err)
}
defer resp.Body.Close()
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,
)
}
return "", fmt.Errorf(
"模型 API 返回 HTTP %d: %s",
resp.StatusCode,
strings.TrimSpace(string(errorBody)),
)
}
var result ChatResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
if ctx.Err() != nil {
return "", fmt.Errorf("读取响应时请求结束: %w", ctx.Err())
}
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"))
if apiKey == "" {
return errors.New("DEEPSEEK_API_KEY 未设置")
}
model := strings.TrimSpace(os.Getenv("DEEPSEEK_MODEL"))
if model == "" {
model = defaultModel
}
client := &http.Client{
// 整个 HTTP 调用的兜底限制。
Timeout: 60 * time.Second,
}
ctx, cancel := context.WithTimeout(
context.Background(),
30*time.Second,
)
defer cancel()
messages := []ChatMessage{
{
Role: "system",
Content: "你是一名 Go 后端技术助手,请用中文回答。",
},
{
Role: "user",
Content: "请解释 context.Context 在 Go 后端中的作用。",
},
}
answer, err := callModel(
ctx,
client,
apiKey,
model,
messages,
)
if err != nil {
return err
}
fmt.Println("模型回答:")
fmt.Println(answer)
return nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "调用失败:", err)
os.Exit(1)
}
}
运行:
powershell
$env:DEEPSEEK_API_KEY = "你的真实API_KEY"
go run .
示例会发送真实模型请求。把 Context 超时临时改成很短的值,可以观察超时分支如何返回,但这不保证模型服务端一定没有开始处理。
五、三种时间限制的关系
| 机制 | 作用范围 | 示例 |
|---|---|---|
context.WithTimeout |
一组相关操作共享的截止时间 | 整个模型调用最多 30 秒 |
context.WithCancel |
由业务主动停止工作 | 用户点击停止生成 |
http.Client.Timeout |
单次 HTTP 交互的总时间 | 网络层最多 60 秒 |
通常最先到期的限制先生效。若 Context 是 30 秒、HTTP 客户端是 60 秒,模型调用会受 30 秒预算约束。
上下游预算应保持合理关系。若 Gin 请求总预算是 10 秒,模型客户端不应自行创建不受上游控制的 90 秒任务。
六、区分超时与主动取消
go
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
// 到达截止时间
}
if errors.Is(ctx.Err(), context.Canceled) {
// 被主动取消,或父 Context 被取消
}
不能把所有 client.Do 错误都当成超时。DNS、TLS、连接拒绝也可能返回错误,但并不意味着 Context 已经取消。
同时要注意:请求超时只说明客户端结束等待,不说明服务端没有执行模型推理。重试这类"结果未知"的请求可能造成重复调用与额外费用。
七、在 Gin 中传递请求 Context
Gin 的 *gin.Context 和标准库 context.Context 是不同类型。常见写法是:
go
func AskHandler(c *gin.Context) {
ctx := c.Request.Context()
answer, err := aiService.Ask(ctx, c.Param("question"))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{
"error": "AI 服务调用失败",
})
return
}
c.JSON(http.StatusOK, gin.H{
"answer": answer,
})
}
aiService.Ask 是示意性的业务方法。关键链路是:
text
c.Request.Context()
↓
业务层 Ask(ctx, ...)
↓
模型客户端 callModel(ctx, ...)
↓
http.NewRequestWithContext(ctx, ...)
不要直接把 *gin.Context 传给标准库 HTTP 客户端。应传入 c.Request.Context()。
八、常见错误
1. 创建 Context 却不用
只调用 WithTimeout,但仍然使用 http.NewRequest,取消信号不会自动附加到请求上。
2. 在底层函数里创建新的根 Context
模型客户端如果收到上游 Context,又自己从 context.Background() 创建新 Context,就会切断取消传播。上层负责设定预算,底层接收并使用传入的 Context。
3. 忘记 cancel
即使请求成功,也应调用 cancel(),及时释放相关资源。
4. 忽略响应读取阶段的取消
Context 可能在收到响应头后、读取响应体时取消。读取和解码错误也应结合 ctx.Err() 判断。
九、本课总结
- Context 用于传播取消信号和截止时间,不负责强制终止任意函数。
WithTimeout设置预算,WithCancel支持业务主动停止。- 必须通过
NewRequestWithContext将 Context 绑定到 HTTP 请求。 - HTTP 客户端超时是网络层兜底,不能代替上游取消传播。
- Gin 中使用
c.Request.Context(),不要直接传*gin.Context。 - 请求超时不代表服务端没有执行,重试时要考虑重复成本。
十、课后练习
练习 1:5 秒后发生什么
go
ctx, cancel := context.WithTimeout(
context.Background(),
5*time.Second,
)
defer cancel()
5 秒后 Context 会自动杀死所有相关函数吗?
参考答案 :不会强制杀死任意函数。5 秒后
ctx.Done()关闭,ctx.Err()返回DeadlineExceeded;支持 Context 的操作会据此停止。
练习 2:为什么还要绑定请求
已经创建带超时的 Context,为什么还要使用 http.NewRequestWithContext?
参考答案:创建 Context 只建立控制信号,必须把 Context 绑定到 HTTP 请求,传输层才能响应它的取消和截止时间。
练习 3:Gin 中传什么
标准库 HTTP 客户端应该接收 c 还是 c.Request.Context()?
参考答案 :接收
c.Request.Context(),它的类型是标准库context.Context,并承载原始 HTTP 请求的取消与截止信号。
下一篇:Go AI Agent 实战 16|重试、指数退避、限流与成本控制
专栏:Go 开发者学 AI Agent:从大模型到工程落地