Rust 前端补齐按请求水印控制:PR#56338 分析
执行摘要
PR#56338 让 Rust 前端首次具备与 Python 前端一致的「按请求开关水印」能力:/v1/chat/completions、/v1/completions、原始 /inference/generate 路由与 gRPC GenerateRequest 都能把 watermarking 一路透传到 engine-core 的 MessagePack 采样参数,四条路径默认 true,显式 false 即退出引擎已配置的水印。改动是典型的「逐层加字段 + 分层测试」:14 个文件、+133/-1,默认路径与改动前行为等价,回退风险低;主要风险在于默认值语义散落在多处,以及新增 wire 字段在版本混跑时的兼容性。维护者 BugenZhao 触发 Codex 评审后直接批准("Thanks @NickLucche !"),全程无返工。
功能与动机
- 直接动机来自 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." 这句话直接决定了 proto 使用 optional 以及各层默认值的写法。
实现拆解
数据流自上而下分四层改造,外加测试与文档配套。主链路为 HTTP types → vllm_text::SamplingParams → lower_sampling_params → EngineCoreSamplingParams(msgpack),gRPC 则走 proto → to_text_request 的平行分支。
| 层 |
文件 |
关键符号 |
变更 |
| HTTP 入口 schema |
rust/src/server/src/routes/openai/chat_completions/types.rs、.../completions/types.rs |
ChatCompletionRequest、CompletionRequest |
新增 watermarking: bool + #[serde(default = "default_true")],Default impl 置 true |
| 请求转换 |
.../chat_completions/convert.rs、.../completions/convert.rs |
prepare_chat_request、prepare_completion_request |
把 request.watermarking 拷进内部 SamplingParams |
| 内部采样参数 |
rust/src/text/src/request.rs |
SamplingParams |
新增 watermarking: bool,Default 为 true;raw /inference/generate 因此自动获得该字段 |
| 下沉与协议编码 |
rust/src/text/src/lower.rs、rust/src/engine-core-client/src/protocol/sampling.rs |
lower_sampling_params、EngineCoreSamplingParams、default_watermarking |
各加一行解构/构造;新增 serde 默认值函数,for_test() 与 7 处快照同步 |
| gRPC 三态 |
rust/proto/inference.proto、rust/src/server/src/grpc/convert.rs |
GenerateRequest、to_text_request |
proto 增加 optional bool watermarking;转换层 unwrap_or(true) 收敛三态 |
| 测试与文档 |
routes/tests.rs、inference/generate/convert.rs、grpc/convert.rs、engine-core-client/src/tests/*、docs/features/watermarking.md |
三个新测试函数 |
覆盖 HTTP 全链路、raw 路由、protobuf 往返、Python 兼容 fixture,并补文档 |
- HTTP schema 层先落地字段:
ChatCompletionRequest 与 CompletionRequest 都新增 watermarking: bool,serde 默认与 Default impl 双写 true,行内注释统一为 "Whether to apply the engine's configured watermark to this request."。把「省略即开启」编码进反序列化层,保证老客户端不传该字段时行为不变。
- 转换层与内部 SamplingParams 对齐:两个
prepare_* 函数在构造 SamplingParams 时补上 watermarking: request.watermarking;rust/src/text/src/request.rs 新增同名字段并默认 true。因为 raw /inference/generate 路由复用同一个 vllm_text::SamplingParams,它天然获得该字段,测试 prepare_generate_request_preserves_watermarking_defaults_and_opt_out 只需验证 {"sampling_params": {"watermarking": false}} 原样穿过。
- 下沉到 engine-core 并适配 msgpack 缺键:
lower_sampling_params 解构时多绑定一个 watermarking,再原样写入 EngineCoreSamplingParams;协议 DTO 新增 default_watermarking() 与 #[serde(default = "default_watermarking")],专门应对 Python SamplingParams 的 omit_defaults=True——缺键必须落到 true,否则会被 bool 的默认 false 静默关掉水印。7 处 expect! 快照随之更新,把字段纳入快照比对。
- gRPC 用 proto3
optional 保留三态:inference.proto 增加 optional bool watermarking,to_text_request 用 req.watermarking.unwrap_or(true) 收敛;测试先做 encode_to_vec() / decode() 往返再断言,覆盖流式与非流式两种分支。这是本 PR 唯一真正的设计取舍:若用普通 proto3 标量,「未设置」与「显式 false」无法区分,opt-out 语义就会丢失。
- 测试与兼容 fixture 一起补齐:
routes/tests.rs 的新测试用三重循环覆盖 2 端点 × 2 种 stream × 3 种取值共 12 个组合;rust/src/engine-core-client/src/tests/client.rs 与 tests/python_compat.py 各加 2 行,把新字段纳入 Python 兼容基准;docs/features/watermarking.md 增加 +6/-1 说明。
rust/src/engine-core-client/src/protocol/sampling.rs
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
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
新增 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");
}
}
}
}
评论区精华
BugenZhao:先评论 @codex review,随后 APPROVED 并回复 "Thanks @NickLucche !"。
chatgpt-codex-connector[bot]:"Codex Review: Didn't find any major issues. Swish!"(Reviewed commit: 0dbb72b7c6)
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."
NickLucche(作者):"@BugenZhao lemme know if you have concerns, it's just a FE parity PR"。
解读:这是一次「低争议 + 自动化评审」的合并。没有任何 review comment 线程,说明改动被整体视为机械的字段透传;作者主动声明「纯前端对齐」,维护者用 Codex 做了一遍正确性扫描后批准。需要留意的是,fork PR 无法触发 Claude 自动评审,真正起作用的只有 Codex 与人工审查,而 Codex 的结论并未覆盖「引擎侧是否真的停止加水印」这一层。
风险与影响
- 默认值语义分散在多处:
true 分别硬编码在 ChatCompletionRequest / CompletionRequest 的 serde 默认与 Default impl、vllm_text::SamplingParams::default()、default_watermarking() 以及 to_text_request 的 unwrap_or(true),共五处以上。一旦 Python 侧默认语义调整,HTTP 与 gRPC 可能分叉。
- 新增 wire 字段的版本混跑兼容性:
EngineCoreSamplingParams 现在总会序列化 watermarking 键,gRPC 侧也新增了 proto 字段。跨版本混部(新 Rust 前端 + 旧 engine-core)时未知 msgpack 键是否被接受、绑定是否需同步刷新,本 PR 未做版本协商或降级路径;这属于需要在真实部署矩阵中验证的点,无法从本次 diff 确认。
- 测试为 mock 后端,不覆盖真实水印行为:三处新测试只断言「字段到达 engine-core」,
FakeChatBackend + mock engine task 无法验证采样阶段水印是否真的被抑制;若 engine-core 侧字段名或语义有偏差,Rust 侧测试仍会全绿,真实行为验证依赖 Python 侧 e2e(见 #56309)。
- 回退风险低:改动前 Rust 前端丢弃该字段、由 engine-core 使用默认(等价
true),改动后默认同样为 true,存量调用不受影响;Some(false) 是纯新增能力。
- proto 变更的工程配套:3 行 proto 变更需要重新生成绑定并由 CI 体现,字段号在后续演进中需保持稳定,review 时不宜忽略。
影响范围上,调用方(HTTP 与 gRPC 用户)首次获得按请求关闭水印的能力,默认行为不变、gRPC 新增字段向后兼容;系统层面是 Rust 前端与 engine-core 采样参数契约的接口扩展,lower_sampling_params 的快照更新把该字段纳入回归保护网;团队层面则沉淀出一套可复用的「五段式透传」模板,后续新采样开关可照此复制。
推荐与关联脉络
是否值得精读:中等偏上。负责 Rust 前端或水印特性的工程师值得通读(约 15 分钟);纯 Python 侧开发者只需了解「Rust 前端已支持按请求 opt-out」这一结论。
值得关注的设计决策:
- proto3
optional 换三态——用 optional bool 区分「未设置」与「显式 false」,决定了 unwrap_or(true) 的写法,是本次唯一有信息量的取舍。
- serde 默认值函数与 Python
omit_defaults=True 的耦合——default_watermarking() 存在的原因是 msgpack 会丢默认键,这类「跨语言默认值对齐」在本仓库反复出现(#50894、#56791 都在 omit_defaults 相关路径上修过问题)。
- 默认值当前散落多处,是后续值得收敛为单一常量的清理点。
关联脉络:本 PR 是 RFC Issue #53916 前端对齐工作的收尾环节,与同族的 #56309(e2e 检测 + GSM8K 质量回归)互补,合起来才构成「能力可用 + 行为可验证」的闭环;与 rust/src/text/src/lower.rs、EngineCoreSamplingParams 的历次改动共享「Rust 前端与 Python 语义对齐」主题;同一个 grpc/convert.rs 上的前作 #56406(selected-token logprob 模式保留)也属于「转换层不能丢语义」的同类修复,本 PR 延续了该文件对可选字段的谨慎风格。
参与讨论