模块 00 · 基础功底 · 第 0.1 课

0.1 Go:从基础语法到并发,Agent 运行时的地基

第 0 部分 · 第 1 课 | 预计 5–6 小时(基础 2 小时 + 并发 3–4 小时)| 前置:会任意一门编程语言

本课并发部分的代码都对应仓库里能跑的实现:projects/00-stream-proxy/(go test -race -count=1 ./... 通过)。

读前说明

这一课分两部分:

每段代码都按三步讲:它要解决什么问题 → 代码(关键行带注释)→ 逐行解读 + 容易错的地方。第一次读时如果某段看不懂,先看它下面的解读和时间线,再回头看代码。


为什么 Agent 后端用 Go

一个生产级 Agent 每一轮大致在做:

调用 LLM(流式)→ 解析出 N 个 tool_call → 并行执行工具 → 汇总结果 → 下一轮
                     ↑ 任一环节都要:超时、取消、限流、不泄漏

翻成大白话:

这就是一个“带预算的并发调度器”。Go 的优势:编译成单个二进制、部署简单;内置并发原语;标准库自带 HTTP 服务端和 JSON;类型系统够用但不复杂。本课结束时,你能写出 Agent Loop 里最核心的两段代码:并行工具执行 和 可取消的流式读取。


第一部分:Go 基础

B1. 环境与项目结构

要解决的问题:怎么建一个 Go 项目、怎么跑起来、怎么引第三方库。

go version                 # 确认安装,本仓库用 1.23+
mkdir hello-agent && cd hello-agent
go mod init github.com/duang777/hello-agent   # 初始化模块,生成 go.mod

新建 main.go:

package main // 可执行程序的入口包必须叫 main

import "fmt" // 引入标准库的 fmt 包(格式化输出)

func main() { // 程序从 main 函数开始执行
    fmt.Println("hello, agent")
}
go run .          # 编译并运行当前目录
go build -o app . # 编译出二进制文件 app,可以直接拷到服务器上跑
go get golang.org/x/sync@latest   # 引入第三方库,自动写进 go.mod / go.sum
go mod tidy       # 清理没用到的依赖、补上缺的

逐行解读

一个典型的 Agent 服务目录(先有个印象,后面课程会逐步填满):

hello-agent/
├── go.mod
├── main.go            # 入口:读配置、启动 HTTP 服务
├── agent/             # package agent:Agent Loop
│   ├── loop.go
│   └── loop_test.go   # 测试文件以 _test.go 结尾
└── tools/             # package tools:各种工具实现
    └── search.go

B2. 包与可见性:大写就是 public

package tools

func Search(q string) string { return "..." } // 大写开头:其他包能用 tools.Search
func buildURL(q string) string { return "..." } // 小写开头:只有 tools 包内部能用

Go 没有 public / private 关键字,首字母大小写就是访问控制。函数、类型、struct 字段、常量都适用。这一点会影响 JSON(见 B9):小写字段 JSON 包看不见,不会被编码。

B3. 变量、常量与基本类型

var name string = "search"   // 完整写法:var 变量名 类型 = 值
var count int                // 不赋值也行,自动是“零值”:int 是 0,string 是 "",bool 是 false
model := "glm-4"             // 短声明:类型自动推断,只能在函数内部用(最常用)
const maxTurns = 10          // 常量,编译期确定,不能改

timeout := 20 * time.Second  // time.Duration 类型,常用于超时

常用类型速查:

类型 例子 说明
string "你好" 不可变,UTF-8 编码
int / int64 42 一般用 int 就行
float64 0.7 比如 temperature
bool true
[]byte []byte("hi") 字节切片,读网络、读文件、JSON 都用它
time.Duration 3 * time.Second 时间长度
error errors.New("x") 错误,是个接口,见 B5

零值很重要:Go 里任何变量声明了就有值,不存在“未初始化”。指针、slice、map、channel、接口的零值都是 nil。

B4. 函数:多返回值

要解决的问题:函数既要返回结果,又要告诉调用方“有没有出错”。

func divide(a, b float64) (float64, error) { // 返回两个值:结果和错误
    if b == 0 {
        return 0, errors.New("除数不能为 0")
    }
    return a / b, nil // nil 表示“没有错误”
}

result, err := divide(10, 2) // 同时接收两个返回值
if err != nil {              // Go 里最常见的三行
    fmt.Println("出错了:", err)
    return
}
fmt.Println(result) // 5

_, err = divide(1, 0) // 不需要的返回值用 _ 丢掉

逐行解读

B5. 错误处理:包装、判断、自定义

要解决的问题:错误一层层往上传,既要保留原始原因,又要加上“在哪一步出的错”。

var ErrToolNotFound = errors.New("tool not found") // “哨兵错误”:包级变量,用来做比较

func findTool(name string) error {
    if name != "search" {
        return ErrToolNotFound
    }
    return nil
}

func runTool(name string) error {
    if err := findTool(name); err != nil {
        return fmt.Errorf("run %q: %w", name, err) // %w:把原错误“包”进新错误
    }
    return nil
}

