云计算百科
云计算领域专业知识百科平台

让 LLM 稳定输出 JSON,我们踩了四道坎

在这里插入图片描述

TL;DR(30 秒速览)

  • 场景:Agent 的最终输出要以 JSON 交给下游系统(工作流编排、配置落库、结构化报告),格式错一个字符全链路就断
  • 第一坎:只在 Prompt 里写"请输出 JSON"——大概率能用,小概率翻车(多 Markdown 围栏、带解释性废话、漏字段)
  • 第二坎:加 JSON Schema 校验 + 失败后把错误列表喂回 LLM 重试——格式正确率上来了,但纯靠 LLM 自觉
  • 第三坎:上模型原生的 Structured Output 参数(response_format: json_schema)——API 层保证格式,但又引出新问题
  • 第四坎:输出大 JSON 时 LLM 调用直接卡死——流式停顿超时、输出 token 上限截断、tool call 参数长度限制
  • 最终方案:四级防御——宽容提取 → Schema 校验重试 → 原生结构化输出 → 大 JSON 分块提交
  • 核心代码:JsonExtractor + OutputSchemaValidator + OutputSchemaCompletionCheck + AgentLoopRunner

前情提要:上一篇我们讲了 LLM 四层错误分类与流式超时检测——四层策略逐层检测、双超时机制、跨 Provider 兼容。这篇把视角拉回到一个更基础、也更普遍的问题:任何需要 LLM 输出结构化 JSON 的场景,怎么做到工程级可靠?(大 JSON 分块提交的完整实现,另见 AI 创造 AI 一篇。)


为什么 JSON 输出是个"看着简单、做起来要命"的问题

EasyAI 里有大量场景需要 LLM 的输出是严格 JSON:

场景JSON 去向格式错误的后果
Swarm Leader 决策 解析成任务分配指令 团队停摆
Agent 输出 Schema 落库 / 交给下游系统 下游系统直接报错
AI 生成 Agent 配置 校验后写入数据库 配置不可用
投研报告结构化 前端渲染变量卡片 页面渲染失败

LLM 的本质是概率预测下一个 token。你让它写文章,错一个字无所谓;你让它输出 JSON,错一个逗号整个输出就是废的。而"输出合法 JSON"这个约束,恰好是概率模型最不擅长的硬约束。

我们的方案不是一步到位设计出来的,而是被生产问题逼出来的四个阶段。


第一坎:只在 Prompt 里写"请输出 JSON"

最朴素的做法:

你的最终回复必须是合法 JSON,格式如下:
{"analysis": "…", "score": 0-100, "reasons": […]}

95% 的情况下这能工作。但生产环境里,那 5% 会以各种姿势出现:

// 翻车姿势 1:Markdown 围栏
```json
{"analysis": "…"}

// 翻车姿势 2:解释性废话 好的,以下是分析结果: {“analysis”: “…”} 希望对你有帮助!

// 翻车姿势 3:字段幻觉 {“analysis”: “…”, “score”: “85”} // score 应该是数字,不是字符串

第一道防线:**宽容提取**。既然不能保证 LLM 只输出 JSON,就把 JSON 从任意文本里挖出来。`JsonExtractor` 按三级优先级提取:

```kotlin
internal object JsonExtractor {
fun extract(text: String): String? {
// 1. 优先匹配 ```json … ```代码围栏
val match = CODE_FENCE_PATTERN.find(text)
if (match != null) {
val candidate = match.groupValues[1].trim()
if (isValidJson(candidate)) return candidate
}
// 2. 整段文本直接当 JSON 解析
val trimmed = text.trim()
if (isValidJson(trimmed)) return trimmed
// 3. 在文本中定位 { … } 或 [ … ] 块(括号配对扫描)
val jsonObject = findJsonBlock(trimmed, '{', '}')
if (jsonObject != null && isValidJson(jsonObject)) return jsonObject
val jsonArray = findJsonBlock(trimmed, '[', ']')
if (jsonArray != null && isValidJson(jsonArray)) return jsonArray
return null
}
}

Swarm 的 Leader 决策解析更进一步,做了 JSON 优先 + 正则兜底 + 安全默认值 三级降级——解析失败不会让团队停摆,而是返回一个"继续工作"的保守决策:

// LeaderDecisionParser.parse()
return try {
parseJson(leaderOutput) // 策略 1:JSON 解析
} catch (e: Exception) {
try {
parseRegex(leaderOutput) // 策略 2:正则兜底
} catch (e: Exception) {
LeaderDecision( // 策略 3:安全默认值
analysis = leaderOutput.take(500),
newTasks = emptyList(),
isComplete = false, // 保守:不停止,继续下一轮
)
}
}

教训一:永远不要假设 LLM 的输出是纯净的,提取层必须宽容。但宽容提取只解决"挖出来",不解决"内容对不对"。


