用户体验:如何解决流式传输与JSON结构化的矛盾

在前面的内容里,我们讨论了一些让大模型高质量输出内容的方法。其中让大模型输出 JSON 格式的数据,是一个非常有效且方便的方法。但是,当我们要进一步改善用户体验,希望通过流式传输减少等待时间时,就会发现 JSON 数据格式本身存在一个问题。对于从事前端行业的你来说,JSON 应该并不陌生,它是一种封闭的数据结构,通常以左花括号“{”开头,右花括号“}”结尾。封闭的数据结构,意味着一般情况下,前端对 JSON 的解析必须等待 JSON 数据全部传输完成,否则会因为 JSON 数据不完整而导致解析报错。这就导致一个问题,即使我们在前端用流式获取 JSON 数据,我们也得等待 JSON 完成后才能解析数据并更新 UI,这就让原本流式数据快速响应的特性失效了。那么有没有办法解决这个问题呢?JSON 的流式解析办法是有的。为了解决这个问题,有些人主张规范大模型的输出,比如采取 NDJSON(Newline-Delimited JSON)的方式,要求大模型输出的内容分为多行,每一行是一个独立的 JSON。但是这么做对大模型的输出进行了限制,不够灵活,而且很可能会影响大模型推理的准确性,有点得不偿失。另外一些人则使用 JSONStream 库,根据大模型输出的 JSON 配合 JSONStream 使用,这样能一定程度上解决问题,但是也不够通用,必须要事先针对大模型输出的特定结构进行处理,而且只能在 Server 端进行处理,没法直接在前端使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@RestController
@RequestMapping("/api/travel")
public class TravelController {

private final ChatClient chatClient;

public TravelController(ChatClient chatClient) {
this.chatClient = chatClient;
}

@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<Map<String, Object>> streamScenicSpots() {
String prompt = """
请依次介绍中国的5个著名景点。
每个景点单独输出一个JSON对象,不要合并。
格式如下:
{"name": "景点名", "desc": "简介"}
每个景点之间用字符串 <END> 分隔。
""";

StringBuilder buffer = new StringBuilder();

return chatClient.prompt()
.user(prompt)
.stream()
.flatMap(chunk -> {
String text = chunk.getOutput().getContent();
buffer.append(text);

// 按 <END> 分割
List<Map<String, Object>> ready = new ArrayList<>();
int idx;
while ((idx = buffer.indexOf("<END>")) != -1) {
String jsonChunk = buffer.substring(0, idx).trim();
buffer.delete(0, idx + "<END>".length());
try {
Map<String, Object> json = new ObjectMapper().readValue(jsonChunk, Map.class);
ready.add(json);
} catch (Exception e) {
// 不完整的JSON先跳过
}
}

// 返回所有完整JSON块
return Flux.fromIterable(ready);
});
}
}