第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 asyncio | Go 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 时最常变的两个东西:
- 用哪个大模型——今天是 OpenAI,明天可能换通义千问,后天可能换 DeepSeek
- 工具怎么实现——开发环境用 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 时真正常用到的那部分:
- goroutine:Agent 的"多线程大脑"。多个工具同时调,别让用户等——
go关键字一行搞定 - channel:组件之间的"传话筒"。模型调完了把结果放进 channel,工具组件从 channel 拿走。缓冲 channel + select 是 Agent 的标配
- interface:面向抽象写代码。换模型、换工具实现、Mock 切真实——改一行,不是改一个项目
- 泛型:让 Tool 定义编译期安全。Eino 帮你封装好了,直接用就行
- context:每个请求的"总开关"。超时取消、级联停止、值传递——Agent 系统里无处不在
✅ 知识点检查
学完这一章,试试回答这几个问题:
- [ ] goroutine 和 Python 的 asyncio 本质区别是什么?什么场景下 Agent 适合用并发,什么场景不适合?
- [ ] 缓冲 channel 和非缓冲 channel 的区别?Agent 开发中为什么缓冲 channel 更常用?
- [ ] interface 帮 Agent 解决了什么问题?为什么说"面向接口写代码"让换模型只需要改一行?
- [ ] 泛型让 Tool 定义更安全——具体安全在哪?
- [ ] context 的三种最常见用法是什么?为什么说"每次做完耗时操作,回头看看 ctx 还活着没"?
📚 延伸阅读
- Go 并发模式:https://go.dev/blog/pipelines
- Effective Go:https://go.dev/doc/effective_go
- Go 泛型教程:https://go.dev/doc/tutorial/generics
- 本书配套源码:关注公众号「图解AI系列」免费领取
🎯 下一章预告
第4章,我们正式进入 AI Agent 的世界——
"Agent 不是魔法,它只是个聪明点的程序。看完这一章,你就知道它到底是个什么东西——而且能用 Eino 跑通第一个。"