err := runTool("weather")
fmt.Println(err)                              // run "weather": tool not found
fmt.Println(errors.Is(err, ErrToolNotFound))  // true:能穿透包装,找到里面的原错误

逐行解读

自定义错误类型(需要带额外信息时):

type ToolError struct {
    Tool string
    Code int
}

func (e *ToolError) Error() string { // 只要有 Error() string 方法,就是 error
    return fmt.Sprintf("tool %s failed with code %d", e.Tool, e.Code)
}

var te *ToolError
if errors.As(err, &te) { // errors.As:沿包装链找“这种类型”的错误,找到就赋值给 te
    fmt.Println(te.Code)
}

B6. struct 与方法

要解决的问题:把相关的数据打包成一个类型,并给它定义行为。

type Message struct {   // 定义一个新类型 Message
    Role    string      // "user" / "assistant" / "tool"
    Content string
}

type Conversation struct {
    ID       string
    Messages []Message  // 字段可以是 slice
}

// 方法:在 func 和名字之间写“接收者”(c *Conversation)
func (c *Conversation) Add(role, content string) {
    c.Messages = append(c.Messages, Message{Role: role, Content: content})
}

func (c Conversation) Len() int { // 值接收者:只读,不修改
    return len(c.Messages)
}

conv := &Conversation{ID: "c1"} // & 取地址,得到 *Conversation(指针)
conv.Add("user", "你好")
fmt.Println(conv.Len()) // 1

逐行解读

指针速查:

x := 1
p := &x   // p 是指向 x 的指针,类型 *int
*p = 2    // 通过指针改 x
fmt.Println(x) // 2

什么时候用指针:要在函数里修改对象;对象很大不想拷贝;需要表示“没有”(nil)。

B7. slice 与 map

// slice:可变长度的数组
tools := []string{"search", "calc"}
tools = append(tools, "weather")       // 追加,必须接收返回值
fmt.Println(len(tools), tools[0])      // 3 search

for i, name := range tools {           // 遍历:下标 + 值
    fmt.Println(i, name)
}

results := make([]string, 3)           // 提前开好长度为 3 的 slice,元素是零值 ""

// map:键值对
registry := map[string]int{"search": 1}
registry["calc"] = 2                   // 写
v, ok := registry["weather"]           // 读:ok 表示键是否存在
if !ok {
    fmt.Println("没有这个工具", v)     // v 是零值 0
}
delete(registry, "calc")               // 删

容易错的地方

B8. 接口:Go 的多态

要解决的问题:Agent 有很多工具,Loop 不想关心每个工具的具体实现,只想“调用它”。

type Tool interface {          // 接口:只规定“有哪些方法”
    Name() string
    Run(ctx context.Context, args string) (string, error)
}

type Calculator struct{}       // 一个具体工具

func (Calculator) Name() string { return "calc" }
func (Calculator) Run(ctx context.Context, args string) (string, error) {
    return "42", nil
}

// Calculator 没有写任何 "implements Tool",但它有 Tool 要求的全部方法,所以它就是 Tool
var t Tool = Calculator{}

tools := map[string]Tool{}     // 工具注册表:名字 → 工具
tools[t.Name()] = t
out, err := tools["calc"].Run(context.Background(), `{"expr":"6*7"}`)

逐行解读

B9. JSON:和模型 API 打交道的核心

要解决的问题:模型 API 收发的都是 JSON,要在 Go 的 struct 和 JSON 文本之间互转。

// 给 B6 的 Message 加上 JSON 标签后,就能直接用来调模型 API
type ChatRequest struct {
    Model       string    `json:"model"`                 // 标签:JSON 里叫 model
    Messages    []Message `json:"messages"`
    Temperature float64   `json:"temperature,omitempty"` // omitempty:零值时不输出
    Stream      bool      `json:"stream"`
}

type Message struct {
    Role    string `json:"role"`
    Content string `json:"content"`
}

// struct → JSON(编码)
req := ChatRequest{Model: "glm-4", Messages: []Message{{Role: "user", Content: "你好"}}, Stream: true}
b, err := json.Marshal(req) // b 是 []byte
// {"model":"glm-4","messages":[{"role":"user","content":"你好"}],"stream":true}

// JSON → struct(解码)
var resp struct {           // 匿名 struct:只用一次,不用起名字
    Choices []struct {
        Message Message `json:"message"`
    } `json:"choices"`
}
err = json.Unmarshal([]byte(`{"id":"x","choices":[{"message":{"role":"assistant","content":"hi"}}]}`), &resp)
fmt.Println(resp.Choices[0].Message.Content) // hi

逐行解读

B10. defer:函数退出时一定执行

func readConfig(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer f.Close() // 登记:函数返回时关闭文件,不管从哪个 return 出去

    return io.ReadAll(f)
}
func f() {
    defer fmt.Println("3")  // 先登记,最后才执行
    defer fmt.Println("2")
    fmt.Println("1")
}
// 输出:1 2 3  —— 多个 defer 按“后登记先执行”

