
【Bug已解决】Omit Responses API reasoning field when unset 解决方案原始报错Omit Responses API reasoning field when unset 场景调用 Responses API 时如果调用方没有显式设置reasoning参数请求体里应当完全不出现这个键但客户端错误地把它序列化成了null或空对象{}导致服务端要么拒绝请求、要么把没传误解成用户主动要求关闭推理行为偏离预期。 关键词请求序列化、可选字段省略、哨兵值、未设置 vs 显式空值、API 契约。一、现象长什么样你只想发一个最简单的请求{ model: gpt-x, input: 你好 }没有碰reasoning这个参数。但抓包发现实际发出去的是{ model: gpt-x, input: 你好, reasoning: null }或者更隐蔽的{ model: gpt-x, input: 你好, reasoning: {} }随后服务端返回 400或者更糟——返回 200却把reasoning: null/reasoning: {}当成用户明确要求不要推理 / 用默认推理配置于是你的请求走了和完全不传 reasoning完全不同的代码路径。问题出在客户端把所有字段都无差别序列化没有区分用户没设置和用户显式设为 null / 空对象。二、背景为什么省略键和值为 null是两回事服务端对可选字段的语义通常是键不存在使用服务端默认行为例如按账号/模型默认开启某种推理预算。键存在且为 null往往被解释成调用方主动声明了一个状态。对reasoning这类参数null可能被解析成关闭推理或清除推理配置与不传语义相反。键存在且为{}可能被当成使用全默认推理配置而服务端某些版本对空对象校验严格直接抛 400。一旦客户端在用户没设置时也不小心把键带上就等于悄悄改变了请求语义。这类 bug 在以下场景尤其常见用 dataclass / 结构体承载请求所有字段都有默认值哪怕是None序列化时None也被写出用json.dumps(obj.__dict__)之类整对象 dump字段在不在取决于结构体初始化多层嵌套外层省略了但内层对象即便为空也被 new 出来最终写出{}。三、根因没有未设置的表示只有值为空根本原因是语言层面没有区分未设置和显式设为 None/空。Python 里reasoningNone和没传 reasoning在dict层面都表现为键对应 None序列化后都是reasoning: null。要让未设置真正消失必须在序列化阶段能识别它。两种典型错误写法import json # 错误 A默认值 None无差别 dump class BadRequest: def __init__(self, model, input, reasoningNone): self.model model self.input input self.reasoning reasoning req BadRequest(gpt-x, 你好) print(json.dumps(req.__dict__)) # 输出含 reasoning: null# 错误 B用 dict 且字段总是存在 def build(model, user_input, reasoningNone): body {model: model, input: user_input} body[reasoning] reasoning # 即便 reasoning 是 None 也写进去了 return body print(json.dumps(build(gpt-x, 你好))) # 输出含 reasoning: null两者都把未设置伪装成了null。四、最小可运行复现下面这段代码完整复现该 bug并顺带给出正确版本对照import json class Request: def __init__(self, model, user_input, reasoningNone): self.model model self.input user_input self.reasoning reasoning def to_bad_body(self): # 错误无差别序列化 return {model: self.model, input: self.input, reasoning: self.reasoning} def to_good_body(req): # 正确仅当 reasoning 不是 None 才带上 body {model: req.model, input: req.input} if req.reasoning is not None: body[reasoning] req.reasoning return body if __name__ __main__: req Request(gpt-x, 你好) # 没设置 reasoning print(错误序列化:, json.dumps(req.to_bad_body())) print(正确序列化:, json.dumps(to_good_body(req)))运行输出错误序列化: {model: gpt-x, input: 你好, reasoning: null} 正确序列化: {model: gpt-x, input: 你好}正确版本里reasoning键彻底消失与服务端使用默认的语义一致。五、方案用哨兵值区分未设置与显式 None当 API 真的允许显式传 null或者需要区分未设置 / 设为 None / 设为具体值三种状态时reasoning is not None就不够了——因为用户可能确实想传None。此时要引入哨兵对象import json from typing import Any, Dict, Optional # 唯一哨兵绝不出现在正常数据中 _UNSET object() class Request: def __init__(self, model: str, user_input: str, reasoning: Any _UNSET): self.model model self.input user_input self.reasoning reasoning def to_body(self) - Dict[str, Any]: body: Dict[str, Any] {model: self.model, input: self.input} if self.reasoning is not _UNSET: # 只有真的设置过才写 body[reasoning] self.reasoning return body if __name__ __main__: # 情况1完全没设置 print(json.dumps(Request(gpt-x, hi).to_body())) # {model: gpt-x, input: hi} # 情况2显式传 None假设协议允许 print(json.dumps(Request(gpt-x, hi, None).to_body())) # {model: gpt-x, input: hi, reasoning: null} # 情况3显式传具体配置 print(json.dumps(Request(gpt-x, hi, {effort: low}).to_body())) # {model: gpt-x, input: hi, reasoning: {effort: low}}通过哨兵_UNSET三种状态被清晰区分未设置→键消失显式 None→写出 null若协议需要具体值→写出值。六、方案dataclass 字段级省略如果请求体较大、字段多手写if容易漏。用dataclass 遍历字段更稳import json import dataclasses from typing import Any, Dict, Optional _UNSET object() dataclasses.dataclass class ChatRequest: model: str input: str reasoning: Any _UNSET temperature: Any _UNSET top_p: Any _UNSET stream: Any _UNSET def to_body(self) - Dict[str, Any]: body: Dict[str, Any] {} for f in dataclasses.fields(self): val getattr(self, f.name) if val is _UNSET: continue # 未设置省略 body[f.name] val return body if __name__ __main__: r ChatRequest(modelgpt-x, inputhi, temperature0.7) print(json.dumps(r.to_body())) # {model: gpt-x, input: hi, temperature: 0.7} # reasoning / top_p / stream 全部省略这样新增字段时只要记得给默认值_UNSET就不会再有人把未设置写成null。七、方案嵌套对象也要懒创建reasoning常常是嵌套对象{effort: low, summary: auto}。常见错误是提前self.reasoning {}导致未设置时也写出{}。正确做法是默认_UNSET且只在用户真正提供嵌套字段时才构造import json from typing import Any, Dict, Optional _UNSET object() class ReasoningConfig: def __init__(self, effort: Any _UNSET, summary: Any _UNSET): self.effort effort self.summary summary def to_body(self) - Optional[Dict[str, Any]]: if self.effort is _UNSET and self.summary is _UNSET: return None # 一个都没设 - 整体不出现 out: Dict[str, Any] {} if self.effort is not _UNSET: out[effort] self.effort if self.summary is not _UNSET: out[summary] self.summary return out class Request: def __init__(self, model: str, user_input: str, reasoning: Any _UNSET): self.model model self.input user_input self.reasoning reasoning def to_body(self) - Dict[str, Any]: body {model: self.model, input: self.input} if self.reasoning is not _UNSET: rc self.reasoning.to_body() if hasattr(self.reasoning, to_body) else self.reasoning if rc is not None: body[reasoning] rc return body if __name__ __main__: r1 Request(gpt-x, hi) # 无 reasoning r2 Request(gpt-x, hi, ReasoningConfig(effortlow)) print(json.dumps(r1.to_body())) # {model: gpt-x, input: hi} print(json.dumps(r2.to_body())) # {model: gpt-x, input: hi, reasoning: {effort: low}}嵌套层同样遵循没设就省略从源头杜绝reasoning: {}。八、验证把省略语义用测试锁死这类 bug 容易在重构序列化层时复发用单测固定行为def test_reasoning_omitted_when_unset(): r Request(gpt-x, hi) assert reasoning not in r.to_body() def test_reasoning_present_when_set(): r Request(gpt-x, hi, ReasoningConfig(effortlow)) assert r.to_body().get(reasoning) {effort: low} def test_reasoning_empty_config_omitted(): # 空配置对象不应写出 {} r Request(gpt-x, hi, ReasoningConfig()) assert reasoning not in r.to_body() if __name__ __main__: test_reasoning_omitted_when_unset() test_reasoning_present_when_set() test_reasoning_empty_config_omitted() print(序列化省略语义测试通过。)把这些测试纳入 CI每次改请求构建逻辑都必须跑避免未设置字段被偷偷带上再发生。九、排查清单遇到未设置字段被发出去按顺序查抓包看实际请求体未设置的字段是否仍以null/{}出现找序列化入口是json.dumps(obj.__dict__)还是手工body[x] x前者最容易把None带出。看字段默认值请求结构体里可选字段默认是None还是哨兵None无法区分未设置与显式空。看嵌套对象是否提前self.reasoning {}导致空对象被写出看协议边界服务端是否把null/{}当成主动状态若有省略键才是正确做法。看测试覆盖是否有断言未设置字段不在请求体中没有就补。看重构历史最近是否改动过序列化层回归测试是否覆盖省略语义十、小结未设置字段被序列化出来表面是小事本质是没有在客户端建模未设置这一状态——None和不存在在dict里无法区分于是null/{}被误发。修复路径引入哨兵值_UNSET区分未设置与显式 None / 具体值序列化时仅写出非哨兵字段未设置即省略键嵌套对象懒创建空配置不写出{}用单测把省略语义锁死在 CI。做到这四点无论请求体多复杂用户没碰的参数都不会再悄悄出现在线上请求里服务端也就能始终走默认行为这条正确路径。