第二坎:JSON Schema 校验 + 把错误喂回去重试

提取出来的 JSON 字段缺失、类型不对怎么办?答案是拿 JSON Schema 当合同,不合格就打回重做。

OutputSchemaValidator 负责校验:

fun validateOutput(schema: String, assistantText: String): ValidationResult {
val jsonText = JsonExtractor.extract(assistantText)
?: return ValidationResult.Invalid(listOf("No valid JSON found in response"))
return try {
validateJson(schema, jsonText) // jsonsKema 全量 JSON Schema 校验
} catch (e: Exception) {
ValidationResult.Invalid(listOf("Validation error: ${e.message}"))
}
}

关键设计在校验失败之后。EasyAI 的 Agent 循环有一个 AgentCompletionCheck 扩展点——Agent 自认为"说完了"的时候,先过一遍检查。OutputSchemaCompletionCheck 挂在这里:

// OutputSchemaCompletionCheck.check()
val result = validator.validateOutput(schema, text)
return when {
result is ValidationResult.Valid -> CompletionCheckResult.Done // 合格,放行

(retryCounters[sessionKey] ?: 0) >= maxRetries ->
CompletionCheckResult.Done // 重试超限,返回原结果(有界失败)

else ->
// 把具体错误列表注入重试 Prompt,让 LLM 定向修复
CompletionCheckResult.Continue(prompt = buildRetryPrompt(errors, schema, ...))
}

重试 Prompt 不是简单地说"再输出一遍",而是把具体的校验错误喂回去:

Your previous response did not match the required output format (attempt 1/2).

Validation errors:
– $.score: expected number, found string
– $.reasons: required property missing

Please reformat your response as a valid JSON object matching this schema:
{ …schema… }
Output ONLY the JSON object, no additional text.

实测这一招的修复率非常高——LLM 看到"score 应该是 number 不是 string"这种精确错误,基本一次就能改对。

教训二:校验必须闭环。只校验不重试等于没校验;重试时不带错误信息等于让 LLM 重新抽卡。


第三坎:模型原生 Structured Output,API 层兜底

Schema 校验重试虽好,但每次失败都要多一轮 LLM 调用——费钱又费时间。2024 年后主流模型都提供了原生结构化输出能力,在解码阶段就约束 token 只能落在 Schema 允许的路径上,格式错误在理论上归零。

EasyAI 把它做成了协议无关的一层:

// AgentLoopRunner:构建 ChatOptions 时注入 outputSchema
val chatOptions = if (context.outputSchema != null && baseChatOptions is StructuredOutputChatOptions) {
val applyStructuredOutput = !context.outputSchemaMultiTurn || forceStructuredOutput
if (applyStructuredOutput) {
baseChatOptions.mutate().outputSchema(context.outputSchema).build()
} else baseChatOptions
} else baseChatOptions

StructuredOutputChatOptions 向下映射到各协议的原生参数:

协议参数映射
OpenAI 系 response_format: { type: "json_schema", json_schema: {…} }
Anthropic 系 output_config.output_format: { type: "json_schema", schema: {…} }

但这里踩出了一个隐蔽的新坑:结构化输出和工具调用互斥。

强制 JSON 输出模式下,模型被约束"只能输出 JSON",它就没法再正常发起工具调用了——而多轮 Agent 恰恰需要先调一堆工具收集信息,最后一轮才产出 JSON。

解法是 multi-turn 延迟模式(outputSchemaMultiTurn):

Turn 1~N:正常工具调用(不启用结构化输出)
Turn N+1:Agent 认为任务完成 → OutputSchemaCompletionCheck 首次触发
→ 注入"现在输出最终 JSON"的 Prompt
→ 同时 enableForcedStructuredOutput(),下一轮 LLM 调用才挂上原生结构化输出参数

// AgentLoop 中:完成检查触发时才打开 API 级结构化输出
if (check is OutputSchemaCompletionCheck && context.outputSchemaMultiTurn && context.outputSchema != null) {
loopRunner.enableForcedStructuredOutput()
}

教训三:原生结构化输出是最强约束,但要选对时机——在工具调用阶段启用它会废掉 Agent 的手脚。


第四坎:大 JSON 输出,LLM 调用直接卡死

前三坎解决了"格式对不对",第四坎解决的是"能不能输出完"。

EasyAI 的"AI 创造 AI"功能要生成 14 个 Agent 的 Swarm 配置,JSON 超过 10000 token。最初我们让 LLM 在一次回复(或一次 tool call 参数)里直接输出完整配置,结果遇到三连击:

问题现象根因
流式卡死 请求挂起 120s 后被判超时 超长 JSON 生成中模型长时间不吐新 chunk,触发流式停顿检测
输出截断 JSON 在中间戛然而止 撞到模型 max output tokens,半截 JSON 无法解析
参数超长 tool call 直接失败 部分模型对单次工具调用参数有长度上限