固定搭配:打开资源的下一行就写 defer 关闭。关文件、关 HTTP 响应体、解锁、取消 context,全是这个模式。第二部分的代码里到处都是。

B11. 泛型(了解即可)

func Map[T, R any](xs []T, f func(T) R) []R { // [T, R any]:类型参数
    out := make([]R, 0, len(xs))
    for _, x := range xs {
        out = append(out, f(x))
    }
    return out
}

list := []Tool{Calculator{}}
names := Map(list, func(t Tool) string { return t.Name() }) // 自动推断 T=Tool, R=string,结果 ["calc"]

Go 1.18 起支持泛型。日常业务代码用得不多,主要用在通用工具函数和容器上。看得懂就行。

B12. 测试:表驱动

要解决的问题:每课的练习都要“测试跑通才算完成”,所以先会写测试。

// divide_test.go,和 divide.go 放在同一目录、同一个包
package calc

import "testing"

func TestDivide(t *testing.T) { // 测试函数:Test 开头,参数是 *testing.T
    cases := []struct {         // 表驱动:把输入和期望写成一张表
        name    string
        a, b    float64
        want    float64
        wantErr bool
    }{
        {"正常", 10, 2, 5, false},
        {"除零", 1, 0, 0, true},
    }

    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) { // 每行一个子测试,失败时能看到是哪一行
            got, err := divide(tc.a, tc.b)
            if (err != nil) != tc.wantErr {
                t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
            }
            if got != tc.want {
                t.Errorf("got %v, want %v", got, tc.want)
            }
        })
    }
}
go test ./...            # 跑所有测试
go test -run TestDivide  # 只跑名字匹配的
go test -v ./...         # 打印每个子测试
go test -race ./...      # 检测数据竞争(并发部分必用)

逐行解读

第一部分小练习(20 分钟)

写一个工具注册表 Registry:

  1. 有方法 Register(t Tool) 和 Call(ctx context.Context, name, args string) (string, error)。
  2. 调用不存在的工具时,返回包装过的 ErrToolNotFound,调用方能用 errors.Is 判断出来。
  3. 用表驱动测试覆盖:调用存在的工具、调用不存在的工具。
参考实现
package tools

import (
    "context"
    "errors"
    "fmt"
)

var ErrToolNotFound = errors.New("tool not found")

type Tool interface {
    Name() string
    Run(ctx context.Context, args string) (string, error)
}

type Registry struct {
    tools map[string]Tool
}

func NewRegistry() *Registry {
    return &Registry{tools: make(map[string]Tool)} // map 必须初始化
}

func (r *Registry) Register(t Tool) {
    r.tools[t.Name()] = t
}

func (r *Registry) Call(ctx context.Context, name, args string) (string, error) {
    t, ok := r.tools[name]
    if !ok {
        return "", fmt.Errorf("call %q: %w", name, ErrToolNotFound)
    }
    return t.Run(ctx, args)
}
package tools

import (
    "context"
    "errors"
    "testing"
)

type echo struct{}

func (echo) Name() string { return "echo" }
func (echo) Run(ctx context.Context, args string) (string, error) { return args, nil }

func TestRegistryCall(t *testing.T) {
    r := NewRegistry()
    r.Register(echo{})

    cases := []struct {
        name    string
        tool    string
        want    string
        wantErr error
    }{
        {"存在", "echo", "hi", nil},
        {"不存在", "nope", "", ErrToolNotFound},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            got, err := r.Call(context.Background(), tc.tool, "hi")
            if !errors.Is(err, tc.wantErr) {
                t.Fatalf("err = %v, want %v", err, tc.wantErr)
            }
            if got != tc.want {
                t.Errorf("got %q, want %q", got, tc.want)
            }
        })
    }
}
`errors.Is(nil, nil)` 返回 true,所以“存在”那一行也能用同一个判断。

第二部分:Go 并发

0. 并发语法速查(看代码前必读)

后面的代码会反复用到下面这些写法。defer、匿名 struct 和 JSON 标签在第一部分 B9、B10 已经讲过。

0.1 go 关键字:开一个后台任务

go doSomething()        // 在后台执行 doSomething,当前代码不等它,继续往下走

go func() {             // 也可以直接写一个匿名函数丢到后台
    fmt.Println("我在后台跑")
}()                     // 注意最后的 ():定义完立刻调用

go 启动的东西叫 goroutine,可以理解成“非常便宜的线程”,开几万个都没问题。但它有个特点:启动它的函数返回了,它也不会自动停。所以后面会一直强调“每个 goroutine 怎么结束”。

0.2 channel:goroutine 之间传数据的管道

ch := make(chan string)     // 造一根能传 string 的管道(无缓冲)
ch2 := make(chan string, 3) // 带缓冲:管道里最多能“存” 3 个值

ch <- "hello"   // 往管道里放(发送)
v := <-ch       // 从管道里取(接收)
close(ch)       // 关闭管道:告诉接收方“不会再有数据了”

