文章背景:在一个多智能体系统项目里,我们让不同的 Agent 分别接入不同厂商的大模型同
Go + Gemini 多模型并发:让不同大脑同台竞技,以及差点打爆 API 的那段经历
发布时间: 2026-06-26 (25 days ago)
GOAIGemini

文章背景:在一个多智能体系统项目里,我们让不同的 Agent 分别接入不同厂商的大模型同时运行。并发编排层由我开源的 Go 多智能体库 GopherGraph 驱动。这篇文章聊两件事:第一,这个"异构实验"里涌现出了什么有趣的现象;第二,并发调用多家 API 时遭遇的频率限制(Rate Limit)噩梦,以及我们是怎么一步步解决的。


一、实验的起点:同一个问题,交给不同的"大脑"

这个想法其实来自化学里的一个概念——同分异构体

同分异构体是指:分子式完全相同(比如都是 C₄H₁₀),但原子在空间里的排列方式不同,因此化学性质截然不同。丁烷和异丁烷,看起来"一样",但沸点、反应性完全不同。

我们的实验也是如此:给不同的 Agent 输入完全相同的 Prompt 模板(相同的"分子式"),但背后调用的大模型不同(不同的"空间结构")——DeepSeek 用一个,Qwen 用一个,Gemini 用一个。

观察它们面对同样处境时,涌现出什么样的差异化行为。

这个实验的意义不仅仅是"对比哪个模型更聪明",更有价值的问题是:当多个异构 Agent 被放入同一个环境里,他们的群体行为会涌现出什么?


二、实验设计:同一套规则,不同的灵魂

Prompt 的设计原则

为了让实验结果有意义,我们对 Prompt 的设计做了严格约束:

所有 Agent 共享同一套 Prompt 模板,模板里包含:

  1. 身份设定:Agent 的姓名、性格标签(比如"谨慎内向"、"热情外向")
  2. 环境观察:当前时刻 Agent 能感知到的信息(周边有谁、距离多远、对方在做什么)
  3. 历史记忆:从向量数据库里检索出来的相关历史片段(上次和谁说了什么)
  4. 输出格式约束:要求返回严格的 JSON 结构,包含行动指令、目标位置、说话内容

模板本身是中性的、没有偏向性的。理论上,如果所有大模型能力完全相同,它们的输出应该趋于一致。

但现实完全不是这样。

我们观察到的差异

经过数十轮的运行,不同模型驱动的 Agent 表现出了非常鲜明的"个性":

倾向社交 vs 倾向独处

有些模型特别喜欢让 Agent 主动发起对话。只要视野范围内有其他 Agent,它就会让自己的角色走过去搭话,而且说话内容往往比较长、比较热情,充满开场白式的问候语。

另一些模型则截然相反,即使周边有 Agent,也会选择继续向某个目的地移动,偶尔才说一两句简短的话,或者干脆保持沉默。

对"无人区"的处理方式

当 Agent 的视野范围内什么都没有时,不同模型的反应差异极大:

  • 有的模型会让 Agent 原地等待,输出"正在观察周围"之类的状态
  • 有的模型会让 Agent 主动去探索某个坐标,给自己找事做
  • 有的模型在这种情况下偶尔会出现输出格式不稳定的情况,结构化 JSON 里混入一些额外的自言自语

对同一事件的"解读"

这是最有趣的部分。当两个 Agent 同时在视野范围内,并且其中一个正在说话,不同模型会给出截然不同的反应:一个可能让 Agent 回应这句话,另一个可能完全无视并继续走路,还有一个可能会让 Agent 停下来"围观",但不发言。

这些差异没有好坏之分,但它们让整个环境变得出乎意料地生动。用一个词形容:涌现性(Emergence)——没有人规定"你们要表现得像真实社会",但它自己演化出来了。


三、并发的代价:API 频控打爆警告

上面的实验听起来很美好。但在工程实践上,我们很快遇到了一个极其棘手的问题。

问题是怎么来的

为了让所有 Agent 同时做决策(而不是排队等),我们使用了并发架构——底层由开源库 GopherGraph 编排。它是我用 Go 写的一个多智能体工作流引擎,核心能力是把"让所有 Agent 并发思考,收齐结果再统一处理"这件事,用声明式的有向图 API 表达出来,不需要手写一堆 goroutine 和 WaitGroup。