更麻烦的是:一个字段错了,就要整个重新生成——又是 10000 token 的等待和费用。

最终方案:把"一次大输出"改成"多次小输出"

核心思路:不让 LLM 一口吐出大 JSON,而是提供 submit_config_block 工具,每块只含一个 Agent 或一个 Task(200~800 token),后端负责组装:

LLM: submit_config_block(blockType="agent", blockIndex=0, data={宏观分析师})
LLM: submit_config_block(blockType="agent", blockIndex=1, data={技术分析师})
LLM: submit_config_block(blockType="task", blockIndex=0, data={数据收集任务})
…每块独立、短小、可流式
LLM: finalize_config() → 后端组装全部块 → 统一校验 → 提交

这个设计同时消掉了三个问题:

  • 每次输出都很短 → 不会流式停顿,不会撞输出上限
  • 每块独立校验 → 错了只重发那一块,不用整体重来
  • 组装不依赖顺序 → Agent 可以先提交 Task 再提交 Agent,assembleConfigFromBlocks 按类型分桶组装
  • 同时 EasyAI 的流式层本身做了逐 chunk 停顿检测兜底:生产者和消费者之间用 Channel 解耦,消费者带超时接收,"长时间没有新内容"和"HTTP 连接超时"是两个独立判据——即使某次输出真的卡了,也能快速失败重试,而不是傻等到天荒地老。

    分块提交的完整实现细节(资源发现、validate-fix 循环、SSE 实时推送)见 AI 创造 AI 一篇,这里聚焦 JSON 可靠性这条线。

    教训四:大 JSON 的正确解法不是"让模型更努力",而是改变交互结构——化整为零,把单次大赌注拆成多次小赌注。


    全景图:四级防御

    在这里插入图片描述

    ┌─ L1 宽容提取 ──────────── JsonExtractor:围栏/裸 JSON/块扫描,从任意文本挖出 JSON
    ├─ L2 Schema 校验重试 ───── OutputSchemaCompletionCheck:错误列表喂回 LLM,最多重试 2 次
    ├─ L3 原生结构化输出 ────── StructuredOutputChatOptions:解码层约束,multi-turn 延迟启用
    └─ L4 大 JSON 分块提交 ──── submit_config_block:化整为零 + 流式停顿检测兜底

    四级之间是纵深关系,不是四选一:

    • L3 开启的场景(模型支持),L2 退化为兜底检查,几乎不消耗重试
    • 模型不支持 L3(老模型、部分国产网关),L2 独立扛起正确性
    • L1 永远在——因为连"校验失败提示"本身都可能被模型包上围栏
    • L4 只在输出体积大到触发物理限制时启用

    踩坑记录

    坑原因解法
    围栏里的 JSON 才是正文,围栏外还有 JSON LLM 有时在解释文字里也写 JSON 示例 提取优先级:围栏 > 全文 > 块扫描
    重试两次仍不合格 继续重试边际收益趋零,白烧 token 有界失败:超限后返回原结果 + warning,交给上层处理
    结构化输出模式下 Agent 不调工具了 JSON 约束抑制了 tool call multi-turn 延迟:只在最终轮启用
    大 JSON 流式卡死 长输出中途停顿超过 stall 阈值 分块提交 + 逐 chunk 停顿检测(独立于 HTTP 超时)
    校验错误太笼统,LLM 改不对 只说 “invalid” 不说哪里 invalid jsonsKema 输出 JSONPath 级错误($.score: expected number)
    重试计数器跨会话串台 异常退出(abort/取消)残留状态 每次 Agent 运行开始 resetSession() 清理

    总结

    阶段手段可靠性成本适用
    V1 Prompt 提示 95% 原型
    V2 Schema 校验 + 重试 99% 失败时多一轮调用 所有场景兜底
    V3 原生结构化输出 ~100% 零额外调用 模型支持时首选
    V4 分块提交 解决体积问题 多次短调用 大 JSON 场景

    让 LLM 稳定输出 JSON,没有银弹,只有纵深防御:提取要宽容、校验要闭环、约束交给 API、体积靠拆分。


    下一篇:Token 账单几个小时烧完一周的量:我们把缓存命中率从 20% 拉到 92%

    账单曲线突然垂直上涨,排查发现 Prompt 缓存使用率只有 20% 出头——System Prompt 里的秒级时间戳和 Memory 注入,让前缀缓存全量失效。这篇讲两个事前防御:System Prompt 完全静态化 + 超大工具输出落盘换指针,把缓存命中率拉到 92% 以上。


    开源地址:https://github.com/haibingzhao/easyai

    欢迎 Star、Issue 和 PR。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 让 LLM 稳定输出 JSON,我们踩了四道坎
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!