箭头方向就是数据流向:ch <- x 是 x 流进 ch,<-ch 是从 ch 流出来。

0.3 只读 / 只写 channel 类型

func produce() <-chan int   // 返回值类型 <-chan int:调用方只能从里面读
func consume(ch chan<- int) // 参数类型 chan<- int:函数只能往里面写

这是给编译器看的约束:函数返回 <-chan,调用方就不可能误往里写、也不可能误 close。本课的函数都返回 <-chan。

0.4 闭包:匿名函数能直接用外面的变量

results := make([]string, 3)
for i := 0; i < 3; i++ {
    go func() {
        results[i] = "done" // 直接用外面的 results 和 i
    }()
}

Go 1.22 起,for 循环每一轮的 i 都是新变量,所以上面每个 goroutine 拿到的 i 是自己那一轮的。(老版本里所有 goroutine 共享同一个 i,是经典 bug。本仓库用 Go 1.23。)


1. goroutine 与 channel:三条规则

要解决的问题:后台任务干完活,怎么把结果交回来?

results := make(chan string)      // 无缓冲 channel
go func() {
    results <- "tool A done"      // 后台:把结果放进管道
}()
fmt.Println(<-results)            // 前台:在这里等,直到拿到结果

时间线:

主 goroutine:  创建 channel → 启动后台 → 执行 <-results(卡住等)……………… 拿到值,打印
后台 goroutine:                          执行 results <- "..."(等有人接)→ 交接完成,结束

无缓冲 channel 像“当面交接”:发送方和接收方必须同时到场,任何一方先到都要等另一方。

记住三条规则,能避开 80% 的 bug:

  1. 谁发送,谁关闭。 接收方永远不要 close。关闭后再发送会 panic(程序直接崩)。
  2. 每启动一个 goroutine,都要想清楚它怎么结束。 想不清楚 = 泄漏。
  3. 无缓冲 channel 是同步点,有缓冲 channel 是队列。 缓冲大小 = 允许“发了还没人收”的数量。

生产者模式:返回一个 channel

要解决的问题:函数要“源源不断”产出数据,调用方边收边处理。

func produce(n int) <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch) // 规则 1:发送方负责关闭
        for i := 0; i < n; i++ {
            ch <- i     // 每放一个,都要等调用方取走
        }
    }()                 // 后台开始生产
    return ch           // 立刻返回管道,不等生产完
}

for v := range produce(3) { fmt.Println(v) } // 0 1 2

逐行解读

这个“函数立刻返回一个 channel,后台 goroutine 往里写,写完关闭”的模式,第 5 节的 streamTokens 和练习 1 的 fanIn 都是它的升级版。先把它看懂。


2. select:同时等多件事

要解决的问题:等模型吐字的时候,还要同时盯着“超时了没”“用户走了没”。

select {
case tok := <-tokens:                       // 情况 A:等到一个 token
    fmt.Print(tok)
case <-time.After(30 * time.Second):        // 情况 B:30 秒都没等到
    return errors.New("LLM 30 秒没吐 token")
case <-ctx.Done():                          // 情况 C:被取消了
    return ctx.Err()
}

逐行解读

Agent 里最常见的写法就是“等数据 OR 等取消”,后面几乎每段代码都有。


3. context:取消信号的传播链

要解决的问题:用户关掉网页后,怎么让后台所有正在跑的工作都停下来?

先看链条:

用户关掉网页
  → HTTP 服务端发现连接断了,r.Context() 被取消
    → 你传给 LLM 调用的 ctx 被取消 → 到模型供应商的连接断开,停止计费
    → 你传给每个工具的 ctx 被取消 → 工具停止执行

context.Context(简称 ctx)就是这根“取消信号线”。它是树状的:父 ctx 被取消,所有从它派生出来的子 ctx 都会被取消。这条链断了,就是在烧钱。

func handleChat(w http.ResponseWriter, r *http.Request) {
    // 从请求的 ctx 派生一个子 ctx,并加上“最多 60 秒”的限制
    ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
    defer cancel() // 必写:函数结束时主动释放计时器

    if err := runAgent(ctx); err != nil {   // 把 ctx 一路往下传
        if errors.Is(err, context.Canceled) {
            slog.Info("client gone, stopped") // 用户自己走了,不算错误
            return
        }
        http.Error(w, err.Error(), 500)
    }
}

逐行解读

ctx 被取消后会发生什么? 只做一件事:ctx.Done() 这个 channel 被关闭,ctx.Err() 开始返回非 nil。它不会强行杀掉任何代码。下游代码必须自己去 select 监听 ctx.Done(),才会真的停。这一点后面会反复体现。

规范(照着写就行):


4. 并行工具调用:errgroup

要解决的问题:模型一次要求调用 3 个工具,怎么同时跑、每个有超时、最多同时跑几个、一个失败不影响其他?

串行要 3 × 延迟,并行只要最慢那个:

