"只输出 JSON"根本不够:LLM 结构化输出的坑,我实测了 60 次调用

9 阅读8分钟

周一凌晨两点,告警群里弹了一条消息:选品接口成功率掉到 41%。 第一反应是后端挂了,翻了半天日志,服务进程好好的,就是推荐结果不对。后来把一条出错返回完整打出来才看明白——LLM 给的 JSON 是合法的,字段一个不少,但 reason 全是空字符串,confidence 全是 0。解析层没报错,业务层却拿到一堆"推荐了但说不出为什么推荐"的结果,前端渲染出来全是空白。 这玩意儿排查起来特别烦,不是"报错",是"静默出错"。我盯着那条返回看了半天,才意识到问题出在 LLM 输出这一层。 与其继续猜,我干脆写了脚本,用 DeepSeek 的 API 把各种姿势的"结构化输出"都实测了一遍,60 多次调用。结论挺有意思: "只输出 JSON"这句话,模型根本不当回事。

先复现一下线上那个场景

线上最初的 prompt 是这样的:

请从商品池中推荐3个商品,用JSON返回。
商品池:智能保温杯、无线充电鼠标垫、便携榨汁杯、桌面加湿器、降噪耳机、机械键盘、投影仪支架、人体工学椅

没给格式示例,想着"JSON 嘛,它肯定知道该输出啥"。结果跑了十来次,没有一次输出是能直接用的。最离谱的是字段名,我等着取 items,它给我返回这些:

{"recommended": ["智能保温杯", "无线充电鼠标垫", "便携榨汁杯"]}
{"recommendations": ["智能保温杯", "降噪耳机", "人体工学椅"]}
{"recommended_products": [{"name": "降噪耳机", "reason": "适合专注办公、通勤途中使用"}]}

十来次里,用 items 的一次都没有,recommendedrecommendationsrecommended_products 随机出现。还有差不多一半次数更狠,直接给我返回一个裸数组:

["智能保温杯", "桌面加湿器", "人体工学椅"]

连对象都不包。而且几乎所有输出都被 markdown 代码块 json 包着——你 JSON.parse 直接抛异常。 看到 recommended_products 我有点恍惚,这词儿我 prompt 里压根没写过。后来琢磨明白了,LLM 不认你脑子里的"契约",不给明确的格式定义,它就按训练数据里的习惯瞎猜。猜中谢天谢地,猜不中线上炸了。

以为加个格式示例就稳了,结果被一行 prompt 坑了

字段名问题好解决,把格式示例写进 prompt 就行。我加上了完整 JSON 示例,配合 json_object 模式跑了一轮,连续十来次全部通过,字段名、类型、数量全对。当时我松了口气,想着问题总算是解决了。 当时我松了口气,想着问题总算是解决了,还给自己泡了杯咖啡,寻思着把单元测试补上。

然后产品走过来了。我看他表情就知道,价格那个需求还是没躲过去——有的商品价格不确定,能不能让模型输出 null 而不是瞎编?需求挺合理,我想都没想就在 prompt 里加了一句:"如果某个商品你不确定价格,price 可以填 null。" 就这一句话,出事了。 又跑了几轮,隔三差五就出现三个商品 price 全 null 的情况,统计下来差不多四分之一。智能保温杯的价格它不知道吗?它当然知道。但我给了它一个合法的偷懒出口,它就真的全填 null 了。你以为模型会"只在不确定的时候填 null",实际是"能填 null 就填 null"。

{"items":[
  {"name":"智能保温杯","price":null,"reason":"智能控温,保持饮品温度,日常使用方便又健康。"},
  {"name":"降噪耳机","price":null,"reason":"有效隔绝环境噪音,提升办公学习专注度,适合通勤场景。"},
  {"name":"桌面加湿器","price":null,"reason":"小巧便捷,改善室内干燥空气,提升工作与生活质量。"}
]}

注意,reason 它写得有模有样,不是能力问题,就是态度问题。这也解释了线上为什么批量 null——需求方当时也说过"不知道的就留空",我在 prompt 里原样转达了,然后整批全变空值。回头翻日志,这批"推荐了但说不出理由"的数据其实上线第二天就有了,只是当时量小没人注意,等量上来了才炸出来。 这块我是真没想到,prompt 里一旦出现"可以不做"的字眼,它八成会照着做——不是偶尔,是大概率。当晚我盯着那条全是 null 的日志,默默把"可以填 null"从 prompt 里删了,再补了一句"所有字段必须给出确定值"。第二天 0 报错。心里没多高兴,只觉得早该这样。

想靠 structured output 保命,模型不支持

连着踩了两个坑,我开始认真考虑上模型原生的结构化输出能力。OpenAI 那边 response_format: json_schema 推了那么久,格式正确性由 API 层保证,总该稳了吧? 然后我在 DeepSeek 开放平台的 deepseek-v4-flash 上试 json_schema:

