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

M01 Go 语言 AI 开发基础

这一章是整套课程的工程基础。

后面的 LLM API 接入、工具调用、记忆系统、RAG、多智能体协作,虽然是在讲不同主题,但最终落到代码层面,都要求具备以下 Go 语言工程能力:

  • 项目结构怎么组织
  • HTTP 请求怎么发
  • 流式输出怎么处理
  • 取消信号怎么传播
  • 接口怎么抽象
  • 代码怎么测试

M01 的重点通过「AI 开发场景驱动」的方式讲解 Go 特性,每个知识点都对应后续模块的具体需求。 学完这一章之后,你应该能搭出一个结构清楚、接口明确、便于扩展和测试的 Go 项目骨架,为后续章节打好基础。

这一章不会重新讲解 Go 基础语法,只关注后续 Agent 开发一定会反复用到的工程能力。

学习目标

完成这一章后,你应该能够:

  • 理解 Go 模块系统和 AI Agent 项目的基本目录结构
  • 掌握使用 Goroutine 和 Channel 处理流式输出的基本模式
  • 理解 context.Context 在超时控制和请求取消中的作用
  • 能够使用接口和泛型组织可扩展的 Agent 组件
  • 能够配置适合 LLM 调用场景的 HTTP 客户端
  • 掌握常见 JSON 响应处理方式和基础测试方法

本章内容

  • 为什么 AI Agent 开发需要扎实的 Go 工程基础
  • 如何组织一个适合 Agent 项目的目录结构
  • 如何用 Goroutine 和 Channel 处理流式输出
  • 如何使用 context.Context 传递超时与取消信号
  • 如何用接口和泛型组织 Provider 与工具系统
  • 如何实现可复用的 HTTP 客户端、重试和限流
  • 如何处理常见的 JSON 响应结构
  • 如何为 AI 组件编写单元测试和 Mock

一、Go 在 AI Agent 系统中的作用

AI Agent 开发不是单纯“调一下模型接口”。一个完整的 Agent 系统通常至少包含下面这些部分:

  • 接收用户请求
  • 组织 Prompt 和上下文
  • 调用模型
  • 执行工具
  • 聚合结果
  • 控制超时、重试、限流与错误处理
  • 记录日志、指标和测试结果

这些工作里,真正“由模型完成”的部分其实只占一部分。Go 更重要的角色,是把模型能力组织成一个稳定、可维护、可测试的工程系统。

Go 在 AI Agent 系统中的角色

Go 不是负责“让模型变聪明”,而是负责让 AI 应用真正可落地。

从工程视角看,Go 在这类项目里通常有几个明显优势:

  • 并发处理简单,适合流式响应和多工具调用
  • 标准库完整,HTTP、JSON、Context、测试都能直接用
  • 接口和包管理清楚,适合把系统拆成稳定的层次
  • 部署简单,适合作为服务端或命令行工具交付

二、项目结构与模块组织

做课程项目时,第一件事不是先写某个具体功能,而是先把项目结构搭出来。

如果一开始就把请求代码、业务逻辑、工具实现和配置解析都堆在一个目录里,后面随着模块变多,代码会很快变得难以维护。比较稳妥的做法,是先按职责把目录拆开。

先看一个适合本课程的项目结构:

ai-agent/
├── cmd/                  # 可执行程序入口
│   ├── agent/main.go     # Agent 服务入口
│   └── cli/main.go       # 命令行工具入口
├── internal/             # 内部包,不对外暴露
│   ├── agent/            # Agent 核心逻辑
│   ├── llm/              # LLM Provider 封装
│   ├── tools/            # 工具实现
│   └── memory/           # 记忆系统
├── pkg/                  # 可复用公共包
│   ├── prompt/           # 提示词模板
│   └── schema/           # 通用结构定义
├── config/               # 配置文件
├── docs/                 # 文档
└── Makefile              # 构建脚本

项目的目录结构没有标准答案,这里列出来的是我认为合适的方案,比较适合作为后续 M01-M08 的课程骨架。

1. cmd/ 只放入口,不放业务逻辑

cmd/ 目录的职责通常只有两件事:读取配置、组装依赖。它不应该承载具体业务逻辑。

例如,cmd/agent/main.go 可以做配置加载、日志初始化、HTTP 服务启动,但真正的 Agent 执行流程应该放到 internal/agent 里。