串行:[ 工具A 1s ][ 工具B 2s ][ 工具C 3s ]          = 6s
并行:[ 工具A 1s ]
      [ 工具B 2s     ]
      [ 工具C 3s          ]                        = 3s

errgroup 是 Go 官方扩展库里的工具,可以理解成“一组 goroutine + 等它们全部结束 + 收集错误 + 限制并发数”的打包方案。

import "golang.org/x/sync/errgroup"

// 模型返回的一次工具调用请求
type ToolCall struct {
    ID   string          // 模型给的调用 ID,回传结果时要带上,模型靠它对号入座
    Name string          // 工具名,比如 "search"
    Args json.RawMessage // 参数原文(一段 JSON),先不解析,交给具体工具
}

// 工具执行完的结果
type ToolResult struct {
    ID     string
    Output string
    Err    error
}

func runTools(ctx context.Context, calls []ToolCall) ([]ToolResult, error) {
    g, ctx := errgroup.WithContext(ctx)           // ① 建一个任务组
    g.SetLimit(4)                                 // ② 最多同时跑 4 个
    results := make([]ToolResult, len(calls))     // ③ 提前开好结果数组

    for i, c := range calls {
        g.Go(func() error {                       // ④ 每个工具一个 goroutine
            tctx, cancel := context.WithTimeout(ctx, 20*time.Second) // ⑤ 单工具超时
            defer cancel()
            out, err := execTool(tctx, c)         // ⑥ 真正执行工具
            results[i] = ToolResult{ID: c.ID, Output: out, Err: err} // ⑦ 写自己的格子
            return nil                            // ⑧ 失败也返回 nil
        })
    }
    if err := g.Wait(); err != nil {              // ⑨ 等所有工具结束
        return nil, err
    }
    return results, nil
}

逐行解读

设计取舍(面试常问):为什么工具失败返回 nil?因为对 Agent 来说,“工具报错”是一条有用的信息,比如“文件不存在”“参数格式不对”。把它作为 tool_result 回传给模型,模型通常会换个参数重试。如果 return err,errgroup 会取消其他正在跑的工具,整轮白做。只有“基础设施故障”(比如用户已经走了)才应该中断整轮,做法见自测题 3。


5. 可取消的流式读取:解析 SSE

要解决的问题:模型的流式接口一行一行推数据过来,怎么边读边把文字交给前端,同时在用户取消时立刻停下、不留后台垃圾?

5.1 SSE 长什么样

LLM 流式接口返回的是 SSE(Server-Sent Events),本质就是一段不断变长的纯文本:

data: {"choices":[{"delta":{"content":"你"}}]}

data: {"choices":[{"delta":{"content":"好"}}]}

data: [DONE]

5.2 整体设计

              ┌──────────── 后台 goroutine ────────────┐
HTTP body ──▶ │ 一行行读 → 过滤 data: → 解析 JSON → 取字 │ ──▶ tokens  (正常数据,一个个字)
              │            出错 / 被取消 / 读完        │ ──▶ errc    (为什么结束,最多一个值)
              └────────────────────────────────────────┘

函数立刻返回两根管道:tokens 送文字,errc 送“结束原因”。分开的好处:调用方先 for range tokens 专心收文字,收完再看一眼 errc 知道是正常结束还是出错。

5.3 代码

func streamTokens(ctx context.Context, body io.ReadCloser) (<-chan string, <-chan error) {
    tokens := make(chan string)     // 无缓冲:调用方读一个,这边才发下一个
    errc := make(chan error, 1)     // 缓冲 1:放错误时不用等人来取

    go func() {
        defer close(errc)   // 第三个执行
        defer close(tokens) // 第二个执行
        defer body.Close()  // 第一个执行(defer 后登记先执行)

        sc := bufio.NewScanner(body)                     // 按行读
        sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)  // 单行上限放宽到 1MB
        for sc.Scan() {                                  // 每次读一行,读不到就结束循环
            line := sc.Text()
            if !strings.HasPrefix(line, "data: ") {
                continue // 跳过空行、注释、event: 行
            }
            data := strings.TrimPrefix(line, "data: ")
            if data == "[DONE]" {
                return // 正常结束:errc 里什么都不放
            }
            var chunk struct {
                Choices []struct {
                    Delta struct{ Content string } `json:"delta"`
                } `json:"choices"`
            }
            if err := json.Unmarshal([]byte(data), &chunk); err != nil {
                errc <- fmt.Errorf("bad chunk: %w", err) // JSON 坏了:报错并退出
                return
            }
            if len(chunk.Choices) == 0 {
                continue // 有的 chunk 没有 choices(比如只带用量统计),跳过
            }
            select {
            case tokens <- chunk.Choices[0].Delta.Content: // 把字交出去
            case <-ctx.Done():                              // 或者:调用方不要了
                errc <- ctx.Err()
                return
            }
        }
        if err := sc.Err(); err != nil { // 循环结束不是因为读完,而是读出错(比如断网)
            errc <- err
        }
    }()
    return tokens, errc
}

调用方这样用:

