路由与模型分组
第 12 章你读完了 trait Provider:jackdaw 已经能对一个具体模型发请求。但 agent 在请求里写的常常是 group/coder 这样的组名。中间这一层就是路由。读完本章,你能回答:
- 给你
router.rs的resolve:"group/coder"从字符串到候选列表要经过哪几步?rotate 策略下,连续两次请求的起点分别是谁? - 上游超时与上游 404,试错循环分别怎么走?
RouteError的三个变体各自何时出现? - 流式回答发到一半上游断了,jackdaw 为什么宁可失败,也不换下一个模型重试?
一个名字,两种含义
请求体里 model 只是个字符串:可能指一个具体模型,也可能指组。不先把它变成类型,每个调用点都得自己猜一遍。第一步是解析成枚举:
/// 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>:
/// 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。
成员齐了,只剩一个问题:从谁开始。
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。
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 的文档注释里:
/// 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)剧本化:
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 字符串现场搭出。
#[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 网关。
自检
- 想新增第三种策略
random(每次随机起点),除了router.rs的match group.strategy,还要改哪里?测试要补什么? - 一个 order 组,成员依次
a/m-a、b/m-b;a 的剧本是 Fatal(404),b 是 Ok。router.complete(...)返回什么?b.calls()是多少?若 a 改为 Retryable 呢? - 有人说
stream锁定赢家后"断了也不换人"是偷懒,应该换下一个模型从头重发。用本章的机制反驳他。
参考答案
config.rs的Strategy枚举加变体,并让 serde 继续接受小写字符串(参照测试strategy_parses_lowercase_from_toml);router.rs的 match 补分支,注意空成员列表;测试至少加一个"多次请求起点会变"的用例,结构仿照rotate_advances_the_starting_member(jackdaw 依赖清单里没有rand,不想加依赖就先用计数器凑合)。- Fatal:立即返回
RouteError::Fatal { status: 404, .. },b.calls() == 0——fatal 不转移。Retryable:循环继续,返回Ok(("b/m-b", _)),且a.calls() == 1、b.calls() == 1。 - 已发出的字节收不回:SSE 没有撤回机制,agent 已拿到半段回答;换人重发只会重复或拼出两个模型的杂交文本。错误顺着流如实传出,好过看似完整的错乱回答。