Skip to content

路由与模型分组 ​

第 12 章你读完了 trait Provider:jackdaw 已经能对一个具体模型发请求。但 agent 在请求里写的常常是 group/coder 这样的组名。中间这一层就是路由。读完本章,你能回答:

  • 给你 router.rs 的 resolve:"group/coder" 从字符串到候选列表要经过哪几步?rotate 策略下,连续两次请求的起点分别是谁?
  • 上游超时与上游 404,试错循环分别怎么走?RouteError 的三个变体各自何时出现?
  • 流式回答发到一半上游断了,jackdaw 为什么宁可失败,也不换下一个模型重试?

一个名字,两种含义 ​

请求体里 model 只是个字符串:可能指一个具体模型,也可能指组。不先把它变成类型,每个调用点都得自己猜一遍。第一步是解析成枚举:

rust
/// A parsed model reference.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelRef {
    /// Direct reference: name of a `[providers.<name>]` table plus the
    /// upstream model id.
    Provider { provider: String, model_id: String },
    /// Routing-group reference: `group/<id>`.
    Group { id: String },
}

impl ModelRef {
    pub fn parse(raw: &str) -> Result<Self, String> {
        let raw = raw.trim();
        let Some((head, rest)) = raw.split_once('/') else {
            return Err(format!(
                "invalid model reference {raw:?}: no `/` found. \
                 Expected `<provider>/<model-id>` or `group/<id>`"
            ));
        };
        // ...
        if head == RESERVED_PROVIDER_NAME {
            // The reserved name routes to groups, so `group/coder` means
            // "group with id `coder`", never "provider named group".
            Ok(ModelRef::Group {
                id: rest.to_string(),
            })
        } else {
            Ok(ModelRef::Provider {
                provider: head.to_string(),
                model_id: rest.to_string(),
            })
        }
    }
}

读法:先看两个变体——恰好对应两种合法写法。parse 按第一个 / 把字符串切成 head 与 rest;head 等于保留字 group(常量 RESERVED_PROVIDER_NAME)就构造 Group,否则 head 是供应商名、rest 整段原样作 model_id——因此 openrouter/deepseek/deepseek-chat 的 model_id 是 deepseek/deepseek-chat,多段 id 不会被切碎。Err 装的是给用户看的报错原文,调用方直接打印。配套的配置校验(第 11 章)据此立规:供应商名不得叫 group。

从引用到候选:order 与 rotate ​

解析只是认出名字;路由要回答"这一单按什么顺序试谁"。resolve 把 ModelRef 变成 Vec<Candidate>:

rust
/// One concrete upstream to try: a provider handle plus the model id that
/// must be written into the request body.
#[derive(Clone)]
pub struct Candidate {
    pub provider: Arc<dyn Provider>,
    pub model_id: String,
}

读法:Candidate = provider 句柄(Arc<dyn Provider>,第 7 章的特征对象)+ 要写进请求体的 model id。单模型引用解析成长度 1 的列表;路由组的每个成员字符串再走一遍 ModelRef::parse 并查 providers 表。成员必须是供应商引用、不许嵌套组——加载时 validate 拒一次,resolve 里再拒一次:两层防线保证 resolve 对任何输入只返回 Ok 或 Err,不 panic。

成员齐了,只剩一个问题:从谁开始。

rust
                match group.strategy {
                    Strategy::Order => {}
                    Strategy::Rotate => {
                        if !members.is_empty() {
                            let start =
                                self.rotate_counter.fetch_add(1, Ordering::Relaxed) % members.len();
                            members.rotate_left(start);
                        }
                    }
                }

读法:从 match group.strategy 看起。Order 分支是空的——什么都不做,就是保持配置顺序。Rotate 分支三步:fetch_add(1, …) 原子地"加一并返回旧值"(第一次返回 0,第二次返回 1);对成员数取模得到起点;rotate_left 把列表旋转到该起点开头。两个成员的组,请求 1 从 a 开始、请求 2 从 b 开始——这就是轮询:负载摊开,而它记下的全部"历史"只是一个整数,没有任何成败统计。Ordering::Relaxed 是原子操作的内存顺序参数,第一次读只需知道"它足以让并发请求拿到不同的起点",更深的并发语义本章不展开。

试错循环:故障转移发生的地方 ​

