Prhub

#38690 [Feat][Responses API] Support custom tools, encrypted reasoning replay, developer tier and model validation

原始 PR 作者 Dovis01 合并时间 2026-09-12 21:29 文件变更 8 提交数 11 评论 5 代码增减 +2045 / -666

执行摘要

Responses API 补齐 custom 工具、加密推理回放与模型校验

PR body 用一张症状—根因表说明了动机:Codex-class 客户端在 /v1/responses 上暴露了一连串翻译层缺口——声明 custom 工具得不到调用且 tool_choice="required" 直接 400;include=["reasoning.encrypted_content"] 只被校验却不填充,store=false 客户端无法回放推理轨迹;developer 消息折叠成 system 后层级丢失被后续 user 消息压过;未知 model 仍用已加载模型回答并返回 HTTP 200;具名 tool_choice 生成了合法工具 JSON 却被当普通文本返回;流式与全量输出在 required/native parser 上判定不一致;截断/中止流恒以 response.completed 结束,违反 response.incomplete / response.failed 契约;previous_response_id 续写丢失工具调用与推理,新 instructions 还会与上一轮拼接。作者明确说明这些都是 request/response translation concerns,不涉及调度、kernel 或采样。

值得精读,尤其是三类人:维护 OpenAI 兼容入口的工程师(应重点看 responses_adapters.py 的 shim 设计与 serving_responses.py 的 _validate_model / effective_tool_choice / _terminal_stream_event 三处入口校验)、做推理/回放功能的工程师(看 encode_reasoning_state 的「编码非加密」定位与 msg_store 的 _response_input_history 结构)、以及 gpt-oss / Harmony 相关开发者(看 harmony_utils.py 的 channel↔phase 双向映射)。建议重点关注的设计决策:① 用「单字符串属性的 function 工具」承载 custom 工具,并用增量反转义保持 delta 与终值一致;② 具名 tool_choice 与 required 共享一条强制解析路径,未声明的名字在生成前 400 而非生成后丢弃;③ 用「缓冲到 phase 可知」换 added/done/final 三处 phase 一致;④ 主动把 compact、真 grammar 强制、真加密列为 out of scope 而不是半实现。阅读时建议优先补看 PR body 的症状表与 issue 里 JustinTong0323 的验证记录,再对照 diff 验证每条症状的落点。

讨论亮点

review 侧信息很薄:review_comments_count = 0,唯一一条审核是 JustinTong0323 的 APPROVED(无正文),最终由 ShangmingCai 合并;讨论实际发生在 PR body 和 issue 评论里。

  • JustinTong0323 在 issue 评论贴出后 7 个 follow-up commit(0c08092..cc5b0e2)的验证记录:> Unit tests(pinned openai==2.6.1, CPU): 392 passed, 137 subtests passed;Codex conformance suite 27 cases: 24 PASS / 1 FAIL / 2 SKIP;FAIL 是 compaction_replay(POST /v1/responses/compact 不存在,故意不在本 PR 范围),2 个 SKIP 是模型每次只发一个调用、并行路径根本没被跑到(model behavior, not a server failure);真实 codex-cli 0.153.4 会话 31/31 请求 HTTP 200,成功从零写出带 32 个单测的终端贪吃蛇并修好一个计算器解析器。
  • PR body 主动划出 out of scope:POST /v1/responses/compact、custom 工具的真实 grammar 强制(需要 staged generation)、真正的加密推理状态与服务端密钥管理、Harmony 下超过显式 400 的强制 tool_choice 以及 Harmony json_schema。
  • commit abb8b3f 的 message 记录了一个明确的取舍:> Buffer text events when tools are enabled until the next tool call or terminal event resolves commentary versus final_answer. This delays text delivery for tool-capable requests but keeps added, done, and final output phases consistent.——即为了 phase 一致性主动牺牲工具请求的流式文本时延。
  • 过程中出现 4 次 /rerun-failed-ci(均来自 JustinTong0323),body 里贴出的 CI 状态为 Base :no_entry_sign:、Extra 与 AMD ROCm 均 :x:,说明验证靠反复重跑收敛而非一次绿。

