M01.Go 语言 AI 开发基础
《Go 语言 AI Agent 开发课程》
模块一:Go 语言 AI 开发基础
| 模块编号 | MODULE 01 |
|---|---|
| 模块名称 | Go 语言 AI 开发基础 |
| 建议课时 | 8 课时(每课时 45 分钟) |
| 难度等级 | 基础篇 · 适合有 Go 语法基础的学员 |
| 前置要求 | 了解 Go 基本语法(变量、函数、结构体) |
| 核心目标 | 掌握 AI 开发所需的 Go 工程化能力,具备构建健壮 HTTP 客户端、并发流处理和可测试组件的能力 |
一、模块概述
本模块是整个课程的工程基础篇。AI Agent 开发本质上是一项高并发、网络密集型的工程工作:每一次 LLM 调用都是一次 HTTP 请求,流式输出需要 Goroutine 处理,工具调用需要并发执行,整个系统需要优雅地处理超时与取消。
Go 语言天生适合这类场景——轻量级协程、清晰的接口抽象、强大的标准库——但前提是开发者需要掌握正确的 Go 惯用法。本模块的目标不是重复 Go 入门教程,而是聚焦于 AI Agent 开发所必需的核心工程能力。
教学重点:通过「AI 开发场景驱动」的方式讲解 Go 特性,每个知识点都对应后续模块的具体需求。
二、学习目标
知识目标
- 理解 Go 模块系统(go.mod / go.sum)与工程化目录结构的设计原则
- 掌握 Goroutine 生命周期管理与 Channel 通信模式
- 深入理解 context.Context 的传播机制与最佳实践
- 掌握 interface{} 与泛型在构建可扩展组件时的应用
- 理解 HTTP 客户端的连接池、重试、限流原理
- 掌握 JSON 序列化/反序列化的进阶用法
- 理解 Table-Driven Test 与 Mock 在 AI 组件测试中的应用
能力目标
- 能够设计符合 Go 惯用法的 AI Agent 项目目录结构
- 能够实现生产级的并发流式处理管道
- 能够编写带超时控制的 HTTP 客户端
- 能够为 LLM 接口编写完整的单元测试
素质目标
- 养成「接口优先」的组件设计思维
- 建立「可测性优先」的代码编写习惯
三、课时安排详解
L01:工程化项目结构设计(45 分钟)
1.1 课时目标
- 理解 Go 模块系统的工作原理
- 能够为 AI Agent 项目设计合理的目录结构
- 掌握 internal 包的使用场景
1.2 导入(10 分钟)
提问切入:「如果你要构建一个需要调用 OpenAI、支持插件、可以部署为微服务的 AI Agent,你会怎么组织代码?」
1.3 核心内容(25 分钟)
Go 模块系统核心命令
go mod init github.com/yourname/ai-agent
go get github.com/sashabaranov/go-openai
go mod tidy # 清理未使用依赖推荐的 AI Agent 项目结构(详细讲解每个目录的职责):
ai-agent/
├── cmd/ # 可执行程序入口
│ ├── agent/main.go # Agent 服务
│ └── cli/main.go # 命令行工具
├── internal/ # 内部包(不对外暴露)
│ ├── llm/ # LLM 提供商封装
│ ├── agent/ # Agent 核心逻辑
│ ├── tools/ # 工具实现
│ └── memory/ # 记忆系统
├── pkg/ # 可复用公共包
│ ├── prompt/ # 提示词模板
│ └── schema/ # 数据结构定义
├── config/ # 配置文件
├── docs/ # 文档
└── Makefile # 构建脚本关键设计原则:
internal/ 包只能被同模块代码引用,强制封装
cmd/ 只做配置解析和依赖注入,不含业务逻辑
每个包应只有一个清晰的职责(单一职责原则)
1.4 实践练习(8 分钟)
动手创建课程项目骨架,运行 go mod init,安装 go-openai 依赖,观察 go.sum 的变化。
1.5 小结与答疑(2 分钟)
强调:好的项目结构是 AI Agent 可维护性的基石,目录结构即是架构意图的表达。
L02:Goroutine 与 Channel 在流式处理中的应用(45 分钟)
2.1 课时目标
- 理解 Goroutine 与操作系统线程的区别
- 掌握 Channel 的方向性、缓冲与关闭语义
- 能够实现 LLM 流式输出的 Channel 处理管道
2.2 问题引入(8 分钟)
展示 LLM 流式 API 的原始响应(Server-Sent Events),提问:「如果用同步代码处理,用户需要等待全部 Token 生成完毕才能看到结果。如何用 Go 实现边生成边展示?」
2.3 核心概念(15 分钟)
Goroutine 基础回顾(快速过,假设学员已了解)
go func() {
// 在新 goroutine 中执行
}()Channel 关键语义(重点讲解)**
// 无缓冲 Channel:同步通信
ch := make(chan string)
// 有缓冲 Channel:异步,不阻塞直到缓冲满
ch := make(chan string, 64)
// Channel 关闭:发送方关闭,接收方用 range 检测
close(ch)
for msg := range ch {
// 直到 ch 关闭
}2.4 完整案例讲解(15 分钟)
实现流式 Token 处理管道:
// StreamTokens 从 SSE 流中提取 Token,通过 Channel 发送
func StreamTokens(ctx context.Context, body io.ReadCloser) <-chan string {
tokens := make(chan string, 64)
go func() {
defer close(tokens) // 关键:goroutine 退出时关闭 Channel
defer body.Close()
scanner := bufio.NewScanner(body)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") { continue }
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" { return }
token, err := parseToken(data)
if err != nil { continue }
select {
case <-ctx.Done(): return // 支持取消
case tokens <- token: // 发送 token
}
}
}()
return tokens // 立即返回,不阻塞
}调用方示例:
for token := range StreamTokens(ctx, resp.Body) {
fmt.Print(token) // 实时打印每个 Token
}2.5 常见陷阱讨论(5 分钟)
- 陷阱一:忘记 close(ch) 导致调用方 range 永远阻塞
- 陷阱二:Goroutine 泄漏——Channel 无人接收,Goroutine 永远阻塞
- 陷阱三:在 select 中遗漏 ctx.Done(),导致无法取消
2.6 实践练习(2 分钟布置)
课后练习:改写上述代码,增加一个「超时自动取消」功能,使用 context.WithTimeout。
L03:Context 包——超时控制与请求取消(45 分钟)
3.1 课时目标
- 理解 Context 的树状传播模型
- 掌握 WithTimeout、WithCancel、WithValue 的正确用法
- 能够在 AI Agent 的 LLM 调用链中正确传递 Context
3.2 为什么 Context 对 AI Agent 至关重要(10 分钟)
场景展示:一个用户发起 AI Agent 任务,Agent 调用 LLM → 调用工具 → 再次调用 LLM。中途用户取消了请求,或 LLM 响应超时。没有 Context 机制,这些中间调用将无法感知取消信号,造成资源浪费和响应延迟。
3.3 Context 树状模型(15 分钟)
// 根 Context
ctx := context.Background()
// 带 30 秒超时的子 Context
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel() // 必须调用!防止 Context 泄漏
// 带取消的子 Context
ctx, cancel := context.WithCancel(ctx)
// 带值的 Context(仅传递请求级元数据)
ctx = context.WithValue(ctx, traceIDKey{}, "req-123")Context 传播规则(重要):
- 父 Context 取消 → 所有子 Context 自动取消
- Context 只向下传播,不向上
- 同一 Context 对象可传入多个 Goroutine,是并发安全的
3.4 在 Agent 调用链中使用 Context(15 分钟)
// 正确示例:整个调用链共享同一个 Context
func (a *Agent) Execute(ctx context.Context, task string) (string, error) {
// 为整个 Agent 运行设置最大超时
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
// 每次 LLM 调用单独设置较短超时
llmCtx, llmCancel := context.WithTimeout(ctx, 30*time.Second)
defer llmCancel()
resp, err := a.llm.Complete(llmCtx, prompt)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return "", fmt.Errorf("LLM 调用超时: %w", err)
}
return "", err
}
return resp, nil
}3.5 反模式讨论(5 分钟)
- 反模式一:context.Background() 到处使用,失去传播链
- 反模式二:将 Context 存储在结构体中(应通过参数传递)
- 反模式三:忘记 defer cancel() 导致 Context 泄漏
L04:Interface 与泛型——构建灵活的 Agent 组件(45 分钟)
4.1 核心问题
如何设计一个 LLM Provider 接口,使得 OpenAI、Claude、本地模型可以无缝替换?如何让 Agent 的工具系统支持任意类型的输入输出?
4.2 Interface 设计原则(20 分钟)
原则一:接口应该由消费者定义,而非提供者
// 好的做法:Agent 包定义它需要的最小接口
package agent
type LLMProvider interface {
Complete(ctx context.Context, req CompletionRequest) (CompletionResponse, error)
Stream(ctx context.Context, req CompletionRequest) (<-chan Token, error)
}原则二:小接口比大接口更灵活(ISP 原则)
// 不好:一个大接口
type LLM interface { Complete(...); Stream(...); CountTokens(...); ListModels(...) }
// 好:分离的小接口,按需组合
type Completer interface { Complete(ctx context.Context, req CompletionRequest) (CompletionResponse, error) }
type Streamer interface { Stream(ctx context.Context, req CompletionRequest) (<-chan Token, error) }
type TokenCounter interface { CountTokens(text string) int }原则三:使用接口组合
type FullLLM interface {
Completer
Streamer
TokenCounter
}4.3 泛型在 Agent 工具系统中的应用(20 分钟)
// 类型安全的工具接口(Go 1.18+ 泛型)
type Tool[In, Out any] interface {
Name() string
Description() string
Execute(ctx context.Context, input In) (Out, error)
}
// 具体实现
type WebSearchTool struct{ apiKey string }
func (t *WebSearchTool) Execute(ctx context.Context, input SearchInput) ([]SearchResult, error) {
// 实现搜索逻辑
}
// 工具注册表
type ToolRegistry struct {
tools map[string]func(ctx context.Context, rawInput json.RawMessage) (any, error)
}4.4 实践练习
实现一个 MockLLMProvider 满足 LLMProvider 接口,用于后续的单元测试。
L05:HTTP 客户端最佳实践(45 分钟)
5.1 为什么需要定制 HTTP 客户端
Go 默认的 http.DefaultClient 没有超时设置,连接池默认配置不适合高并发 LLM 调用场景。本课时讲解如何配置一个生产级 HTTP 客户端。
5.2 核心配置详解(25 分钟)
func NewHTTPClient() *http.Client {
transport := &http.Transport{
// 连接池配置
MaxIdleConns: 100, // 最大空闲连接数
MaxIdleConnsPerHost: 10, // 每个 Host 最大空闲连接
MaxConnsPerHost: 20, // 每个 Host 最大连接数
IdleConnTimeout: 90 * time.Second,
// TLS 配置
TLSHandshakeTimeout: 10 * time.Second,
// 保持长连接
DisableKeepAlives: false,
}
return &http.Client{
Transport: transport,
// 不设置全局 Timeout!应通过 Context 控制
// Timeout: 30 * time.Second, // 错误:会中断流式响应
}
}重试机制实现:
func (c *LLMClient) doWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
var lastErr error
for attempt := 0; attempt < c.maxRetries; attempt++ {
if attempt > 0 {
// 指数退避
backoff := time.Duration(math.Pow(2, float64(attempt))) * time.Second
select {
case <-time.After(backoff):
case <-ctx.Done(): return nil, ctx.Err()
}
}
resp, err := c.client.Do(req.Clone(ctx))
if err == nil && resp.StatusCode != 429 { return resp, nil }
lastErr = err
}
return nil, fmt.Errorf("重试 %d 次后失败: %w", c.maxRetries, lastErr)
}5.3 令牌桶限流实现(15 分钟)
// 基于 golang.org/x/time/rate 的令牌桶
import "golang.org/x/time/rate"
type RateLimitedClient struct {
client *http.Client
limiter *rate.Limiter // 每秒 10 个请求
}
func (c *RateLimitedClient) Do(ctx context.Context, req *http.Request) (*http.Response, error) {
if err := c.limiter.Wait(ctx); err != nil { // 等待令牌
return nil, fmt.Errorf("限流等待被取消: %w", err)
}
return c.client.Do(req)
}L06:JSON 序列化进阶与 API 响应处理(45 分钟)
6.1 AI API 响应的特殊挑战
LLM API 的响应结构复杂且多变:字段可能为 null、类型可能是联合类型、流式响应需要逐行解析。本课时讲解处理这些场景的惯用法。
6.2 关键技巧(35 分钟)
技巧一:使用指针处理可选字段
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
// 使用指针表示可选字段
Name *string `json:"name,omitempty"`
// ToolCalls 可能是 null 或空数组
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}技巧二:自定义 JSON 解析器处理联合类型
// Content 可以是 string 或 []ContentPart
type Content struct {
Text string
Parts []ContentPart
}
func (c *Content) UnmarshalJSON(data []byte) error {
// 先尝试解析为字符串
var text string
if err := json.Unmarshal(data, &text); err == nil {
c.Text = text; return nil
}
// 再尝试解析为数组
return json.Unmarshal(data, &c.Parts)
}技巧三:SSE 流式解析
func ParseSSELine(line string) (string, bool) {
if !strings.HasPrefix(line, "data: ") { return "", false }
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" { return "", false }
var chunk StreamChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil { return "", false }
if len(chunk.Choices) == 0 { return "", true }
return chunk.Choices[0].Delta.Content, true
}L07~L08:单元测试与 Mock——为 AI 组件编写可测代码(90 分钟)
7.1 为什么 AI 组件难以测试
- LLM 调用需要真实 API Key,不适合 CI 环境
- LLM 响应具有随机性,不能做精确断言
- 工具调用可能产生副作用(发送邮件、删除文件)
解决方案:通过 interface + Mock 将 LLM 和外部依赖抽象掉。
7.2 Table-Driven Test 模式(30 分钟)
func TestParseSSELine(t *testing.T) {
tests := []struct {
name string
input string
wantText string
wantMore bool
}{
{"normal token", `data: {"choices":[{"delta":{"content":"Hello"}}]}`, "Hello", true},
{"done signal", "data: [DONE]", "", false},
{"empty line", "", "", false},
{"invalid json", "data: {invalid}", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
text, more := ParseSSELine(tt.input)
if text != tt.wantText || more != tt.wantMore {
t.Errorf("ParseSSELine(%q) = (%q, %v), want (%q, %v)",
tt.input, text, more, tt.wantText, tt.wantMore)
}
})
}
}7.3 Mock LLM Provider(30 分钟)
// MockLLMProvider 实现 LLMProvider 接口
type MockLLMProvider struct {
CompleteFunc func(ctx context.Context, req CompletionRequest) (CompletionResponse, error)
Calls []CompletionRequest // 记录调用历史
}
func (m *MockLLMProvider) Complete(ctx context.Context, req CompletionRequest) (CompletionResponse, error) {
m.Calls = append(m.Calls, req)
if m.CompleteFunc != nil { return m.CompleteFunc(ctx, req) }
return CompletionResponse{Content: "mock response"}, nil
}
// 在测试中使用
func TestAgent_Execute(t *testing.T) {
mock := &MockLLMProvider{
CompleteFunc: func(ctx context.Context, req CompletionRequest) (CompletionResponse, error) {
return CompletionResponse{Content: "Final Answer: 42"}, nil
},
}
agent := NewAgent(mock)
result, err := agent.Execute(context.Background(), "What is 6*7?")
// 断言结果
if err != nil { t.Fatal(err) }
if result != "42" { t.Errorf("got %q, want 42", result) }
// 断言调用次数
if len(mock.Calls) != 1 { t.Errorf("expected 1 call, got %d", len(mock.Calls)) }
}7.4 httptest.Server 测试 HTTP 客户端(20 分钟)
func TestLLMClient_Complete(t *testing.T) {
// 创建测试服务器,模拟 OpenAI API
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 验证请求格式
var req CompletionRequest
json.NewDecoder(r.Body).Decode(&req)
if req.Model == "" { t.Error("model not set") }
// 返回模拟响应
json.NewEncoder(w).Encode(CompletionResponse{Content: "test response"})
}))
defer server.Close()
client := NewLLMClient(server.URL, "test-key")
resp, err := client.Complete(context.Background(), CompletionRequest{
Model: "gpt-4", Messages: []Message{{Role: "user", Content: "hello"}},
})
if err != nil || resp.Content != "test response" {
t.Fatalf("unexpected: err=%v, content=%q", err, resp.Content)
}
}四、课后作业
必做作业
- 创建 ai-agent 项目骨架,定义 LLMProvider 接口,实现 OpenAIProvider 结构体(暂时返回固定字符串)
- 实现 StreamTokens 函数,使用 httptest.Server 编写测试,验证流式解析的正确性
- 为 LLMClient 添加重试机制,编写 Table-Driven Test 覆盖正常、超时、429 限流三种场景
选做作业(进阶)
- 使用 go/ast 包实现一个简单的代码分析工具,统计项目中 interface 的数量
- 研究 golang.org/x/sync/errgroup 包,思考如何用于并发工具调用
五、学习评估
| 评估维度 | 具体要求 | 权重 |
|---|---|---|
| 代码规范 | 通过 go vet 和 golint 检查,无警告 | 20% |
| 接口设计 | LLMProvider 接口设计合理,满足最小化原则 | 30% |
| 测试覆盖 | 关键函数测试覆盖率 ≥ 80%,使用 go test -cover 验证 | 30% |
| 并发正确性 | 使用 go test -race 无数据竞争报告 | 20% |
六、参考资料
- 官方文档:https://pkg.go.dev/context
- 官方博客:The Go Blog - Concurrency Patterns (https://go.dev/blog/pipelines)
- 书目:《The Go Programming Language》Chapter 8: Goroutines and Channels
- 代码库:go-openai SDK 源码阅读 (github.com/sashabaranov/go-openai)
- 工具:golangci-lint 代码质量检查工具
— 模块一教案完 · 下一模块:LLM API 集成与提示工程 —