大模型接口返回200还不够:用Python给聊天响应做结构校验
调试聊天模型接口时,常见的写法是请求结束后直接读取 choices[0].message.content。正常返回时很简洁,但HTTP成功与“能给业务返回文本”之间,还隔着一层响应结构校验。这里整理一个只用Python标准库的练习,把这一层单独写出来,用模拟数据验证。
本文代码面向一种明确的业务约定:非流式、第一候选项、纯文本聊天响应。它不是某个云服务所有响应类型的完整规范,也没有进行真实在线推理或模型效果评测。示例全部使用自建数据,不需要API Key,不创建云资源。
一、先明确三层成功条件
第一层是传输调用成功。网络超时、证书问题和连接失败属于调用层,应该由HTTP客户端处理。第二层是HTTP状态符合预期。本文的业务约定只接受200;401、429等状态不进入文本提取。第三层才是响应结构满足当前调用方的需求:顶层对象、非空choices列表、合法的message对象,以及非空字符串content。
这三层需要分开。只判断状态码,不能保证列表里有元素;只捕获KeyError,也不能覆盖空列表的IndexError以及结构类型不匹配。与其让异常发生在很深的下标表达式中,不如在业务边界给出短小、稳定的错误码。
二、把校验写成独立函数
下面的extract_text不发网络请求。调用方传入状态码和响应正文,它只返回文本或抛出ResponseContractError。返回值保留原始字符串,包括有效文本两侧的空白;strip只用来识别全空白内容。
错误消息只包含固定原因与HTTP状态,不附带原始正文。正文可能包含用户问题、模型回答或服务诊断信息,直接拼到异常里会让它们进入后续日志。此处采用信息最少的默认行为,生产环境可另外记录经过审查的请求标识。
代码中对每一层分别检查类型,因而能把“缺字段”和“字段存在但类型不对”收敛到可理解的业务错误。先验证整个对象,再访问子对象,也使后续维护者更容易看清每一个分支。
"""Validate a non-streaming text chat response without logging its body."""
import json
class ResponseContractError(ValueError):
pass
def extract_text(status, body):
if status != 200:
raise ResponseContractError(f"http_status:{status}")
try:
data = json.loads(body)
except (ValueError, TypeError, UnicodeError):
raise ResponseContractError("invalid_json") from None
if not isinstance(data, dict):
raise ResponseContractError("root_not_object")
choices = data.get("choices")
if not isinstance(choices, list) or not choices:
raise ResponseContractError("choices_empty_or_invalid")
first = choices[0]
if not isinstance(first, dict):
raise ResponseContractError("choice_not_object")
message = first.get("message")
if not isinstance(message, dict):
raise ResponseContractError("message_not_object")
content = message.get("content")
if not isinstance(content, str) or not content.strip():
raise ResponseContractError("text_missing_or_empty")
return content
def run_demo():
good = {"choices": [{"message": {"content": "你好"}}]}
cases = [
("success", 200, json.dumps(good), "你好"),
("http401", 401, "PRIVATE_BODY", "http_status:401"),
("non_json", 200, "PRIVATE_BODY", "invalid_json"),
("array_root", 200, "[]", "root_not_object"),
("missing_choices", 200, "{}", "choices_empty_or_invalid"),
("empty_choices", 200, '{"choices":[]}', "choices_empty_or_invalid"),
("wrong_choices", 200, '{"choices":{}}', "choices_empty_or_invalid"),
("wrong_choice", 200, '{"choices":[null]}', "choice_not_object"),
("missing_message", 200, '{"choices":[{}]}', "message_not_object"),
("null_content", 200, '{"choices":[{"message":{"content":null}}]}', "text_missing_or_empty"),
("blank_content", 200, '{"choices":[{"message":{"content":" "}}]}', "text_missing_or_empty"),
("tool_only", 200, '{"choices":[{"message":{"tool_calls":[]}}]}', "text_missing_or_empty"),
]
for name, status, body, expected in cases:
try:
actual = extract_text(status, body)
except ResponseContractError as exc:
actual = str(exc)
assert actual == expected, (name, actual, expected)
assert "PRIVATE_BODY" not in actual
print(name, "OK")
print(f"passed {len(cases)} cases")
if __name__ == "__main__":
run_demo()
将文件保存为chat_response_contract.py,执行python chat_response_contract.py即可运行。代码没有第三方依赖,也不包含任何真实凭证。
三、12个用例覆盖了什么
本地使用Python3.11实际运行,输出最后一行为passed 12 cases。用例包括正常文本、401、非JSON正文、数组顶层、缺失choices、空choices、错误choices类型、空候选项、缺失message、null内容、空白内容以及仅工具调用的消息。
每个用例不仅检查是否报错,还检查具体错误码。比如空choices不应该产生模糊的“程序出错”,而应该进入choices_empty_or_invalid分支。测试中的PRIVATE_BODY是专门放入的标记,断言确保返回结果与错误文字没有包含这段正文。
这只能说明这12个自建输入的处理符合函数约定,不能证明真实服务始终返回这种结构,也不能证明所有敏感内容都不可能在其他日志环节泄露。特别是HTTP客户端的调试日志仍需单独检查。
四、工具调用为什么也会被拒绝
有些合法响应的重点是tool_calls而不是content。对支持工具调用的智能体来说,这需要进入工具调度分支;对本文这个“必须取得非空文本”的函数来说,它不满足约定,所以会被明确拒绝。这是调用方的选择,不应因此认定服务返回错误。
同样,流式SSE、多模态内容数组、多候选项合并、拒绝响应、截断标志和finish_reason等都需要根据实际协议增加处理。不要把这段纯文本示例直接当作通用SDK。尤其不能把空内容擅自转为成功,或者用空字符串掩盖协议变化。
五、接回真实客户端时再补哪些环节
接入网络请求时,应保留默认的TLS证书校验,从环境变量读取密钥,为客户端配置适当超时,并对网络异常单独处理。具体超时含义要以所用HTTP库的文档为准,连接超时和读取超时也不等同于整个业务流程的硬截止时间。
对话请求是否重试,需要考虑服务计费和幂等语义。收到错误后自动重试多次,可能产生重复调用;本文没有实现重试。可先让业务显示短错误码,再由操作者判断是否继续。响应体大小限制、日志脱敏和请求标识关联也应在真实集成时补齐。
这次练习的目的,是先用没有网络和凭证的固定输入,把成功与失败分支做清楚。之后再接真实模型时,就能更容易分辨问题是在网络层、HTTP状态层,还是响应内容层。
本文示例与文字由AI辅助编写,示例已按文中范围完成本地验证。
- 点赞
- 收藏
- 关注作者
评论(0)