实现拆解

  1. 新增适配层 python/sglang/srt/entrypoints/openai/responses_adapters.py(+215,全新文件):把两个 chat-completions 路径无法表达的概念收拢到一个纯函数模块。custom_tool_parameters() 把 custom 工具伪装成只含 input: string 的 function 工具,custom_tool_description() 把 format.type == "grammar" 的 syntax/definition 折进工具描述(只描述、不强制,因为 payload 位于模型自己的 tool-call 信封内,约束解码无法只作用于它);encode_custom_tool_input() / decode_custom_tool_input() 负责 payload 与 shim arguments 的双向转换,后者对裸文本与非 input 单键对象做了兜底;decode_custom_tool_input_prefix() 配合 _payload_start() / _unicode_escape() 在半截 arguments JSON 上做增量反转义(含 \uXXXX 代理对),保证流式 delta 与最终 item 的 input 严格一致。此外 label_developer_content() 给 developer 块加 Developer instructions: 前缀,encode_reasoning_state() / decode_reasoning_state() 用 sglang-reasoning-v1. 前缀 + zlib + urlsafe base64 承载推理轨迹。
  2. 扩展协议模型 python/sglang/srt/entrypoints/openai/protocol.py(+43/-8):ResponseTool 增加 format 字段描述 custom 工具输入格式;新增 ResponseInputMessageParam(继承 EasyInputMessageParam,total=False)与 ResponseOutputMessage(继承 openai 的 ResponseOutputMessage)各自带 phase: Optional[Literal["commentary", "final_answer"]],并接入 ResponseInputOutputItem 与 ResponsesResponse.output 两处 Union,绕开被 pin 的 SDK 模型;新增 is_include_encrypted_reasoning();effective_tool_choice() 从只认具名 function 扩展为 function / custom,其余对象形式一律退化成 auto;ChatCompletionMessageGenericParam 也加上 phase,使 phase 能在 chat 内部消息上流转。
  3. 服务端主流程 python/sglang/srt/entrypoints/openai/serving_responses.py(+701/-631):_known_model_names() 汇总 served_model_name 与 LoRA registry adapter 名,_validate_model() 对未知 model 返回 404 OpenAI-style error(None、served 名、adapter 名、base-model:adapter 均可通过);create_responses() 在生成前完成 model 校验、required 接受 function/custom、具名 choice 未声明则 400;强制解析路径在全量与流式之间共享 _tool_parser_owns_format() 判定(统一 supports_structural_tag 与 parses_required_natively);终止事件改由 _terminal_stream_event() / _error_from_finish_reason() 依据最终 status 产出 completed/incomplete/failed;存储历史改为 messages[2:](Harmony,丢弃按请求重建的前缀)或 _response_input_history(request) 加完整 output item,回放复用与客户端一致的 normalizer;_make_reasoning_item() 在 include 命中时填充 encrypted_content,_make_tool_call_item() / _flatten_tool_output() 支撑 custom_tool_call 与非流式工具结果归并。
  4. Harmony 桥接 python/sglang/srt/entrypoints/harmony_utils.py(+102/-20):文本分片过滤加入 output_text,修复 assistant 文本在回放时被丢弃;reasoning 分支由「断言 content 长度为 1」改为优先 summary/content、为空再回落 decode_reasoning_state(encrypted_content);function_call_output/custom_tool_call_output 统一处理并支持 list 形态 output 拼接,custom_tool_call 回放时用 encode_custom_tool_input() 重新包信封;parse_output_message() / parse_remaining_state() 增加 python recipient → ResponseCodeInterpreterToolCall、commentary 无 recipient → message(phase=commentary)、final → phase=final_answer,并把 split(".")[-1] 换成 removeprefix("functions.") 避免函数名含点被截断。
  5. 测试与 CI 配套:新增 test/registered/unit/entrypoints/openai/test_responses_custom_tools.py(+442,含 encode/decode、增量前缀解码、glm47 真实线格式、回放与流式 delta 重建);test_serving_responses_stream.py(+322/-2)新增终态事件、native parser 一致性、Harmony 流式 item 生命周期与截断 item 收敛用例;test_serving_responses.py(+215/-5)覆盖存储回放等价于客户端自带上文、instructions 不跨请求继承、phase 保持与 Harmony 强制 choice 的 400 文案;test/.../openai/utils.py 给 MockTokenizerManager 补 SERVED_MODEL_NAME = "x"、served_model_name、lora_registry = None,否则 model 校验上线后所有单测都会 404。测试统一 register_cpu_ci(est_time=10, suite="base-a-test-cpu"),进入 CPU CI 基线套件。
