记录一下那天晚上的排查过程。内容审核类请求走 json_schema,遇到敏感话题时返回体里没有 content,只有一个 refusal 字符串,下游按 JSON 解析直接空指针。

刚开始盯着「finish_reason 依然是 stop,看起来和正常结束没区别」,折腾了半个多小时,排掉了。

把日志和配置对完,能解释这个现象的基本就这几处。模型有权拒答,此时结果放在 message.refusal 而不是 content。也可能是refusal 是纯文本,完全不遵循你定义的 schema。

还有一种情况,客户端只判断了 content 是否为空,没有写拒答分支。

先把完整的 message 结构打出来确认字段

curl -s https://api.openai.com/v1/chat/completions -H "Authorization: Bearer $OPENAI_API_KEY" -H "Content-Type: application/json" -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"敏感话题"}],"response_format":{"type":"json_schema","json_schema":{"name":"r","strict":true,"schema":{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"],"additionalProperties":false}}}}' | jq '.choices[0].message'

解析前先判断 refusal 键是否存在

python -c "import json;d=json.load(open('resp.json'));print('refusal' in d['choices'][0]['message'])"

给下游加一条拒答兜底文案

python -c "print('该问题暂不支持回答')"

等前面都确认过,再统计日志里的拒答比例

python -c "import json;print(sum(1 for l in open('log.jsonl') if 'refusal' in l))"

改完先别撤,确认这几项。敏感样本走拒答分支并返回兜底文案,普通样本正常解析成 JSON,日志里 refusal 计数与实测样本数一致,不再出现空指针。

提醒一点。拒答是正常返回不是异常,接口照样 200,想靠状态码发现它永远发现不了,解析层必须显式处理这个字段。改动前把原配置备份一份,出问题能马上回滚,这比任何技巧都实用。

作者 9823189