
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:
| 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() → 后端组装全部块 → 统一校验 → 提交
这个设计同时消掉了三个问题:
同时 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。
网硕互联帮助中心


评论前必须登录!
注册