LLM结构化输出与流式响应:从JSON修复到SSE全链路

LLM结构化输出与流式响应:从JSON修复到SSE全链路

做 LLM 应用,有两个基础设施级的问题绕不开:一是怎么让 LLM 返回可靠的结构化数据(而不是一段自由文本),二是怎么把 LLM 的逐 token 输出实时推送给用户。前者关系到下游业务逻辑能不能跑通,后者关系到用户体验能不能接受。

今天结合我在 nvc-guide的实战经验,聊聊这两个问题。

结构化输出:让 LLM 返回可用的数据

LLM 本质上是文本生成模型,输出的是非结构化的自然语言字符串。但下游业务逻辑需要的是结构化的 Java 对象------评估分数、用户画像、场景配置,这些东西必须是程序能解析的。

三种实现方案

方案 原理 可靠性 兼容性
Prompt 约束 在 system prompt 中描述 JSON 结构 所有模型
Function Calling 通过 API 的 tools 参数声明 JSON Schema 依赖 API 版本
Output Converter + 重试修复 Prompt 约束 + 后处理 + 本地修复 + 重试 中高 兼顾

我在项目里用的是第三种------Spring AI 的 BeanOutputConverter。原因很简单:需要兼容多个 Provider(DashScope、智谱、LMStudio),不是所有 API 都支持 Function Calling。

BeanOutputConverter 的工作原理

