Skip to content

第3章 写Agent之前,这些Go够用了

第2章装好了环境,这一章快速过一遍Go——不是从 fmt.Println 开始教,是挑Agent开发里真正常用到的部分。如果你Go已经熟了,可以扫一遍直接跳到第4章。如果有些生疏,跟着过一遍,后面写Agent代码的时候不会卡在语法上。


3.1 goroutine:Agent的多线程大脑

3.1.1 Agent为什么需要并发

想象一个场景:用户问"帮我查一下北京天气、上海天气、深圳天气"。

如果串行执行——先查北京,等返回;再查上海,等返回;最后查深圳。每次等 300ms,总共 900ms。用户盯着屏幕,心里骂娘。

Agent 最需要的就是同时干多件事。goroutine 让这件事简单到只需要一个 go 关键字。

go
func queryWeather(city string) string {
    time.Sleep(300 * time.Millisecond) // 模拟API调用
    return fmt.Sprintf("%s:晴,25℃", city)
}

func main() {
    cities := []string{"北京", "上海", "深圳"}
    results := make(chan string, len(cities))

    // 三个 goroutine 同时查
    for _, city := range cities {
        go func(c string) {
            results <- queryWeather(c)
        }(city)
    }

    // 收集结果
    for i := 0; i < len(cities); i++ {
        fmt.Println(<-results)
    }
}
// 总耗时 ≈ 300ms,而不是 900ms

和 Python 的 asyncio 对比一下:

Python asyncioGo goroutine
启动方式await asyncio.gather(...)go func()
并发模型协程 + 事件循环(单线程)goroutine + 多线程调度
阻塞风险不小心写个同步调用,整个事件循环卡住几乎不会——Go runtime 自动调度
学习成本async/await、事件循环、asyncio.run()一个 go 关键字

可以理解为:Python 的 asyncio 是"手动挡"并发,Go 的 goroutine 是"自动挡"。


3.1.2 sync.WaitGroup:等大家都干完

Agent 经常需要"发起一堆并发调用,全部完成后再继续":

go
func queryMultipleTools() {
    var wg sync.WaitGroup
    tools := []string{"天气查询", "新闻搜索", "股价查询"}

    for _, tool := range tools {
        wg.Add(1) // 登记:多一个任务
        go func(name string) {
            defer wg.Done() // 做完打卡
            result := callTool(name)
            fmt.Println(name, ":", result)
        }(tool)
    }

    wg.Wait() // 等所有工具都返回
    fmt.Println("全部完成!")
}

3.1.3 什么时候不用goroutine

goroutine 不是万能的。如果第二步依赖第一步的结果,老老实实写同步代码:

go
// 这种场景不需要 goroutine
// 因为查天气依赖查城市ID的结果
cityID := queryCityID("北京")
weather := queryWeatherByID(cityID) // 依赖 cityID

判断标准:工具调用之间有没有依赖关系?有就同步,没有就并发。


3.2 channel:Agent各组件之间的"传话筒"

3.2.1 为什么Agent需要channel

Agent 不是一个函数从头跑到尾。它是由多个组件拼接起来的——模型调用、工具执行、记忆读写……这些组件之间需要通信

channel 就是 Go 提供的"传话筒"——一个组件把结果放进 channel,另一个组件从 channel 取走。

go
// Agent 的模型组件把生成结果发给工具组件
resultChan := make(chan string, 10)

// 模型组件:生成回答
go func() {
    answer := model.Generate("今天天气怎么样?")
    resultChan <- answer // 把结果放进管道
}()

// 工具组件:拿到回答,决定要不要调工具
answer := <-resultChan // 从管道取结果
if needsTool(answer) {
    toolResult := callTool(answer)
    fmt.Println(toolResult)
}

3.2.2 两种channel:缓冲 vs 非缓冲

go
// 非缓冲:放进去必须有人立刻取,否则阻塞
ch1 := make(chan string)

// 缓冲:可以放3个,没人取也不阻塞(4个就开始阻塞)
ch2 := make(chan string, 3)

Agent 场景中缓冲 channel 更常用——工具调用的结果可能"先来后到":

go
// 三个工具并发执行,返回顺序不确定
// 用缓冲 channel 谁先回来谁先放
results := make(chan ToolResult, 3) // 缓冲3个,不会阻塞

go func() { results <- callWeatherAPI() }()
go func() { results <- callNewsAPI() }()
go func() { results <- callStockAPI() }()

// 按返回顺序处理,不需要等最慢的那个
for i := 0; i < 3; i++ {
    r := <-results
    fmt.Printf("[%s] 返回结果\n", r.ToolName)
}

3.2.3 select:谁先回来先处理谁

Agent 有时候需要"同时等好几个结果,哪个先来先处理哪个"。select 就是这个场景的利器:

