新增: F-01模型能力阶段4路由器(ModelRouter+select 7步过滤链)
This commit is contained in:
@@ -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
328
crates/df-ai/src/router.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user