文件 模块 状态 重要度
python/sglang/srt/entrypoints/openai/serving_responses.py 响应服务层 modified 8.84
python/sglang/srt/entrypoints/openai/responses_adapters.py 响应适配层 added 8.94
python/sglang/srt/entrypoints/harmony_utils.py 消息桥接层 modified 7.35
python/sglang/srt/entrypoints/openai/protocol.py 协议模型 modified 7.59
test/registered/unit/entrypoints/openai/test_responses_custom_tools.py 单元测试 added 7.48
test/registered/unit/entrypoints/openai/test_serving_responses_stream.py 单元测试 modified 7.24
test/registered/unit/entrypoints/openai/test_serving_responses.py 单元测试 modified 6.99
test/registered/unit/entrypoints/openai/utils.py 单元测试 modified 3.66

关键符号

_known_model_names _validate_model create_responses _response_tools_to_chat_tools _normalize_response_message_for_chat _response_input_history _make_reasoning_item _make_tool_call_item _error_from_finish_reason _terminal_stream_event _tool_parser_owns_format _flatten_tool_output responses_stream_generator responses_full_generator effective_tool_choice is_include_encrypted_reasoning custom_tool_parameters custom_tool_description encode_custom_tool_input decode_custom_tool_input decode_custom_tool_input_prefix label_developer_content encode_reasoning_state decode_reasoning_state parse_response_input parse_output_message parse_remaining_state

关键源码片段

python/sglang/srt/entrypoints/openai/serving_responses.py core-logic

Responses API 的主服务层,承载 model 校验、tool_choice 前置拒绝、终态 SSE 事件、状态化回放与 phase 保持,是本 PR 逻辑最密集、回归面最大的文件(+701/-631)。

# python/sglang/srt/entrypoints/openai/serving_responses.py(整理后的关键实现)
​
def _known_model_names(self) -> set[str]:
    """调用方可以寻址的 model id,口径对齐 ``/v1/models``。"""
    names = {self.tokenizer_manager.served_model_name}
    # LoRA adapter 也算合法 model id;registry 不存在时静默跳过。
    registry = getattr(self.tokenizer_manager, "lora_registry", None)
    if registry is not None:
        names.update(registry.get_all_adapters().keys())
    return names
​
​
def _validate_model(self, model: Optional[str]) -> Optional[ORJSONResponse]:
    """拒绝未知 ``model``,与 Responses API 的行为对齐。
​
    ``None`` 表示「用当前已加载的模型」;LoRA adapter 既可以按名字寻址,
    也可以用 ``base-model:adapter`` 形式。
    """
    if model is None:
        return None
    base_model, _ = self._parse_model_parameter(model)
    known = self._known_model_names()
    if model in known or base_model in known:
        return None
    return self.create_error_response(
        message=f"The model '{model}' does not exist",
        err_type="invalid_request_error",
        status_code=HTTPStatus.NOT_FOUND,
        param="model",
    )
​
​
async def create_responses(self, request, raw_request=None):
    if not self.tokenizer_manager:
        return self.create_error_response("Model not loaded")
​
    # 未知 model 直接 404,而不是「静默用已加载模型回答 + HTTP 200」。
    model_error = self._validate_model(request.model)
    if model_error is not None:
        return model_error