go
func queryWithTimeout() {
    resultChan := make(chan string, 1)
    
    go func() {
        resultChan <- callSlowAPI() // 可能很慢
    }()

    select {
    case result := <-resultChan:
        fmt.Println("拿到结果:", result)
    case <-time.After(2 * time.Second):
        fmt.Println("超时了,不等了——Agent不能傻等")
    }
}

这个模式在 Agent 开发里极其常见——永远不要让用户的请求无限等待



3.3 interface:面向抽象写代码,什么LLM都能接

3.3.1 Agent最需要interface的地方

写 Agent 时最常变的两个东西:

  1. 用哪个大模型——今天是 OpenAI,明天可能换通义千问,后天可能换 DeepSeek
  2. 工具怎么实现——开发环境用 Mock,生产环境连真实 API

如果没有 interface,每次换模型都得改代码。有了 interface:

go
// 定义一个 ChatModel 接口
type ChatModel interface {
    Generate(ctx context.Context, prompt string) (string, error)
}

// OpenAI 实现
type OpenAIModel struct {
    client *openai.Client
}

func (m *OpenAIModel) Generate(ctx context.Context, prompt string) (string, error) {
    return m.client.Chat(ctx, prompt)
}

// 通义千问实现
type QwenModel struct {
    client *qwen.Client
}

func (m *QwenModel) Generate(ctx context.Context, prompt string) (string, error) {
    return m.client.Chat(ctx, prompt)
}

// 你的 Agent 代码——不关心具体是哪个模型
type Agent struct {
    model ChatModel // 面向接口,不是具体实现
}

func (a *Agent) Ask(question string) string {
    result, _ := a.model.Generate(context.Background(), question)
    return result
}

// 切换模型?一行代码的事
agent := &Agent{
    model: &QwenModel{}, // 从 OpenAI 换到通义千问
}

3.3.2 interface值判断:搞清楚"背后是谁"

有时候 Agent 需要知道"现在用的是哪个模型",比如打日志:

go
func logModelInfo(model ChatModel) {
    switch m := model.(type) {
    case *OpenAIModel:
        fmt.Println("当前模型:OpenAI")
    case *QwenModel:
        fmt.Println("当前模型:通义千问")
    default:
        fmt.Printf("当前模型:%T\n", m)
    }
}

3.3.3 空interface:实在不知道类型的时候

Go 1.18 之后有了泛型,空 interface (interface{}any) 的使用场景少了很多。但 Agent 开发中还是偶尔会碰到——比如处理 LLM 返回的 JSON,字段类型不确定:

go
// LLM 返回的 JSON 反序列化后类型不确定
var result any
json.Unmarshal(response, &result)

// 安全地取值
if m, ok := result.(map[string]any); ok {
    if name, ok := m["name"].(string); ok {
        fmt.Println("用户名:", name)
    }
}

能用泛型解决的就用泛型——下一节就讲。


3.4 泛型:让Tool定义更安全

3.4.1 没有泛型之前有多痛苦

Eino 出来之前,Go 写 Agent Tool 长这样:

go
// 参数是 interface{},传啥都行,炸了再说
func (t *WeatherTool) Run(params interface{}) (interface{}, error) {
    // 运行时类型断言——错了就 panic
    p := params.(WeatherParams)
    // ...
}

一句话:类型错误要到运行时才发现。

有了泛型之后:

go
// 编译期就确定参数和返回值类型
type Tool[Params any, Result any] struct {
    Name string
    Run  func(ctx context.Context, params Params) (Result, error)
}

// 定义一个具体的 Tool,类型完全确定
var weatherTool = Tool[WeatherParams, WeatherResult]{
    Name: "weather",
    Run: func(ctx context.Context, p WeatherParams) (WeatherResult, error) {
        // p 就是 WeatherParams 类型,编辑器有提示
        return queryWeather(p.City, p.Date)
    },
}

如果传错类型——编译就报错,根本跑不到线上。


3.4.2 泛型约束:限制"参数能是什么类型"

有时候你需要限制泛型的类型范围:

go
// 要求 Tool 的参数必须是可序列化的
type Serializable interface {
    ToJSON() string
}

// Params 必须实现 Serializable 接口
type Tool[Params Serializable, Result any] struct {
    Run func(ctx context.Context, params Params) (Result, error)
}

Agent 开发里常见的约束场景:要求工具参数能打日志、能序列化、能校验。


3.4.3 泛型不是银弹

泛型让 Tool 定义安全了,但别一上来就写泛型。遵循 Go 社区的共识:

  • 一个类型只在一个地方用 → 不用泛型
  • 同一个逻辑要处理多种类型 → 用泛型
  • 只是为了"看起来通用" → 别写泛型,浪费可读性

Eino 框架已经帮你把泛型用好了,你写 Agent 代码时直接使用 Eino 的 Tool 定义就行——不用自己造轮子。


