Prhub

#49496 [Rust Frontend] Fix finish reason for named tool choices

原始 PR 作者 reidliu41 合并时间 2026-07-28 18:53 文件变更 3 提交数 2 评论 2 代码增减 +88 / -17

执行摘要

修复命名工具选择的 finish_reason 为 stop

Rust 前端在 chat completions 响应中,当存在工具调用时总是返回 finish_reason 为 "tool_calls",但对于强制命名函数选择(如 {"type": "function", "function": {"name": "get_weather"}}),OpenAI 规范预期返回 "stop"。Python 前端已正确处理该逻辑,Rust 前端的行为不一致会导致依赖 finish_reason 的客户端出现差异。

  1. 该 PR 实现简洁,设计清晰,推荐阅读以理解如何在 Rust 前端中传递请求级标志到响应构建层。
  2. 值得关注的设计决策:使用 Boolean 标志而非枚举,因为当前只需区分 named 与其余模式;通过 ResponseOptions 传递,保持了与已有选项的一致风格。
  3. 测试覆盖了 streaming 和 non-streaming 两种模式,且重构了公共规格,是好的测试实践。
  4. 建议未来考虑为 required 模式补充测试,确保回归覆盖。
讨论亮点

代码审查由 BugenZhao 执行一次即批准("LGTM"),没有提出额外修改意见。Claude Review 因 PR 来自 fork 自动跳过。无实质性讨论或争议。

实现拆解

  1. 新增标志字段:在 convert.rsResponseOptions 结构体中添加 is_named_tool_choice: bool 字段,并添加文档注释说明。
  2. 检测命名工具选择:在 prepare_chat_request 函数中通过 matches!(&request.tool_choice, Some(ToolChoice::Function { .. })) 判断是否为强制命名函数调用,并将结果存入 options.is_named_tool_choice
  3. 传递标志到响应构建:在 chat_completions.rscollect_chat_completionchat_completion_chunk_stream 函数中,从 ResponseOptions 解构出 is_named_tool_choice,并传递到最终决定 finish_reason 的逻辑中。
  4. 调整 finish_reason 判断逻辑:修改 chat_finish_reason_to_openai 函数的接口,将原先的 saw_tool_calls: bool 参数改为 use_tool_calls_finish_reason: bool,在调用侧计算 saw_tool_calls && !is_named_tool_choice,使得命名工具选择场景下即使 saw_tool_calls 为 true 也不返回 "tool_calls"。
  5. 新增与重构测试:在 tests.rs 中抽取公共规格构造函数 weather_tool_call_output_specs,新增 named_tool_choice_uses_stop_finish_reason 测试用例,同时覆盖 streaming 和 non-streaming 两种模式,断言 finish_reason 为 "stop" 且不包含 "tool_calls"。现有测试 tool_calls_are_mapped_to_tool_call_sse_chunks 重构为调用 weather_tool_call_output_specs
文件 模块 状态 重要度
rust/src/server/src/routes/tests.rs 测试 modified 7.24
rust/src/server/src/routes/openai/chat_completions.rs 路由层 modified 6.06
rust/src/server/src/routes/openai/chat_completions/convert.rs 请求转换 modified 5.02

关键符号

weather_tool_call_output_specs named_tool_choice_uses_stop_finish_reason collect_chat_completion chat_completion_chunk_stream final_chunk chat_finish_reason_to_openai prepare_chat_request

关键源码片段

rust/src/server/src/routes/openai/chat_completions.rs core-logic

核心逻辑改动:在 collect_chat_completion 和 chat_completion_chunk_stream 中引入 is_named_tool_choice 标志,调整 chat_finish_reason_to_openai 的参数语义,实现 named 模式下 finish_reason 为 stop。