​
    # tool_choice="required" 现在接受 function 与 custom 两类可强制的工具。
    if request.tool_choice == "required" and not any(
        tool.type in ("function", "custom") for tool in (request.tools or [])
    ):
        return self.create_error_response(
            'tool_choice="required" requires at least one tool with '
            'type="function" or type="custom"; other built-in tool types '
            "cannot be forced."
        )
​
    # 具名 tool_choice 收敛成 {"type": "function", "name": ...};
    # 未声明的名字在生成前就 400,避免「生成了合法工具 JSON 却当普通文本返回」。
    tool_choice = request.effective_tool_choice()
    if isinstance(tool_choice, dict) and not any(
        tool.type in ("function", "custom") and tool.name == tool_choice["name"]
        for tool in request.tools or []
    ):
        return self.create_error_response(
            f"Tool {tool_choice['name']!r} is not declared in tools",
            param="tool_choice",
        )
​
    # ... 省略 logprobs / json_schema / web_search 的既有校验 ...
​
    # 存储历史:保留原始输入 + 完整 output item,
    # 回放时走与客户端自带上文完全相同的 normalizer。
    if request.store:
        self.msg_store[request.request_id] = (
            messages[2:] # Harmony:丢弃按请求重建的 system/developer 前缀
            if self.use_harmony
            else self._response_input_history(request)
        )
python/sglang/srt/entrypoints/harmony_utils.py core-logic

Harmony 回放与输出的桥接层:修复 assistant 文本被过滤、encoded-only 推理无法回放、custom_tool_call 缺回放路径,并把 message channel 双向映射到 phase。

# python/sglang/srt/entrypoints/harmony_utils.py(整理后的关键分支)
​
def parse_response_input(response_msg, prev_responses):
    # ... 前面的 message / developer 分支省略 ...
    if response_msg["type"] in ("function_call_output", "custom_tool_call_output"):
        call_id = response_msg["call_id"]
        call_response = None
        for previous in reversed(prev_responses):
            # 历史里既有 TypedDict 也有 pydantic 对象,先统一成 dict 再比对。
            if not isinstance(previous, dict):
                previous = previous.model_dump()
            if (
                previous.get("type") in ("function_call", "custom_tool_call")
                and previous.get("call_id") == call_id
            ):
                call_response = previous
                break
        if call_response is None:
            raise ValueError(f"No call message found for {call_id}")
        output = response_msg.get("output", "")
        if isinstance(output, list):
            # custom_tool_call_output 的 output 允许是 output_text 分片列表。
            output = "".join(
                part.get("text", "") for part in output if isinstance(part, dict)
            )
        msg = Message.from_author_and_content(
            Author.new(Role.TOOL, f"functions.{call_response['name']}"), output
        )
    elif response_msg["type"] == "reasoning":
        # 优先 summary / content;两者皆空时回落到 encrypted_content 的编码态。
        text = ""
        for field in ("summary", "content"):
            text = "\n".join(
                part.get("text", "")
                for part in response_msg.get(field) or []
                if isinstance(part, dict)
            )
            if text:
                break
        if not text:
            text = decode_reasoning_state(response_msg.get("encrypted_content")) or ""
        msg = Message.from_role_and_content(Role.ASSISTANT, text).with_channel(
            "analysis"
        )
    elif response_msg["type"] in ("function_call", "custom_tool_call"):
        # custom_tool_call 的 input 要重新包成 shim 的 JSON 参数再喂回模型。
        arguments = (
            encode_custom_tool_input(response_msg.get("input") or "")
            if response_msg["type"] == "custom_tool_call"
            else response_msg.get("arguments") or "{}"
        )
        if isinstance(arguments, dict):
            arguments = orjson.dumps(arguments).decode()
        msg = Message.from_role_and_content(Role.ASSISTANT, arguments)
        msg = (
            msg.with_channel("commentary")
            .with_recipient(f"functions.{response_msg['name']}")
            .with_content_type("json")
        )