复制代码
1. getFormat() → 从目标 Class 提取字段结构,生成 JSON Schema 格式指令
2. 注入到 system prompt 中,告诉 LLM "请按此格式输出"
3. LLM 返回文本后:
   a. 去除 Markdown 代码块标记 (```json)
   b. 提取 JSON 子串
   c. ObjectMapper.readValue() 反序列化
   d. 失败则尝试本地修复 → 重试

LLM 输出 JSON 的常见错误

三类高频错误:

  1. Markdown 包裹:输出 `` ```json {...} `````,需要去壳
  2. 多余文本:JSON 前后夹杂解释性文字
  3. 未转义引号 :JSON 字符串值中包含未转义的 "

第三类最棘手。LLM 在生成评估反馈时,经常在中文描述中嵌入未转义的英文引号,导致 JSON 解析失败。

JSON 修复算法

我用有限状态机(FSM) 思路逐字符扫描修复未转义引号:

复制代码
状态转移:
  [不在字符串中] --遇到"--> [在字符串中]
  [在字符串中]   --遇到\--> [转义中] --下一个字符--> [在字符串中]
  [在字符串中]   --遇到"--> 判断:是终止符还是内容中的引号?
                        ├── 下一个有效字符是 , } ] : → 终止字符串
                        └── 否则 → 这是未转义引号,替换为 \"

核心判断逻辑:遇到 " 后,向后扫描跳过空白,如果下一个有效字符是 ,}]:,则认为这是 JSON 结构的字符串终止符;否则认为是内容中的引号,需要转义。

这种方案比纯正则更精确,因为它理解 JSON 的嵌套结构,不会误杀合法的转义序列。

重试机制设计

重试不是简单的"再来一次",而是智能修复 prompt

java 复制代码
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
    String attemptSystemPrompt = attempt == 1
        ? securedSystemPrompt
        : buildRetrySystemPrompt(securedSystemPrompt, lastError);
    // 调用 LLM + 解析
}

重试时:

  • 追加 STRICT_JSON_INSTRUCTION,强化 JSON 约束
  • 将上次错误原因注入 prompt(截断到 200 字符),让 LLM 知道哪里出了问题
  • 本地 JSON 修复优先于 LLM 重试(成本几乎为零)

这个设计将结构化输出的成功率从约 85% 提升到了 99% 以上。

用 Record 作为目标类型

所有结构化输出目标都用 Java record 定义:

java 复制代码
public record NvcEvaluationResult(
    @JsonProperty("observation_score") Integer observationScore,
    @JsonProperty("feeling_score") Integer feelingScore,
    // ...
) {}

Record 的优势:不可变(天然线程安全)、简洁(一行定义所有字段)、Jackson 原生支持。

流式响应:SSE 的原理与实现

LLM 的输出天然是逐 Token 生成的单向流。如果等所有 token 生成完再返回,用户可能要等 5-10 秒才能看到第一个字。流式输出让用户边生成边看,体验好很多。

为什么选 SSE 而不是 WebSocket

维度 SSE WebSocket
通信方向 服务端 → 客户端(单向) 双向
协议 HTTP 独立的 ws:// 协议
自动重连 原生支持 需手动实现
适用场景 AI 流式输出、通知推送 聊天室、游戏

LLM 的输出天然是单向 Token 流,不需要客户端持续向服务端推送数据。SSE 的单向模型完美匹配,且基于 HTTP 天然兼容负载均衡、CDN、防火墙。

SSE 协议格式

复制代码
event: message\n
data: 你好,我是 NVC 教练\n
\n
event: done\n
data: {"length": 42}\n
\n

每条事件由 event:(事件类型)、data:(事件数据)组成,事件之间用 \n\n 分隔。

Java 后端实现

Spring WebFlux + Flux 是当前 LLM 应用的主流方案。Spring AI 的 ChatClient.stream() 返回 Flux<String>,天然适配 WebFlux 的响应式编程模型。

项目里有两套 SSE 流式接口:

  • 主 Agent 对话:事件类型包括 thinking / tool_call / tool_result / content / done / error
  • 练习模式:事件类型包括 metadata / message / done

AgentLoop 使用 Flux.create(sink -> {...}) 手动控制事件发射,而非依赖 Spring AI 的 stream().content()。原因是 Agent Loop 需要在 LLM 调用和工具执行之间插入自定义事件(thinking、tool_call、tool_result),普通的 token 流无法表达这种结构化的生命周期事件。

前端消费 SSE

项目没有使用浏览器原生的 EventSource API,而是使用 fetch + ReadableStream。原因:SSE 接口是 POST 请求且需要携带认证 Header,EventSource 只支持 GET。

typescript 复制代码
const response = await fetch('/api/chat/stream', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer xxx', 'Accept': 'text/event-stream' },
  body: JSON.stringify({ message: '你好' })
});

const reader = response.body.getReader();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const text = decoder.decode(value, { stream: true });
  // 解析 event: 和 data: 行
}

换行符处理

SSE 协议用 \n 作为行分隔符,LLM 输出的换行符必须转义:

java 复制代码
private String escapeNewlines(String text) {
    return text.replace("\n", "\\n").replace("\r", "\\r");
}

不转义的话,前端会收到断裂的事件,解析出错。

容错设计

场景 处理方式
LLM 调用失败 3 次重试 + 降级响应
流式中途出错 doOnError 保存已接收的部分内容
客户端断开连接 sink.isCancelled() 检测 + 主动终止循环
总超时 120s 超时发送 error 事件
工具调用死循环 最多 10 轮工具调用

一个关键设计决策:流式输出和持久化分离 。流式过程中只做清洗和输出,持久化(保存消息、更新摘要)在 doOnComplete 中完成,避免 I/O 操作阻塞流式输出。

小结

结构化输出和流式响应是 LLM 应用的两个基础设施。结构化输出的核心是"约束 + 修复 + 重试"三层防御,本地 JSON 修复优先于 LLM 重试。流式响应的核心是 SSE + Flux,前端用 fetch + ReadableStream 消费。两者都需要完善的容错设计------LLM 的输出是概率性的,任何异常都不能中断用户体验。

相关推荐
JouYY13 小时前
大模型底层学习(三)-从零训练一个 BPE 分词器
架构·llm·agent
蛋先生DX13 小时前
大模型参数存储格式揭秘:BF不是男朋友
深度学习·算法·llm
前网易架构师-高司机17 小时前
带标注的羽毛球运动员,裁判,羽毛球识别数据集,识别率84.4%,2879张图,支持yolo,coco json,voc xml,文末有模型训练代码
xml·yolo·json·数据集·羽毛球·裁判
leisoo809718 小时前
股票数据本地化存储实战:JSON、数据库与列式存储的方案对比
jvm·数据库·json
切糕师学AI19 小时前
从压缩到查询:PostgreSQL中JSON数据的存储与处理实践
数据库·postgresql·json
鬼手点金20 小时前
与LLM结合的主流智能爬虫框架
爬虫·python·llm·post·request·firecrawl·crawl4ai
AI大佬的小弟1 天前
大模型名词精讲 03:Prompt
llm·prompt·提示词·few-shot·zero-shot·提示词工程·大模型名词精讲
doubt。1 天前
大模型prompt工程Zero-Shot与Few-Shot以及json格式
人工智能·深度学习·机器学习·语言模型·json·prompt