
TL;DR30 秒速览场景Agent 的最终输出要以 JSON 交给下游系统工作流编排、配置落库、结构化报告格式错一个字符全链路就断第一坎只在 Prompt 里写请输出 JSON——大概率能用小概率翻车多 Markdown 围栏、带解释性废话、漏字段第二坎加 JSON Schema 校验 失败后把错误列表喂回 LLM 重试——格式正确率上来了但纯靠 LLM 自觉第三坎上模型原生的 Structured Output 参数response_format: json_schema——API 层保证格式但又引出新问题第四坎输出大 JSON 时 LLM 调用直接卡死——流式停顿超时、输出 token 上限截断、tool call 参数长度限制最终方案四级防御——宽容提取 → Schema 校验重试 → 原生结构化输出 → 大 JSON 分块提交核心代码JsonExtractorOutputSchemaValidatorOutputSchemaCompletionCheckAgentLoopRunner前情提要上一篇我们讲了 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% 会以各种姿势出现// 翻车姿势 1Markdown 围栏 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()returntry{parseJson(leaderOutput)// 策略 1JSON 解析}catch(e:Exception){try{parseRegex(leaderOutput)// 策略 2正则兜底}catch(e:Exception){LeaderDecision(// 策略 3安全默认值analysisleaderOutput.take(500),newTasksemptyList(),isCompletefalse,// 保守不停止继续下一轮)}}教训一永远不要假设 LLM 的输出是纯净的提取层必须宽容。但宽容提取只解决挖出来不解决内容对不对。第二坎JSON Schema 校验 把错误喂回去重试提取出来的 JSON 字段缺失、类型不对怎么办答案是拿 JSON Schema 当合同不合格就打回重做。OutputSchemaValidator负责校验funvalidateOutput(schema:String,assistantText:String):ValidationResult{valjsonTextJsonExtractor.extract(assistantText)?:returnValidationResult.Invalid(listOf(No valid JSON found in response))returntry{validateJson(schema,jsonText)// jsonsKema 全量 JSON Schema 校验}catch(e:Exception){ValidationResult.Invalid(listOf(Validation error:${e.message}))}}关键设计在校验失败之后。EasyAI 的 Agent 循环有一个AgentCompletionCheck扩展点——Agent 自认为说完了的时候先过一遍检查。OutputSchemaCompletionCheck挂在这里// OutputSchemaCompletionCheck.check()valresultvalidator.validateOutput(schema,text)returnwhen{resultisValidationResult.Valid-CompletionCheckResult.Done// 合格放行(retryCounters[sessionKey]?:0)maxRetries-CompletionCheckResult.Done// 重试超限返回原结果有界失败else-// 把具体错误列表注入重试 Prompt让 LLM 定向修复CompletionCheckResult.Continue(promptbuildRetryPrompt(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 OutputAPI 层兜底Schema 校验重试虽好但每次失败都要多一轮 LLM 调用——费钱又费时间。2024 年后主流模型都提供了原生结构化输出能力在解码阶段就约束 token 只能落在 Schema 允许的路径上格式错误在理论上归零。EasyAI 把它做成了协议无关的一层// AgentLoopRunner构建 ChatOptions 时注入 outputSchemavalchatOptionsif(context.outputSchema!nullbaseChatOptionsisStructuredOutputChatOptions){valapplyStructuredOutput!context.outputSchemaMultiTurn||forceStructuredOutputif(applyStructuredOutput){baseChatOptions.mutate().outputSchema(context.outputSchema).build()}elsebaseChatOptions}elsebaseChatOptionsStructuredOutputChatOptions向下映射到各协议的原生参数协议参数映射OpenAI 系response_format: { type: json_schema, json_schema: {...} }Anthropic 系output_config.output_format: { type: json_schema, schema: {...} }但这里踩出了一个隐蔽的新坑结构化输出和工具调用互斥。强制 JSON 输出模式下模型被约束只能输出 JSON它就没法再正常发起工具调用了——而多轮 Agent 恰恰需要先调一堆工具收集信息最后一轮才产出 JSON。解法是multi-turn 延迟模式outputSchemaMultiTurnTurn 1~N正常工具调用不启用结构化输出 Turn N1Agent 认为任务完成 → OutputSchemaCompletionCheck 首次触发 → 注入现在输出最终 JSON的 Prompt → 同时 enableForcedStructuredOutput()下一轮 LLM 调用才挂上原生结构化输出参数// AgentLoop 中完成检查触发时才打开 API 级结构化输出if(checkisOutputSchemaCompletionCheckcontext.outputSchemaMultiTurncontext.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 或一个 Task200~800 token后端负责组装LLM: submit_config_block(blockTypeagent, blockIndex0, data{宏观分析师}) LLM: submit_config_block(blockTypeagent, blockIndex1, data{技术分析师}) LLM: submit_config_block(blockTypetask, blockIndex0, data{数据收集任务}) ...每块独立、短小、可流式 LLM: finalize_config() → 后端组装全部块 → 统一校验 → 提交这个设计同时消掉了三个问题每次输出都很短→ 不会流式停顿不会撞输出上限每块独立校验→ 错了只重发那一块不用整体重来组装不依赖顺序→ Agent 可以先提交 Task 再提交 AgentassembleConfigFromBlocks按类型分桶组装同时 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 才是正文围栏外还有 JSONLLM 有时在解释文字里也写 JSON 示例提取优先级围栏 全文 块扫描重试两次仍不合格继续重试边际收益趋零白烧 token有界失败超限后返回原结果 warning交给上层处理结构化输出模式下 Agent 不调工具了JSON 约束抑制了 tool callmulti-turn 延迟只在最终轮启用大 JSON 流式卡死长输出中途停顿超过 stall 阈值分块提交 逐 chunk 停顿检测独立于 HTTP 超时校验错误太笼统LLM 改不对只说 “invalid” 不说哪里 invalidjsonsKema 输出 JSONPath 级错误$.score: expected number重试计数器跨会话串台异常退出abort/取消残留状态每次 Agent 运行开始resetSession()清理总结阶段手段可靠性成本适用V1Prompt 提示95%零原型V2Schema 校验 重试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。