// 在 collect_chat_completion 函数中,从 ResponseOptions 解构 is_named_tool_choice
async fn collect_chat_completion(
    stream: ChatEventStream,
    request_id: String,
    response_model: String,
    created: u64,
    ApiServerOptions { .. }: ApiServerOptions,
    ResponseOptions {
        // ... 其他字段
        is_named_tool_choice, // 新增:是否为强制命名函数选择
    }: ResponseOptions,
) -> Result<ChatCompletionResponse, ApiError> {
    // ... 收集中间输出 ...
    let saw_tool_calls = message.tool_calls().next().is_some();
    // 当 is_named_tool_choice 为 true 时,即使 saw_tool_calls 为 true,
    // 也不使用 "tool_calls" 作为 finish_reason,而是保留引擎返回的 stop
    let finish_reason =
        chat_finish_reason_to_openai(&finish_reason, saw_tool_calls && !is_named_tool_choice)?
            .to_string();
    // ... 构建响应 ...
}// chat_finish_reason_to_openai 函数的参数语义从 saw_tool_calls 改为 use_tool_calls_finish_reason
fn chat_finish_reason_to_openai(
    finish_reason: &FinishReason,
    use_tool_calls_finish_reason: bool, // 当该参数为 true 且引擎 FinishReason 为 Stop 时返回 "tool_calls"
) -> Result<&'static str, ApiError> {
    match finish_reason {
        FinishReason::Stop(_) if use_tool_calls_finish_reason => Ok("tool_calls"),
        FinishReason::Stop(_) => Ok("stop"),
        FinishReason::Length => Ok("length"),
        FinishReason::Abort => Ok("abort"),
        // ...
    }
}
rust/src/server/src/routes/openai/chat_completions/convert.rs data-contract

新增 is_named_tool_choice 字段定义与初始化逻辑,是数据契约变更的入口。

#[derive(Debug, Clone, Default, PartialEq)]
pub(super) struct ResponseOptions {
    // ... 原有字段 ...
    /// Whether the request forces one named function tool.
    /// 当 tool_choice 为 {"type": "function", "function": {"name": ...}} 时为 true。
    pub is_named_tool_choice: bool,
}// 在 prepare_chat_request 函数中初始化该字段
let is_named_tool_choice = matches!(&request.tool_choice, Some(ToolChoice::Function { .. }));
// 然后构造 PreparedRequest 时将 is_named_tool_choice 传入 options
let prepared = PreparedRequest {
    options: ResponseOptions {
        // ... 其他字段 ...
        is_named_tool_choice,
    },
    chat_request,
};

评论区精华

总体审查 other

审查人 BugenZhao 直接批准(评论 "LGTM"),未提出修改意见。

结论:审查通过,无修改请求。 · 已解决

风险与影响

  1. 字段遗漏风险:新增的 is_named_tool_choice 字段仅在 prepare_chat_request 中初始化,如果还有其他构造 ResponseOptions 的路径(例如单元测试中直接构造)未设置该字段,将使用默认值 false(通过 Default 派生),导致行为退化到与修复前一致。但代码中只有一处构造路径,且 Default 值为 false 是安全的。
  2. 参数语义变更chat_finish_reason_to_openai 的参数从 saw_tool_calls 改为 use_tool_calls_finish_reason,所有调用点(collect_chat_completionfinal_chunk)均已更新,但需确保未来新增调用点时不会误用旧语义。
  3. 回归风险:现有测试 tool_calls_are_mapped_to_tool_call_sse_chunks 覆盖了 auto 模式,未包含 required 或 any 模式,但 required 模式应继续返回 "tool_calls",当前逻辑(saw_tool_calls && !is_named_tool_choice)在 required 模式下 is_named_tool_choice 为 false,行为正确。
  4. 兼容性:变更不涉及 API 数据结构,仅修改 finish_reason 字符串,兼容性风险低。
  1. 用户影响:使用强制命名函数工具的客户端将看到 finish_reason 从 "tool_calls" 变为 "stop",与 Python 前端一致,有助于依赖此字段的判断(如是否继续调用工具)。客户端无需修改即可获得正确行为。
  2. 系统影响:代码改动集中在 Rust 前端 chat completions 路由,无性能开销,无数据库或外部依赖变更。
  3. 团队影响:通过该 PR 对齐了 Rust 与 Python 前端的行为,降低了维护成本,为后续工具调用相关功能提供了更清晰的数据流基础。
新增标志可能遗漏初始化 参数语义变更需确保所有调用点已更新 auto/required 模式回归风险较低

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论