tokens, errc := streamTokens(ctx, resp.Body)
for tok := range tokens {          // 一直读到 tokens 被关闭
    fmt.Print(tok)
}
if err := <-errc; err != nil {     // 正常结束时 errc 里没值且已关闭,读到 nil
    return err
}

5.4 逐段解读

函数签名

三个 defer(顺序很重要)

后台 goroutine 不管从哪个 return 退出,都会按这个顺序收尾:

  1. body.Close():关闭 HTTP 响应体,释放网络连接,让它能被下一个请求复用。
  2. close(tokens):通知调用方“没有更多字了”,调用方的 for range tokens 因此退出。
  3. close(errc):如果前面没放错误,调用方 <-errc 读到的就是 nil,不会永远卡住。

按行读:bufio.Scanner

过滤和解析

最关键的 select

select {
case tokens <- 字:     // 尝试把字交给调用方
case <-ctx.Done():     // 同时盯着取消
}

为什么不直接写 tokens <- 字?因为 tokens 是无缓冲的,调用方不来取,这行就会永远卡住。如果调用方已经不读了(用户关了页面),这个 goroutine 就永远停在这里,连接也永远不关 → 泄漏。和 ctx.Done() 放进同一个 select,取消时就能跳出来退出。

一句话规则:每一次 channel 发送,都要和 ctx.Done() 放进同一个 select。

为什么 errc 要缓冲 1(最容易卡住的地方,画个时间线):

假设 JSON 解析出错,errc 无缓冲:

后台:   errc <- err   ← 卡住:无缓冲,要等有人来取
调用方: for range tokens  ← 卡住:在等 tokens 被关闭
        (tokens 要等后台 return 后的 defer 才关闭,而后台卡在上一行)
结果:   两边互相等 → 死锁

errc 缓冲 1:

后台:   errc <- err   ← 立刻完成,错误存进缓冲
        return → defer 关 body、关 tokens、关 errc
调用方: for range tokens 发现 tokens 关了,退出循环
        <-errc 拿到刚才那个错误

规律:最多只发一次的“结果/错误” channel,给缓冲 1,发送方永远不会因为没人读而卡住。

5.5 四种结束方式汇总

怎么结束的 errc 里有什么 调用方看到
读到 [DONE] 空 err == nil,正常结束
JSON 解析失败 bad chunk: ... 报错
ctx 被取消 context.Canceled 或 DeadlineExceeded 报错
网络断了 / body 读失败 sc.Err() 的错误 报错

四条路都会走到 defer,所以连接一定关、两根管道一定关、goroutine 一定退出。这就是“不泄漏”的完整含义。


6. 三个高频坑

坑 症状 修法
goroutine 泄漏 内存、goroutine 数只涨不跌 所有阻塞的发送都配 ctx.Done();测试里检查 runtime.NumGoroutine() 或用 goleak
response body 没关 连接耗尽、请求越来越慢 defer resp.Body.Close(),必要时 io.Copy(io.Discard, body) 先读干净再关
map 并发写 fatal error: concurrent map writes,程序直接退出 按下标写 slice,或用 sync.Mutex 加锁;跑测试加 -race

泄漏的最小例子,对照着看就明白了:

// ❌ 泄漏:调用方只读一个就走了,后台卡在第二次发送,永远不退出
func leaky() <-chan int {
    ch := make(chan int)
    go func() {
        for i := 0; ; i++ {
            ch <- i
        }
    }()
    return ch
}

// ✅ 不泄漏:发送时同时监听 ctx,调用方 cancel 后后台立刻退出
func safe(ctx context.Context) <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)
        for i := 0; ; i++ {
            select {
            case ch <- i:
            case <-ctx.Done():
                return
            }
        }
    }()
    return ch
}

本课小结

概念 一句话
error 与 %w 错误层层包装,用 errors.Is 穿透判断
接口 隐式实现,工具注册表、测试替身都靠它
JSON 标签 大写字段 + json:"name" 映射 API 字段
goroutine 后台任务,便宜,但必须想清楚怎么结束
channel goroutine 之间传数据;发送方负责关闭
select 同时等多件事,谁先来处理谁;“等数据 OR 等取消”是标配
context 取消信号线,父取消子全取消;只发信号,不强杀,下游要自己监听
errgroup 并行一组任务 + 限并发 + 等全部结束
缓冲 1 的 errc 只发一次的结果用缓冲 1,避免互相等待

练习(今天做完)

  1. 基础:写 fanIn(ctx, chs ...<-chan string) <-chan string,把多个 channel 合并成一个;ctx 取消后所有 goroutine 退出。 - 提示:参考第 1 节生产者模式;每路输入一个 goroutine;用 sync.WaitGroup 等它们都结束后再关闭输出。
  2. 核心:用第 4 节的 runTools,写三个假工具(分别 sleep 1s / 2s / 25s),验证:总耗时约 20s(第三个超时)、超时工具的错误被写进 ToolResult.Err。 - 提示:假工具里不要用 time.Sleep,要用 select 同时等 time.After 和 ctx.Done()。
  3. 进阶:用 httptest.NewServer 写一个假 SSE 服务端,每 100ms 吐一个 token;客户端读 5 个后 cancel,断言服务端检测到连接断开(handler 里 <-r.Context().Done())。
  4. 所有测试用 go test -race 跑通。

