Spring AI 入门第二课:流式输出 & 运行时动态参数配置
第一课跑通了最基础的同步调用,这节课深入两个核心能力:流式聊天(打字机效果) 和 运行时动态修改模型参数。把代码和原理都梳理一遍。
一、先看整体代码结构
Controller 里一共四个接口:
| 接口 | 返回值 | 特点 |
|---|---|---|
/generate |
String |
同步阻塞调用,第一课内容 |
/generatestream |
Flux<ChatResponse> |
原始流式响应,返回完整对象 |
/generatestream2 |
Flux<String> |
加工后的流式响应,直接吐文本,前端更好用 |
/runtimeoptions |
String |
运行时动态传 temperature,覆盖配置文件 |
下面重点拆解后三个。
二、核心概念铺垫
1. 为什么要用流式输出?
同步调用 chatModel.call(message) 的问题:
- 大模型生成完整回答需要几秒甚至几十秒
- 这段时间前端一直转圈,用户体验很差
- 全部生成完才一次性返回,数据量大时等待感更强
流式输出(Streaming) 的思路:
- 模型生成一个字/一个 token 就推一个
- 前端边接收边渲染,像 ChatGPT 一样的打字机效果
- 底层用的是 SSE(Server-Sent Events) 技术
2. Flux 是什么?
Flux<T> 是 Project Reactor 响应式编程里的核心类,表示包含 0~N 个元素的异步流。
简单理解:
- 普通
String→ 一次性返回一个完整字符串 Flux<String>→ 源源不断地吐出多个字符串片段,来一个发一个
Spring WebFlux 原生支持 Flux 作为返回值,自动帮你处理 SSE 格式。
3. Prompt、Message、ChatResponse 体系
第一课直接传 String,第二课开始接触 Spring AI 的标准对象模型:
Prompt(一次完整的对话请求)
└── List<Message>(消息列表)
├── UserMessage(用户消息)
├── SystemMessage(系统提示词)
└── AssistantMessage(AI 历史回复)
└── ChatOptions(本次请求的模型参数,可选)
ChatResponse(一次完整的对话响应)
└── List<Generation> 生成结果
└── getOutput() → AssistantMessage
└── getText() → 实际文本内容
三、流式接口一:原始版本 generateStream
java
@GetMapping("/generatestream")
public Flux<ChatResponse> generateStream(
@RequestParam(value="message", defaultValue="你是谁") String message) {
Prompt prompt = new Prompt(new UserMessage(message));
Flux<ChatResponse> stream = chatModel.stream(prompt);
return stream;
}
代码解读
- 构建 Prompt 对象 :把用户输入的字符串包装成
UserMessage,再封装成Prompt - 调用
chatModel.stream(prompt):返回Flux<ChatResponse>,每个元素是一个 token 增量的响应 - 直接返回 Flux:Spring MVC / WebFlux 自动识别,以流的形式写回 HTTP 响应
特点
- 返回的是完整的
ChatResponse对象(JSON 格式),包含元数据 - 前端需要自己从每个 chunk 里提取文本内容
- 适合需要获取 token 用量、finish_reason 等完整信息的场景
⚠️ 注意:
System.out.println("Deepseek回复:"+stream)打印出来的只是 Flux 对象的引用,不是实际内容。Flux 是懒执行的,真正订阅(也就是前端开始消费)的时候才会触发数据流动。
四、流式接口二:实用版本 generateStream2
java
@GetMapping("/generatestream2")
public Flux<String> generateStream2(
@RequestParam(value="message", defaultValue="你是谁") String message,
HttpServletResponse response) {
response.setContentType("text/event-stream");
response.setCharacterEncoding("UTF-8");
Prompt prompt = new Prompt(new UserMessage(message));
Flux<ChatResponse> stream = chatModel.stream(prompt);
Flux<String> resp = stream.map(new Function<ChatResponse, String>() {
@Override
public String apply(ChatResponse chatResponse) {
return chatResponse.getResult().getOutput().getText();
}
});
return resp;
}
相比版本一的改进
1. 手动设置 SSE 响应头
java
response.setContentType("text/event-stream");
response.setCharacterEncoding("UTF-8");
明确告诉浏览器:这是 SSE 流式响应,请一条一条接收处理。
2. map 转换:从 ChatResponse 提取纯文本
用 stream.map() 把每个 ChatResponse 对象转换成纯文本字符串:
java
chatResponse.getResult().getOutput().getText()
链式调用路径:ChatResponse → Generation(result)→ AssistantMessage(output)→ String(text)
3. 前端友好
返回 Flux<String>,前端用 EventSource 或 fetch + ReadableStream 直接拿文本拼接就行,不用解析 JSON。
可以用 Lambda 简化
匿名内部类写法有点啰嗦,可以改成:
java
Flux<String> resp = stream.map(chatResponse ->
chatResponse.getResult().getOutput().getText()
);
五、运行时动态参数:runtimeOptions
java
@GetMapping("/runtimeoptions")
public String runtimeOptions(
@RequestParam(value="message", defaultValue="你是谁") String message,
@RequestParam(value="temp", required=false) Double temp) {
Prompt prompt;
if (temp != null) {
DeepSeekChatOptions build = DeepSeekChatOptions.builder()
.temperature(temp)
.build();
prompt = new Prompt(message, build);
} else {
prompt = new Prompt(message);
}
ChatResponse response = chatModel.call(prompt);
return response.getResult().getOutput().getText();
}
核心知识点
1. 配置优先级:运行时 > 全局配置
application.properties里的temperature=0.8是全局默认值- 通过
DeepSeekChatOptions构建的参数是单次请求级别的,优先级更高 - 每次调用都可以传不同的 temperature,互不影响
2. Builder 模式构建选项
java
DeepSeekChatOptions.builder()
.temperature(temp)
.model("deepseek-chat") // 也可以动态切换模型
.topP(0.9) // 其他参数也能设
.build();
除了 temperature,还能动态设置 model、topP、maxTokens 等几乎所有模型参数。
3. Prompt 的双参数构造
new Prompt(message, build) 把消息内容和本次的参数选项打包在一起,传给 chatModel.call()。
应用场景
- 前端让用户滑动调节"创意度"(temperature)
- 同一个应用里不同功能用不同参数:代码生成调低温度,写文案调高温度
- A/B 测试不同参数的效果
六、前端对接流式输出的思路(补充)
前端页面要对接 /generatestream2,不能再用普通 fetch 一次性拿结果,要用 ReadableStream 逐块读取:
javascript
// 伪代码思路
const response = await fetch('/ai/generatestream2?message=' + msg);
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
// 把 chunk 追加到 AI 消息气泡里,实现打字机效果
aiBubble.textContent += chunk;
}
这样就能实现和 ChatGPT 官网一模一样的逐字输出效果。
七、对比总结
| 调用方式 | 方法 | 返回类型 | 用户体验 | 适用场景 |
|---|---|---|---|---|
| 同步调用 | call() |
ChatResponse / String |
差,全程等待 | 简单测试、后端内部调用 |
| 流式调用 | stream() |
Flux<ChatResponse> |
好,逐字输出 | 需要完整元数据 |
| 流式调用(加工后) | stream() + map |
Flux<String> |
好,逐字输出 | 前端直接渲染,推荐 |
| 动态参数 | Prompt + ChatOptions |
同同步/流式 | - | 需要按请求调整参数 |
八、踩坑提醒
- Flux 是懒执行的:不订阅就不会真正发请求,直接打印 stream 对象看不到内容
- SSE 必须设置 Content-Type :
text/event-stream,否则浏览器当成普通响应一次性解析 - getResult() vs getResults() :单条结果用
getResult()(拿第一个),多条用getResults(),别搞混 - 流式输出中最后一条可能是空的:模型输出结束时的 finish chunk,text 可能为 null,前端要做判空
- 中文乱码问题 :记得设置
characterEncoding=UTF-8,浏览器默认可能用 GBK
总结 :这节课的两个重点------stream() 流式调用解决了用户体验问题,ChatOptions 运行时参数解决了灵活性问题。掌握这两个,基本就能做出一个体验不错的聊天应用了。下一课应该会进入多轮对话、对话记忆(ChatMemory)的部分,继续深入~