squash合并: - 意图识别层论证(8维度+10业界佐证) - 多主题上下文管理愿景+并存论证+补充论证(多轮agentic) - 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化) - 前端架构技术债清单归档
368 lines
14 KiB
Rust
368 lines
14 KiB
Rust
//! 模型路由器 — 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` 方法)。
|
|
|
|
// 阶段5: 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
|
|
// 直接 import 维度枚举构造 TaskRequirements(对齐任务规格 import 风格),re-export 避免调用点
|
|
// 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。
|
|
// 注:CostTier/IntelligenceTier 路由已解耦(2026-06-18 B-260618-03)——provider /v1/models API
|
|
// 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。
|
|
pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig};
|
|
|
|
/// 任务对模型的需求(3 维度)。
|
|
///
|
|
/// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/上下文,
|
|
/// 交 ModelRouter::select 在候选池中选最优模型。
|
|
///
|
|
/// 路由已解耦(2026-06-18 B-260618-03):原 `min_intelligence`/`max_cost` 两字段删除。
|
|
/// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 +
|
|
/// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier)
|
|
/// 保留供未来出现真实判别源时再接回。
|
|
#[derive(Debug, Clone)]
|
|
pub struct TaskRequirements {
|
|
/// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里)
|
|
pub modalities: Vec<Modality>,
|
|
/// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse)
|
|
pub needs_tool_use: bool,
|
|
/// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)
|
|
pub estimated_context: usize,
|
|
}
|
|
|
|
/// 模型路由器(单元结构,无状态)。
|
|
///
|
|
/// select 为关联函数:给定需求 + 候选池,执行过滤链选最优模型。
|
|
pub struct ModelRouter;
|
|
|
|
impl ModelRouter {
|
|
/// 在候选池中选出最优模型(过滤链)。
|
|
///
|
|
/// 步骤:
|
|
/// 1. enabled — 只选启用的
|
|
/// 2. 模态匹配 — 任务所需模态全在模型模态里
|
|
/// 3. 能力匹配 — needs_tool_use 时候选必须含 ToolUse
|
|
/// 4. 窗口够大 — context_window >= estimated_context
|
|
/// 5. max_by_key 选最优:纯 weight 主导(权重高者胜)
|
|
///
|
|
/// 路由已解耦(2026-06-18 B-260618-03):原「智力达标」/「成本可控」两步删除,
|
|
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序纯 weight 主导。
|
|
/// cost_tier/intelligence 数据无客观依据(provider /v1/models 不返回,靠预设表+模型名
|
|
/// 启发式猜),不参与硬路由。枚举保留供未来真实判别源再接回。
|
|
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.context_window >= req.estimated_context) // 4. 窗口够大
|
|
.max_by_key(|m| m.weight) // 5. 纯 weight 主导
|
|
}
|
|
}
|
|
|
|
/// 阶段5 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。
|
|
///
|
|
/// 给定 TaskRequirements + 候选池,返回最优模型的 `model_id`。
|
|
/// 调用点用法:`provider.model_configs`(Vec<ModelConfig>)→ `select_model_id(&req, &pool)`
|
|
/// → `Option<String>`;None 时兜底 `provider.default_model`(行为不变,平滑过渡)。
|
|
///
|
|
/// 行为不变保证:
|
|
/// - 池空(用户未通过 Settings 拉取模型)→ 返回 None → 调用点兜底 default_model
|
|
/// - 池非空但无候选满足需求 → 返回 None → 兜底 default_model
|
|
/// - 池非空命中 → 返回 model_id(F-01 路由目标,拉取即启用路由)
|
|
pub fn select_model_id(req: &TaskRequirements, pool: &[ModelConfig]) -> Option<String> {
|
|
ModelRouter::select(req, pool).map(|m| m.model_id.clone())
|
|
}
|
|
|
|
#[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,
|
|
estimated_context: 0,
|
|
}
|
|
}
|
|
|
|
// ── 阶段5 select_model_id helper ──
|
|
|
|
#[test]
|
|
fn select_model_id_empty_pool_returns_none() {
|
|
// 池空(用户未拉取模型)→ None,调用点兜底 default_model
|
|
let pool: Vec<ModelConfig> = vec![];
|
|
assert!(select_model_id(&req(), &pool).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn select_model_id_hit_returns_model_id() {
|
|
// 池非空命中 → 返回 model_id 字符串(非引用)
|
|
let pool = vec![model("glm-4-flash")];
|
|
assert_eq!(select_model_id(&req(), &pool).as_deref(), Some("glm-4-flash"));
|
|
}
|
|
|
|
// ── 步骤 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());
|
|
}
|
|
|
|
#[test]
|
|
fn modality_subset_multimodal_required_filters_text_only() {
|
|
// 多模态子集匹配:任务需 [Text, Vision](两个模态都得支持),
|
|
// 模型仅 [Text](缺 Vision)→ 步骤 2 `.all(|r| m.modalities.contains(r))` 不成立,滤掉。
|
|
// 区别于 modality_mismatch_filtered(单模态 Vision):本测覆盖「任务需多模态全子集」路径。
|
|
let pool = vec![ModelConfig {
|
|
modalities: vec![Modality::Text],
|
|
..model("text-only")
|
|
}];
|
|
let r = TaskRequirements {
|
|
modalities: vec![Modality::Text, Modality::Vision],
|
|
..req()
|
|
};
|
|
assert!(ModelRouter::select(&r, &pool).is_none());
|
|
|
|
// 对照:模型补全 Vision 后通过(Text+Vision ⊇ 任务需求 Text+Vision)
|
|
let pool_ok = vec![ModelConfig {
|
|
modalities: vec![Modality::Text, Modality::Vision],
|
|
..model("vision-capable")
|
|
}];
|
|
assert_eq!(
|
|
ModelRouter::select(&r, &pool_ok).unwrap().model_id,
|
|
"vision-capable"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn enabled_false_excluded_from_routing() {
|
|
// enabled=false 不参与路由:混池里禁用候选 weight 更高(100),
|
|
// 但应被步骤 1 `.filter(|m| m.enabled)` 滤掉,只选 enabled=true 的低 weight 候选。
|
|
// 区别于 all_disabled_returns_none(全禁用返 None):本测覆盖「部分禁用」混池场景。
|
|
let pool = vec![
|
|
ModelConfig {
|
|
enabled: false,
|
|
weight: 100, // 若未被 enabled 过滤,weight 100 会胜
|
|
..model("disabled-heavy")
|
|
},
|
|
ModelConfig {
|
|
enabled: true,
|
|
weight: 30,
|
|
..model("enabled-light")
|
|
},
|
|
];
|
|
assert_eq!(
|
|
ModelRouter::select(&req(), &pool).unwrap().model_id,
|
|
"enabled-light"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_candidate_pool_returns_none() {
|
|
// 空候选池 → None(与 select_model_id_empty_pool_returns_none 对应,
|
|
// 本测覆盖 select() 关联函数本身而非 helper;语义等价但断言点不同)。
|
|
let pool: Vec<ModelConfig> = vec![];
|
|
assert!(ModelRouter::select(&req(), &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(原智力/成本过滤已解耦 B-260618-03):窗口够大 ──
|
|
|
|
#[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());
|
|
}
|
|
|
|
// ── 步骤 5:max_by_key (纯 weight) ──
|
|
|
|
#[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_first_match() {
|
|
// 同 weight 70,纯 weight 主导(无 cost_tier tie-break):max_by_key 遇并列 key
|
|
// 返回最后一个(rust Iterator::max_by_key 语义)。验证同 weight 不再按 cost 取舍。
|
|
let pool = vec![
|
|
ModelConfig {
|
|
weight: 70,
|
|
cost_tier: CostTier::High,
|
|
..model("expensive")
|
|
},
|
|
ModelConfig {
|
|
weight: 70,
|
|
cost_tier: CostTier::Low,
|
|
..model("low-cost")
|
|
},
|
|
];
|
|
assert_eq!(
|
|
ModelRouter::select(&req(), &pool).unwrap().model_id,
|
|
"low-cost"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn all_dimensions_match_picks_best() {
|
|
// 3+ 候选各维度参差,验证过滤链全过 + max_by_key 纯 weight 选最优。
|
|
// (B-260618-03:智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
|
|
//
|
|
// 候选:
|
|
// a: weight 60 → 通过全部过滤,key=60
|
|
// b: weight 80 → 通过,key=80 — weight 最高档(与 c 并列)
|
|
// c: weight 80 → 通过,key=80 — 同 weight 80,max_by_key 并列返回最后
|
|
// d: weight 90 → 通过(B-260618-03 后 intelligence 不参与过滤),key=90 — weight 最高,胜
|
|
// e: enabled=false → 步骤 1 滤掉
|
|
//
|
|
// 预期:d 胜(weight 90 最高,不再被 intelligence 滤掉)
|
|
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::Low,
|
|
intelligence: IntelligenceTier::Plus,
|
|
..model("c")
|
|
},
|
|
ModelConfig {
|
|
weight: 90,
|
|
cost_tier: CostTier::Low,
|
|
intelligence: IntelligenceTier::Lite,
|
|
..model("d")
|
|
},
|
|
ModelConfig {
|
|
enabled: false,
|
|
weight: 100,
|
|
..model("e")
|
|
},
|
|
];
|
|
let r = TaskRequirements {
|
|
modalities: vec![Modality::Text],
|
|
needs_tool_use: true,
|
|
estimated_context: 0,
|
|
};
|
|
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "d");
|
|
}
|
|
}
|