具体来说,每次世界心跳(Tick)时,GopherGraph 会把所有 Agent 的决策节点展开为并发分支同时发起 LLM 调用,全部完成后再通过 Merger 函数把结果汇聚回主状态。对调用方来说,整个并发过程是透明的。

10 个 Agent,同时发请求,听起来没什么问题。

但现在这 10 个 Agent 里:3 个用 DeepSeek,3 个用 Qwen,2 个用 Gemini,2 个用 OpenAI。每家的 API 都有自己的频率限制(Rate Limit)

以免费额度或低价套餐为例,常见的限制大概是这样的(不同账号和套餐差异很大,仅供参考):

限制维度 含义 常见数值范围
RPM(每分钟请求数) 每分钟最多发多少次请求 几十次 ~ 几百次不等
TPM(每分钟 Token 数) 每分钟最多处理多少 Token 几万 ~ 几十万不等
RPD(每天请求数) 每天的总请求上限 几百次 ~ 几千次不等

当我们在同一毫秒内并发发出 10 个请求,在服务端看来,这是在极短的时间内产生了一个巨大的流量尖峰。即使总量没超,峰值也可能触发限流。

结果就是:部分 Agent 的请求被服务器返回 429 Too Many Requests,这次 Tick 的决策数据不完整,对应的 Agent 就"卡住"了。

问题的本质

频控问题的本质是:你的并发模型(同时发请求)和 API 服务商的期望(平滑地接收请求)之间的矛盾。

服务商希望你的请求像涓涓细流一样均匀地流进来,而我们给的是一次性的洪峰。

要解决这个问题,有几种思路,我们经历了以下几个演进阶段。


四、解决方案的演进

阶段一:最朴素的方案——直接重试

第一反应是:遇到 429 就重试呗。

go 复制代码
// 伪代码示意
func callLLMWithRetry(ctx context.Context, prompt string) (string, error) {
    maxRetries := 3
    for i := 0; i < maxRetries; i++ {
        result, err := callLLM(ctx, prompt)
        if err == nil {
            return result, nil
        }
        if isRateLimitError(err) {
            time.Sleep(time.Second * 2) // 等 2 秒再试
            continue
        }
        return "", err // 其他错误直接返回
    }
    return "", fmt.Errorf("超过最大重试次数")
}

这个方案有效,但有两个问题:

  1. 重试时间是固定的:所有失败的请求都等 2 秒,然后一起重试,可能再次产生洪峰,继续 429,陷入循环。
  2. 延迟叠加:如果一次 Tick 里有多个 Agent 触发重试,等待时间会把整个 Tick 的执行时间拖得很长。

阶段二:指数退避——更聪明的重试

指数退避(Exponential Backoff)是处理服务端限流的经典策略:第一次失败等 1 秒,第二次失败等 2 秒,第三次等 4 秒……每次等待时间翻倍,避免重试风暴。

同时,加入随机抖动(Jitter),让等待时间不是固定的 1、2、4 秒,而是 1±0.5、2±1、4±2 这样带随机范围的值。这样即使多个 Agent 同时触发重试,它们的重试时间也会自然错开。

go 复制代码
// 伪代码示意
func exponentialBackoff(attempt int) time.Duration {
    base := time.Duration(1<<attempt) * time.Second // 1s, 2s, 4s, 8s...
    // 加入随机抖动:在基础时间的 ±50% 范围内随机
    jitter := time.Duration(rand.Int63n(int64(base)))
    return base + jitter
}

这个方案在总量没超限的情况下效果很好,重试成功率大幅提升。

但我们很快发现,重试本质上是一种被动防御——问题已经发生了,再去处理。能不能在问题发生之前就避免它?

阶段三:主动错峰——请求时序控制

我们注意到,频控触发的核心原因是同一时刻发出大量请求

既然如此,能不能在并发请求发出之前,就主动把它们在时间轴上分散开?

我们引入了一个简单但非常有效的机制:在每个 Agent 发起请求之前,随机等待一段时间

go 复制代码
// 每个 Agent 在思考之前先"随机睡一会儿"
sleepDuration := time.Duration(rand.Intn(3000)) * time.Millisecond
time.Sleep(sleepDuration)

// 然后再发请求
decision, err := callLLM(ctx, prompt)

