0.1 Go:从基础语法到并发,Agent 运行时的地基
第 0 部分 · 第 1 课 | 预计 5–6 小时(基础 2 小时 + 并发 3–4 小时)| 前置:会任意一门编程语言
本课并发部分的代码都对应仓库里能跑的实现:
projects/00-stream-proxy/(go test -race -count=1 ./...通过)。
读前说明
这一课分两部分:
- 第一部分:Go 基础。只讲写 Agent 后端真正会用到的那部分语法:模块、类型、函数、错误处理、struct、接口、JSON、测试。学过其他语言的话,大约 2 小时。已经会 Go 的可以直接跳到第二部分。
- 第二部分:Go 并发。Agent 运行时的核心:goroutine、channel、select、context、errgroup、流式读取。
每段代码都按三步讲:它要解决什么问题 → 代码(关键行带注释)→ 逐行解读 + 容易错的地方。第一次读时如果某段看不懂,先看它下面的解读和时间线,再回头看代码。
为什么 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 # 清理没用到的依赖、补上缺的
逐行解读
- module(模块):一个项目就是一个模块,
go.mod里记着模块名和依赖版本。模块名习惯写成代码仓库地址,但不强制。 - package(包):一个目录就是一个包,目录里所有
.go文件第一行的package xxx必须一致。main包是特殊的,代表“可执行程序”。 go.sum记录依赖的校验和,要一起提交到 git,不要手改。
一个典型的 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) // 不需要的返回值用 _ 丢掉
逐行解读
- 参数类型写在名字后面:
a, b float64表示 a 和 b 都是 float64。 - 返回值类型写在参数列表后面,多个返回值用括号括起来。
- Go 没有 try/catch。约定是:最后一个返回值是
error,调用方马上检查if err != nil。看起来啰嗦,但每个出错点都一目了然。 :=是“声明并赋值”,=是“给已有变量赋值”。上面第二次用=是因为err已经声明过了。
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:能穿透包装,找到里面的原错误
逐行解读
if err := f(); err != nil { ... }:if 的“带初始化”写法,err只在这个 if 里有效,避免变量到处飘。fmt.Errorf("...%w", err):生成一个新错误,消息里带上下文,同时保留原错误。%q是带引号打印字符串。errors.Is(err, 目标):沿着包装链一层层找,有没有等于目标的错误。不要用err == ErrToolNotFound直接比,包装过就不相等了。- 第二部分会大量出现
errors.Is(err, context.Canceled)、errors.Is(err, context.DeadlineExceeded),就是这个用法。
自定义错误类型(需要带额外信息时):
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
逐行解读
Message{Role: role, Content: content}:struct 字面量,按字段名赋值,没写的字段是零值。- 指针接收者
(c *Conversation):方法里能修改原对象。值接收者(c Conversation):方法拿到的是一份拷贝,改了也不影响原对象。 - 经验法则:只要有一个方法需要修改对象,这个类型的所有方法都用指针接收者,保持一致。
&Conversation{...}:直接创建并取地址。Go 会自动处理conv.Add和(*conv).Add的转换,不用手写。
指针速查:
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") // 删
容易错的地方
append可能分配新的底层数组,一定要写s = append(s, x)。- 只声明不初始化的 map 是 nil,往 nil map 写会 panic:
var m map[string]int; m["a"] = 1直接崩。用make(map[string]int)或字面量初始化。 - map 遍历顺序是随机的,需要顺序就先把 key 排序。
- map 不能并发写(第二部分 6 节会讲);slice 按不同下标并发写是安全的。
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"}`)
逐行解读
- 隐式实现:Go 不需要声明“我实现了某接口”。只要方法签名对得上,就自动满足。好处是接口可以由“使用方”定义,具体实现完全不用知道它。
- 接口变量里装的是“具体类型 + 具体值”,调用方法时会自动调到具体实现上。
error本身就是个接口:type error interface { Error() string },这就是 B5 里自定义错误能用的原因。- 测试时可以写一个假的
Tool塞进注册表,不用调真接口。这是 Go 里做依赖替换最常见的方式。
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
逐行解读
- 反引号里的
json:"..."是结构体标签,告诉 JSON 包字段在 JSON 里的名字。Go 字段要大写才能被 JSON 包访问(B2),而 API 字段一般是小写,所以靠标签映射。 omitempty:值为零值(0、""、false、nil)时这个字段不输出。注意Stream bool没加,所以false也会输出。json.Unmarshal(数据, &目标):第二个参数必须传指针,不然改不到你的变量。- JSON 里有、struct 里没声明的字段(比如上面的
id)会被自动忽略。所以解析模型响应时只声明你需要的字段就行。 json.RawMessage:类型是[]byte,告诉 JSON 包“这段先别解析,原样留着”。第二部分 4 节的工具参数Args就是这么处理的:Loop 不关心参数长什么样,交给具体工具自己解析。
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 ./... # 检测数据竞争(并发部分必用)
逐行解读
t.Errorf:记录失败,继续跑后面的检查。t.Fatalf:记录失败并立刻结束这个测试。- 表驱动的好处:加用例只需要加一行,不用复制粘贴整段测试。
(err != nil) != tc.wantErr:一行判断“该出错的没出错”和“不该出错的出错了”两种情况。
第一部分小练习(20 分钟)
写一个工具注册表 Registry:
- 有方法
Register(t Tool)和Call(ctx context.Context, name, args string) (string, error)。 - 调用不存在的工具时,返回包装过的
ErrToolNotFound,调用方能用errors.Is判断出来。 - 用表驱动测试覆盖:调用存在的工具、调用不存在的工具。
参考实现
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)
}
})
}
}
第二部分: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:
- 谁发送,谁关闭。 接收方永远不要
close。关闭后再发送会 panic(程序直接崩)。 - 每启动一个 goroutine,都要想清楚它怎么结束。 想不清楚 = 泄漏。
- 无缓冲 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
逐行解读
produce本身几乎瞬间返回,它只是“造管道 + 启动工人”。真正的生产在后台 goroutine 里。for v := range ch:反复从 ch 取值,直到 ch 被关闭且取空才退出循环。- 如果忘了
close(ch):生产完 3 个后,调用方的range还在等第 4 个,永远等不到 → 程序卡死。这就是为什么defer close(ch)是固定搭配。 - 后台 goroutine 怎么结束?循环跑完 → defer 关闭 → 函数返回。结束路径清清楚楚,符合规则 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()
}
逐行解读
select会同时挂在所有 case 上,哪个先就绪就执行哪个,其余的放弃。像同时等三个人的电话,谁先打来接谁。time.After(30 * time.Second)返回一个 channel,30 秒后会自动往里放一个值。所以它可以当“闹钟”用。ctx.Done()也是一个 channel,ctx 被取消时它会被关闭(关闭的 channel 一读就能读到,所以 case 立刻就绪)。下一节详细讲 ctx。- 多个 case 同时就绪时,Go 随机挑一个,不保证顺序。
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)
}
}
逐行解读
r.Context():Go 的 HTTP 服务端给每个请求自带一个 ctx,客户端断开时它会自动被取消。context.WithTimeout(父, 60s)返回两样东西:ctx:新的子 ctx。父 ctx 取消、或者 60 秒到了,它都会被取消(两个条件谁先到算谁)。cancel:一个函数,调用它可以手动取消这个子 ctx。defer cancel():活提前干完了也要调一下,告诉系统“这个计时器不用了”。不调的后果见文末自测题 4。errors.Is(err, context.Canceled):判断错误是不是“被取消”造成的。用户主动离开不该报 500。
ctx 被取消后会发生什么? 只做一件事:ctx.Done() 这个 channel 被关闭,ctx.Err() 开始返回非 nil。它不会强行杀掉任何代码。下游代码必须自己去 select 监听 ctx.Done(),才会真的停。这一点后面会反复体现。
规范(照着写就行):
- ctx 永远是函数的第一个参数:
func runAgent(ctx context.Context, ...),不要塞进 struct。 - 下层函数只负责监听
ctx.Done(),不负责cancel;谁创建谁 cancel。 - 发 HTTP 请求用
http.NewRequestWithContext(ctx, ...),ctx 取消时连接会自动断开,不用你自己管。
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
}
逐行解读
- ①
errgroup.WithContext(ctx)返回任务组g和一个新的 ctx。注意这里把外面的ctx变量覆盖了,下面用的都是这个新 ctx。它的特点:组里任何一个任务返回了错误,它就会被取消,其他任务就能感知到“该停了”。 - ②
SetLimit(4):同时最多 4 个在跑。第 5 个调用g.Go时会等,直到前面有一个结束空出位置。防止模型一次要 50 个工具把下游打爆。 - ③ 结果用提前开好长度的 slice,每个 goroutine 只写
results[i]自己那一格。不同下标互不干扰,所以不需要加锁。如果换成 map 一起写,会直接崩(见第 6 节)。 - ④
g.Go(func() error {...}):相当于go func(){...}(),但 errgroup 会帮你记账,Wait时知道还有几个没完。闭包里直接用i和c是安全的(Go 1.22+,见 0.4)。 - ⑤ 从组 ctx 再派生一个 20 秒超时的
tctx,只给这一个工具用。于是这个工具会在三种情况下被要求停:自己超过 20 秒、组里别人报错、最外层用户取消。 - ⑥
execTool是你实现的具体工具(查天气、跑代码……)。它必须监听tctx,否则超时没用,见练习 2 的解读。 - ⑦ 结果按下标写,顺序和
calls一一对应。回传给模型时ID不会错位。 - ⑧ 关键设计:工具失败了不返回 err,而是把错误塞进
ToolResult.Err。 - ⑨
g.Wait()阻塞到组里所有 goroutine 都结束,返回其中第一个非 nil 错误(这里永远是 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]
- 每个事件一行,以
data:开头,事件之间空一行。 delta.content是“这次新增的文字”,不是全文。把所有 delta 拼起来才是完整回答。[DONE]是 OpenAI 格式约定的结束标记。
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 逐段解读
函数签名
- 参数
body io.ReadCloser:就是 HTTP 响应体resp.Body,能读、也能关。 - 返回
(<-chan string, <-chan error):两根只读管道,调用方没法误写误关(见 0.3)。
三个 defer(顺序很重要)
后台 goroutine 不管从哪个 return 退出,都会按这个顺序收尾:
body.Close():关闭 HTTP 响应体,释放网络连接,让它能被下一个请求复用。close(tokens):通知调用方“没有更多字了”,调用方的for range tokens因此退出。close(errc):如果前面没放错误,调用方<-errc读到的就是 nil,不会永远卡住。
按行读:bufio.Scanner
sc.Scan()每调用一次读一行,返回 true;读到末尾或出错返回 false,循环结束。sc.Text()拿到这一行的内容(不含换行符)。sc.Buffer(初始, 上限):Scanner 默认一行最多 64KB。如果模型一次推了一大段工具参数,单行可能超过,会直接报token too long。这里放宽到 1MB。
过滤和解析
- 不以
data:开头的行(空行、event:、: keep-alive注释)都跳过。 - 匿名 struct 只声明了
choices[].delta.content这一条路径,JSON 里其他字段(id、model、role……)自动忽略(见 B9)。Content没写标签也能匹配content,因为 Go 的 JSON 字段名匹配不区分大小写。
最关键的 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,避免互相等待 |
练习(今天做完)
- 基础:写
fanIn(ctx, chs ...<-chan string) <-chan string,把多个 channel 合并成一个;ctx 取消后所有 goroutine 退出。 - 提示:参考第 1 节生产者模式;每路输入一个 goroutine;用sync.WaitGroup等它们都结束后再关闭输出。 - 核心:用第 4 节的
runTools,写三个假工具(分别 sleep 1s / 2s / 25s),验证:总耗时约 20s(第三个超时)、超时工具的错误被写进ToolResult.Err。 - 提示:假工具里不要用time.Sleep,要用select同时等time.After和ctx.Done()。 - 进阶:用
httptest.NewServer写一个假 SSE 服务端,每 100ms 吐一个 token;客户端读 5 个后 cancel,断言服务端检测到连接断开(handler 里<-r.Context().Done())。 - 所有测试用
go test -race跑通。
自测题
- 为什么
errc要用缓冲 1 的 channel?换成无缓冲会发生什么? errgroup.WithContext返回的 ctx,在什么情况下会被取消?- 工具执行失败时,为什么本课选择
return nil?什么情况下应该return err? context.WithTimeout之后不调用cancel(),会有什么后果?
做完练习后,把代码放到
projects/00-stream-proxy/,go test -race -count=1 ./...全部通过,这一课就算完成。
参考答案与详解
先自己做,卡住超过 20 分钟再看。
自测题答案
1. errc 为什么要缓冲 1?换成无缓冲会怎样?
出错时,后台 goroutine 的顺序是:errc <- err → return → 执行 defer(关闭 tokens)。
- 无缓冲:发送必须等到有人接收才完成。但此时调用方正卡在
for tok := range tokens,在等 tokens 被关闭;而 tokens 要等 goroutine 退出才关闭。两边互相等待 → 死锁(或 goroutine 永久泄漏)。 - 缓冲 1:错误直接放进缓冲区,发送立即完成,goroutine 退出,tokens 关闭,调用方跳出循环后再从 errc 读到错误。
时间线图见第 5.4 节。规律:“报告最终结果”的 channel,如果最多只发一次,就给缓冲 1。
2. errgroup.WithContext 返回的 ctx 什么时候会被取消?
三种情况,任一发生即取消:
- 某个
g.Go里的函数第一次返回非 nil 错误(其余 goroutine 通过这个 ctx 感知到,应尽快退出); g.Wait()返回时(所有任务都结束了,ctx 随之取消);- 传进来的父 ctx 被取消(比如客户端断开、整轮超时)。
所以第 4 节里每个工具都要用这个派生 ctx,而不是外层的 ctx,才能做到“一个失败、全体停”。
3. 工具失败为什么 return nil?什么时候该 return err?
return nil:工具报错(文件不存在、命令退出码非 0、单个工具超时)对模型来说是一条有用的观察。把错误写进ToolResult.Err,作为tool_result发回给模型,它往往能自己换参数重试。如果return err,errgroup 会取消其他正在跑的工具,整轮白做。return err:当错误说明这一轮已经没意义时:- 父 ctx 被取消(用户走了、整轮超时);
- 基础设施故障,比如沙箱进程崩溃、鉴权失效,所有工具都会失败;
- 安全违规,比如工具试图越权访问,应立即中止。
怎么区分“单个工具超时”和“用户走了”?看是哪个 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 会一直挂在父 ctx 的子节点列表上,直到超时触发才释放。如果父 ctx 生命周期很长(比如服务级的 ctx),高 QPS 下这些对象会堆积,表现为内存缓慢上涨。
- 依赖这个 ctx 的下游资源(HTTP 连接、子 goroutine)也不会被提前释放。
go vet会报lostcancel警告。
结论: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
}
逐行解读
chs ...<-chan string:可变参数,调用时可以传任意多根管道,函数里chs就是一个 slice。sync.WaitGroup:一个计数器。Add(n)加 n,Done()减 1,Wait()阻塞到归零。v, ok := <-ch:从关闭的 channel 读,会立刻拿到零值且ok == false。这是判断“输入结束了”的标准写法。- 为什么要两层 select:外层解决“等输入时被取消”,内层解决“发给 out 时被取消”。只写外层的话,如果调用方不读 out 了,worker 会卡在
out <- v上永远出不来。这正是第 5 节那条规则:每次发送都配ctx.Done()。 - 为什么不让 worker 自己
close(out):out 有好几个发送方。第一个 worker 结束时如果关了 out,别的 worker 还在往里发 → panic。所以单独开一个收尾 goroutine,等所有发送方都结束再关。这是规则 1 在“多个发送方”时的写法。
它在三种情况下都能正确结束:
| 情况 | 发生了什么 |
|---|---|
| 所有输入正常关闭 | 每个 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)
}
}
逐行解读
context.Background():最顶层的空 ctx,永远不会被取消。测试和main里用它当根。- 假工具用
select同时等“活干完”和“被打断”,哪个先到算哪个。工具 c 要 2.5 秒,但 2 秒时tctx超时,ctx.Done()先就绪,返回context.DeadlineExceeded。 - 时间断言给了一个范围(1.9s ~ 2.3s),因为调度有误差,不能写死等于 2 秒。
errors.Is(err, context.DeadlineExceeded):判断错误是不是“超时”。超时和取消是两种不同的错误:超时是DeadlineExceeded,手动取消是Canceled。t.Fatalf:测试失败并立刻停止这个测试,打印格式化信息。
最重要的认识:超时只是发信号,工具自己必须监听。 如果假工具里写的是 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: 上游连接没断,还在烧钱")
}
}
分步解读
- 假服务端:
httptest.NewServer在本机随便找个端口起一个真的 HTTP 服务,srv.URL就是它的地址。handler 每 100ms 推一行 SSE,同时监听r.Context().Done():客户端一断开,就关闭disconnected通知测试。 -w.(http.Flusher):类型断言,拿到“能立刻推送”的能力。不调Flush(),写的内容会攒在缓冲区里,客户端一个字都收不到。 -chan struct{}:不传数据、只当信号用的 channel,关闭它就等于“亮灯”。 - 客户端:用
NewRequestWithContext把请求和 ctx 绑在一起。这是能“取消请求”的前提。 - 读 5 个后 cancel:
cancel()触发两件事: - HTTP 客户端发现 ctx 取消,关闭底层 TCP 连接,服务端的r.Context()随之被取消; -streamTokens里,要么sc.Scan()读 body 失败,要么 select 命中ctx.Done(),后台 goroutine 退出。 - 排空:
for range tokens {}把剩下可能还在路上的值读掉,直到 tokens 被关闭,确保后台 goroutine 真的退出了,再去读 errc。这里只断言err != nil,而不是errors.Is(err, context.Canceled):取决于后台当时卡在哪一步,你可能拿到ctx.Err(),也可能拿到“读 body 失败”的错误,两种都对。 - 断言服务端感知:用 select 给 2 秒期限。2 秒内
disconnected被关闭就通过;否则说明连接没断,测试失败。
练习 4:go test -race
go test -race -count=1 ./...
./...:当前目录及所有子目录下的包都测。-race:开启数据竞争检测。如果第 4 节把results换成map[string]ToolResult并发写而不加锁,这里会直接报DATA RACE并指出是哪两行代码在抢。-count=1:关闭测试缓存。Go 默认会缓存“没改过代码的测试结果”,并发测试建议每次都真跑。- 想自动检查 goroutine 泄漏,可以引入
go.uber.org/goleak,在TestMain里加goleak.VerifyTestMain(m):测试结束时还有多余 goroutine 活着,就判失败。
© 2026 duang777 · 课件源文件:notes/00-foundations/01-go.md