Prhub

#39001 [Router] Fleet-wide sampling contract 2/3: enforce and inject per request

原始 PR 作者 Kangyan-Zhou 合并时间 2026-09-16 15:29 文件变更 4 提交数 4 评论 2 代码增减 +1293 / -59

执行摘要

路由器按请求强制与注入采样契约,拒绝码与指标落地

PR body 把整个改造拆成三步,并明确指出 step 1(#39000,已合并)只加了类型、解析器和两个 flag:"this is the PR that makes the flag do something"。要解决三个问题:(1) 引擎默认值漂移——只要运维声明了契约,请求省略字段时就必须注入配置值,"so the engine's defaults cannot drift from what the operator declared";(2) 拒绝必须发生在准入前——reject 下客户端显式发送不同值直接 400,"costing no queue slot and no engine round-trip";(3) 灰度可观测——把 reject 滚到整个 fleet 会把正常流量变成 400,"how much and on which parameter has to be answerable without reading logs",因此需要独立错误码与按参数打标的计数器。PR 还澄清与引擎自身 --preferred-sampling-params 的分工:引擎侧修复只能覆盖单引擎的注入半边,「准入前拒绝、band、跨 fleet 统一契约」这半边只能留在路由器。

值得精读,尤其是三处设计决策:(1) ProbedValue 三态把「省略 / 可读数字 / 读不出」在反序列化期一次定形,避免了在每次请求上物化客户端大小的 serde_json::Value;(2) parse_as_engine_number 的注释解释了为什么不能靠推导 pydantic 规则写这个函数(Python 数字字面量规则与 pydantic 在 1._5、1e_5、-_1 上不一致),以及为什么 reject 选择 fail-closed;(3) 采样键 last-wins、路由键重复即 400 的非对称设计,理由是「让契约比较的值与引擎真正采样用的值一致」。建议重点关注:注入语义对客户端的影响评估、Unusable 误拒的线上可观测性、以及 stack 3 是否真的消除了 build_outgoing_body 的额外解析开销。适合作为「如何为跨 fleet 的强制契约编写 fail-closed 守卫、并为灰度准备指标」的工程范例。

讨论亮点

本轮只有一条实质性讨论线程,但深度很高。sherlockwu 指出 reject 契约可被绕过:{"temperature": false} 与 {"temperature": "0.5_0"} 在路由器侧都探测为 Unusable(既不比较也不注入),而引擎分别按 0.0 与 0.5 采样,并提议「把布尔转 0/1,并支持 pydantic 的数字字符串语法(含下划线)」。Kangyan-Zhou 复现确认,但只采纳了规范化的一半:用 Python 自己的数字字面量规则实现「支持下划线」会 5/18 错,因为 pydantic 不用 Python 的规则(它先解析 trim 后的串,失败才去下划线且不再 trim),于是改为 fail-closed——reject 直接拒绝读不出来的值,parse_as_engine_number 只决定「引擎接受的值里有多少被精确回答、有多少落到 400」。讨论中还确认了两点工程判断:一是引擎的 lax 转换表未文档化、且 sglang 声明 pydantic 无版本下限,同一 fleet 的引擎未必共享同一张表,因此契约强度不能建立在探测的保真度上;二是后续自查发现的两个副作用(1e400 破坏整包解析、违规计数在第一个参数处提前返回)也被同一轮修复覆盖。评审侧 sherlockwu 给出 APPROVED。

实现拆解

  1. 探测结构扩容 + 手写反序列化(experimental/sgl-router/src/server/routes/chat.rs):RequestProbe 从 #[derive(Debug, Deserialize)] 改为 #[derive(Debug, Default)] 加手写 Deserialize(ProbeVisitor::visit_map),新增 sampling: [ProbedValue; SamplingField::ALL.len()],按 SamplingField::index 索引,治理新参数无需改结构;同时删掉原先独立的 HashMap<String, IgnoredAny> 形状锚定 pass,因为 deserialize_map 本身就会拒绝 null / [] / "hi",多 MiB 的 body 不再被走两遍。新增 ProbeKey,用借用的 &str 匹配出判别式,未识别键不再产生 String。动机:这个探测在每个请求上都跑,无论是否配置了契约。

  2. 值的三态定形:ProbedValue = Absent / Number(f64) / Unusable,全部在反序列化期间解析完成,不保留任何客户端大小的数据(visit_seq / visit_map 用 IgnoredAny 排空后返回 Unusable)。visit_str 走新增的 parse_as_engine_number;visit_bool 把 true / false 读成 1.0 / 0.0(引擎 lax 模式如此);visit_unit 把 null 归为 Absent。解析限长 MAX_SAMPLING_NUMERIC_LEN = 64,去下划线写入固定栈缓冲,客户端可控长字符串不进入分配路径。

  3. 逐参数判定与注入:apply_sampling_overrides 遍历所有受管参数——Absent 注入配置值(两种冲突模式都注入);Number 与配置不一致时 reject 400、allow 放行;Unusable 在 reject 下被拒。即使多个参数同时违规,每个参数都会被判定并计数,只是客户端只被告知一个(修复了「先到先停」导致同时违反 temperature 与 top_p 只记一个的问题)。注入集合由 chat_completions 穿进既有的 build_outgoing_body 解析路径,绕开该解析的零拷贝注入留给 stack 3。重复键语义被刻意区分:采样键 last-wins(对齐引擎的 json.loads),router-actionable 的 RoutingKey(stream / model / max_tokens / max_completion_tokens)重复即 400,因为同一 body 对「如何路由自己」说了两件事在边缘就是歧义。

  4. 错误面与指标:experimental/sgl-router/src/server/error.rs 新增 ApiError::SamplingContract { param, detail },经 status_and_code 映射为 400 + x-router-error-code: sampling_contract_violation,与畸形 JSON、缺 model 的 bad_request 区分;experimental/sgl-router/src/server/metrics.rs 新增 record_sampling_contract_rejection 与 sgl_router_sampling_contract_rejections_total{param} 的渲染段,param 来自 &'static str 固定枚举,标签集合有界。

  5. 容错、测试与配套修复:探测这 8 个键让 serde_json 开始转换它们的数字字面量(此前 IgnoredAny 只扫描),{"temperature": 1e400} 会让整包解析失败并报出「body must be a JSON object」这种与请求体不符的信息,因此 parse_probe 退回不转换的 probe_without_sampling_values(read_sampling: false)。测试方面,chat.rs 有 21 个单元测试覆盖拒绝契约、band 准入、allow 既不掩盖也不拒绝客户端值、null 视为省略、双向重复键、超大非数字值排空不物化、探测值归一化为数字;numeric_strings_are_read_the_way_the_engine_reads_them 的 63 个输入期望值是把值跑过 pydantic 2.13.5 与 sglang 自己的字段声明生成的,而非推导;error.rs / metrics.rs 各补一个错误码与计数器测试,cargo test 793 passed。配套上,tests/proxy/pd_protocol_binding.rs 的 config() 补 sampling_overrides: Default::default(),修掉 #39000 与 #39006 之间的语义冲突导致的 main 编译红灯。

文件 模块 状态 重要度
experimental/sgl-router/src/server/routes/chat.rs 请求路由 modified 9.05
experimental/sgl-router/src/server/metrics.rs 指标采集 modified 7.2
experimental/sgl-router/src/server/error.rs 错误响应 modified 6.85
experimental/sgl-router/tests/proxy/pd_protocol_binding.rs 协议绑定 modified 3.49

关键符号

parse_as_engine_number ProbedValue::deserialize ProbedValue::visit_str ProbedValue::visit_bool ProbedValue::visit_seq ProbedValue::visit_map ProbeKey::deserialize RequestProbe RoutingKey::bit apply_sampling_overrides chat_completions build_outgoing_body ApiError::SamplingContract status_and_code record_sampling_contract_rejection sampling_contract_rejections_are_keyed_by_param sampling_contract_has_a_distinct_code_from_other_bad_requests

关键源码片段

experimental/sgl-router/src/server/metrics.rs observability-metric

新增 `sampling_contract_rejections_total` 字段、`record_sampling_contract_rejection` 记录函数与 Prometheus 渲染段,使 `reject` 的灰度具备「挡了多少、挡在哪个参数」的可观测性;并带一个按参数打标的单元测试。

experimental/sgl-router/src/server/metrics.rs:注册表字段、记录函数与渲染段,构成完整的计数器闭环。

// MetricsRegistry 新增字段:`param` 是固定枚举的 wire name,标签集合因此有界。
sampling_contract_rejections_total: Mutex<HashMap<&'static str, Arc<AtomicU64>>>,
​
impl MetricsRegistry {
    /// 递增 `sgl_router_sampling_contract_rejections_total{param}`。
    ///
    /// 仅在 fleet 级采样契约于 `--sampling-param-conflict reject` 下拒绝请求时记录。
    /// 这是该 flag 的灰度指标:它回答「契约挡掉了多少客户端流量、挡在哪个参数上」——
    /// 这个问题否则无法回答,因为拒绝和别的 400 在客户端看来一模一样。
    pub fn record_sampling_contract_rejection(&self, param: &'static str) {
        let mut guard = self.sampling_contract_rejections_total.lock();
        let counter = guard
            .entry(param)
            .or_insert_with(|| Arc::new(AtomicU64::new(0)))
            .clone();
        drop(guard);
        counter.fetch_add(1, Ordering::Relaxed);
    }
}
​
// render_with_workers 中的渲染段:真正发生过拒绝之前不输出任何 series。
out.push_str(
    "# HELP sgl_router_sampling_contract_rejections_total Requests refused by the fleet-wide sampling contract (--override-sampling-params under --sampling-param-conflict reject), by parameter.\n",
);
out.push_str("# TYPE sgl_router_sampling_contract_rejections_total counter\n");
let guard = self.sampling_contract_rejections_total.lock();
let mut entries: Vec<(&str, u64)> = guard
    .iter()
    .map(|(k, v)| (*k, v.load(Ordering::Relaxed)))
    .collect();
entries.sort_by_key(|entry| entry.0);
for (param, value) in entries {
    out.push_str(&format!(
        "sgl_router_sampling_contract_rejections_total{{param=\"{}\"}} {}\n",
        escape_label(param),
        value,
    ));
}
drop(guard);
experimental/sgl-router/src/server/error.rs data-contract

新增 `ApiError::SamplingContract` 变体并映射到独立的 400 + `sampling_contract_violation` 错误码,使契约拒绝可与畸形 JSON、缺 `model` 的 `bad_request` 区分并独立告警;同时补上区分性测试。

experimental/sgl-router/src/server/error.rs:新的错误变体、状态码映射,以及保证「可独立告警」的测试。

/// 被 fleet 级采样契约拒绝的请求(`--override-sampling-params` 配合
/// `--sampling-param-conflict reject`)。
///
/// 有意与 [`Self::BadRequest`] 分开:把契约滚到整个 fleet 上会把原本正常服务的
/// 流量变成 400,而运维的第一个问题就是「多少、挡在哪个参数」。折进 `bad_request`
/// 就答不出来 —— 那个 code 已经被畸形 JSON 和缺 `model` 字段占用了。
/// `param` 是 [`crate::config::SamplingField::wire_name`] 的 `&'static str`,
/// 因此还能安全地当有界的指标标签用。
#[error("{param} violates this deployment's sampling contract: {detail}")]
SamplingContract { param: &'static str, detail: String },
​
impl ApiError {
    fn status_and_code(&self) -> (StatusCode, &'static str) {
        match self {
            ApiError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
            // 独立 code:客户端可从 `x-router-error-code` 头读到它,
            // 不与 bad_request 混在一起。
            ApiError::SamplingContract { .. } => {
                (StatusCode::BAD_REQUEST, "sampling_contract_violation")
            }
            ApiError::ModelNotFound(_) => (StatusCode::NOT_FOUND, "model_not_found"),
            // ... 其余分支保持不变
        }
    }
}
​
// 测试:拒绝必须与普通 bad_request 可区分,且参数与实际值要回传客户端。
#[test]
fn sampling_contract_has_a_distinct_code_from_other_bad_requests() {
    let err = ApiError::SamplingContract {
        param: "temperature",
        detail: "got 0.5, expected 1 (or omit the field)".into(),
    };
    let (status, code_header, env) = parse_envelope(err.into_response());
    assert_eq!(status, StatusCode::BAD_REQUEST);
    assert_eq!(code_header.as_deref(), Some("sampling_contract_violation"));
    assert_eq!(env.error.code, "sampling_contract_violation");
    assert_ne!(env.error.code, "bad_request");
    assert!(
        env.error.message.contains("temperature") && env.error.message.contains("0.5"),
        "got: {}",
        env.error.message
    );
}

评论区精华

`reject` 契约可被 `false` 与 `"0.5_0"` 绕过 安全

sherlockwu 给出对照表:`{"temperature": false}` 在路由器侧探测为 `Unusable`(既不比较也不注入),引擎却按 `0.0` 采样;`"0.5_0"` 同理,路由器读不通、pydantic 读成 `0.5`。他提议做规范化:布尔转 `0`/`1`,并支持 Pydantic 含下划线的数字字符串语法。Kangyan-Zhou 用真实探测与 pydantic 2.13.5(针对 sglang 自己的 `temperature: Optional[float] = None` 声明)跑出 33 个输入、7 个分歧,确认全部落在 sherlockwu 指出的两类。

结论:采纳修复但改变承重点:布尔在 `visit_bool` 里读成 `1.0` / `0.0`,下划线路径由 `parse_as_engine_number` 处理,而残留的 `Unusable` 在 `reject` 下直接拒绝(fail-closed),不再放行——契约强度不再依赖探测的保真度。 · 已解决

pydantic 的 lax 转换表不可推导,契约不能建立在其上 设计

作者自陈尝试过按 reviewer 提案直接实现「支持 Pydantic 的数字字符串语法」,但用 Python 自身的数字字面量规则(下划线只能夹在数字之间)会 5/18 错——pydantic 不用 Python 的规则:它先解析 trim 后的串,失败才去下划线且不再 trim;并且 sglang 声明 `pydantic` 时没有版本下限,fleet 内引擎未必共享同一张表。

结论:改为把规则显式写出并只用于「决定多少输入被精确回答」,测试期望值用 pydantic 2.13.5 实跑生成(`numeric_strings_are_read_the_way_the_engine_reads_them`,63 个输入),而不是从规则推导。 · 已解决

违规计数不能停在第一个参数 正确性

`apply_sampling_overrides` 原先在遇到第一个违规时即返回,导致同时违反 `temperature` 与 `top_p` 的 fleet 在 `sgl_router_sampling_contract_rejections_total` 里只看到一个参数,灰度画像失真。

结论:改为遍历所有配置参数逐个判定并计数,客户端仍然只被告知其中一个;每个参数都会独立打点,保证按参数灰度的解读正确。 · 已解决

探测采样键后 `1e400` 破坏整包解析 正确性

此前这些键由 `IgnoredAny` 只扫描,改成 `ProbedValue` 探测后 serde_json 会 ** 转换 ** 它们的数字字面量,而超出 f64 范围时它报错而非饱和,于是 `{"temperature": 1e400}` 让整个 parse 失败,并以「body must be a JSON object」这种与请求体不符的信息 400——在一个根本没配置契约的路由器上;同一个字面量放在未被探测的键下却不受影响,这个不对称暴露了问题。

结论:`parse_probe` 增加退回不转换的 `probe_without_sampling_values`(`read_sampling: false`)再走一遍,恢复「探测不改变无关请求的可接受性」。 · 已解决

跨 PR 语义冲突让 main 的 `cargo check --all-targets` 变红 other

#39000 给 `ModelConfig` 新增 `sampling_overrides` 字段,#39006 加入带穷尽 `ModelConfig` 字面量的 `pd_protocol_binding.rs`;两个 PR 各自 CI 绿,合并后在文本上无冲突但在语义上冲突,导致所有 router PR 的 lint 全红。

结论:本 PR 顺手补上 `sampling_overrides: Default::default()` 解除 main 红灯;也提示配置面快速演进期需要穷尽字面量之外的保护(如 `..Default::default()` 或字段构造助手)。 · 已解决

风险与影响

  1. reject 的 fail-closed 可能误拒(可用性风险):Unusable 一律 400,而 parse_as_engine_number 只是对 pydantic lax 转换表的近似实现,因此存在「引擎本来接受、路由器却拒绝」的假阳性,尤其在 fleet 内 pydantic 版本不一致时。灰度必须依赖新增计数器判断拒绝量,而非日志。
  2. 注入改写客户端语义(行为变更):一旦设置 --override-sampling-params,省略字段的请求会被注入配置值,两种冲突模式都如此;依赖引擎默认值的客户端采样行为会被静默改写。allow 模式不做比较,因此该模式下契约的「强度」实际来自注入而非拒绝。
  3. 解析路径回归面扩大:把 8 个键从 IgnoredAny 扫描改成探测转换后,出现了 1e400 破坏整包解析并被误报成 body 形状错误的问题(已用 fallback pass 修复),说明探测与扫描的语义差异会反复出现,后续新增受管参数时同样要验证。
  4. 重复键行为的不对称:采样键 last-wins、路由键 400。这与此前 #[derive(Deserialize)] 对重复字段的处理是否一致、是否有客户端依赖旧行为,材料中未给出验证,需要在发布说明中明确。
  5. 缺少代理层 e2e 覆盖:PR 自陈 accuracy tests 不适用,e2e proxy 测试与零拷贝注入都留给 stack 3;当前只有单元测试断言 400 信封、error code header 与计数器,跨进程真实转发路径尚未覆盖。
  6. 跨 PR 语义冲突:第 4 个 commit 修的是 main 上 cargo check --all-targets 红灯——#39000 给 ModelConfig 加字段、#39006 加入带穷尽字面量的 pd_protocol_binding.rs,两个各自 CI 绿的 PR 合并后编译失败。同类冲突在 router 配置面快速演进期仍会复发。

对用户与客户端:flag 未设置(默认)时请求路径行为完全不变;配置契约后,省略采样字段的请求会被注入运维声明值,发送不同值的请求在 reject 下拿到 400 sampling_contract_violation(错误消息含参数名、实际值与期望值,可直接自助排查),在 allow 下不受影响。对系统:chat 入口多了一次受控探测解析,但替代了此前的两次 deserialize pass,未配置契约时零分配(Vec::with_capacity(0)),且拒绝发生在准入前,不占队列位、不走引擎往返。对运维:sgl_router_sampling_contract_rejections_total{param} 让「契约挡了多少流量、挡在哪个参数」可回答,使 reject 具备按参数灰度的能力,错误码与普通 bad_request 分流也便于独立告警。对团队:本 PR 确立了「路由器与引擎对同一请求值的解读必须对齐,无法对齐时宁拒不猜」的判据,stack 3 会在此基础上继续收敛注入路径的开销与 e2e 覆盖。

核心请求路径变更 reject 模式可能误拒 注入改写采样语义 缺少代理层 e2e 覆盖 跨 PR 语义冲突

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论