需求听起来特别简单:用户用一句话描述想下什么,模型从里面抽出平台、清晰度、要不要字幕,给下游当参数用。
"让它返回 JSON 不就行了。"我当时是这么说的。
四层兜底就是这么来的。
第一层:它不肯只说 JSON
第一版提示词结尾写的是「请以 JSON 格式返回结果」。跑出来长这样:
好的,根据您的描述,我整理出以下信息:
```json
{
"platform": "bilibili",
"quality": "1080p",
"subtitle": true
}
希望这对你有帮助!如果需要调整可以告诉我。
模型很客气,但这堆东西没法直接 `json.loads`。于是有了第一层:把围栏去掉,从第一个 `{` 截到最后一个 `}`。
```python
m = re.search(r'\{.*\}', text, re.S)
data = json.loads(m.group(0)) if m else None
这行代码活了大概两天。第三天有人输入里带了一段 CSS 片段,正文里就有花括号,正则贪婪匹配直接把后面一段不相干的东西也吞进去了,解析异常。
改成非贪婪、改成找配对的括号、改成先剥 ``` 围栏……每修一次都能举出下一条能打穿它的输入。我到这时候才承认,靠正则从自然语言里"抠"结构化数据,本身就是条错路。
第二层:把格式写死在提示词里
第二版不再说"请以 JSON 格式",改成给一份完整的 schema,并且明确禁止别的输出:
SYSTEM = """只输出一个 JSON 对象,不要任何解释、前后缀或 Markdown 围栏。
字段固定为:
platform: 字符串,取值只能是 bilibili / douyin / youtube / other
quality: 字符串,取值只能是 720p / 1080p / 4k / best
subtitle: 布尔值
信息不足时对应字段填 null,不要猜测。"""
这一版效果好了非常多,绝大多数请求都能直接 json.loads。"绝大多数"是关键词——线上跑一周,还是攒下了几类漏网的:
quality给了"高清"而不是枚举里的值,模型觉得这是同义替换subtitle给了字符串"true",不是布尔- 偶尔整个对象少一个字段
- 极少数时候前面还是带一句"好的"
温度压到 0.4 之后频率降了,没归零。这类模型不是编译器,提示词是"强烈建议"不是"强制约束",这个心态得先摆正。
第三层:schema 校验与重试
既然约束不住,就在本地兜住。校验这层我写得很直白,没引第三方库:
def parse_or_none(text):
try:
d = json.loads(text.strip().removeprefix('```json').removesuffix('```'))
except (ValueError, TypeError):
return None
if not isinstance(d, dict):
return None
if any(k not in d for k in ('platform', 'quality', 'subtitle')):
return None
if d['quality'] not in ('720p', '1080p', '4k', 'best', None):
return None
if not isinstance(d['subtitle'], (bool, type(None))):
return None
return d
解析失败就带着原始输出再问一次,提示它"上次返回的不是合法 JSON,原因是 xxx,只返回 JSON"。重试一次的成功率挺高,大概能把整体可用率拉到 99% 以上。
代价有俩,都不太显眼。一是失败那次调用照样计费,重试等于这笔请求最多付两份钱;二是延迟翻倍,本来 2 秒的活变成 4 秒多,用户那头能感觉到。所以我把重试限定在解析失败这一种情况,其他错误一律不重试。
如果服务商支持 response_format={"type": "json_object"},第三层可以省掉大半。我们试过,确实稳,但换一家就没有这个参数了,代码里还得留着兜底分支。兼容协议只保证接口长得一样,不保证这些可选能力一样。
第四层:两次都失败怎么办
最后一次都解析不出来,就当作没抽到,走纯文本流程让用户自己选。不抛异常、不弹错误、不把半个 JSON 塞给下游。
这层最重要,却最容易漏。因为开发时模型一直很配合,你会下意识觉得"解析不出来"是不会发生的事。我们第一版就是把 json.loads 直接写在请求处理里,某天上游返回了一段空内容,整条接口 500。
顺带说,有人问过为什么不用现成框架里的 output parser。理由跟之前那篇讲 LangChain 的差不多:多一层抽象,出错时我看不见原始返回长什么样。这个场景下"看得见"比"少写几行"值钱。
站内客服那块倒是一点都不用这么复杂。它只要一段纯文本,(resp.choices[0].message.content or '').strip() 加一次截断就够了,连 or '' 都是被空值坑过之后补的。复杂度得跟着需求走,不能因为"AI 就该有 schema"就都上全套。
线上攒下来的脏返回比我预想的花样多。有一回模型在 JSON 后面补了句"以上仅供参考",json.loads 直接挂;有一回把布尔写成了字符串 "false";还有一回返回了半个对象,最后一个字段没闭合,一看就是撞到 max_tokens 被截断了。最后这种最麻烦,它不是格式错,是内容缺,重试时得把"上次被截断"这件事也告诉模型。
重试还带来一个我当时没想的问题:下游如果按请求 id 做了幂等,重试同一个 id 没问题;可我们这条链路是纯计算、不落库,重试就是重新抽一次,两次结果可能不一样。真出现"第一次抽对了、重试抽错了",该以哪次为准?现在的代码以最后一次为准,说不出什么道理,只是实现起来最顺手。
也有人问过为什么不用 pydantic 做校验。理由跟正则那层差不多:字段就三个,写个函数十几行,一眼能看出哪条不合规;引一个库进来,报错信息反而要再翻译一层。等哪天字段涨到十几个、嵌套两层,我大概会换。
VidDown 站上那些在线工具的返回结果倒是简单,没让我们写这么多层。
那段剥围栏的正则现在还在代码里,注释写着"临时方案,后面换成 json_object"。我刚看了一眼提交时间,四个月前。