Go AI Agent 实战 15|context.Context、超时与取消机制

本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。

当前进度:阶段二 Go 调用大模型 · 第 8 课(15)

上一篇:多轮对话与上下文历史管理

一、本课学习目标

学完本课,你应该能够:

  1. 理解 context.Context 的取消和截止时间机制。
  2. 使用 context.WithTimeout 限制模型调用时间。
  3. 使用 context.WithCancel 主动取消任务。
  4. 使用 http.NewRequestWithContext 绑定请求。
  5. 区分 Context 超时、HTTP 客户端超时和主动取消。
  6. 在 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() 判断。

九、本课总结

  1. Context 用于传播取消信号和截止时间,不负责强制终止任意函数。
  2. WithTimeout 设置预算,WithCancel 支持业务主动停止。
  3. 必须通过 NewRequestWithContext 将 Context 绑定到 HTTP 请求。
  4. HTTP 客户端超时是网络层兜底,不能代替上游取消传播。
  5. Gin 中使用 c.Request.Context(),不要直接传 *gin.Context。
  6. 请求超时不代表服务端没有执行,重试时要考虑重复成本。

十、课后练习

练习 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:从大模型到工程落地

相关推荐
可乐ea1 分钟前
智能体的模型列表为什么要从三处来源合成:一次让 GPT-6.1 Sol 选不上的发布延迟
人工智能·gpt·chatgpt·智能体框架·模型列表·多源数据合成·客户端工程
freewebsys8 分钟前
【原始POSTMAN】最老一个版本的postman 大家还记得吗?官方已经不支持没有升级了,下载了组件用AI直接升级了下可以使用了!
人工智能
云南新人和数采集团19 分钟前
云南靠谱软件开发公司选型指南:以昆明新腾科技为例
大数据·人工智能·科技
发现你走远了1 小时前
05-权限不是开关:工具调用如何在放行、拦截与追问之间做决定
人工智能·agent·claude
AllData公司负责人1 小时前
AI大模型数据开发底座系列说明书:AllData集成DB-GPT开源项目建设 AI 多模态分析底座,涵盖模态解析/智能大屏/MCP 扩展!
大数据·数据库·人工智能·gpt·数据采集·数据接入·模态解析
KCENN2 小时前
安全触边在电梯轿厢门防夹保护系统中的选型配置与长效维护要点
安全
syyjhchou3 小时前
自己搭企业内部中转站,怎么让请求更容易命中缓存
人工智能·后端·语言模型·aigc
张然Adolf3 小时前
人工智能电力系统应用新手实战指南
人工智能
高洁013 小时前
数据同化与动态状态估计:让孪生持续贴合现实
人工智能·python·深度学习·transformer·迁移学习
feasibility.3 小时前
一物降一物?AI逆向没有终局:REA拆App,BLA设套,LASIK清场
人工智能·爬虫·github·逆向·skill·rea