跳至内容
M01.Go 语言 AI 开发基础

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)
    }
}

四、课后作业

必做作业

  1. 创建 ai-agent 项目骨架,定义 LLMProvider 接口,实现 OpenAIProvider 结构体(暂时返回固定字符串)
  2. 实现 StreamTokens 函数,使用 httptest.Server 编写测试,验证流式解析的正确性
  3. 为 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 集成与提示工程 —

最后更新于 • Q1mi