用 Rust 从零构建 AI Agent(五):多 Agent 协作团队

多 Agent 的诱惑
Part 1 的单 Agent 循环处理一个问题、一个工具、一个答案。Part 4 的状态机处理分阶段任务。但有些事天然想被拆开:研究、质疑、整合——三个不同的活儿,三种不同的系统提示词,三种不同的温度,三种看同一个输入的视角。让同一个 Agent 同时戴三顶帽子,得到的是一种“自信但平庸”的混合回答,没人想要。
surviving 的模式是 crew:一小群专家,各干各的活,由一个运行器决定谁回答什么。Claude Code CLI 直接把这个做进了产品里。它的 AgentTool 会 spawn 几个内置子代理(explore 只读搜索、plan 只读规划、verification 对抗性检查、general-purpose 兜底),主 Agent 按名字委托。一个 CLI 能给人“像五个 Agent 在工作”的感觉,靠的就是这个架构。
多 Agent 团队模式:researcher 和 skeptic 并行 fan-out;synthesizer 把两者输出整合成一份平衡答案。
本文用 Rust 实现同样的形状。Eugene v0.5 引入 eugene-crew:Agent trait、用 tokio 并行跑多个 Agent 的 Crew、给自由查询选专家的 Router,以及为准确率让两个 Agent 对辩的 debate。Part 3 的 Skill 注册表和 Part 4 的状态机也会一起出现;新东西是让多个循环彼此对话的那一层。
多 Agent 陷阱
先看警告。多 Agent 设计领域的源头材料用一句话开头:stop it。增加 Agent 是简单答案,却往往让事情更糟。协调税很重:每多一个 Agent,就多一份延迟、多一份 token、多一个幻觉入口,以及故障排查时多一份混乱。两 Agent 的成本和时间大致是单 Agent 的两倍;三 Agent 三倍;五 Agent 看起来在 PPT 里很 impressive,第一次遇到真实故障就会散架。
多 Agent crew 只有在角色真正不同时才值得。研究员和写作者是不同角色:一个朝外收集事实,一个朝内组织成文。它们用不同的提示词很合适。研究员和“温度稍高一点的研究员”不是不同角色。把一个工作拆成两个,你只是付双倍价钱来取平均。
判断要不要加 Agent 的标准是:做这份工作的人会请同事回答同一个问题,还是不同的问题?如果是同一个问题,那只有一个 Agent。如果同事的专长能发现第一个人漏掉的东西,那才值得两个。
Agent trait
一个 Agent 是从自由查询到自由回答的函数。具体怎么产生回答由实现决定:一次带聚焦系统提示的 LLM 调用、走 Part 3 注册表的工具链、查数据库。运行时不管。
#[async_trait]
trait Agent: Send + Sync + 'static {
fn description(&self) -> AgentDescription;
async fn handle(&self, query: &str) -> Result<String, CrewError>;
}
struct AgentDescription {
pub name: String,
pub summary: String,
}
description 做两件事:给 crew 按名调度;给 router 展示一句话摘要,让模型选 specialist。Claude Code 的 AgentDefinition 也是这个形状:一个名字 + 一段描述,模型在 AgentTool 的工具列表里看到它。
Part 5 gist 里的具体 Agent 都是同一个形状:一个 Specialist 结构体,存系统提示、HTTP 客户端和 API key。区别只在提示词。
fn researcher(http: reqwest::Client, api_key: String) -> Specialist {
Specialist {
name: "researcher",
summary: "gathers facts and concrete examples on the topic",
system: "You are the researcher on a small editorial team. Given a \
topic, list 3-5 concrete facts, trends, or examples that an \
informed person would care about. Be specific and current. \
No hedging. Plain prose, no markdown.",
http,
api_key,
}
}
skeptic 和 synthesizer 结构相同,只是提示词不同。人设就是全部差异。三个结构体,相同字段,不同声音。
Crew 运行器
Crew 拥有调度表。加一个 Agent 是一行;跑一个 Agent 是一个异步调用。有意思的是并行和串行两种变体。
async fn run_parallel(
&self,
calls: &[(String, String)],
) -> Vec<(String, Result<String, CrewError>)> {
let futs = calls.iter().map(|(name, query)| async move {
let name = name.clone();
let result = match self.agents.get(&name) {
Some(a) => a.handle(query).await,
None => Err(CrewError::UnknownAgent(name.clone())),
};
(name, result)
});
join_all(futs).await
}
futures 的 join_all 让这很便宜。每个调用变成一个 future,所有 future 在独立的网络往返中等待,tokio 在同一个任务里并发轮询。两个各耗时 1 秒的 Agent 总耗时约 1 秒,不是 2 秒。Part 3 注册表里并行 tool_use 分发也用的同一个原语。一个 Agent 内部的多工具调用,和 Crew 里的多 Agent,用完全相同的并发原语。
run_sequential 是链式变体:前一个 Agent 的输出成为下一个 Agent 的输入。适合流水线,后一个专家精炼或格式化前一个的产物。这其实就是 Part 4 状态机做的事:命名节点之间用 Goto 传递状态。两个 crate 可以组合:一个 Agent 实现可以驱动一个 Graph<S>,一个 Node<S> 可以 dispatch 一个 Agent。按工作选模型。
Router:让模型选
几乎所有多 Agent 系统都被两种模式驱动。要么调用者知道该问哪个专家(显式调用一个,或 fan out 到多个),要么调用者不知道,想让别人决定。后一种就是 Router 的用途。
crate 提供一个 Router trait 和最小的 KeywordRouter(按名字是否出现在查询里匹配)。真实路由器通常问模型。它的系统提示类似:
You are a triage clerk. Given a user message and a list of specialists,
pick exactly one specialist by name. Reply with only the name, no other text.
Router 把 AgentDescription 列表展示给模型,模型返回名字,Crew 按名调度。Claude Code CLI 在 AgentTool 里也是这个模式:父 Agent 在自己的系统提示里看到可用子代理的描述,决定调用哪个 subagent_type。
有两个成本点要记住。Router 本身是一次模型调用。如果二选一路由错误选择的成本比路由调用本身还低,那就不要路由——直接并行调用两个 Agent,取更便宜/更合适的。另一个点是幻觉:模型可能选一个根本不存在的 Agent。保持列表短、描述清晰,并在模型返回不存在的名字时静默拒绝。
Debate 协议
当准确率比吞吐更重要时,有时需要两个故意持不同意见的 Agent。一个 pro 正方辩护,一个 con 反方攻击,一个 judge 裁判读完整对话并给出裁决。最终回答用户的是裁判,而不是任一辩手。
pub async fn debate(
pro: &dyn Agent,
con: &dyn Agent,
judge: Option<&dyn Agent>,
topic: &str,
rounds: u32,
) -> Result<String, CrewError>;
每一轮,pro 先提出论点;con 读完后提出反驳;对话记录累积。rounds 轮交换后,可选的裁判读完整 transcript 给出 verdict。没有裁判时返回 transcript,适合人类做最终权衡。
Debate 协议大致对应 Claude Code 的 verification agent:一个专门尝试破坏另一个 Agent 产出的 specialist,提示词比实现者更严格,并显式列出应抵抗的 failure mode。那种敌意就是 feature:一个同意你的批评者不是批评者。
在高 stakes 准确率领域才用 debate 协议:法律、医疗、安全相关。其他地方用更简单的 crew 模式。两者都不便宜, casually 使用是浪费;但当替代方案是交付错误答案时,它们物有所值。
与前面 crate 组合
Crew 不替换 Skill 或 Graph,而是坐在它们上面。一个合理的生产 Agent 会三层都用。
Crew
├── Agent: researcher
│ └── Skill Registry (Part 3): search, read_file, fetch
├── Agent: skeptic
│ └── Skill Registry (Part 3): search, fact-check
└── Agent: synthesizer
└── Graph (Part 4): draft → review → revise
Crew 把任务分派给 Agent。每个 Agent 内部驱动 Part 3 的 Skill 注册表来调用工具。合成者 Agent 自己可能跑一个 Part 4 的图来起草、审阅、修改答案。组合就是重点。每个 crate 解决一层;Agent 是这些层的堆叠。
Eugene v0.5 实战
Part 5 的 gist 用三个专家处理一个主题。Researcher 和 Skeptic 并行分派;Synthesizer 基于两者输出综合:
let calls = vec![
("researcher".to_string(), topic.clone()),
("skeptic".to_string(), topic.clone()),
];
let results = crew.run_parallel(&calls).await;
let mut researcher_out = String::new();
let mut skeptic_out = String::new();
for (name, result) in results {
let text = result.map_err(|e| anyhow!("{name}: {e}"))?;
match name.as_str() {
"researcher" => researcher_out = text,
"skeptic" => skeptic_out = text,
other => bail!("unexpected agent: {other}"),
}
}
let synthesis_input = format!(
"Topic: {topic}\n\n--- Researcher's findings ---\n{researcher_out}\n\n\
--- Skeptic's objections ---\n{skeptic_out}\n\nWrite the balanced answer."
);
let final_answer = crew.run("synthesizer", &synthesis_input).await?;
对 "Should I learn Rust in 2026?" 运行,你会得到三段:研究员热情但具体的理由清单、怀疑者列出的真实缺点、合成者一段平静的平衡回答。同一个 crew 对 "Should we add a Postgres dependency?" 会产出完全不同的、同样有用的东西。Agent 不需要知道彼此,只需要知道 orchestrator 在它们之间传递了什么。
总墙钟时间 = 并行阶段最慢 Agent 的时间 + 合成者时间。一个问题 naive 地问单 Agent 可能要 5 秒且答案更差,crew 2.5 秒给出更好结果。
这揭示了什么
Crew 不是框架。它是一个 HashMap<String, Box<dyn Agent>> 加几个调度方法。真正的工作在专家的提示词里,在决定什么时候拆任务的分寸里。编排是机械的;让谁进房间是编辑判断。
Tokio 让并行的代码复杂度成本几乎为零。join_all 是一个函数调用。并行的美元和延迟成本不为零,所以文章开头的警告很重要。两个想法同时成立:只在角色真正不同时用 crew,一旦决定拆分,就可以放心拆分,因为运行时支持很便宜。
同一个 Agent trait 无需修改就能覆盖四种工作形状:面向模型的 specialist Agent、状态机包装器、单个 Skill 包装器、辩论参与者。组合规则是:并行用于 fan-out,串行用于精炼,路由用于分派,辩论用于准确率。四种模式,一个 trait。
下一步:Provider 抽象
本文所有 Agent 都只和一个 provider 对话——Anthropic。只要永远用 Claude 这就没问题。但一旦用户想换成 OpenAI 来省钱、Gemini 来换能力、或 Ollama 来本地开发,代码里硬编码的 URL 和 header 就成了问题。Part 6 引入 eugene-providers:一个 Provider trait,带四个主流 provider 的适配器、成本/延迟对比,以及一个发现:provider 之间几乎所有有意义的差异都在 wire format 的边缘,而不是中间部分。Part 5 写的 Agent 保持不变。
相关代码与参考
eugene/crates/eugene-crew:完整 Crew 运行器实现,包含 6 个单元测试:按名调度、并行执行、串行线程、关键词路由、辩论协议、未知 Agent 错误路径。crate 刻意保持小巧:编排层不需要框架,主要杠杆来自tokio::spawn、join_all和每个 specialist 的聚焦系统提示。 ⭐ Star on GitHub- Claude Code 的 AgentTool 和内置子代理注册表
- CrewAI 的任务图:顺序、层级、并行执行
- 验证 Agent 契约:非平凡工作后的对抗性检查
- Generator/Challenger 辩论循环:高 stakes 准确率的经典模式
觉得有帮助?
如果这篇内容对你有用,欢迎:
- 点赞 / 转发 / 收藏,让更多 Rust + AI 开发者看到;
- 关注公众号「全栈之巅-梦兽编程」,每周更新 Rust / AI 工程实践;
- 在评论区留下你的问题,关于多 Agent 设计、路由策略或辩论协议都可以聊;
- 了解 AI 编程助手服务,用 Claude Code 级能力提升你的团队效率。