这样做的好处是,业务逻辑不会和启动流程耦合在一起,后面无论你要做 CLI、Web 服务还是后台任务,都可以复用同一套核心逻辑。

2. internal/ 用来表达“这是项目内部实现”

internal/ 是 Go 在工程上非常有用的一个约束。放在这个目录下的包,只能被当前模块内部引用,不能被外部项目直接依赖。

对课程项目来说,这个约束很有价值。因为像 internal/llminternal/agentinternal/tools 这些目录,本来就是项目内部实现细节,并不打算作为公共 SDK 暴露出去。

3. pkg/ 放可以复用的公共能力

pkg/ 不一定每个项目都必须有,但当某部分代码确实需要被多个子系统复用时,把它单独抽出来通常更清楚。

例如,提示词模板、通用结构定义、公共序列化逻辑,都可以放在 pkg/ 目录下。

4. 先初始化模块,再引入依赖

在 Go 项目里,go.modgo.sum 是工程基础的一部分。

go mod init github.com/yourname/ai-agent
go get github.com/sashabaranov/go-openai
go mod tidy

这里不只是“把依赖装上”这么简单。go.mod 记录的是模块边界,go.sum 记录的是依赖校验信息。后面无论做构建、测试还是 CI,这两个文件都会参与整个工程流程。

cmd/ 里不要堆业务逻辑;internal/ 里不要顺手写成“全局工具箱”;目录一旦失去边界,后面每一章都会越来越难收拾。

三、Goroutine 与 Channel:处理流式输出的基础模式

后面的模型调用会频繁遇到流式输出。

无论是 OpenAI 风格的 SSE,还是其他 Provider 的事件流,调用方通常都不希望等模型全部生成完再一次性返回,而是希望边生成边消费。

这类场景非常适合用 Goroutine 和 Channel 处理。

先看最核心的思路:主流程发起请求之后,另起一个 Goroutine 持续读取流式响应,再通过 Channel 把结果逐步交给调用方。

func StreamTokens(ctx context.Context, body io.ReadCloser) <-chan string {
    tokens := make(chan string, 64)

    go func() {
        defer close(tokens)
        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:
            }
        }
    }()

    return tokens
}

流式处理与 Context 传播

这段代码要注意的不是语法,而是它表达的并发模式。

1. 读取流和消费流分离

StreamTokens 返回后,调用方可以立即进入消费逻辑,而不需要等后台读取全部完成。

for token := range StreamTokens(ctx, resp.Body) {
    fmt.Print(token)
}

这种“生产者在后台持续写入,消费者在前台持续读取”的模式,是后面做模型流式输出最常见的一种写法。

2. 发送方负责关闭 Channel

这里用 defer close(tokens) 非常关键。

谁负责发送,谁就负责关闭。否则调用方 range 这个 Channel 时,可能会一直阻塞下去。

3. 一定要响应 ctx.Done()

如果用户取消请求,或者上层已经超时,但这里没有监听 ctx.Done(),后台 Goroutine 仍然会继续阻塞在读取或发送上,最终造成资源泄漏。

4. 缓冲区大小要结合场景决定

这里把 Channel 缓冲设成 64,主要是为了减少短时间内的发送阻塞。这个值不是固定标准,但在处理事件流时,适当的缓冲通常会让调用链更平滑。

这一节最容易踩的坑有三个:忘记关闭 Channel、没有处理取消信号、后台持续发送但前台没有消费。

四、Context:超时控制与请求取消

在 Agent 系统里,context.Context 几乎会贯穿整个调用链。

一个典型场景是:用户发起请求,Agent 调用 LLM,模型决定调用某个工具,工具执行完成后,再继续下一轮模型推理。这个过程中,只要用户主动取消,或者某一步已经超时,整条调用链都应该尽快停止。

这正是 Context 要解决的问题。

先看几个最常见的用法:

ctx := context.Background()

ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

ctx, cancel = context.WithCancel(ctx)

ctx = context.WithValue(ctx, traceIDKey{}, "req-123")

1. Context 是一条向下传播的取消链

它最重要的作用不是“存值”,而是传播取消信号和超时控制。

  • 父 Context 取消时,子 Context 会一起取消
  • Context 只向下传播,不向上影响
  • 同一个 Context 可以安全地传给多个 Goroutine

