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
@GetMapping("/generatestream")publicFlux<ChatResponse>generateStream(@RequestParam(value="message",defaultValue="你是谁")Stringmessage){Promptprompt=newPrompt(newUserMessage(message));Flux<ChatResponse>stream=chatModel.stream(prompt);returnstream;}代码解读
- 构建 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
@GetMapping("/generatestream2")publicFlux<String>generateStream2(@RequestParam(value="message",defaultValue="你是谁")Stringmessage,HttpServletResponseresponse){response.setContentType("text/event-stream");response.setCharacterEncoding("UTF-8");Promptprompt=newPrompt(newUserMessage(message));Flux<ChatResponse>stream=chatModel.stream(prompt);Flux<String>resp=stream.map(newFunction<ChatResponse,String>(){@OverridepublicStringapply(ChatResponsechatResponse){returnchatResponse.getResult().getOutput().getText();}});returnresp;}相比版本一的改进
1. 手动设置 SSE 响应头
response.setContentType("text/event-stream");response.setCharacterEncoding("UTF-8");明确告诉浏览器:这是 SSE 流式响应,请一条一条接收处理。
2. map 转换:从 ChatResponse 提取纯文本
用stream.map()把每个ChatResponse对象转换成纯文本字符串:
chatResponse.getResult().getOutput().getText()链式调用路径:ChatResponse→Generation(result)→AssistantMessage(output)→String(text)
3. 前端友好
返回Flux<String>,前端用EventSource或fetch + ReadableStream直接拿文本拼接就行,不用解析 JSON。
可以用 Lambda 简化
匿名内部类写法有点啰嗦,可以改成:
Flux<String>resp=stream.map(chatResponse->chatResponse.getResult().getOutput().getText());五、运行时动态参数:runtimeOptions
@GetMapping("/runtimeoptions")publicStringruntimeOptions(@RequestParam(value="message",defaultValue="你是谁")Stringmessage,@RequestParam(value="temp",required=false)Doubletemp){Promptprompt;if(temp!=null){DeepSeekChatOptionsbuild=DeepSeekChatOptions.builder().temperature(temp).build();prompt=newPrompt(message,build);}else{prompt=newPrompt(message);}ChatResponseresponse=chatModel.call(prompt);returnresponse.getResult().getOutput().getText();}核心知识点
1. 配置优先级:运行时 > 全局配置
application.properties里的temperature=0.8是全局默认值- 通过
DeepSeekChatOptions构建的参数是单次请求级别的,优先级更高 - 每次调用都可以传不同的 temperature,互不影响
2. Builder 模式构建选项
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逐块读取:
// 伪代码思路constresponse=awaitfetch('/ai/generatestream2?message='+msg);constreader=response.body.getReader();constdecoder=newTextDecoder();while(true){const{done,value}=awaitreader.read();if(done)break;constchunk=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)的部分,继续深入~