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 的常见错误
三类高频错误:
- Markdown 包裹:输出 `` ```json {...} `````,需要去壳
- 多余文本:JSON 前后夹杂解释性文字
- 未转义引号 :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 的输出是概率性的,任何异常都不能中断用户体验。