M01 Go 语言 AI 开发基础
这一章是整套课程的工程基础。
后面的 LLM API 接入、工具调用、记忆系统、RAG、多智能体协作,虽然是在讲不同主题,但最终落到代码层面,都要求具备以下 Go 语言工程能力:
- 项目结构怎么组织
- HTTP 请求怎么发
- 流式输出怎么处理
- 取消信号怎么传播
- 接口怎么抽象
- 代码怎么测试
M01 的重点通过「AI 开发场景驱动」的方式讲解 Go 特性,每个知识点都对应后续模块的具体需求。 学完这一章之后,你应该能搭出一个结构清楚、接口明确、便于扩展和测试的 Go 项目骨架,为后续章节打好基础。
学习目标
完成这一章后,你应该能够:
- 理解 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 在这类项目里通常有几个明显优势:
- 并发处理简单,适合流式响应和多工具调用
- 标准库完整,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/llm、internal/agent、internal/tools 这些目录,本来就是项目内部实现细节,并不打算作为公共 SDK 暴露出去。
3. pkg/ 放可以复用的公共能力
pkg/ 不一定每个项目都必须有,但当某部分代码确实需要被多个子系统复用时,把它单独抽出来通常更清楚。
例如,提示词模板、通用结构定义、公共序列化逻辑,都可以放在 pkg/ 目录下。
4. 先初始化模块,再引入依赖
在 Go 项目里,go.mod 和 go.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
}
这段代码要注意的不是语法,而是它表达的并发模式。
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,主要是为了减少短时间内的发送阻塞。这个值不是固定标准,但在处理事件流时,适当的缓冲通常会让调用链更平滑。
四、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() 不要省略
只要用了 WithTimeout 或 WithCancel,一般就应该及时调用 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
}五、接口与泛型:组织可扩展的 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 做替换。

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)
}
}httptest 做依赖替换,测试会简单很多。九、本章实战:搭一个最小 AI Agent 工程骨架
学完这一章后,建议先不要急着接入真实模型,而是先把工程骨架搭起来。
建议最少完成下面几件事:
- 初始化
go.mod,搭好cmd/、internal/、pkg/、config/、docs/目录 - 定义
LLMProvider接口 - 实现一个最小的
OpenAIProvider结构体,先返回固定结果 - 实现
StreamTokens,把流式处理管道打通 - 用
httptest和 Mock 补一组最基本的测试
这样做的目的,是先把后面会反复用到的“骨架”搭起来。到了 M02,我们再在这套骨架上接入真正的模型 API。
小结
这一章的主线可以概括为下面几点:
- AI Agent 开发首先是工程问题,其次才是模型问题。
- 项目结构、并发模式、Context 传播、接口抽象和测试方式,都会贯穿后续所有章节。
- 流式输出和外部 API 调用,是后面系统设计的基础场景。
- 代码从一开始就要为扩展和测试留边界。
完成这一章后,你应该已经有能力搭出一个最小可用的 Go Agent 项目骨架。
课后练习
必做
- 创建
ai-agent项目骨架,定义LLMProvider接口,实现一个暂时返回固定字符串的OpenAIProvider - 实现
StreamTokens,并使用httptest.Server验证流式解析是否正确 - 为
LLMClient添加重试机制,编写测试覆盖正常、超时、429 限流三种场景
选做
- 使用
go/ast统计项目中接口的数量 - 研究
golang.org/x/sync/errgroup,思考如何用于并发工具调用
参考资料
context官方文档- Go Blog: Concurrency Patterns
- 《The Go Programming Language》第 8 章
go-openai源码golangci-lint