Error code: 400 - This response_format type is unavailable now

直接不支持。json_object 模式倒是支持,但它只保证"输出是合法 JSON",不保证 schema 对——字段缺失、类型错误、值无效,它一概不管。我把复杂 schema(6 个字段,含数组、数字范围)丢给它,配合完整格式示例能连续全对,但一旦示例没给到位,它照样偷工减料。换句话说,json_object 就是把"格式示例"升级成了"格式强制",字段语义对不对还是看模型心情。 这算是个很现实的问题:不是所有模型都支持 json_schema,生产环境很可能同时接多个模型,你没法假设每个都支持。

解法:GuardedLLM,把校验层焊死在解析前面

绕了一圈,最后老实了。一开始我压根没想写类,就在原来的 get_json 里塞了个 while 循环,解析失败就重试,跑了两天发现不行——没告诉模型错在哪,它只会换一种姿势继续错。后来干脆把那段撕了,揉成下面这个带校验和反馈重试的 GuardedLLM。结构化的坑靠 prompt 只能减少不能消除,正确姿势是让校验层直接参与决策:不合格,就把错在哪反馈给模型,让它改。 先解决解析容错,markdown 代码块、前后缀废话都容忍掉:

def parse_llm_json(text: str) -> dict:
    text = text.strip()
    m = re.search(r"```(?:json)?\s*(.*?)```", text, re.S)
    if m:
        text = m.group(1).strip()
    else:
        start, end = text.find("{"), text.rfind("}")
        if start != -1 and end > start:
            text = text[start:end + 1]
    return json.loads(text)

再写 schema 校验和语义断言。jsonschema 管字段类型、必填项、枚举;语义断言管"值有没有意义"——空字符串、null、confidence 超范围它管不了:

def semantic_check(data: dict) -> list[str]:
    errors = []
    items = data.get("items", [])
    for i, it in enumerate(items):
        if not it.get("name") or not it["name"].strip():
            errors.append(f"items[{i}].name 为空")
        if not it.get("reason") or not it["reason"].strip():
            errors.append(f"items[{i}].reason 为空")
        conf = it.get("confidence")
        if isinstance(conf, (int, float)) and not (0 <= conf <= 1):
            errors.append(f"items[{i}].confidence={conf} 超出范围")
    return errors

最后是核心的 GuardedLLM:解析 → 校验 → 不过就把错误列表反馈给模型重试,最多 3 次:

class GuardedLLM:
    def __init__(self, max_retry=2):
        self.max_retry = max_retry

    def generate(self, task: str) -> dict:
        messages = [{"role": "user", "content": task}]
        for attempt in range(self.max_retry + 1):
            text = call_model(messages)          # 调用 LLM
            try:
                data = parse_llm_json(text)
                jsonschema.validate(data, SCHEMA)
                errors = semantic_check(data)
            except Exception as e:
                errors = [str(e)[:120]]
                data = None
            if not errors:
                return {"ok": True, "attempts": attempt + 1, "data": data}
            messages += [
                {"role": "assistant", "content": text},
                {"role": "user", "content":
                 f"输出未通过校验:{errors}。请修正后重新输出完整 JSON,不要输出任何解释。"},
            ]
        return {"ok": False, "attempts": self.max_retry + 1, "data": None}

重试的关键是把具体的校验错误喂回去,而不是笼统说"你输出不对"。模型看到 items[1].reason 为空 这种明确信息,基本都能自己改对。拿最早那个宽松 prompt 实测时,典型的重试是这样:第一次模型返回 {"recommended_products": [{"name": "降噪耳机", "reason": "降噪效果好"}]},校验层给出 ['缺 items 字段'],我把这条错误原样喂回去,它第二次就老老实实输出 {"items": [...]} 了。重试 prompt 里还得加"不要输出任何解释",不然模型一边给 JSON 一边附"好的,已经修正了"的废话。我拿之前那个全员不合格的宽松 prompt 又测了 6 次,全部在第二次重试后通过;正常场景里也有一次要靠重试救回来。样本不大,但方向已经清楚了。

60 多次调用跑下来,结论就一句话:prompt 能把问题率降下来,但永远有漏网之鱼——宽松 prompt 全军覆没,加格式示例还剩一次直接空输出,prompt 里给"可填 null"退路就冒出四分之一的全 null,只有 GuardedLLM 把校验和重试焊上去之后,才做到不管宽松还是严格场景都没再翻车。

回到最初那个告警,根因其实就一句话:我把"能不能留空"的选择权交给了模型,而模型一定会选择留空。现在我的代码里,任何 LLM 输出的字段在进入业务逻辑前都必须过校验,校验不过就让模型重试,再不过就告警人工处理。 后来产品再提"这个字段可能为空"的需求,我先把规则立了:name、reason 必须非空,拿不准的字段宁可给合理默认值,也别留 null。校验那层代码我留着,反正以后还会用到。