自测题

  1. 为什么 errc 要用缓冲 1 的 channel?换成无缓冲会发生什么?
  2. errgroup.WithContext 返回的 ctx,在什么情况下会被取消?
  3. 工具执行失败时,为什么本课选择 return nil?什么情况下应该 return err?
  4. context.WithTimeout 之后不调用 cancel(),会有什么后果?

做完练习后,把代码放到 projects/00-stream-proxy/,go test -race -count=1 ./... 全部通过,这一课就算完成。


参考答案与详解

先自己做,卡住超过 20 分钟再看。

自测题答案

1. errc 为什么要缓冲 1?换成无缓冲会怎样?

出错时,后台 goroutine 的顺序是:errc <- err → return → 执行 defer(关闭 tokens)。

时间线图见第 5.4 节。规律:“报告最终结果”的 channel,如果最多只发一次,就给缓冲 1。

2. errgroup.WithContext 返回的 ctx 什么时候会被取消?

三种情况,任一发生即取消:

  1. 某个 g.Go 里的函数第一次返回非 nil 错误(其余 goroutine 通过这个 ctx 感知到,应尽快退出);
  2. g.Wait() 返回时(所有任务都结束了,ctx 随之取消);
  3. 传进来的父 ctx 被取消(比如客户端断开、整轮超时)。

所以第 4 节里每个工具都要用这个派生 ctx,而不是外层的 ctx,才能做到“一个失败、全体停”。

3. 工具失败为什么 return nil?什么时候该 return err?

怎么区分“单个工具超时”和“用户走了”?看是哪个 ctx 出的问题:

out, err := execTool(tctx, c)
if ctx.Err() != nil {          // 组 ctx(上一层)被取消:整轮中止
    return ctx.Err()
}
// 走到这里说明只是这个工具自己的 tctx 超时或工具本身出错:记下来,继续
results[i] = ToolResult{ID: c.ID, Output: out, Err: err}
return nil

tctx 是 ctx 的子节点:tctx 超时不影响 ctx;但 ctx 取消一定会连带 tctx。所以检查 ctx.Err() 就能区分。

4. context.WithTimeout 之后不调 cancel() 会怎样?

结论:ctx, cancel := context.WithXxx(...) 的下一行永远是 defer cancel()。提前完成时它会立刻释放资源;多调一次 cancel 也是安全的。


练习参考实现

练习 1:fanIn

要做的事:有好几根输入管道(比如几个工具同时在吐日志),合并成一根输出管道给调用方读。

ch1 ──▶ worker1 ──┐
ch2 ──▶ worker2 ──┼──▶ out ──▶ 调用方
ch3 ──▶ worker3 ──┘
          全部 worker 结束后,由一个“收尾 goroutine”关闭 out
func fanIn(ctx context.Context, chs ...<-chan string) <-chan string {
    out := make(chan string)
    var wg sync.WaitGroup
    wg.Add(len(chs))            // 计数器:有几路输入就记几

    for _, ch := range chs {
        go func() {             // 每路输入一个 worker
            defer wg.Done()     // worker 退出时计数 -1
            for {
                select {
                case v, ok := <-ch:          // 外层:等输入
                    if !ok {
                        return               // 这一路输入已关闭,worker 结束
                    }
                    select {
                    case out <- v:           // 内层:把值转发出去
                    case <-ctx.Done():       // 转发时也能被取消
                        return
                    }
                case <-ctx.Done():           // 外层:等输入时也能被取消
                    return
                }
            }
        }()
    }

    go func() {                 // 收尾 goroutine
        wg.Wait()               // 等计数归零 = 所有 worker 都退出了
        close(out)              // 这时才安全地关闭 out
    }()
    return out
}

逐行解读

它在三种情况下都能正确结束:

情况 发生了什么
所有输入正常关闭 每个 worker 读到 ok == false 退出 → 计数归零 → out 关闭 → 调用方的 range 结束
ctx 取消 每个 worker 不管卡在外层还是内层 select 都会命中 ctx.Done() 退出 → out 关闭
调用方不读了但 cancel 了 卡在 out <- v 的 worker 被内层 select 救出来

练习 2:并行工具与超时

要验证的事:三个工具并行跑,最慢的那个被超时截断,总时间 ≈ 超时时间,而不是三者相加。

为了不让测试真跑 20 秒,把超时提成包级变量,测试里调成 2 秒:

var toolTimeout = 20 * time.Second // runTools 里用它替换写死的 20*time.Second

var fakeDelay = map[string]time.Duration{} // 每个假工具要“干”多久

func execTool(ctx context.Context, c ToolCall) (string, error) {
    select {
    case <-time.After(fakeDelay[c.Name]):   // 假装干活这么久
        return c.Name + " ok", nil
    case <-ctx.Done():                       // 被超时或取消打断
        return "", ctx.Err()
    }
}