​
​
def parse_output_message(message):
    # ... web_search / python 等 recipient 分支省略 ...
    if message.recipient is not None and message.recipient.startswith("functions."):
        # 用 removeprefix 取代 split(".")[-1],避免函数名里带点被截断。
        function_name = message.recipient.removeprefix("functions.")
        # ... 逐个 content 生成 ResponseFunctionToolCall ...
    elif message.channel == "final" or (
        message.channel == "commentary" and message.recipient is None
    ):
        # final channel 是 final_answer;commentary 但无 recipient 的正文
        # 按 Codex 语义仍是 commentary 阶段。
        text_item = ResponseOutputMessage(
            phase="final_answer" if message.channel == "final" else "commentary",
            id=f"msg_{random_uuid()}",
            content=contents,
            role=message.author.role,
            status="completed",
            type="message",
        )

评论区精华

custom 工具的 grammar 只描述不强制 设计

`custom_tool_description()` 把 `format.type == "grammar"` 的 syntax/definition 折进工具描述文本。PR body 解释:payload 位于模型自己的 tool-call 信封内,受限解码无法只作用在 payload 上,所以「A `grammar` format is described to the model rather than enforced」。

结论:接受为当前限制并列入 out of scope(需要 staged generation 才能真正强制),仅通过描述引导模型。 · 已决策(延后实现)

encrypted_content 是编码而非加密 安全

`encode_reasoning_state()` 用 zlib + urlsafe base64 打包推理轨迹。PR body 明确写道:> SGLang holds no key, so it is encoded, not cryptographically protected — opaque to clients, not a confidentiality boundary. 真正的加密推理状态与服务端密钥管理被列入 out of scope。

结论:接受编码方案,但语义上必须被当作「对客户端不透明」而非安全边界;`decode_reasoning_state()` 对客户端传入 blob 无解压体积上限。 · 已决策(接受,需关注解压风险)

phase 未知时缓冲 message SSE 事件 性能

commit `abb8b3f` 的 message 记录:工具请求的文本事件被缓冲到「下一个 tool call 或终态事件」确定 commentary 还是 final_answer 之后才发出,用文本时延换 added / done / final output 三处 phase 一致;text-only 流不受影响。

结论:采用缓冲方案,代价是工具请求首字延迟上升;测试以 `test_final_output_preserves_text_tool_text_order` 的 phase 断言锁定行为。 · 已采用(存在时延代价)

Harmony 强制 tool_choice 改为显式 400 正确性

原先 `_make_request_with_harmony` 对 `tool_choice != "auto"` 直接 `raise NotImplementedError`;现在按 required / 具名 / none 分别生成解释文案,指出 recipient-header 解码约束尚未实现。测试 `test_harmony_forced_choices_explain_missing_routing_constraints` 断言 400 且不触发 `generate_request`。

结论:从「未实现异常」升级为可读的 400,行为更明确但仍不支持真正强制。 · 已解决(能力仍受限)

全量与流式共享 native 解析判定 正确性

PR body 指出此前流式只检查 `supports_structural_tag`,不检查 `parses_required_natively`,导致 required/native parser 在两条路径上输出不一致;本 PR 抽出 `_tool_parser_owns_format` 统一判定,并由 `test_required_native_parser_matches_full_response` 用 hunyuan 原生格式逐字节切分验证。

结论:已统一并补回归测试,流式与全量在工具名与 arguments 上逐字一致。 · 已解决

存储历史改为原始输入 + 完整 output item 设计

`msg_store` 由只存输入消息改为存 `_response_input_history(request)` 加完整 output items(Harmony 走 `messages[2:]`),回放复用与客户端自带上文相同的 normalizer;request 级 `instructions` 只作用于当前请求,不再跨 `previous_response_id` 拼接。测试用「stored replay 与 explicit replay 结果相等」的方式锁定语义。

结论:已解决,并作为契约写进测试;`msg_store` 值类型相应放宽为 `list[dict]`。 · 已解决

验证证据:单测 + Codex 一致性 + 真机 CLI 测试

审核人 JustinTong0323 在 issue 评论给出后 7 个 commit 的完整验证记录:CPU 单测 392 passed / 137 subtests passed;Codex conformance 27 例 24 PASS / 1 FAIL / 2 SKIP(FAIL 是 out of scope 的 `/responses/compact`,SKIP 是模型未触发并行调用);真实 codex-cli 0.153.4 会话 31/31 HTTP 200;GSM8K 全量 1319 题 accuracy 97.42%、stop_rate 100%。

