☰
Java多模型流式调用:统一SSE与JSON流解析实战
2026/10/7 6:19:13 网站建设 项目流程

1. 为什么说“OpenAI接口是普通话,其他大模型是方言”——Java工程师的真实体感

刚接手公司新项目时,我被安排对接三个大模型:OpenAI的GPT-4、阿里千问Qwen2、百度文心一言ERNIE Bot。本以为都是“调API”,写个HTTP请求+JSON解析就完事了——结果第一天就卡在流式响应上。OpenAI返回的是标准SSE(Server-Sent Events)格式,每行以data:开头,末尾带双换行;千问返回的是自定义JSON数组嵌套结构,字段名全小写还带下划线;文心一言更绝,流式数据混在event: message和data:之间,中间还夹着id:和retry:字段……那一刻我才真正懂了标题里那句“OpenAI是普通话,其他是方言”的分量——不是比喻,是血泪经验。

这句话背后,是Java工程师每天要面对的真实战场:协议不统一、字段命名不一致、流式解析逻辑碎片化、错误码体系五花八门。你写的工具类,可能只适配OpenAI;换一家厂商,就要重写80%的解析逻辑;加一个新模型,就得再啃一遍文档、调试半天边界case。这不是技术深度问题,而是基础设施缺失带来的重复劳动。而Java作为企业级后端主力语言,恰恰最需要稳定、可复用、可维护的客户端封装——它不像Python有openai官方SDK兜底,也不像JS能靠fetch+EventSource快速搭起demo。Java生态里,我们得自己造轮子,还得造得足够结实。

所以这篇内容,不讲“如何调用API”,不堆砌curl命令,也不罗列各家文档链接。我要带你从Java视角,一层层拆开这个“协议方言墙”:字段怎么映射才不踩坑?流式响应怎么解析才不丢帧?如何设计一个能同时兼容OpenAI、千问、文心、GLM的通用Client?所有代码都基于JDK17+,用OkHttp+Jackson+Reactor实操验证,连SSE事件解析的Buffer边界处理、JSON字段缺失容错、空行跳过逻辑都给你写透。如果你正被多模型接入折磨,或者面试官突然问“Java怎么实现流式调用”,这篇文章就是你抄作业的底稿。

2. 协议解构:从HTTP响应头到字段语义,Java眼里真正的“方言差异”

2.1 OpenAI:SSE协议的“普通话”范本

OpenAI的流式接口(如/v1/chat/completions?stream=true)严格遵循W3C SSE标准。它的HTTP响应头明确声明:

Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive

而响应体是纯文本流,每条消息由若干字段行+空行组成,典型结构如下:

event: message data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1715678901,"model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}

注意三个关键点:

  • event:字段标识消息类型,OpenAI只用message一种,但规范允许扩展(如error、ping);
  • data:字段承载JSON payload,且必须以data:开头,后面紧跟JSON字符串;
  • 每条完整消息以双换行\n\n分隔,这是SSE协议的硬性约定,不是OpenAI自创。

Java解析时,我们用OkHttp的ResponseBody.source()获取BufferedSource,逐行读取。核心逻辑是:遇到data:行,就截取后续内容;遇到空行,就触发一次JSON反序列化。这里没有魔法,只有对RFC 5322的忠实实现。

2.2 千问(Qwen):JSON Array的“方言变体”

阿里千问的流式接口(如/v1/chat/completions启用stream=true)走的是另一条路:直接返回JSON数组流。响应头是标准application/json,但响应体不是单个JSON对象,而是一串用换行符分隔的JSON对象:

