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

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

先复现一下线上那个场景

线上最初的 prompt 是这样的:

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

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

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

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

css 复制代码
["智能保温杯", "桌面加湿器", "人体工学椅"]

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

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

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

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

json 复制代码
{"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:

python 复制代码
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 代码块、前后缀废话都容忍掉:

python 复制代码
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 超范围它管不了:

scss 复制代码
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 次:

python 复制代码
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。校验那层代码我留着,反正以后还会用到。

相关推荐
xn71333 小时前
Funes Agent Memory 实测:Codex 长期记忆召回、旧记忆污染与 no-answer 边界
人工智能·llm·ai编程
William一直在路上3 小时前
GPT-6 Astra 研究报告——模型能力、API 演进与 Agent/MCP 生态影响分析
人工智能·gpt·llm·openai·astra
玉宇夕落3 小时前
从InMemory内存记忆到文件持久化,手把手教你管理AI的“记忆”
llm
神秘的猪头3 小时前
新版 LangChain Agent 核心架构:State、Context、ToolRuntime 与 Middleware
langchain·llm·fastapi
武子康4 小时前
Agent 的工具没变,SGLang 缓存为什么没命中?
人工智能·llm·agent
MomentYY5 小时前
大模型 Memory 管理:它凭什么知道你之前说过什么?
llm·agent·ai编程
桃西西呀6 小时前
换个会话就失忆?拆开 Agent 记忆的 4 层与 4 个流派,附9个坑的自检清单
人工智能·llm·ai编程
桃西西呀6 小时前
风控模型说自己 80% 准,却漏掉了 76% 该拦的人:一次把模型评估讲透
人工智能·机器学习·llm
lucas_AI9 小时前
把提示词「编译」成函数:Compile by Training 让大模型退居二线
llm