func TestRunTools(t *testing.T) {
    toolTimeout = 2 * time.Second
    fakeDelay = map[string]time.Duration{
        "a": 100 * time.Millisecond,
        "b": 200 * time.Millisecond,
        "c": 2500 * time.Millisecond, // 比超时长,会被打断
    }
    calls := []ToolCall{{ID: "1", Name: "a"}, {ID: "2", Name: "b"}, {ID: "3", Name: "c"}}

    start := time.Now()
    res, err := runTools(context.Background(), calls)
    elapsed := time.Since(start)

    if err != nil {
        t.Fatalf("unexpected err: %v", err)
    }
    if elapsed < 1900*time.Millisecond || elapsed > 2300*time.Millisecond {
        t.Fatalf("elapsed = %v, want ~2s (并行,受最慢的超时限制)", elapsed)
    }
    if res[0].Err != nil || res[1].Err != nil {
        t.Fatalf("a/b should succeed: %+v", res)
    }
    if !errors.Is(res[2].Err, context.DeadlineExceeded) {
        t.Fatalf("c should time out, got %v", res[2].Err)
    }
}

逐行解读

最重要的认识:超时只是发信号,工具自己必须监听。 如果假工具里写的是 time.Sleep(2500 * time.Millisecond),ctx 超时了它也照样睡满 2.5 秒,测试会失败。真实工具里用 exec.CommandContext(跑命令)、http.NewRequestWithContext(发请求),它们内部已经帮你监听了 ctx。

仓库实现里 execTool 写成了 var execTool = func(...)(变量),测试里直接替换成假实现,效果一样。

练习 3:取消流式读取,服务端感知断开

要验证的事:客户端 cancel 之后,服务端那边真的知道连接断了。在生产里就是“用户关页面 → 到模型供应商的连接也断开 → 停止计费”。

func TestStreamCancel(t *testing.T) {
    disconnected := make(chan struct{}) // 服务端发现断开时关闭它,当“信号灯”用

    // 1. 起一个假 SSE 服务端
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "text/event-stream")
        flusher := w.(http.Flusher)
        for i := 0; ; i++ {
            select {
            case <-r.Context().Done(): // 客户端断开时触发
                close(disconnected)
                return
            case <-time.After(100 * time.Millisecond):
                fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"t%d\"}}]}\n\n", i)
                flusher.Flush() // 立刻推给客户端,不然会攒在缓冲区里
            }
        }
    }))
    defer srv.Close()

    // 2. 客户端发请求,请求绑定一个可取消的 ctx
    ctx, cancel := context.WithCancel(context.Background())
    req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        t.Fatal(err)
    }

    // 3. 读 5 个 token 后取消
    tokens, errc := streamTokens(ctx, resp.Body)
    for i := 0; i < 5; i++ {
        <-tokens
    }
    cancel()

    // 4. 排空 tokens,确认后台 goroutine 退出,并拿到一个错误
    for range tokens {
    }
    if err := <-errc; err == nil {
        t.Fatal("want a cancellation error, got nil")
    }

    // 5. 断言服务端在 2 秒内感知到断开
    select {
    case <-disconnected:
    case <-time.After(2 * time.Second):
        t.Fatal("server never saw the disconnect: 上游连接没断,还在烧钱")
    }
}

分步解读

  1. 假服务端:httptest.NewServer 在本机随便找个端口起一个真的 HTTP 服务,srv.URL 就是它的地址。handler 每 100ms 推一行 SSE,同时监听 r.Context().Done():客户端一断开,就关闭 disconnected 通知测试。 - w.(http.Flusher):类型断言,拿到“能立刻推送”的能力。不调 Flush(),写的内容会攒在缓冲区里,客户端一个字都收不到。 - chan struct{}:不传数据、只当信号用的 channel,关闭它就等于“亮灯”。
  2. 客户端:用 NewRequestWithContext 把请求和 ctx 绑在一起。这是能“取消请求”的前提。
  3. 读 5 个后 cancel:cancel() 触发两件事: - HTTP 客户端发现 ctx 取消,关闭底层 TCP 连接,服务端的 r.Context() 随之被取消; - streamTokens 里,要么 sc.Scan() 读 body 失败,要么 select 命中 ctx.Done(),后台 goroutine 退出。
  4. 排空:for range tokens {} 把剩下可能还在路上的值读掉,直到 tokens 被关闭,确保后台 goroutine 真的退出了,再去读 errc。这里只断言 err != nil,而不是 errors.Is(err, context.Canceled):取决于后台当时卡在哪一步,你可能拿到 ctx.Err(),也可能拿到“读 body 失败”的错误,两种都对。
  5. 断言服务端感知:用 select 给 2 秒期限。2 秒内 disconnected 被关闭就通过;否则说明连接没断,测试失败。

练习 4:go test -race

go test -race -count=1 ./...

© 2026 duang777 · 课件源文件:notes/00-foundations/01-go.md