候选列表只是计划,真正决定行为的是循环。先记住结果的三个形状 RouteError:Unresolved(引用解析不了,或查不到对应的 provider/组)、Fatal(上游明确拒绝,状态码与 body 原样透传)、AllFailed(全员可重试地失败,每成员一行明细)。分类标准来自第 12 章:网络错误/超时/429/5xx → Retryable;其余 4xx → Fatal。

rust
        for candidate in &candidates {
            let attempt = candidate
                .provider
                .complete(request.with_model(&candidate.model_id))
                .await;
            match attempt {
                Ok(response) => return Ok((candidate.display(), response)),
                Err(ProviderError::Retryable(why)) => {
                    tracing::warn!(
                        candidate = candidate.display(),
                        error = %why,
                        "retryable failure, trying next model"
                    );
                    details.push(format!("{}: {}", candidate.display(), why));
                }
                Err(ProviderError::Fatal { status, body }) => {
                    // The request itself is the problem — the next model
                    // would hit the same wall. Stop and pass it through.
                    // ...
                    return Err(RouteError::Fatal { status, body });
                }
            }
        }
        Err(RouteError::AllFailed { details })

读法:数据从 request.with_model(&candidate.model_id) 流向当前候选的 provider。Ok 带赢家返回,candidate.display() 是 provider名/model-id——组名到此为止,只会解析到赢家(这个值之后会成为响应头 x-jackdaw-model,第 14 章展开)。Retryable:记日志、往 details 推一行,试下一个。Fatal:立即返回、不转移——注释说得直白,请求本身有问题,换模型会撞同一堵墙(model id 拼错,换谁都是 404)。循环走完仍无成功,才返回 AllFailed { details },每个成员恰好一行。这条循环就是故障转移(failover)的全部运行时行为,就在这二十来行里;单模型引用也走同一条管道——它是长度 1 的列表,失败即 AllFailed,details 只有一行。

首字节之后,不再转移 ​

流式路径为什么不能"断了就换下一个重发"?方向先给:已发给 agent 的字节收不回来。答案原文写在 stream 的文档注释里:

rust
    /// Streaming path. Failover is only possible *before* the first byte
    /// reaches the client, so this loop stops at the first candidate whose
    /// upstream accepts the request (status already checked by the
    /// provider). Everything after that point — including a stream that
    /// breaks mid-answer — is delivered in-band through the stream, because
    /// rewinding bytes the client already received is impossible.
    pub async fn stream(&self, request: ChatRequest) -> Result<(String, ByteStream), RouteError> {
        // ...
    }

读法:循环只在首字节到达客户端之前转移;上游接受请求、状态码过了检查,赢家即锁定。之后的一切——包括回答到一半流断掉——都顺着流本身传下去。机制原因:HTTP 响应是单向管道,SSE 没有"撤回已发事件"的操作;agent 手里已有半段回答,此时换模型重发,只会重复或拼出两个模型的杂交文本。对照非流式 complete:响应体攒齐才返回,客户端一个字节都没拿到,换人零代价,所以可以放心循环。

WARNING

"流断了自动换人重发"听着贴心,实际会把两份半截输出拼在一起。看似完整的错乱回答,比一个诚实的错误流危害大得多。

用模拟替身测干净这条循环 ​

故障转移依赖"上游真的失败",总不能为跑测试去打真 API——何况 jackdaw 的规格要求测试全部离线、不需要任何密钥。于是测试用模拟替身:内存里的 FakeProvider,行为由三变体枚举 Behavior(Ok/Retryable/Fatal)剧本化:

rust
    struct FakeProvider {
        name: String,
        behavior: Behavior,
        calls: AtomicUsize,
    }

    #[async_trait::async_trait]
    impl Provider for FakeProvider {
        fn name(&self) -> &str {
            &self.name
        }
        async fn complete(&self, _request: ChatRequest) -> Result<ChatResponse, ProviderError> {
            self.calls.fetch_add(1, Ordering::Relaxed);
            match self.behavior {
                Behavior::Ok => Ok(ChatResponse(json!({"role": "assistant"}))),
                Behavior::Retryable => Err(ProviderError::Retryable("stub 500".into())),
                Behavior::Fatal => Err(ProviderError::Fatal {
                    status: 404,
                    body: "{\"error\":{}}".into(),
                }),
            }
        }
        // ...
    }

读法:替身完整实现了 Provider 这个特征,但 complete 里没有 HTTP,只有按剧本返回的模式匹配;calls 每次调用加一,是断言"每个成员被试了几次"的观察点。构造 Router 用 router_with(&group_config("order", …), …):配置、providers、组,全部由一段 TOML 字符串现场搭出。