2. 不要在结构体里保存 Context

比较推荐的做法,是把 Context 作为每次调用的第一个参数传进来,而不是把它存在结构体字段里。

func (a *Agent) Execute(ctx context.Context, task string) (string, error) {
    // ...
}

因为 Context 描述的是“这一次请求”的生命周期,不应该和某个长生命周期对象绑定在一起。

3. defer cancel() 不要省略

只要用了 WithTimeoutWithCancel,一般就应该及时调用 cancel。这一步不只是为了语义完整,也是在帮助底层尽快释放相关资源。

4. 调用链上可以分层设置超时

整个 Agent 执行可能允许几分钟,但其中某一次模型调用只愿意等 30 秒,这时就可以在大 Context 之下再包一层短超时。

func (a *Agent) Execute(ctx context.Context, task string) (string, error) {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
    defer cancel()

    llmCtx, llmCancel := context.WithTimeout(ctx, 30*time.Second)
    defer llmCancel()

    resp, err := a.llm.Complete(llmCtx, task)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            return "", fmt.Errorf("LLM 调用超时: %w", err)
        }
        return "", err
    }

    return resp, nil
}
后面的工具调用、RAG 检索、多智能体协作,都会沿用同一套 Context 传播思路。

五、接口与泛型:组织可扩展的 Agent 组件

后面这套课程会接入多个 Provider,也会逐步增加更多工具和组件。如果没有清楚的接口边界,代码会很快和具体实现绑死。

所以,在课程一开始就要把接口设计思路立起来。

1. 接口应该由消费者定义

以模型调用为例,上层 Agent 真正关心的是“能不能完成一次请求”和“能不能流式返回”,而不是底层平台到底是 OpenAI、Claude 还是本地模型。

type LLMProvider interface {
    Complete(ctx context.Context, req CompletionRequest) (CompletionResponse, error)
    Stream(ctx context.Context, req CompletionRequest) (<-chan Token, error)
}

这个接口应该放在真正使用它的那一层,而不是放在具体 Provider 实现里。这样做的好处是,上层抽象始终围绕业务需要,而不是围绕某个 SDK 的形状。

2. 小接口通常比大接口更灵活

很多初学者容易一开始就定义一个很大的接口,把补全、流式输出、模型列表、Token 计算、健康检查全塞进去。这样做短期看起来“完整”,长期通常不利于扩展。

更稳妥的方式,是按能力拆成小接口,再按需要组合。

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
}

3. 泛型适合做类型安全的工具抽象

后面的工具系统需要处理不同类型的输入输出。Go 1.18 之后,可以用泛型让这部分抽象更自然一些。

type Tool[In, Out any] interface {
    Name() string
    Description() string
    Execute(ctx context.Context, input In) (Out, error)
}

例如,一个搜索工具可能接收 SearchInput,返回 []SearchResult;一个计算工具可能接收 CalcInput,返回 CalcResult。如果都用 any 强行兜底,虽然也能做,但调用时会更容易出错。

六、HTTP 客户端:连接池、重试与限流

在 AI Agent 项目里,HTTP 客户端通常不是一个“写完就不管”的细节,而是系统稳定性的一部分。

如果直接使用默认的 http.DefaultClient,很多场景下都会不够用。尤其是模型调用量变大之后,连接池、超时控制、重试和限流都会变得重要。

先看一个更适合课程项目的客户端配置:

func NewHTTPClient() *http.Client {
    transport := &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        MaxConnsPerHost:     20,
        IdleConnTimeout:     90 * time.Second,
        TLSHandshakeTimeout: 10 * time.Second,
        DisableKeepAlives:   false,
    }

    return &http.Client{
        Transport: transport,
    }
}

这里有一个容易忽略的点:不要简单地给 http.Client 配一个全局 Timeout,尤其是在你需要处理流式响应的时候。

很多模型接口的流式输出会持续一段时间,如果客户端全局超时过短,就会直接把连接切掉。更稳妥的方式,是把超时控制交给 context.Context

1. 请求失败时要有基本的重试策略