结论:以「单测 + 一致性套件 + 真机会话 + 精度回归」四层证据支撑合并;唯一 FAIL 与两个 SKIP 均已在记录中说明原因。 · 已完成验证

风险与影响

  1. 向后兼容破坏(中高):_validate_model() 让未知 model 从「用已加载模型回答 + HTTP 200」变成 404。虽然 None、served 名、adapter 名、base-model:adapter 都能通过,但老客户端若传路由前缀或自定义别名会被直接拒绝;_known_model_names() 还依赖 tokenizer_manager.served_model_name 与可选 lora_registry,这两个属性缺失会退化为只认 served 名。
  2. custom 工具异常路径不一致(中):decode_custom_tool_input_prefix() 强依赖参数 JSON 里出现 "input": ",测试里 decode_custom_tool_input_prefix('{"city": "Beijing"}') 断言为空串。若模型换用别的键名,流式场景会一个 response.custom_tool_call_input.delta 都不发,而最终 item 仍能通过 decode_custom_tool_input() 的单键兜底拿到 payload——同一响应内流式增量与终值语义不一致,客户端重建逻辑容易被误导。
  3. encrypted_content 并非加密(中):encode_reasoning_state() 是 zlib + base64 的纯编码,decode_reasoning_state() 对客户端传入的 blob 直接 zlib.decompress()。一方面名字暗示保密性而实际无密钥,容易被下游误当安全边界;另一方面解压客户端可控数据存在内存放大风险,代码里没有解压体积上限或输入长度检查。
  4. 流式事件契约回归面大(中):Harmony 流式改为由单一 emitter 统一持有 item id / call id / output_index / sequence_number,并要求终态响应复用已发出 item。test_serving_responses_stream.py 覆盖了 split/coalesced 消息与截断 item,但任何 emitter 与 parser snapshot 失配都会让依赖 output_index 的客户端错位,属于高敏感区。
  5. 流式时延回退(低-中):按 commit abb8b3f 的描述,启用 tools 的请求会把 message SSE 事件缓冲到下一个 tool call 或终态事件才能确定 commentary / final_answer,文本首字延迟上升;text-only 流不受影响。
  6. 评审深度不足(流程风险):+2045/-666 的大改动、msg_store 值类型由 list[ChatCompletionMessageParam] 放宽为 list[dict]、ResponseInputOutputItem 联合类型重排等结构性变化,只有一条无正文 APPROVED,0 条内联评论;好在配套单测(392 passed / 137 subtests)与真机验证记录补齐了部分信心。

用户侧:使用 Codex CLI / Codex-class 客户端且 wire_api="responses"、store=false 的场景直接受益——custom 工具可用、具名与 required 的 tool_choice 才会真正触发工具调用、推理轨迹可跨请求回放、截断流不再伪装成成功、phase 让 commentary / final_answer 可区分。使用 gpt-oss(Harmony)的用户行为变化最大:tool_choice != auto 从 NotImplementedError 变成带解释的 400,previous_response_id 的 system/developer 前缀改为按请求重建,历史回放语义更接近规范但不再继承旧 instructions。系统侧:改动集中在 sglang/srt/entrypoints/openai/ 与 entrypoints/harmony_utils.py,不动 scheduler / kernel / 采样,理论上不影响吞吐与显存,但引入了 responses_adapters.py 这一新的翻译层依赖,serving_responses.py 与 harmony_utils.py 都要从它导入,后续任何人改线格式都需要同步两处。团队侧:新增 442 行 custom 工具测试并注册到 base-a-test-cpu,CPU CI 时长与维护面积上升;serving_responses.py 单文件 ±1300 行级别的重排会让后续 git blame / 回溯变难。

核心协议路径变更 向后兼容破坏(未知 model 变 404) 流式事件契约调整 编码非加密的语义风险 custom 工具异常路径不一致 无内联评审记录

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论