文章背景:在一个多智能体系统项目里,我们让不同的 Agent 分别接入不同厂商的大模型同时运行。并发编排层由我开源的 Go 多智能体库 GopherGraph 驱动。这篇文章聊两件事:第一,这个"异构实验"里涌现出了什么有趣的现象;第二,并发调用多家 API 时遭遇的频率限制(Rate Limit)噩梦,以及我们是怎么一步步解决的。
一、实验的起点:同一个问题,交给不同的"大脑"
这个想法其实来自化学里的一个概念——同分异构体。
同分异构体是指:分子式完全相同(比如都是 C₄H₁₀),但原子在空间里的排列方式不同,因此化学性质截然不同。丁烷和异丁烷,看起来"一样",但沸点、反应性完全不同。
我们的实验也是如此:给不同的 Agent 输入完全相同的 Prompt 模板(相同的"分子式"),但背后调用的大模型不同(不同的"空间结构")——DeepSeek 用一个,Qwen 用一个,Gemini 用一个。
观察它们面对同样处境时,涌现出什么样的差异化行为。
这个实验的意义不仅仅是"对比哪个模型更聪明",更有价值的问题是:当多个异构 Agent 被放入同一个环境里,他们的群体行为会涌现出什么?
二、实验设计:同一套规则,不同的灵魂
Prompt 的设计原则
为了让实验结果有意义,我们对 Prompt 的设计做了严格约束:
所有 Agent 共享同一套 Prompt 模板,模板里包含:
- 身份设定:Agent 的姓名、性格标签(比如"谨慎内向"、"热情外向")
- 环境观察:当前时刻 Agent 能感知到的信息(周边有谁、距离多远、对方在做什么)
- 历史记忆:从向量数据库里检索出来的相关历史片段(上次和谁说了什么)
- 输出格式约束:要求返回严格的 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("超过最大重试次数")
}
这个方案有效,但有两个问题:
- 重试时间是固定的:所有失败的请求都等 2 秒,然后一起重试,可能再次产生洪峰,继续 429,陷入循环。
- 延迟叠加:如果一次 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 的情况。
这是一个典型的被约束逼出来的好设计。
六、关于异构实验的一点反思
回到最开始的同分异构体实验。
在工程层面,让多个不同厂商的模型同时跑有明显的好处:
- 规避单一厂商风险:某家厂商的 API 突然抖动或宕机,只影响部分 Agent,系统整体仍然可用
- 成本优化:可以把高频但简单的决策交给便宜的模型,把关键复杂的决策交给高质量的模型
- 实验性:当你不确定哪个模型更适合某类任务时,可以让它们在同一环境里 A/B,用运行数据来回答问题
但这种架构的代价是运维复杂度的提升:你需要同时维护多个厂商的 API Key 和配置,每家的错误码和频控规则都不一样,需要分别适配。
这种复杂度是否值得,取决于你的系统规模和具体需求。对我们来说,涌现出来的有趣行为差异,让这一切都是值得的。
结语
这篇文章的两个主题——异构多模型实验和 API 频控处理——在我们项目里其实是同一个问题的两面。
因为想要做异构实验,所以需要并发;因为并发,所以遭遇了频控;因为要解决频控,所以被逼着把请求优先级和并发控制都想清楚了。
工程问题就是这样,往往是一个问题套着另一个问题。但解决它们的过程,也是对系统理解不断加深的过程。
如果你也在做类似的 LLM 应用,希望这篇文章里的踩坑经验对你有用。有问题欢迎评论区交流。