在模型调用场景里,偶发网络错误、临时 429 限流或者上游短暂抖动并不少见。一个基础的指数退避重试通常是有价值的。

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 != http.StatusTooManyRequests {
            return resp, nil
        }
        lastErr = err
    }

    return nil, fmt.Errorf("重试 %d 次后失败: %w", c.maxRetries, lastErr)
}

2. 限流最好作为客户端层能力提前放进去

如果一个 Provider 每秒只能接受有限数量的请求,那么在客户端层面加一层限流,会比等接口报错再补救更稳。

import "golang.org/x/time/rate"

type RateLimitedClient struct {
    client  *http.Client
    limiter *rate.Limiter
}

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)
}
需要流式响应时,不要把“统一超时”直接写进 http.Client.Timeout。这类超时更适合通过 Context 控制。

七、JSON 序列化与流式响应处理

LLM 接口看起来是在返回 JSON,但和普通业务接口相比,它的响应结构通常更复杂一些。

常见情况包括:

  • 某些字段可能不存在,或者值是 null
  • 同一个字段在不同场景下可能是不同类型
  • 流式响应并不是一个完整 JSON,而是一段段事件流

所以,这一节的重点不是“怎么用 json.Unmarshal”,而是怎么让数据结构更贴近真实 API 的不确定性。

1. 可选字段可以用指针表示

type Message struct {
    Role      string     `json:"role"`
    Content   string     `json:"content"`
    Name      *string    `json:"name,omitempty"`
    ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}

当某个字段可能缺失时,用指针通常能更清楚地区分“没有值”和“零值”。

2. 联合类型可以通过自定义解析处理

有些接口返回的 content 可能是字符串,也可能是数组。这时可以通过自定义 UnmarshalJSON 做一层兼容处理。

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

3. 流式响应通常要单独解析

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
}

八、测试与 Mock:让 AI 组件可验证

AI 组件和普通业务代码相比,测试难点更明显:

  • LLM 调用依赖外部 API Key
  • 真实响应有随机性
  • 工具调用可能带副作用

所以,比较稳妥的思路是:把外部依赖抽象成接口,再通过 Mock 和 httptest 做替换。

接口、Mock 与测试关系图

1. Table-Driven Test 适合覆盖多输入场景

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

2. Mock Provider 用来替换真实模型调用

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
}

3. httptest.Server 适合测试 HTTP 客户端

func TestLLMClient_Complete(t *testing.T) {
    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)
    }
}
先面向接口编程,再通过 Mock 和 httptest 做依赖替换,测试会简单很多。

九、本章实战:搭一个最小 AI Agent 工程骨架

学完这一章后,建议先不要急着接入真实模型,而是先把工程骨架搭起来。

建议最少完成下面几件事:

  1. 初始化 go.mod,搭好 cmd/internal/pkg/config/docs/ 目录
  2. 定义 LLMProvider 接口
  3. 实现一个最小的 OpenAIProvider 结构体,先返回固定结果
  4. 实现 StreamTokens,把流式处理管道打通
  5. httptest 和 Mock 补一组最基本的测试

这样做的目的,是先把后面会反复用到的“骨架”搭起来。到了 M02,我们再在这套骨架上接入真正的模型 API。

小结

这一章的主线可以概括为下面几点:

  1. AI Agent 开发首先是工程问题,其次才是模型问题。
  2. 项目结构、并发模式、Context 传播、接口抽象和测试方式,都会贯穿后续所有章节。
  3. 流式输出和外部 API 调用,是后面系统设计的基础场景。
  4. 代码从一开始就要为扩展和测试留边界。

完成这一章后,你应该已经有能力搭出一个最小可用的 Go Agent 项目骨架。

课后练习

必做

  1. 创建 ai-agent 项目骨架,定义 LLMProvider 接口,实现一个暂时返回固定字符串的 OpenAIProvider
  2. 实现 StreamTokens,并使用 httptest.Server 验证流式解析是否正确
  3. LLMClient 添加重试机制,编写测试覆盖正常、超时、429 限流三种场景

选做

  • 使用 go/ast 统计项目中接口的数量
  • 研究 golang.org/x/sync/errgroup,思考如何用于并发工具调用

参考资料

  • context 官方文档
  • Go Blog: Concurrency Patterns
  • 《The Go Programming Language》第 8 章
  • go-openai 源码
  • golangci-lint
最后更新于 • Q1mi