这 3000 毫秒(0 ~ 3 秒)的随机窗口,让原本集中在同一毫秒的 10 个请求,自然地散布在了 3 秒的时间线上。

从 API 服务商的角度来看,收到的就是平滑的请求流,而不是洪峰。

这个改动只有一行,但效果立竿见影:频控触发率降到接近零。

当然,这个方案的代价是把最快的情况下的 Tick 时间增加了 0-3 秒。但由于 LLM 调用本身就需要几秒,这个延迟几乎被掩盖了,从用户感知的角度来说几乎没有影响。

阶段四:按厂商分组限流——更精细的控制

上面的随机抖动是一个全局策略,对所有 Agent 一视同仁。但实际上,不同厂商的频控策略是不同的:Gemini 的免费额度极其有限,DeepSeek 相对宽松。

更精细的做法是:为每个 LLM 厂商维护一个独立的令牌桶(Token Bucket)或信号量(Semaphore),限制同时向这家厂商发请求的并发数。

go 复制代码
// 伪代码示意
type ProviderLimiter struct {
    semaphore chan struct{} // 信号量,控制并发
}

// 初始化:Gemini 最多同时 2 个并发请求,DeepSeek 最多 5 个
limiters := map[string]*ProviderLimiter{
    "gemini":   {semaphore: make(chan struct{}, 2)},
    "deepseek": {semaphore: make(chan struct{}, 5)},
}

func (l *ProviderLimiter) Acquire() {
    l.semaphore <- struct{}{} // 占用一个槽位,如果满了就阻塞等待
}

func (l *ProviderLimiter) Release() {
    <-l.semaphore // 释放槽位
}

// 使用
limiter := limiters[npc.Provider]
limiter.Acquire()
defer limiter.Release()
result, err := callLLM(ctx, prompt)

这个方案让每家厂商的请求并发数在任何时刻都不超过预设上限,把"依赖运气的随机抖动"升级为"确定性的并发控制"。

两种方案并不互斥,我们现在同时使用了随机抖动(第一道防线,预防洪峰)和信号量限流(第二道防线,控制并发上限)。


五、意外收获:频控倒逼出了更好的架构

处理频控问题的过程,意外地带来了一个副产品——我们开始认真思考请求的优先级

在一个多 Agent 系统里,不是所有的 LLM 调用都同等重要。如果系统同时有 10 个请求在等待,哪些应该先发出去?

比如:

  • 某个 Agent 刚刚触碰到了一个关键的剧情节点,他的决策会影响周围多个 Agent
  • 另外几个 Agent 只是在空旷区域漫无目的地闲逛

前者的优先级显然更高。但在最初的设计里,所有请求是完全平等的,没有优先级的概念。

频控问题让我们不得不引入优先级队列,这反过来让整个多智能体系统的行为变得更有"重点",减少了在不重要的决策上浪费 Token 的情况。

这是一个典型的被约束逼出来的好设计


六、关于异构实验的一点反思

回到最开始的同分异构体实验。

在工程层面,让多个不同厂商的模型同时跑有明显的好处:

  1. 规避单一厂商风险:某家厂商的 API 突然抖动或宕机,只影响部分 Agent,系统整体仍然可用
  2. 成本优化:可以把高频但简单的决策交给便宜的模型,把关键复杂的决策交给高质量的模型
  3. 实验性:当你不确定哪个模型更适合某类任务时,可以让它们在同一环境里 A/B,用运行数据来回答问题

但这种架构的代价是运维复杂度的提升:你需要同时维护多个厂商的 API Key 和配置,每家的错误码和频控规则都不一样,需要分别适配。

这种复杂度是否值得,取决于你的系统规模和具体需求。对我们来说,涌现出来的有趣行为差异,让这一切都是值得的。


结语

这篇文章的两个主题——异构多模型实验和 API 频控处理——在我们项目里其实是同一个问题的两面。

因为想要做异构实验,所以需要并发;因为并发,所以遭遇了频控;因为要解决频控,所以被逼着把请求优先级和并发控制都想清楚了。

工程问题就是这样,往往是一个问题套着另一个问题。但解决它们的过程,也是对系统理解不断加深的过程。

如果你也在做类似的 LLM 应用,希望这篇文章里的踩坑经验对你有用。有问题欢迎评论区交流。