Prhub

#56338 [Rust Frontend] Forward per-request watermarking controls

原始 PR 作者 NickLucche 合并时间 2026-09-14 23:17 文件变更 14 提交数 1 评论 7 代码增减 +133 / -1

执行摘要

Rust 前端打通按请求水印开关,HTTP/gRPC 均可 opt-out

PR body 直接说明动机:"Rust frontend requests currently drop watermarking: false, so callers cannot opt out of the engine's configured watermark as they can through the Python frontend." 即 Rust 前端完全不解析该字段,显式关闭会被静默丢弃,形成与 Python 前端的能力不对齐。上游需求来自 RFC Issue #53916(Native Text Watermarking Support),该 RFC 把水印视为负责任的 LLM 部署能力,并援引 EU AI Act Article 50(2)、California SB 942 的溯源与透明度要求,强调水印是采样过程的修改、"cannot be left as an out-of-tree functionality"。若前端无法按请求透传开关,运维侧就无法在合规水印与评测/质量场景之间切换。PR body 同时给出实现约束:"Add an optional gRPC request field that preserves the distinction between omission and explicit false. All paths default to true, matching Python."

建议精读程度中等偏上:负责 Rust 前端或水印特性的工程师值得花 15 分钟通读,重点关注 rust/src/server/src/grpc/convert.rs 的 Option<bool> 三态处理与 rust/src/engine-core-client/src/protocol/sampling.rs 的 default_watermarking() 设计;纯 Python 侧开发者只需了解「Rust 前端已支持按请求 opt-out」这一结论。三个值得留存的设计点:一是用 proto3 optional 换「未设置 vs 显式 false」的三态区分,决定了 unwrap_or(true) 的写法;二是 serde 默认值函数与 Python omit_defaults=True 的耦合,这是本仓库反复踩坑的地方(#50894、#56791 都在这条路径上修过问题);三是默认值当前散落多处,是后续可选的清理点。若关心端到端正确性,建议与 #56309 的 e2e/质量测试合并阅读。

讨论亮点

讨论量很小且无争议:BugenZhao 用 @codex review 触发自动评审,Codex 对 commit 0dbb72b7c6 给出 "Didn't find any major issues. Swish!",随后 BugenZhao 直接 APPROVED 并回复 "Thanks @NickLucche !"。作者 NickLucche 主动压低审查预期:"@BugenZhao lemme know if you have concerns, it's just a FE parity PR"。另有 claude[bot] 提示该 PR 来自 fork、自动评审被禁用,需维护者手动评论触发。没有任何 review comment 线程,说明改动被整体视为机械的字段透传;同时也要注意 Codex 的结论只覆盖了前端链路,并未验证引擎侧水印是否真的被抑制。

实现拆解

按数据流自上而下分四层改造,外加测试与文档配套。

  1. HTTP schema 层落地字段:rust/src/server/src/routes/openai/chat_completions/types.rs 的 ChatCompletionRequest 与 rust/src/server/src/routes/openai/completions/types.rs 的 CompletionRequest 各新增 watermarking: bool,带 #[serde(default = "default_true")],并在各自的 Default impl 中显式写 watermarking: true。这样把「省略即开启」编码进反序列化层,老客户端不传字段时行为不变。
  2. 请求转换层与内部采样参数对齐:prepare_chat_request(.../chat_completions/convert.rs)与 prepare_completion_request(.../completions/convert.rs)在构造 vllm_text::SamplingParams 时补上 watermarking: request.watermarking;rust/src/text/src/request.rs 的 SamplingParams 新增同名字段并默认 true。因为 raw /inference/generate 路由复用同一个 SamplingParams,它自动获得该字段,新测试 prepare_generate_request_preserves_watermarking_defaults_and_opt_out 只需验证 {"sampling_params": {"watermarking": false}} 能原样穿过。
  3. 下沉到 engine-core 并适配 msgpack 缺键:rust/src/text/src/lower.rs 的 lower_sampling_params 在解构 SamplingParams 时多绑定一个 watermarking,再原样写入 EngineCoreSamplingParams;rust/src/engine-core-client/src/protocol/sampling.rs 新增 default_watermarking() 与 #[serde(default = "default_watermarking")],专门应对 Python SamplingParams 的 omit_defaults=True——缺键必须落到 true,否则会被 bool 的默认 false 静默关掉水印。for_test() 与 7 处 expect! 快照同步更新,把该字段纳入快照比对。
  4. gRPC 用 proto3 optional 保留三态:rust/proto/inference.proto 为 GenerateRequest 增加 optional bool watermarking,rust/src/server/src/grpc/convert.rs 的 to_text_request 用 req.watermarking.unwrap_or(true) 收敛;这是本 PR 唯一真正的设计取舍——若用普通 proto3 标量,无法区分「未设置」与「显式 false」,opt-out 语义会丢失。

配套测试与文档:rust/src/server/src/routes/tests.rs 新增 openai_watermarking_defaults_and_opt_out_reach_engine_core(2 端点 × 2 种 stream × 3 种取值 = 12 组合);rust/src/server/src/routes/inference/generate/convert.rs 新增 raw 路由测试;rust/src/server/src/grpc/convert.rs 新增 watermarking_defaults_and_opt_out_survive_protobuf_conversion(encode/decode 往返 + 流式/非流式);rust/src/engine-core-client/src/tests/client.rs 与 tests/python_compat.py 各补字段,纳入 Python 兼容基准;docs/features/watermarking.md 增加 +6/-1 说明。

文件 模块 状态 重要度
rust/src/engine-core-client/src/protocol/sampling.rs 采样参数 modified 6.46
rust/src/server/src/grpc/convert.rs 协议转换 modified 6.56
rust/src/server/src/routes/tests.rs 路由测试 modified 6.59
rust/src/text/src/lower.rs 参数下沉 modified 5.27
rust/src/server/src/routes/openai/chat_completions/types.rs 请求类型 modified 4.93
rust/proto/inference.proto 协议定义 modified 3.24
rust/src/text/src/request.rs 采样入参 modified 4.59
rust/src/server/src/routes/inference/generate/convert.rs 推理路由 modified 6.15
rust/src/server/src/routes/openai/chat_completions/convert.rs 请求转换 modified 4.27
rust/src/engine-core-client/src/tests/python_compat.py 兼容测试 modified 3.63
docs/features/watermarking.md 功能文档 modified 2.07

关键符号

lower_sampling_params to_text_request prepare_chat_request prepare_completion_request default_watermarking openai_watermarking_defaults_and_opt_out_reach_engine_core prepare_generate_request_preserves_watermarking_defaults_and_opt_out watermarking_defaults_and_opt_out_survive_protobuf_conversion

关键源码片段

rust/src/engine-core-client/src/protocol/sampling.rs core-logic

engine-core 协议 DTO 新增 `watermarking` 字段与 `default_watermarking()`,是适配 Python `omit_defaults=True` 缺键语义、避免默认落到 `false` 的关键改动。

// rust/src/engine-core-client/src/protocol/sampling.rs
​
// Python 端 SamplingParams 使用 omit_defaults=True,msgpack 会丢弃等于默认值的键;
// 因此 Rust 侧为 watermarking 单独声明默认值函数,保证「缺键」解码为 true,
// 否则 bool 的默认值 false 会静默关闭引擎已配置的水印。
fn default_watermarking() -> bool {
    true
}
​
#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, DefaultFromSerde)]
#[serde(default)]
pub struct EngineCoreSamplingParams {
    #[serde(default = "default_temperature")]
    pub temperature: f32,
    /// Whether to apply the engine's configured watermark to this request.
    /// 省略该键 -> true,与 Python 前端保持一致;
    /// 只有显式传 false 才是 opt-out,请求会退出引擎配置的水印。
    #[serde(default = "default_watermarking")]
    pub watermarking: bool,
    #[serde(default = "default_top_p")]
    pub top_p: f32,
    // ... 其余采样字段保持不变
}
​
impl EngineCoreSamplingParams {
    /// 仅供测试使用的默认构造:新增字段必须同步补齐,
    /// 否则 omit-defaults 的 msgpack 回归测试会立刻失败。
    pub fn for_test() -> Self {
        Self {
            temperature: 1.0,
            watermarking: true,
            top_p: 1.0,
            // ... 其余字段
            routed_experts_prompt_start: 0,
        }
    }
}
rust/src/server/src/grpc/convert.rs core-logic

gRPC 入口用 proto3 `optional` 保留三态并以 `unwrap_or(true)` 收敛,同时新增 encode/decode 往返测试,是本 PR 唯一有信息量的设计取舍所在。

// rust/src/server/src/grpc/convert.rs —— to_text_request 中的水印透传
let sampling = req.sampling.as_ref();
let decoding = req.decoding.as_ref();
let stopping = req.stopping.as_ref();
let response = req.response.as_ref();
let kv = req.kv.as_ref();
​
let mut sampling_params =
    build_sampling_params(req.temperature, sampling, decoding, stopping, response)?;
// proto3 的 optional bool watermarking 保留了「字段未设置」与「显式 false」的区别:
// None(未设置)回退到 true,与 Python 前端默认值一致;
// Some(false) 才是真正的 opt-out,会随请求下沉到 engine-core 关闭水印。
sampling_params.watermarking = req.watermarking.unwrap_or(true);
​
// ... 既有 KVCacheParameters -> SamplingParams 透传逻辑保持不变

同一文件新增的测试把「编码 -> 解码 -> 转换」整条链路固定下来:

#[test]
fn watermarking_defaults_and_opt_out_survive_protobuf_conversion() {
    for watermarking in [None, Some(true), Some(false)] {
        let request = pb::GenerateRequest { watermarking, ..base_request() };
        let encoded = request.encode_to_vec();
        for stream in [false, true] {
            let decoded = pb::GenerateRequest::decode(encoded.as_slice()).unwrap();
            let text = to_text_request(decoded, stream, &["test-model".to_string()])
                .expect("convert request");
            // 与 HTTP 路径同一断言:省略 -> true,显式 false -> false。
            assert_eq!(text.sampling_params.watermarking, watermarking.unwrap_or(true));
        }
    }
}
rust/src/server/src/routes/tests.rs test-coverage

新增 `openai_watermarking_defaults_and_opt_out_reach_engine_core`,用 12 个组合锁定两条 OpenAI 路由的默认值与 opt-out 语义。

// rust/src/server/src/routes/tests.rs
// 覆盖 2 个端点 × stream 开关 × 3 种取值(省略 / true / false),
// 断言最终送到 mock engine-core 的 sampling_params.watermarking == unwrap_or(true),
// 即「省略 -> 默认开启、显式 false -> opt-out」在 HTTP 全链路上成立。
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn openai_watermarking_defaults_and_opt_out_reach_engine_core() {
    for endpoint in ["/v1/chat/completions", "/v1/completions"] {
        for stream in [false, true] {
            for watermarking in [None, Some(true), Some(false)] {
                let (mut app, engine_task) = test_app_with_backend_and_engine_request_check(
                    Arc::new(FakeChatBackend::new()),
                    move |request| {
                        let params = request.sampling_params.as_ref().expect("sampling params");
                        assert_eq!(params.watermarking, watermarking.unwrap_or(true));
                    },
                )
                .await;
​
                let mut body = json!({"stream": stream, "max_tokens": 2});
                if endpoint == "/v1/chat/completions" {
                    body["messages"] = json!([{"role": "user", "content": "hi"}]);
                } else {
                    body["prompt"] = json!("hi");
                }
                if let Some(watermarking) = watermarking {
                    body["watermarking"] = json!(watermarking);
                }
                let response = app
                    .call(
                        Request::builder()
                            .method("POST")
                            .uri(endpoint)
                            .header("content-type", "application/json")
                            .body(Body::from(body.to_string()))
                            .expect("build request"),
                    )
                    .await
                    .expect("call app");
                assert_eq!(response.status(), StatusCode::OK);
                to_bytes(response.into_body(), usize::MAX).await.expect("drain response");
                engine_task.await.expect("mock engine task");
            }
        }
    }
}

评论区精华

维护者批准:确认这是纯前端对齐改动 设计

作者 NickLucche 主动说明 "@BugenZhao lemme know if you have concerns, it's just a FE parity PR";BugenZhao 先触发 `@codex review`,随后直接 APPROVED 并回复 "Thanks @NickLucche !",未对分层透传方案提出异议。

结论:方案被接受,无需返工;「HTTP types -> vllm_text::SamplingParams -> lower -> EngineCoreSamplingParams -> proto」的透传层次被默认为合理。 · 已解决

默认值与 opt-out 三态语义如何保留 设计

PR body 明确要求 "Add an optional gRPC request field that preserves the distinction between omission and explicit `false`",落地为 `inference.proto` 的 `optional bool watermarking` 加 `to_text_request` 里的 `unwrap_or(true)`,并由 `watermarking_defaults_and_opt_out_survive_protobuf_conversion` 做编码 / 解码往返固化;HTTP 侧则用 `#[serde(default = "default_true")]` 表达同一语义。

结论:三态在 gRPC 边界保留、在转换层收敛为布尔默认,方案被采纳且被测试覆盖。 · 已采纳

Codex 自动评审未发现重大问题 other

BugenZhao 评论 `@codex review`;chatgpt-codex-connector[bot] 对 commit `0dbb72b7c6` 完成评审并给出 "Codex Review: Didn't find any major issues. Swish!"。

结论:自动评审通过,成为本次合并的主要正确性依据;但其结论仅覆盖前端链路,未验证引擎侧是否真的停止加水印。 · 已通过

fork PR 的 Claude 自动评审被禁用 other

claude[bot] 提示 "This pull request is from a fork — automated review is disabled. A repository maintainer can comment `@claude review` to run a one-time review."。

结论:该 PR 实际只依赖 Codex 与维护者人工批准,没有第二套自动评审兜底。 · 无需处理

风险与影响

1)默认值语义分散:true 分别硬编码在 ChatCompletionRequest / CompletionRequest 的 serde 默认与 Default impl、vllm_text::SamplingParams::default()、default_watermarking() 以及 to_text_request 的 unwrap_or(true) 中,共 5 处以上;一旦 Python 侧默认语义调整,HTTP 与 gRPC 可能分叉。2)新增 wire 字段的版本混跑:EngineCoreSamplingParams 现在总会序列化 watermarking 键、proto 也新增了字段,跨版本混部(新 Rust 前端 + 旧 engine-core)时未知 msgpack 键是否被接受、绑定是否需同步刷新,本 PR 未做版本协商或降级路径,需在实际部署矩阵中验证(本报告无法从 diff 确认该行为)。3)测试为 mock 后端:三处新测试只断言字段到达 mock engine-core 的 sampling_params.watermarking,无法验证真实采样阶段水印是否被抑制;若 engine-core 侧字段名或语义偏移,Rust 侧测试仍会全绿,真实行为验证依赖 Python 侧 e2e(见 #56309)。4)回退风险低:改动前字段被丢弃、engine-core 走默认(等价 true),改动后默认同样为 true,存量调用不受影响,Some(false) 是纯新增能力。5)proto 工程配套:3 行 proto 变更需要重新生成绑定并保证 CI 覆盖,字段号在未来演进中需保持稳定。

对调用方:使用 Rust 前端(HTTP 与 gRPC)的用户第一次可以按请求关闭水印,与 Python 前端能力对齐;默认行为不变,现有代码无需修改,gRPC 新增字段向后兼容。对系统:Rust 前端与 engine-core 的采样参数契约新增一个布尔字段,属于接口扩展;lower_sampling_params 的 7 处快照更新意味着该字段进入下沉层回归保护网,未来改动会被显式捕捉。对团队与演进方向:这是一套可复用的「五段式透传」模板(HTTP types → vllm_text::SamplingParams → lower_sampling_params → EngineCoreSamplingParams → proto),后续任何新采样开关都可照此复制;同时也说明 Rust 前端正在被系统性追平到 Python 前端的功能面。

默认值语义分散多处 新增 wire 协议字段 mock 测试未验真实水印 跨版本混跑未验证

关联 Issue

#53916 [RFC]: Native Text Watermarking Support

完整报告

参与讨论