新增: F-01模型能力阶段4路由器(ModelRouter+select 7步过滤链)

This commit is contained in:
2026-06-16 23:59:09 +08:00
parent 44bc18294c
commit 8dad15ecc1
2 changed files with 329 additions and 0 deletions

View File

@@ -8,6 +8,7 @@ pub mod model_fetch;
pub mod model_probe;
pub mod openai_compat;
pub mod provider;
pub mod router;
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
// 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。

328
crates/df-ai/src/router.rs Normal file
View File

@@ -0,0 +1,328 @@
//! 模型路由器 — F-01 阶段4
//!
//! 纯函数核心,零 IO / 零状态。给定 TaskRequirements + 候选池,返回最优 ModelConfig。
//! 不接调用点(那是阶段5:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
//! df-ideas / df-nodes ai_node.rs)。
//!
//! 设计来源:docs/02-架构设计/F-01-模型能力系统与智能路由设计-2026-06-16.md §6.1。
//!
//! ModelRouter 为单元结构,select 是无状态关联函数(对齐任务规格,非设计文档的 `&self` 方法)。
use std::cmp::Reverse;
use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig};
/// 任务对模型的需求(5 维度,对齐设计 §6.1)。
///
/// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/智力/成本/上下文,
/// 交 ModelRouter::select 在候选池中选最优模型。
#[derive(Debug, Clone)]
pub struct TaskRequirements {
/// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里)
pub modalities: Vec<Modality>,
/// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse)
pub needs_tool_use: bool,
/// 最低智力要求(模型 intelligence 必须 >= min_intelligence)
pub min_intelligence: IntelligenceTier,
/// 成本上限(可选:None 表示不设上限)
pub max_cost: Option<CostTier>,
/// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)
pub estimated_context: usize,
}
/// 模型路由器(单元结构,无状态)。
///
/// select 为关联函数:给定需求 + 候选池,执行 7 步过滤链选最优模型。
pub struct ModelRouter;
impl ModelRouter {
/// 在候选池中选出最优模型(7 步过滤链,严格对齐设计 §6.1 L469-478)。
///
/// 步骤:
/// 1. enabled — 只选启用的
/// 2. 模态匹配 — 任务所需模态全在模型模态里
/// 3. 能力匹配 — needs_tool_use 时候选必须含 ToolUse
/// 4. 智力达标 — intelligence >= min_intelligence
/// 5. 成本可控 — max_cost 设定时 cost_tier <= max_cost
/// 6. 窗口够大 — context_window >= estimated_context
/// 7. max_by_key 选最优:权重优先,同权重选便宜(cost_tier 小)
///
/// 第 7 步取反说明:CostTier derive Ord(Free<Low<Medium<High),
/// "选便宜"=选小 Ord 值,但 max_by_key 默认选大。
/// 用 `std::cmp::Reverse(m.cost_tier)` 包装让小值变"大":
/// Free(Reverse 值大)胜过 High(Reverse 值小)。比 `-(as i32)` 取反更 idiomatic。
pub fn select<'a>(req: &TaskRequirements, pool: &'a [ModelConfig]) -> Option<&'a ModelConfig> {
pool.iter()
.filter(|m| m.enabled) // 1. 只选启用的
.filter(|m| req.modalities.iter().all(|r| m.modalities.contains(r))) // 2. 模态匹配
.filter(|m| !req.needs_tool_use || m.capabilities.contains(&Capability::ToolUse)) // 3. 能力匹配
.filter(|m| m.intelligence >= req.min_intelligence) // 4. 智力达标
.filter(|m| req.max_cost.map_or(true, |max| m.cost_tier <= max)) // 5. 成本可控
.filter(|m| m.context_window >= req.estimated_context) // 6. 窗口够大
.max_by_key(|m| (m.weight, Reverse(m.cost_tier))) // 7. 权重优先,同权重选便宜
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 构造一个全维度可定制的 ModelConfig(默认全过过滤,调用方按需覆盖字段)。
fn model(model_id: &str) -> ModelConfig {
ModelConfig {
model_id: model_id.into(),
enabled: true,
label: None,
modalities: vec![Modality::Text],
capabilities: vec![Capability::ToolUse],
cost_tier: CostTier::Medium,
intelligence: IntelligenceTier::Standard,
weight: 50,
context_window: 8192,
probe_source: None,
}
}
/// 构造一个宽松需求(默认全过过滤,调用方按需覆盖字段)。
fn req() -> TaskRequirements {
TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: false,
min_intelligence: IntelligenceTier::Lite,
max_cost: None,
estimated_context: 0,
}
}
// ── 步骤 1:enabled 过滤 ──
#[test]
fn empty_pool_returns_none() {
let pool: Vec<ModelConfig> = vec![];
assert!(ModelRouter::select(&req(), &pool).is_none());
}
#[test]
fn all_disabled_returns_none() {
let pool = vec![
ModelConfig {
enabled: false,
..model("a")
},
ModelConfig {
enabled: false,
..model("b")
},
];
assert!(ModelRouter::select(&req(), &pool).is_none());
}
// ── 步骤 2:模态匹配 ──
#[test]
fn modality_mismatch_filtered() {
// 任务需要 Vision,但池里模型只有 Text
let pool = vec![model("text-only")];
let r = TaskRequirements {
modalities: vec![Modality::Vision],
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
}
// ── 步骤 3:能力匹配(ToolUse) ──
#[test]
fn needs_tool_use_filters_non_tool() {
// 任务需要 ToolUse,池里模型 capabilities 无 ToolUse
let pool = vec![ModelConfig {
capabilities: vec![Capability::Embedding],
..model("embed-only")
}];
let r = TaskRequirements {
needs_tool_use: true,
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
}
#[test]
fn needs_tool_use_false_allows_non_tool() {
// 任务不需 ToolUse,池里模型无 ToolUse 仍入选
let pool = vec![ModelConfig {
capabilities: vec![Capability::Embedding],
..model("embed-only")
}];
let r = TaskRequirements {
needs_tool_use: false,
..req()
};
assert_eq!(
ModelRouter::select(&r, &pool).unwrap().model_id,
"embed-only"
);
}
// ── 步骤 4:智力达标 ──
#[test]
fn intelligence_below_min_filtered() {
// 任务最低 Plus,池里模型 Standard
let pool = vec![model("standard")];
let r = TaskRequirements {
min_intelligence: IntelligenceTier::Plus,
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
}
// ── 步骤 5:成本可控 ──
#[test]
fn max_cost_filters_expensive() {
// 任务上限 Low,池里模型 High
let pool = vec![ModelConfig {
cost_tier: CostTier::High,
..model("expensive")
}];
let r = TaskRequirements {
max_cost: Some(CostTier::Low),
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
}
#[test]
fn max_cost_none_allows_any() {
// 任务不设上限,池里模型 High 仍入选
let pool = vec![ModelConfig {
cost_tier: CostTier::High,
..model("expensive")
}];
let r = TaskRequirements {
max_cost: None,
..req()
};
assert_eq!(
ModelRouter::select(&r, &pool).unwrap().model_id,
"expensive"
);
}
// ── 步骤 6:窗口够大 ──
#[test]
fn context_window_insufficient() {
// 任务预估 100000,池里模型 8192
let pool = vec![model("small-window")];
let r = TaskRequirements {
estimated_context: 100_000,
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
}
// ── 步骤 7:max_by_key (weight, Reverse(cost)) ──
#[test]
fn single_match_returns_it() {
let pool = vec![model("only-one")];
assert_eq!(
ModelRouter::select(&req(), &pool).unwrap().model_id,
"only-one"
);
}
#[test]
fn weight_priority_higher_wins() {
// 两候选都满足,weight 70 胜 50
let pool = vec![
ModelConfig {
weight: 50,
..model("low-weight")
},
ModelConfig {
weight: 70,
..model("high-weight")
},
];
assert_eq!(
ModelRouter::select(&req(), &pool).unwrap().model_id,
"high-weight"
);
}
#[test]
fn same_weight_picks_cheaper_cost_tier() {
// 同 weight 70,cost Free 胜 cost High(Reverse 让 Free 的 Ord 值变大)
let pool = vec![
ModelConfig {
weight: 70,
cost_tier: CostTier::High,
..model("expensive")
},
ModelConfig {
weight: 70,
cost_tier: CostTier::Free,
..model("free")
},
];
assert_eq!(
ModelRouter::select(&req(), &pool).unwrap().model_id,
"free"
);
}
#[test]
fn all_dimensions_match_picks_best() {
// 3+ 候选各维度参差,验证 7 步全过 + max_by_key 选最优组合。
//
// 候选:
// a: weight 60, cost Medium → 通过全部过滤,key=(60, Reverse(Medium))
// b: weight 80, cost High → 通过,key=(80, Reverse(High)) — weight 最高,胜
// c: weight 80, cost Free → 通过,key=(80, Reverse(Free)) — 同 weight 80,Free 比 High 便宜,胜过 b
// d: weight 90, cost Low, 但 intelligence Lite(< min Standard) → 步骤 4 滤掉
// e: enabled=false → 步骤 1 滤掉
//
// 预期:c 胜(weight 80 最高档中,Free 最便宜)
let pool = vec![
ModelConfig {
weight: 60,
cost_tier: CostTier::Medium,
intelligence: IntelligenceTier::Standard,
..model("a")
},
ModelConfig {
weight: 80,
cost_tier: CostTier::High,
intelligence: IntelligenceTier::Plus,
..model("b")
},
ModelConfig {
weight: 80,
cost_tier: CostTier::Free,
intelligence: IntelligenceTier::Plus,
..model("c")
},
ModelConfig {
weight: 90,
cost_tier: CostTier::Low,
intelligence: IntelligenceTier::Lite, // 低于 min,滤掉
..model("d")
},
ModelConfig {
enabled: false,
weight: 100,
..model("e")
},
];
let r = TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: true,
min_intelligence: IntelligenceTier::Standard,
max_cost: None,
estimated_context: 0,
};
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "c");
}
}