多 Agent 的诱惑

Part 1 的单 Agent 循环处理一个问题、一个工具、一个答案。Part 4 的状态机处理分阶段任务。但有些事天然想被拆开:研究、质疑、整合——三个不同的活儿,三种不同的系统提示词,三种不同的温度,三种看同一个输入的视角。让同一个 Agent 同时戴三顶帽子,得到的是一种“自信但平庸”的混合回答,没人想要。

surviving 的模式是 crew:一小群专家,各干各的活,由一个运行器决定谁回答什么。Claude Code CLI 直接把这个做进了产品里。它的 AgentTool 会 spawn 几个内置子代理(explore 只读搜索、plan 只读规划、verification 对抗性检查、general-purpose 兜底),主 Agent 按名字委托。一个 CLI 能给人“像五个 Agent 在工作”的感觉,靠的就是这个架构。

多 Agent 团队:研究员与怀疑者并行运行,合成者整合输出 多 Agent 团队模式:researcher 和 skeptic 并行 fan-out;synthesizer 把两者输出整合成一份平衡答案。

本文用 Rust 实现同样的形状。Eugene v0.5 引入 eugene-crewAgent 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,
    }
}

skepticsynthesizer 结构相同,只是提示词不同。人设就是全部差异。三个结构体,相同字段,不同声音。


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
}

futuresjoin_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 保持不变。


相关代码与参考


觉得有帮助?

如果这篇内容对你有用,欢迎:

  • 点赞 / 转发 / 收藏,让更多 Rust + AI 开发者看到;
  • 关注公众号「全栈之巅-梦兽编程」,每周更新 Rust / AI 工程实践;
  • 在评论区留下你的问题,关于多 Agent 设计、路由策略或辩论协议都可以聊;
  • 了解 AI 编程助手服务,用 Claude Code 级能力提升你的团队效率。