3.5 context:优雅地管好每个请求的生命周期

3.5.1 Agent为什么离不开context

Agent 处理一个请求,可能涉及十几个组件:模型调用、工具执行、记忆查询、RAG 检索……

如果用户等得不耐烦,关了浏览器——你还让 Agent 继续跑?

context 就是 Go 提供的"总开关"——一个请求进来,带一个 context。这个请求的整个生命周期里,所有组件都盯着这个 context。一旦用户取消、超时、或者上游服务断开,context 一通知,所有 goroutine 立刻停止。

go
func (a *Agent) HandleRequest(ctx context.Context, question string) error {
    // 第一步:识别意图
    intent, err := a.classifyIntent(ctx, question)
    if err != nil {
        return err // ctx 取消了,立刻返回
    }
    
    // 第二步:并发查多个数据源——每个都带 ctx
    results := make(chan string, 3)
    
    go func() {
        select {
        case results <- a.queryWeather(ctx): // 如果 ctx 取消了,这里会提前返回
        case <-ctx.Done():
            return // 不用等了,上游已经不要结果了
        }
    }()
    
    // ...
}

3.5.2 三个最常用的 context 模式

模式1:设超时

go
// 这个 Agent 请求最多等 5 秒
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

result, err := agent.Ask(ctx, "帮我查天气")

模式2:传递值

go
// 把请求ID放进 context,方便追踪日志
ctx = context.WithValue(ctx, "requestID", "req-12345")

// 在 Agent 的任何组件里都能取到
func logWithID(ctx context.Context, msg string) {
    id := ctx.Value("requestID")
    fmt.Printf("[%v] %s\n", id, msg)
}

模式3:级联取消

go
// 父 context 取消了,所有子 context 都会收到通知
parentCtx, parentCancel := context.WithCancel(context.Background())

go func() {
    // 子 goroutine 继承了 parentCtx
    result := agent.query(ctx, "...") // ctx 是 parentCtx 的派生
}()

parentCancel() // 所有带这个 ctx 的 goroutine 全部停止


3.5.3 一个常见的坑

go
// ❌ 错误示范:goroutine 里忘了检查 ctx
func (a *Agent) queryWithTimeout(ctx context.Context) {
    go func() {
        result := a.model.Generate(ctx, "大段prompt...")
        // 如果 ctx 已经超时或取消了,
        // model.Generate 内部会返回错误
        // 但如果你不检查,会继续往下跑
        fmt.Println(result) // 可能是无效结果!
    }()
}

// ✅ 正确示范:每一步都检查 ctx
func (a *Agent) queryWithTimeout(ctx context.Context) error {
    select {
    case <-ctx.Done():
        return ctx.Err() // 超时或取消,立刻返回
    default:
        // 继续执行
    }
    
    result, err := a.model.Generate(ctx, "prompt...")
    if err != nil {
        return fmt.Errorf("模型调用失败: %w", err)
    }
    
    // 再次检查——模型调用可能花了好几秒
    // 回来的时候 ctx 可能已经超时了
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
        return processResult(result)
    }
}

一句话经验:每次做完一件耗时的事,回头看看 ctx 还活着没。


3.6 本章小结

这一章我们快速过了一遍 Agent 开发中最常用的 Go 特性。不是 Go 语言的全部——是你写 Agent 时真正常用到的那部分:

  1. goroutine:Agent 的"多线程大脑"。多个工具同时调,别让用户等——go 关键字一行搞定
  2. channel:组件之间的"传话筒"。模型调完了把结果放进 channel,工具组件从 channel 拿走。缓冲 channel + select 是 Agent 的标配
  3. interface:面向抽象写代码。换模型、换工具实现、Mock 切真实——改一行,不是改一个项目
  4. 泛型:让 Tool 定义编译期安全。Eino 帮你封装好了,直接用就行
  5. context:每个请求的"总开关"。超时取消、级联停止、值传递——Agent 系统里无处不在

✅ 知识点检查

学完这一章,试试回答这几个问题:

  • [ ] goroutine 和 Python 的 asyncio 本质区别是什么?什么场景下 Agent 适合用并发,什么场景不适合?
  • [ ] 缓冲 channel 和非缓冲 channel 的区别?Agent 开发中为什么缓冲 channel 更常用?
  • [ ] interface 帮 Agent 解决了什么问题?为什么说"面向接口写代码"让换模型只需要改一行?
  • [ ] 泛型让 Tool 定义更安全——具体安全在哪?
  • [ ] context 的三种最常见用法是什么?为什么说"每次做完耗时操作,回头看看 ctx 还活着没"?

📚 延伸阅读


🎯 下一章预告

第4章,我们正式进入 AI Agent 的世界——

"Agent 不是魔法,它只是个聪明点的程序。看完这一章,你就知道它到底是个什么东西——而且能用 Eino 跑通第一个。"