Agent 进入生产环境后,瓶颈往往不在模型推理,而在模型发起的工具调用:搜索可能几十毫秒返回,数据库查询可能耗时数秒,外部 API 还可能超时。covonaut v1.1.4 将工具执行改为单一滚动池调度,并提供并行与混合两档模式;同时加入多模态读取能力,让纯 Go Agent 在处理复杂输入时拥有更完整的执行链路。
滚动池改变了什么
旧式批量并发通常会先收集一批工具调用,再一次性创建任务,由并发上限决定哪些任务等待。如果调度边界与整批任务绑定,慢工具可能长期占用执行槽位,后续短任务即使已经就绪,也难以及时补位。
滚动池采用持续补位的思路:
- 池中最多运行固定数量的工具调用;
- 任意任务结束后,立即从等待队列取出下一个任务;
- 不必等待当前批次全部完成,执行容量可以持续被利用;
- 并发上限仍然有效,不会因为一次模型返回大量 tool calls 就无限创建活跃请求。
假设并发上限为 2,任务耗时依次为 8 秒、1 秒、1 秒和 1 秒。滚动调度会先执行前两个任务;第二个任务在 1 秒后完成,第三个任务立即补位,随后第四个任务继续补位。那个 8 秒的慢任务仍在运行,但不会阻止其他槽位周转。
这对 Agent 尤其重要,因为工具耗时通常高度不均匀:本地计算、缓存读取、数据库访问和第三方 SaaS API 很难拥有相似的延迟分布。
并行与混合模式该怎么选
v1.1.4 提供并行与混合两档调度。具体配置名称应以该版本公开 API 为准,但在工程上可以按工具的副作用和并发安全性来做选择。
并行模式适合彼此独立、可安全并发的操作,例如:
- 搜索多个只读数据源;
- 并行读取若干文档;
- 查询互不依赖的服务;
- 执行无共享状态的计算工具。
混合模式更适合一组工具中既有只读调用,也有需要控制顺序的写操作。例如,读取库存和查询价格可以并发,但创建订单、扣减库存和发送确认通知通常需要依赖关系、幂等键或串行约束。
不要把“支持并行”理解为“所有工具都应该并行”。以下调用仍需谨慎:
- 修改同一条业务记录;
- 依赖前一个工具输出的后续调用;
- 使用非线程安全客户端或共享临时文件;
- 会产生收费、转账、发信等外部副作用的操作;
- 受第三方 API 速率限制的批量请求。
图编排可以表达显式依赖,滚动池则负责同一可执行阶段内的容量利用。两者结合,比单纯提高 goroutine 数量更可控。
用纯 Go 模拟滚动工具池
下面的程序不依赖 covonaut 的具体 API,而是一个可直接运行的滚动池示例,用来展示其调度行为。接入框架时,可将 runTool 替换成实际的工具执行入口,并把并发数、超时和结果回传接到 Agent 上下文中。
将以下内容保存为 main.go:
package main
import (
"context"
"fmt"
"sync"
"time"
)
type ToolCall struct {
Name string
Duration time.Duration
}
type ToolResult struct {
Name string
Err error
}
func runTool(ctx context.Context, call ToolCall) error {
select {
case <-time.After(call.Duration):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func rollingPool(ctx context.Context, limit int, calls []ToolCall) <-chan ToolResult {
jobs := make(chan ToolCall)
results := make(chan ToolResult)
var workers sync.WaitGroup
workers.Add(limit)
for i := 0; i < limit; i++ {
go func(workerID int) {
defer workers.Done()
for call := range jobs {
started := time.Now()
fmt.Printf("worker=%d start tool=%s\n", workerID, call.Name)
err := runTool(ctx, call)
fmt.Printf("worker=%d done tool=%s elapsed=%s\n",
workerID, call.Name, time.Since(started).Round(time.Millisecond))
results <- ToolResult{Name: call.Name, Err: err}
}
}(i + 1)
}
go func() {
defer close(jobs)
for _, call := range calls {
select {
case jobs <- call:
case <-ctx.Done():
return
}
}
}()
go func() {
workers.Wait()
close(results)
}()
return results
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
calls := []ToolCall{
{Name: "slow-search", Duration: 5 * time.Second},
{Name: "cache-read", Duration: 500 * time.Millisecond},
{Name: "price-query", Duration: 700 * time.Millisecond},
{Name: "document-read", Duration: 600 * time.Millisecond},
}
for result := range rollingPool(ctx, 2, calls) {
if result.Err != nil {
fmt.Printf("tool=%s error=%v\n", result.Name, result.Err)
}
}
}
运行:
go run main.go
输出中可以看到,cache-read 完成后,空闲 worker 会立即领取 price-query,而不必等待 slow-search。生产实现还应补充 panic recovery、每工具超时、重试策略、队列容量、指标采集以及结果顺序管理。
如果模型要求工具结果必须按照原始调用顺序返回,可以在任务中携带索引,并在交给模型前重新排序;如果允许流式消费,则可以按完成顺序传递结果,以缩短首个结果的等待时间。
多模态读取不能只停留在“能打开文件”
新增多模态读取后,Agent 的输入不再局限于文本。实际接入图片、文档或其他媒体时,读取层最好先归一化为带元数据的内容对象,再交给模型适配器。下面是一个可用于设计接口的纯 Go 数据结构示例;它不是对 covonaut 公开 API 的声明:
type ContentPart struct {
Kind string // text, image, document
MIMEType string // image/png, application/pdf, ...
Data []byte
Metadata map[string]string
}
生产环境需要额外设置明确边界:
- 限制单文件大小、像素数量、页数和解压后体积;
- 校验实际内容类型,不只信任文件扩展名;
- 对远程地址设置域名白名单,防止 SSRF;
- 日志中避免记录图片原文、文档正文和认证信息;
- 在模型不支持某种媒体时提供 OCR、文本提取或拒绝策略;
- 将解析失败与模型理解失败分开记录,便于定位问题。
升级时重点验证四件事
covonaut 以纯 Go 实现 Agent 主循环、工具调用、图编排和多协议互操作,并采用 MIT 协议。对于希望减少跨语言运行时依赖的 Go 服务,这种技术栈具有部署上的直接优势。不过,从旧版本升级到 v1.1.4 时,不应只验证代码能否编译。
建议在预发布环境检查:
- 结果语义:滚动完成是否改变了工具结果的聚合顺序;
- 并发安全:工具客户端、缓存和共享状态是否允许多个 goroutine 同时访问;
- 资源上限:并发数是否与数据库连接池、HTTP transport 和第三方限流匹配;
- 取消传播:用户终止请求后,排队任务和正在执行的工具能否及时收到
context取消信号。
滚动池优化的是调度效率,不会自动解决慢工具、无界重试或外部服务拥塞。比较稳妥的上线方式,是先为只读工具启用并行调度,记录队列等待时间、工具执行时长、超时率和在途任务数,再逐步纳入带副作用的工具。多模态读取也应从受控文件类型和严格大小限制开始,而不是直接接受任意 URL 与任意格式。