Go 1.27 synctest.Sleep:AI 代理并发测试摆脱真实时间依赖
写过一个需要协调多个 goroutine 的 AI 代理测试吗?如果写过,大概对下面这个模式不陌生:一个 agent 并发调用 LLM、执行工具函数、处理流式响应,中间穿插各种超时和重试逻辑。
为了验证这些行为,测试代码里塞满了 time.Sleep,每次运行都等几秒,还时不时因为 CI 负载高了就随机失败。
Go 1.27 在这个方向上给了一个很实在的改进——testing/synctest 新增了 Sleep 方法。它不是个大功能,但对于写过并发测试的人来说,它把使用门槛降了一大截。
写并发测试的痛
一段典型的 AI 代理代码大概长这样:一个工作器 goroutine 调用外部 API,如果在指定时间内没返回就超时取消,然后重试。
func callWithRetry(ctx context.Context, fn func(context.Context) (string, error), maxWait time.Duration) (string, error) {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
callCtx, cancel := context.WithTimeout(ctx, maxWait)
result, err := fn(callCtx)
cancel()
if err == nil {
return result, nil
}
lastErr = err
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(time.Second):
}
}
return "", lastErr
}
测试这段代码最直接的做法是给一个真的会超时的函数,然后等:
func TestRetryTimeout(t *testing.T) {
slowFn := func(ctx context.Context) (string, error) {
time.Sleep(10 * time.Second)
return "ok", nil
}
ctx := context.Background()
start := time.Now()
_, err := callWithRetry(ctx, slowFn, 100*time.Millisecond)
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected timeout error")
}
t.Log("test took:", elapsed)
}
这个测试有很明显的问题。它至少需要跑几百毫秒(三次超时 + 两次间隔)。如果 CI 机器上调度延迟高,time.After 的间隔可能不够用,测试就报错;如果把间隔设长一点,测试就更慢。要么快、要么稳定——不能两全。
类似的场景在 AI 代理开发中特别常见:轮询 LLM 流式响应、等待工具调用结果、协调多个 agent goroutine 的协作……这些逻辑本质上都依赖时间流逝来触发状态变化,但测试时偏偏不想真的等时间过去。
synctest 的解法
testing/synctest 在 Go 1.24 引入,核心思路是隔离 goroutine 的世界观:它创造了一个叫做 "bubble" 的执行环境,里面的所有 goroutine 共享一个虚拟时钟。
当 bubble 里的 goroutine 全部处于阻塞状态时,虚拟时钟直接推进到下一个能唤醒它们的时间点。这套机制让 time.Sleep(5 * time.Second) 在实际运行中不消耗任何真实时间。
问题是,在 Go 1.27 之前,必须手动在两个操作间切换:
time.Sleep(timeout)
synctest.Wait() // 等所有 goroutine 确实阻塞住了
写过几个测试就会发现这很啰嗦——每次 sleep 后面都得跟一个 Wait,少写一个就能让测试在错误的时间点做断言。
synctest.Sleep:少一个心智负担
Go 1.27 新增的 synctest.Sleep 把这两步合并了:
// Go 1.27 之前
time.Sleep(5 * time.Second)
synctest.Wait()
// Go 1.27
synctest.Sleep(5 * time.Second)
功能等价:让虚拟时钟前进指定时间,然后等 bubble 里的 goroutine 全部稳定下来。但少了一个步骤意味着少了一个"这里该不该加 Wait"的决策点。
回到上面的重试测试,用 synctest 重写:
func TestRetryTimeoutDeterministic(t *testing.T) {
synctest.Run(func() {
slowFn := func(ctx context.Context) (string, error) {
synctest.Sleep(10 * time.Second)
return "ok", nil
}
ctx := context.Background()
_, err := callWithRetry(ctx, slowFn, 100*time.Millisecond)
// 三次 100ms 超时 + 两次 1s 间隔 = 2.3 秒虚拟时间
// 但实际执行是零等待
if err == nil {
t.Fatal("expected timeout error")
}
})
}
这个测试在真实世界里几乎是瞬间完成的,但逻辑上完整经历了三次超时和两次重试间隔。CI 负载高不影响结果,因为根本不依赖真实调度。
给 AI 代理写确定性测试
AI 代理的典型工作流里,并发控制是标配:同时发起多个 LLM 调用、等待最快的结果、处理流式响应、在超时前完成工具执行。用 synctest.Sleep 可以把这些场景的测试从"碰运气"变成"可预测"。
一个需要同时查询两个 LLM 并取最快结果的代理:
func raceLLMs(ctx context.Context, modelA, modelB func(context.Context) (string, error)) (string, error) {
type result struct {
val string
err error
}
ch := make(chan result, 2)
go func() {
v, err := modelA(ctx)
ch <- result{v, err}
}()
go func() {
v, err := modelB(ctx)
ch <- result{v, err}
}()
select {
case r := <-ch:
return r.val, r.err
case <-ctx.Done():
return "", ctx.Err()
}
}
用 synctest 可以写得很干净:
func TestRaceLLMs(t *testing.T) {
synctest.Run(func() {
fastModel := func(ctx context.Context) (string, error) {
synctest.Sleep(10 * time.Millisecond)
return "fast response", nil
}
slowModel := func(ctx context.Context) (string, error) {
synctest.Sleep(5 * time.Second)
return "slow response", nil
}
ctx := context.Background()
got, err := raceLLMs(ctx, fastModel, slowModel)
if err != nil {
t.Fatal("unexpected error:", err)
}
if got != "fast response" {
t.Fatalf("want fast response, got %s", got)
}
})
}
这个测试验证了"最快的模型胜出"这个核心行为。虚拟时间推进 10ms 后 fastModel 返回结果,测试立刻断言——不需要等 slowModel 的 5 秒。
另一个常见场景:验证代理在超时后是否正常回收 goroutine。AI 代理如果因为 LLM 调用卡住而泄漏 goroutine,在生产环境里积累起来会很麻烦。
func TestAgentGoroutineCleanup(t *testing.T) {
synctest.Run(func() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
done := make(chan struct{})
go func() {
llmCall(ctx) // 可能阻塞直到超时
close(done)
}()
// 虚拟时钟推进到超时
synctest.Sleep(2 * time.Second)
select {
case <-done:
// goroutine 正常退出了
default:
t.Fatal("goroutine did not clean up after timeout")
}
})
}
这里 synctest.Sleep(2s) 推动虚拟时钟越过超时边界,然后验证 goroutine 确实响应了取消。整个过程零真实等待。
工程上的影响
CI 时间大幅缩短:所有涉及超时、重试、轮询的测试不再需要真实的 sleep 时间。之前跑一次 agent 测试套件可能需要几十秒到几分钟(取决于超时设置),现在毫秒级完成。
flaky test 减少:之前那些"CI 上偶尔失败但本地跑没问题"的测试,根源往往是测试代码里某个
time.After的时长不够宽裕。换成 synctest 后,测试完全由代码逻辑驱动,不再受宿主机调度影响。race detector 兼容:synctest 设计时就考虑了 race detector——如果在 bubble 里不加同步就访问共享变量,race detector 仍然能正确报告。这意味着丢掉
time.Sleep不会丢掉数据竞争检测。
启用方式
synctest 目前需要用 GOEXPERIMENT=synctest 编译才能启用这个包。生产代码不需要任何修改——synctest 只影响测试文件的编译。
GOEXPERIMENT=synctest go test ./...
如果你的项目已经有 tool 指令或 Makefile 来统一编译参数,加一个环境变量就行。
注意事项
有几个边界条件需要关注:
bubble 内的
sync.Mutex操作不被视为"持久阻塞":如果测试中的 goroutine 因为等待 Mutex 而暂停,synctest 不会认为这是可推进时间的状态——它会把这种情况当作"非持久阻塞",Wait 不会返回。设计原因是标准库里许多地方用了全局 Mutex(比如反射),这些锁可能被 bubble 外的 goroutine 持有,synctest 没法判断。外部 I/O 同样不是持久阻塞:网络读写、文件操作等系统调用不在控制范围内。测试网络代码仍然需要 fake network(比如
net.Pipe或httptest.NewTestServer),但时间和并发逻辑的部分可以由 synctest 接管。死锁 panic:bubble 里的 goroutine 如果进入死锁状态(所有 goroutine 持久阻塞且没有待处理的时间事件),Run 会直接 panic。这其实是件好事——它把并发 bug 从隐性的 goroutine 泄漏变成了显性的 panic。
总结
Go 1.27 的 synctest.Sleep 不是革命性功能,但它是 Go 并发测试走向成熟的重要一步。对于 AI 代理这类高度依赖并发和时间逻辑的代码,它把测试从"碰运气"变成"可预测",CI 更快、更稳、更可信。