rust
    #[tokio::test]
    async fn order_fails_over_to_the_next_member() {
        let a = FakeProvider::shared("a", Behavior::Retryable);
        let b = FakeProvider::shared("b", Behavior::Ok);
        let router = router_with(
            &group_config("order", "\"a/m-a\", \"b/m-b\""),
            vec![a.clone(), b.clone()],
        );

        let (used, _) = router
            .complete(ask_model("group/g"))
            .await
            .expect("b answers");
        assert_eq!(used, "b/m-b");
        assert_eq!(a.calls(), 1, "the failing member is tried exactly once");
        assert_eq!(b.calls(), 1);
    }

读法:a 必失败、b 必成功;断言三件事:赢家是 b/m-b;失败的 a 恰好被试一次(没有隐式重试);b 也只被调用一次。同一文件还覆盖:Fatal 停止循环且 b.calls() == 0;全员失败时 details 每成员一行;rotate 两次请求起点依次为 a、b;流式在首字节前照常转移。

TIP

只跑本模块的测试:cargo test --manifest-path jackdaw/Cargo.toml router(按模块名过滤)。全程离线,几秒跑完。

对照 magpie:smart、usage、intent 为何不做 ​

magpie 的路由组有四种策略 smart / order / rotate / usage,外加规则路由(tokens ≥ N、含图片、reasoning、agent、intent 内置分类器),组还可以嵌套(最多 8 层、无环)。jackdaw 刻意只做 order/rotate + 故障转移,不嵌套。这是教学选择,不是功能缺陷:

  • smart 与 usage 都要额外状态:用量、延迟、成功率的统计、存储与淘汰,每一样都比"一个原子计数器"重一个量级;而且行为随统计漂移——同一请求今天与明天可能走向不同,出问题时难以推理。
  • intent 规则要先调一次模型给请求分类,这本身就引入新的失败点:用一个可能失败的东西保护另一个可能失败的东西。
  • 嵌套组把解析变成图问题(要防环);jackdaw 的 resolve 是一次平铺的循环,十几行读完。

这正是 pi 的哲学(极简核心、透明、无预设)落在 jackdaw 上的样子:策略越简单,你越能从配置直接预测行为。不是做不了——第 16 章把 intent 规则路由、usage 统计列为扩展练习。

INFO

以上 magpie 能力清单来自本教程蓝图的事实清单;smart 具体怎么打分、usage 统计什么口径,清单未载,本章不猜。

小结 ​

  • 字符串 → ModelRef → Vec<Candidate>;保留字 group、禁嵌套,由配置与解析两层兜住。
  • order 保持顺序;rotate 用原子计数器轮换起点,不记任何成败统计。
  • 循环只在 Retryable 上继续;Fatal 立即透传;全员失败时 details 每成员一行。
  • 流式在首字节后锁定赢家:发出的字节收不回,诚实失败好过错乱拼接。
  • FakeProvider 让整条循环离线可测;下一章(第 14 章)把它接进 HTTP 网关。

自检 ​

  1. 想新增第三种策略 random(每次随机起点),除了 router.rs 的 match group.strategy,还要改哪里?测试要补什么?
  2. 一个 order 组,成员依次 a/m-a、b/m-b;a 的剧本是 Fatal(404),b 是 Ok。router.complete(...) 返回什么?b.calls() 是多少?若 a 改为 Retryable 呢?
  3. 有人说 stream 锁定赢家后"断了也不换人"是偷懒,应该换下一个模型从头重发。用本章的机制反驳他。
参考答案
  1. config.rs 的 Strategy 枚举加变体,并让 serde 继续接受小写字符串(参照测试 strategy_parses_lowercase_from_toml);router.rs 的 match 补分支,注意空成员列表;测试至少加一个"多次请求起点会变"的用例,结构仿照 rotate_advances_the_starting_member(jackdaw 依赖清单里没有 rand,不想加依赖就先用计数器凑合)。
  2. Fatal:立即返回 RouteError::Fatal { status: 404, .. },b.calls() == 0——fatal 不转移。Retryable:循环继续,返回 Ok(("b/m-b", _)),且 a.calls() == 1、b.calls() == 1。
  3. 已发出的字节收不回:SSE 没有撤回机制,agent 已拿到半段回答;换人重发只会重复或拼出两个模型的杂交文本。错误顺着流如实传出,好过看似完整的错乱回答。