{"id":"xxx","object":"chat.completion.chunk","created":1715678901,"model":"qwen2","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]} {"id":"xxx","object":"chat.completion.chunk","created":1715678902,"model":"qwen2","choices":[{"index":0,"delta":{"content":" world!"},"finish_reason":null}]}

这看似简单,实则暗藏陷阱:

  • 没有data:前缀,无法用SSE解析器直接复用;
  • 字段命名风格不同:delta.contentvs OpenAI的delta.content(表面一样,但千问的delta对象可能为空,OpenAI保证非空);
  • 缺少role字段:千问流式响应中delta只含content,不返回role,需在首次响应中提取并缓存;
  • 错误响应格式不一致:OpenAI错误走HTTP状态码+JSON body,千问可能返回200但body里是{"error":{"code":"xxx","message":"yyy"}}。

Java处理时,不能依赖SSE库,得用BufferedReader按行读取,每行trim()后用JacksonObjectMapper.readTree()解析。但要注意:空行或空白行必须跳过,否则readTree()会抛JsonParseException。我试过用StreamTokenizer,结果发现千问响应里偶尔有未转义的双引号,反而readTree()的容错更强。

2.3 文心一言:混合协议的“方言混杂”

百度文心一言的流式接口(/v1/chat/completions)最让人头疼——它把SSE和JSON Array混在一起。响应头是text/event-stream,但data:字段里的内容不是纯JSON,而是带event:和data:的嵌套结构:

event: message data: {"id":"xxx","object":"chat.completion.chunk","created":1715678901,"model":"ernie-bot-4","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]} event: message data: {"id":"xxx","object":"chat.completion.chunk","created":1715678902,"model":"ernie-bot-4","choices":[{"index":0,"delta":{"content":" world!"},"finish_reason":null}]}

这等于在SSE框架里塞了一个JSON Array逻辑。Java解析时,你得先按SSE规则切出data:行,再对每行JSON做二次解析。更糟的是:文心一言的delta字段在首帧含role,后续帧可能缺失;finish_reason字段在最后一帧才出现,且值为stop而非OpenAI的stop或length。这意味着你的状态机必须记录role,并在finish_reason出现时触发完成回调。

2.4 字段语义对比表:Java实体类设计的底层依据

字段名OpenAI千问文心一言GLMJava实体设计要点
idstringstringstringstring统一用String id,无歧义
object"chat.completion.chunk""chat.completion.chunk""chat.completion.chunk""chat.completion.chunk"可忽略,或存为String objectType用于调试
createdinteger(Unix timestamp)integerintegerlong统一用long created,避免int溢出
model"gpt-4""qwen2""ernie-bot-4""glm-4"String model,注意大小写敏感
choices[0].index0000固定为0,可省略字段
choices[0].delta.role"assistant"(首帧)缺失"assistant"(首帧)"assistant"(首帧)需缓存,String role = null+setRoleIfAbsent()
choices[0].delta.content"Hello""Hello""Hello""Hello"String content,注意空字符串非null
choices[0].finish_reason"stop"/"length""stop""stop""stop"String finishReason,枚举化建议:STOP,LENGTH,NULL

提示:字段命名差异直接影响Jackson注解。OpenAI用snake_case(finish_reason),千问用camelCase(finishReason),文心一言又用snake_case。Java实体类不能一把梭,必须用@JsonProperty("finish_reason")显式绑定,否则反序列化失败。

3. Java流式调用核心实现:从OkHttp连接到SSE解析器的完整链路

3.1 OkHttp客户端配置:连接池与超时的实战取舍

Java调用流式API,OkHttp是事实标准。但默认配置在流式场景下极易翻车。我踩过的坑包括:连接被服务端主动关闭、响应流中断、内存OOM。解决方案如下:

// 正确配置:长连接+合理超时+连接池复用 OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) // 建连超时,30秒够用 .readTimeout(120, TimeUnit.SECONDS) // **关键!流式读取超时设为120秒** .writeTimeout(30, TimeUnit.SECONDS) // 写超时,一般30秒 .connectionPool(new ConnectionPool(5, 5, TimeUnit.MINUTES)) // 5个空闲连接,5分钟过期 .build();

为什么readTimeout必须设长?因为流式响应是“边生成边发送”,服务端可能每秒只发几个字节。如果设成10秒,网络稍抖动就触发超时,整个流中断。120秒是经验值:GPT-4生成200字通常<10秒,但复杂推理可能达60秒以上,留足缓冲。

连接池设为5,是因为企业级应用常并发调用多个模型。每个模型单独建Client浪费资源,共用一个Client+ConnectionPool即可。注意:OkHttp的ConnectionPool是线程安全的,可全局单例。

注意:不要用new OkHttpClient()裸创建。它用默认配置,readTimeout是0(无限),但实际网络栈有底层超时,行为不可控。必须显式设置。

3.2 SSE解析器:手写还是用库?我的选择与理由

社区有okhttp-sse库,但我在生产环境弃用了。原因有三:

  • 它把SSE解析和HTTP请求耦合,无法灵活注入自定义拦截器(如API Key注入);
  • 对event:字段支持僵硬,不支持自定义事件类型(如文心一言的message之外还有error);
  • Buffer管理不够精细,大流量下偶发BufferUnderflowException。

所以我写了轻量SSE解析器,核心是EventSourceListener接口:

public interface EventSourceListener { void onMessage(String event, String data); // event="message", data=JSON字符串 void onOpen(); // 连接建立 void onClose(); // 连接关闭 void onError(Throwable t); // 解析错误 }

解析逻辑在parseSseStream方法中:

private void parseSseStream(BufferedSource source, EventSourceListener listener) throws IOException { StringBuilder lineBuffer = new StringBuilder(); String currentEvent = "message"; // 默认事件类型 String currentData = ""; while (!Thread.currentThread().isInterrupted()) { if (!source.request(1)) break; // 检查是否有数据 ByteString byteString = source.readByteString(1); char c = (char) byteString.getByte(0); if (c == '\n') { String line = lineBuffer.toString().trim(); if (line.isEmpty()) { // 空行:触发消息事件 if (!currentData.isEmpty()) { listener.onMessage(currentEvent, currentData); currentData = ""; } currentEvent = "message"; // 重置为默认 } else if (line.startsWith("event:")) { currentEvent = line.substring(6).trim(); } else if (line.startsWith("data:")) { currentData += line.substring(5).trim(); } lineBuffer.setLength(0); // 清空buffer } else { lineBuffer.append(c); } } }

这段代码的关键在于:用StringBuilder逐字符构建行,避免BufferedReader.readLine()的阻塞风险。readLine()在流未结束时会一直等,而SSE流可能长时间无数据(如思考中),导致线程挂起。逐字符读+手动识别\n,完全可控。

3.3 流式响应处理器:如何把JSON字符串变成Java对象?

拿到data:后的JSON字符串,下一步是反序列化。Jackson是首选,但必须处理字段缺失和类型不匹配:

// 定义通用Chunk实体(简化版) public class ChatCompletionChunk { @JsonProperty("id") private String id; @JsonProperty("model") private String model; @JsonProperty("choices") private List<Choice> choices; // getter/setter... } public class Choice { @JsonProperty("delta") private Delta delta; @JsonProperty("finish_reason") private String finishReason; // getter/setter... } public class Delta { @JsonProperty("role") private String role; @JsonProperty("content") private String content; // getter/setter... }

反序列化时,用ObjectMapper的宽容模式:

ObjectMapper mapper = new ObjectMapper(); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); // 忽略未知字段 mapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); // 兼容单元素数组 mapper.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true); // 枚举未知值转null ChatCompletionChunk chunk = mapper.readValue(jsonString, ChatCompletionChunk.class);

实操心得:FAIL_ON_UNKNOWN_PROPERTIES必须设为false。因为各家模型字段在迭代,今天没的字段明天可能加,硬校验会导致整个流中断。宁可Java对象里字段为null,也不要崩溃。

3.4 多模型统一Client设计:接口抽象与工厂模式落地

最终目标是:一行代码切换模型。我设计了AiClient接口:

public interface AiClient { Mono<ChatCompletionChunk> streamChat(ChatRequest request); Mono<ChatCompletion> completeChat(ChatRequest request); }

具体实现用工厂模式:

public class AiClientFactory { public static AiClient create(String modelName) { switch (modelName.toLowerCase()) { case "gpt-4": case "gpt-3.5-turbo": return new OpenAiClient(); // 封装SSE解析 case "qwen2": case "qwen1.5": return new QwenClient(); // 封装JSON Array解析 case "ernie-bot-4": return new ErnieClient(); // 封装混合协议解析 default: throw new IllegalArgumentException("Unsupported model: " + modelName); } } }

每个Client内部封装了:

  • 模型专属的API Endpoint(如https://api.openai.com/v1/chat/completionsvshttps://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation);
  • 请求头构造(API Key、Content-Type);
  • 响应体解析逻辑(SSE/JSON Array/混合);
  • 字段映射与归一化(把finish_reason转成统一枚举)。

这样,业务代码只需:

AiClient client = AiClientFactory.create("qwen2"); client.streamChat(request) .doOnNext(chunk -> System.out.print(chunk.getChoices().get(0).getDelta().getContent())) .blockLast(); // 或用WebFlux链式处理

4. 字段深度拆解:Java实体映射中的12个致命细节与避坑指南

4.1finish_reason字段:不只是字符串,是状态机开关

OpenAI的finish_reason有三个值:stop(用户指定停止词)、length(达到max_tokens)、null(流未结束)。千问只有stop,文心一言也是stop。但Java处理时,不能简单存String:

// 错误:直接String private String finishReason; // 正确:枚举+状态判断 public enum FinishReason { STOP, LENGTH, NULL, UNKNOWN } private FinishReason finishReason = FinishReason.UNKNOWN; // 反序列化时 if ("stop".equals(raw)) { this.finishReason = FinishReason.STOP; } else if ("length".equals(raw)) { this.finishReason = FinishReason.LENGTH; } else if (raw == null || raw.trim().isEmpty()) { this.finishReason = FinishReason.NULL; } else { this.finishReason = FinishReason.UNKNOWN; }

为什么重要?因为FinishReason.STOP是你触发“最终回复拼接”的信号。如果用String,业务层要写一堆if ("stop".equals(chunk.getFinishReason())),易错且难维护。

4.2delta.content:空字符串、null、缺失字段的三重陷阱

流式响应中,delta.content可能为:

  • ""(空字符串):服务端发了空内容,需保留;
  • null:某些模型首帧不发content,只发role;
  • 字段缺失:JSON里根本没content键。

Jackson默认把缺失字段设为null,但空字符串是""。Java实体中:

@JsonProperty("content") private String content = ""; // 初始化为空字符串,而非null // 提供安全getter public String getContent() { return Optional.ofNullable(content).orElse(""); }

这样,业务代码永远得到""或实际内容,不会NPE。我见过太多人用chunk.getDelta().getContent().length()>0判断,结果getContent()返回null,直接NullPointerException。

4.3role字段:首帧绑定与上下文传递

role只在首帧出现("assistant"),后续帧省略。Java必须缓存:

public class StreamingContext { private String role; // 从首帧提取 private final List<String> contentChunks = new ArrayList<>(); public void onChunk(ChatCompletionChunk chunk) { Choice choice = chunk.getChoices().get(0); Delta delta = choice.getDelta(); // 首帧提取role if (role == null && delta.getRole() != null) { role = delta.getRole(); } // 拼接content if (delta.getContent() != null) { contentChunks.add(delta.getContent()); } // finish_reason出现时,触发完成 if (choice.getFinishReason() != null) { String fullResponse = String.join("", contentChunks); // 通知业务层 } } }

实操心得:不要在每次onChunk里都chunk.getChoices().get(0)。get(0)是List操作,高频调用有开销。提前Choice choice = chunk.getChoices().get(0)缓存引用。

4.4 字段缺失容错:删除不在JSON Schema的字段

有些模型返回额外字段(如usage在非流式响应中),但流式响应里没有。Java实体若用Lombok@Data,所有字段都会被Jackson初始化。为防干扰,我加了@JsonIgnoreProperties(ignoreUnknown = true)到类上,并在ObjectMapper全局配置:

mapper.setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL);

这样,反序列化时null字段不参与JSON序列化,输出干净。

4.5 时间戳字段:created的时区与精度陷阱

created是Unix时间戳(秒级),但JavaInstant需要毫秒。错误做法:

// 错误:直接Instant.ofEpochSecond(created) —— 丢失毫秒精度 Instant instant = Instant.ofEpochSecond(chunk.getCreated()); // 正确:乘1000转毫秒 Instant instant = Instant.ofEpochMilli(chunk.getCreated() * 1000L);

OpenAI文档写的是“seconds since Unix epoch”,但实测返回值是整数秒。千问和文心一言同理。统一用long created存储,避免int溢出(2038年问题)。

4.6 模型字段:大小写与版本号的标准化

model字段值如"gpt-4-0613"、"qwen2-72b"、"ernie-bot-4"。业务层常需根据模型名路由逻辑。我做了标准化:

public enum ModelType { GPT_4, QWEN2, ERNIE_BOT, GLM4; public static ModelType fromModelName(String modelName) { if (modelName == null) return null; String lower = modelName.toLowerCase(); if (lower.contains("gpt-4") || lower.contains("gpt4")) return GPT_4; if (lower.contains("qwen2") || lower.contains("qwen-2")) return QWEN2; if (lower.contains("ernie") || lower.contains("bot")) return ERNIE_BOT; if (lower.contains("glm") || lower.contains("zhipu")) return GLM4; return null; } }

这样,ModelType.fromModelName(chunk.getModel())就能统一判别,不用散落各处写contains。

4.7 JSON Schema校验:字段存在性断言

开发阶段,我用JSON Schema做字段存在性校验。例如,强制要求choices数组非空:

{ "type": "object", "properties": { "choices": { "type": "array", "minItems": 1, "items": { "type": "object", "properties": { "delta": { "type": "object", "properties": { "content": { "type": ["string", "null"] } } } } } } }, "required": ["choices"] }

用json-schema-validator库在单元测试中校验mock响应,提前暴露字段缺失问题。

4.8 字段注释:Swagger与IDE提示的双重保障

Java字段加@ApiModelProperty(Swagger)和/** */注释:

/** * 模型唯一ID,如"gpt-4" * <p>OpenAI: "gpt-4"</p> * <p>千问: "qwen2"</p> * <p>文心一言: "ernie-bot-4"</p> */ @ApiModelProperty(value = "模型名称", example = "gpt-4") @JsonProperty("model") private String model;

这样,Swagger UI显示清晰,IDE悬停也看到差异说明,新人一眼明白字段含义。

4.9 多字段Group By:流式聚合的内存优化

业务需求常需“按model+finish_reason统计成功率”。流式场景不能等全部响应完再group,得实时聚合。我用ConcurrentHashMap:

private final ConcurrentHashMap<String, AtomicInteger> stats = new ConcurrentHashMap<>(); public void recordStat(String model, String finishReason) { String key = model + "_" + finishReason; stats.computeIfAbsent(key, k -> new AtomicInteger()).incrementAndGet(); }

Key用model_finishReason拼接,避免嵌套Map。AtomicInteger保证线程安全,比synchronized高效。

4.10 特殊字段处理:CLOB与大文本的流式落库

content可能很长(>4KB),DB存CLOB字段。Hibernate的@Lob注解自动处理,但要注意:

  • MySQL需设max_allowed_packet> 4MB;
  • PostgreSQL需用TEXT类型,非VARCHAR;
  • 插入时用session.save(entity),别用executeUpdate。

4.11 字段导出:CSV中多字段换行的转义

导出报表时,content含换行符,CSV需转义:

public static String toCsvCell(String value) { if (value == null) return ""; // 包含逗号、换行、双引号的字段,用双引号包裹,内部双引号转义 if (value.contains(",") || value.contains("\n") || value.contains("\"")) { return "\"" + value.replace("\"", "\"\"") + "\""; } return value; }

4.12 字段加密:API Key等敏感字段的内存保护

apiKey不能明文存String,用char[]:

private final char[] apiKey; public AiClient(char[] apiKey) { this.apiKey = Arrays.copyOf(apiKey, apiKey.length); // 防止外部修改 } // 使用后清零 public void cleanup() { if (apiKey != null) { Arrays.fill(apiKey, '\0'); } }

String不可变,GC前一直存在内存中;char[]可主动清零,更安全。

5. 常见问题与排查技巧实录:从Connection Reset到JSON Parse Error的21个真实案例

5.1 HTTP 401 Unauthorized:API Key注入失效

现象:调用OpenAI返回401,但Postman用同一Key成功。

排查:

  • 检查OkHttp拦截器是否生效:client.interceptors().size()是否>0;
  • 打印请求头:request.header("Authorization")是否为Bearer sk-xxx;
  • Key是否含不可见字符(复制时带空格)?用key.trim().startsWith("sk-")校验。

根因:拦截器里request.newBuilder().header("Authorization", "Bearer " + key),但key是null,结果Header变成Bearer null。

修复:加空值检查:

if (key != null && !key.trim().isEmpty()) { request.newBuilder().header("Authorization", "Bearer " + key.trim()); }

5.2 Connection Reset:服务端主动断连

现象:流式读取中途抛IOException: Connection reset by peer。

原因:服务端超时关闭连接(如OpenAI默认30秒无数据断连)。

方案:

  • 客户端readTimeout设长(120秒);
  • 启用SSEretry:字段(OpenAI不支持,但可模拟);
  • 加心跳:每25秒发ping事件(需服务端支持)。

5.3 JSON Parse Error:Unexpected character

现象:JsonParseException: Unexpected character ('d' (code 100))。

原因:读到了data:行,但没截掉前缀,直接传给readValue。

修复:确保SSE解析器中currentData只存data:后的内容:

if (line.startsWith("data:")) { currentData += line.substring(5).trim(); // substring(5)跳过"data:" }

5.4 空行丢失:流式响应粘包

现象:两条消息合并成一行,如data:{...}data:{...},导致JSON解析失败。

原因:TCP粘包,OkHttp的BufferedSource没按\n\n切分。

方案:SSE解析器必须逐字符读,识别\n\n边界,不能依赖readLine()。

5.5 字段为null:Jackson反序列化失败

现象:delta对象为null,但业务代码调用delta.getContent()NPE。

原因:Delta类没设默认构造函数,Jackson无法实例化。

修复:加@JsonCreator或Lombok@NoArgsConstructor。

5.6 内存OOM:流式响应未及时消费

现象:JVM内存飙升,GC频繁。

原因:Mono背压没处理,下游消费慢,上游缓存大量ChatCompletionChunk。

方案:

  • 用onBackpressureBuffer(100)限制缓存;
  • 或onBackpressureDrop()丢弃旧数据;
  • 业务层确保doOnNext逻辑轻量。

5.7 finish_reason缺失:流未结束假象

现象:最后一帧没finish_reason,业务层以为流还在继续。

原因:网络丢包,最后一帧data:行没收到。

方案:

  • 客户端加超时:timeout(Duration.ofSeconds(5)),5秒无新数据视为结束;
  • 服务端retry:字段保活(如retry: 15000)。

5.8 模型名不匹配:Factory返回null

现象:AiClientFactory.create("Qwen2")返回null,调用时报NPE。

原因:switch里是小写"qwen2",传入是大写"Qwen2"。

修复:统一转小写:modelName.toLowerCase()。

5.9 字段名不一致:Jackson绑定失败

现象:model字段始终为null。

原因:千问返回"model_name",OpenAI返回"model",但Java实体只标@JsonProperty("model")。

方案:用@JsonAlias({"model", "model_name"})。

5.10 SSLHandshakeException:证书问题

现象:javax.net.ssl.SSLHandshakeException: PKIX path building failed。

原因:JDK信任库没更新,或服务端用自签名证书。

方案:

  • 更新JDK证书库:keytool -importcacerts -file cert.crt -keystore $JAVA_HOME/jre/lib/security/cacerts;
  • 开发环境临时禁用SSL验证(仅限测试)。

5.11 流式乱序:index字段非0

现象:choices[0].index为1,但文档说总是0。

原因:多轮对话时,服务端可能返回多个choice(如并行生成),但流式只返回一个。

方案:始终取choices.get(0),忽略index。

5.12 字符编码乱码:中文变??

现象:content里中文显示为??。

原因:OkHttp没设Charset,默认ISO-8859-1。

修复:response.body().string()前,用response.body().string(StandardCharsets.UTF_8)。

5.13 API限频:429 Too Many Requests

现象:突发请求后返回429。

方案:

  • 客户端加令牌桶:Resilience4j RateLimiter;
  • 服务端返回Retry-After头,解析后sleep。

5.14 字段类型错配:created反序列化为double

现象:created字段值为1715678901.0,Javalong接收报错。

原因:JSON里数字没引号,Jackson默认当Double。

方案:ObjectMapper设configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, true),再转long。

5.15 空JSON对象:{}响应

现象:收到空JSON{},readValue抛异常。

方案:预检jsonString.trim().equals("{}"),跳过。

5.16 字段大小写敏感:CONTENTvscontent

现象:千问返回"CONTENT",Java实体content字段为null。

方案:Jackson设mapper.configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true),并@JsonProperty用小写。

5.17 流式中断重试:断线续传

现象:网络抖动,流中断。

方案:记录id和created,重连时带cursor参数(需服务端支持)。

5.18 日志泄露:API Key打印在log

现象:log.info("Request: {}", request)打印出Key。

方案:日志脱敏,或用MaskingLogger过滤敏感字段。

5.19 字段长度超限:content超4K

现象:MySQL插入失败,Data too long for column 'content'。

方案:DB字段设TEXT,Hibernate用@Lob。

5.20 并发安全:StreamingContext共享

现象:多线程调用,contentChunks内容错乱。

方案:StreamingContextper-request,非单例。

5.21 单元测试Mock:SSE流模拟

现象:Mockito难mock流式响应。

方案:用MockWebServer返回真实SSE流:

mockWebServer.enqueue(new MockResponse() .setHeader("Content-Type", "text/event-stream") .setBody("event: message\ndata: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n"));

最后分享一个小技巧:所有流式Client,上线前必做“压力测试”。用JMeter发100并发,持续5分钟,观察内存、GC、错误率。我曾发现千问Client在高并发下BufferedReader锁竞争,换成BufferedSource后TPS提升3倍。协议是死的,但Java的实现方式,决定了你的系统能跑多稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询