执行摘要
- 一句话:Responses API 补齐 custom 工具、加密推理回放与模型校验
- 推荐动作:值得精读,尤其是三类人:维护 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 验证每条症状的落点。
功能与动机
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 或采样。
实现拆解
- 新增适配层
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 承载推理轨迹。
- 扩展协议模型
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 内部消息上流转。
- 服务端主流程
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 与非流式工具结果归并。
- 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.") 避免函数名含点被截断。
- 测试与 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(模块 响应服务层;类别 source;类型 core-logic;符号 _known_model_names, _validate_model, create_responses, _make_reasoning_item): Responses API 的主服务层,承载 model 校验、tool_choice 前置拒绝、终态 SSE 事件、状态化回放与 phase 保持,是本 PR 逻辑最密集、回归面最大的文件(+701/-631)。
python/sglang/srt/entrypoints/openai/responses_adapters.py(模块 响应适配层;类别 source;类型 core-logic;符号 custom_tool_parameters, custom_tool_description, custom_tool_names, encode_custom_tool_input): 全新适配层,集中承载 custom 工具 shim、增量 JSON 反转义、developer 层级标签与 reasoning 状态编解码;被 serving_responses.py 与 harmony_utils.py 共同依赖,是本 PR 的设计核心。
python/sglang/srt/entrypoints/harmony_utils.py(模块 消息桥接层;类别 source;类型 core-logic;符号 parse_response_input, parse_output_message, parse_remaining_state): Harmony 回放与输出的桥接层:修复 assistant 文本被过滤、encoded-only 推理无法回放、custom_tool_call 缺回放路径,并把 message channel 双向映射到 phase。
python/sglang/srt/entrypoints/openai/protocol.py(模块 协议模型;类别 source;类型 data-contract;符号 ResponseInputMessageParam, ResponseOutputMessage, is_include_encrypted_reasoning, effective_tool_choice): 线格式模型的契约来源:新增 format 与 phase 字段、is_include_encrypted_reasoning(),并调整 effective_tool_choice() 与两处 Union,决定了下游所有翻译逻辑的输入类型。
test/registered/unit/entrypoints/openai/test_responses_custom_tools.py(模块 单元测试;类别 test;类型 test-coverage;符号 _custom_request, CustomToolAdapterTestCase, test_payload_survives_encode_decode, test_prefix_decode_tracks_a_growing_buffer): 新增 442 行 custom 工具测试,覆盖 encode/decode 往返、增量前缀解码的单调性、shim 工具形态、具名与 required 的全量/流式一致性,以及真实 glm47 线格式的解析。
test/registered/unit/entrypoints/openai/test_serving_responses_stream.py(模块 单元测试;类别 test;类型 test-coverage;符号 test_truncated_and_aborted_streams_have_matching_terminal_events, test_required_native_parser_matches_full_response, HarmonyStreamLifecycleTestCase, test_truncated_harmony_arguments_close_the_emitted_item): 流式契约的主回归网:新增终态事件(incomplete/failed)、native parser 全量-流式一致、phase 透传与 Harmony 流式 item 生命周期用例。
test/registered/unit/entrypoints/openai/test_serving_responses.py(模块 单元测试;类别 test;类型 test-coverage;符号 test_stored_tool_turn_matches_client_replay_without_old_instructions, test_harmony_instructions_are_rebuilt_for_each_request, test_harmony_replays_output_text_and_encoded_reasoning, test_harmony_replays_dict_tool_calls_and_results_in_one_input): 非流式侧回放与 phase 的回归网:断言存储回放等价于客户端自带上文、旧 instructions 不继承、Harmony instructions 按请求重建与强制 choice 的 400 文案。
test/registered/unit/entrypoints/openai/utils.py(模块 单元测试;类别 test;类型 test-support;符号 MockTokenizerManager.SERVED_MODEL_NAME): 测试夹具适配:MockTokenizerManager 补上 served_model_name 与 lora_registry,否则 model 校验上线后整套 Responses 单测都会 404。
关键符号:_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
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
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",
)
评论区精华
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:,说明验证靠反复重跑收敛而非一次绿。
-
custom 工具的 grammar 只描述不强制 (design): 接受为当前限制并列入 out of scope(需要 staged generation 才能真正强制),仅通过描述引导模型。
- encrypted_content 是编码而非加密 (security): 接受编码方案,但语义上必须被当作「对客户端不透明」而非安全边界;
decode_reasoning_state() 对客户端传入 blob 无解压体积上限。
- phase 未知时缓冲 message SSE 事件 (performance): 采用缓冲方案,代价是工具请求首字延迟上升;测试以
test_final_output_preserves_text_tool_text_order 的 phase 断言锁定行为。
- Harmony 强制 tool_choice 改为显式 400 (correctness): 从「未实现异常」升级为可读的 400,行为更明确但仍不支持真正强制。
- 全量与流式共享 native 解析判定 (correctness): 已统一并补回归测试,流式与全量在工具名与 arguments 上逐字一致。
- 存储历史改为原始输入 + 完整 output item (design): 已解决,并作为契约写进测试;
msg_store 值类型相应放宽为 list[dict]。
- 验证证据:单测 + Codex 一致性 + 真机 CLI (testing): 以「单测 + 一致性套件 + 真机会话 + 精度回归」四层证据支撑合并;唯一 FAIL 与两个 SKIP 均已在记录中说明原因。
风险与影响
- 风险:
- 向后兼容破坏(中高):
_validate_model() 让未知 model 从「用已加载模型回答 + HTTP 200」变成 404。虽然 None、served 名、adapter 名、base-model:adapter 都能通过,但老客户端若传路由前缀或自定义别名会被直接拒绝;_known_model_names() 还依赖 tokenizer_manager.served_model_name 与可选 lora_registry,这两个属性缺失会退化为只认 served 名。
- 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——同一响应内流式增量与终值语义不一致,客户端重建逻辑容易被误导。
encrypted_content 并非加密(中):encode_reasoning_state() 是 zlib + base64 的纯编码,decode_reasoning_state() 对客户端传入的 blob 直接 zlib.decompress()。一方面名字暗示保密性而实际无密钥,容易被下游误当安全边界;另一方面解压客户端可控数据存在内存放大风险,代码里没有解压体积上限或输入长度检查。
- 流式事件契约回归面大(中):Harmony 流式改为由单一 emitter 统一持有 item id / call id / output_index / sequence_number,并要求终态响应复用已发出 item。
test_serving_responses_stream.py 覆盖了 split/coalesced 消息与截断 item,但任何 emitter 与 parser snapshot 失配都会让依赖 output_index 的客户端错位,属于高敏感区。
- 流式时延回退(低-中):按 commit
abb8b3f 的描述,启用 tools 的请求会把 message SSE 事件缓冲到下一个 tool call 或终态事件才能确定 commentary / final_answer,文本首字延迟上升;text-only 流不受影响。
- 评审深度不足(流程风险):+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 工具异常路径不一致, 无内联评审记录
关联脉络
- PR #38908 Fix gpt-oss RunAI streamer weight ownership: 同属 gpt-oss / Harmony 服务路径的质量修复;本 PR 在
harmony_utils.py 与 serving_responses.py 的 Harmony 分支上做了 channel↔phase 映射、回放与流式生命周期改造,两条线都在加固 gpt-oss 的 Responses 服务体验。
- PR #36411 [Perf] Optimize Qwen3-VL unique-image serving on H100: 同属 OpenAI 入口层(
entrypoints/openai/),都改动了 serving 侧请求翻译与 serving_chat.py 相关链路;本 PR 复用了 chat serving 的 _parse_model_parameter 等入口设施。
- PR #39035 [Session] Fix session idle timeout after rejected requests: 同属 OpenAI 入口层的请求生命周期修复,都涉及「被拒绝的请求不应污染后续状态」这一类语义;本 PR 把未声明 tool_choice 与未知 model 提到生成前拒绝,思路与之相近。
参与讨论