☰
一名 Java 后端的 AI 转型实录(二)
2026/10/17 2:51:33 网站建设 项目流程

一名 Java 后端的 AI 转型实录(二):Day 2,让模型 “认识” 我

AI 转型系列・第 2 篇

昨天我们已经让 Spring AI 输出第一句对话,今天要实现流式输出,并且让模型拥有对话记忆。代码编写只花十分钟,但排查乱码耗费俩小时,还上演了一场和 AI 助手的字节码对质。

承上启下:今日两大核心任务

Day1 实现的/chat接口属于同步调用:发送提问,阻塞等待,拿到完整的模型返回结果。今天我们要完成两个核心能力:

  1. 流式输出:复刻 ChatGPT 逐字输出效果,基于stream()+ SSE 实现服务端推送;
  2. 对话记忆:实现会话隔离,让模型记住上下文对话(MessageChatMemoryAdvisor)。

遵循用中学原则,两个能力都走核心链路手写实现。业务代码本身难度不高,但框架底层、HTTP 协议层面的坑消耗了大部分时间。

任务一:stream () 流式输出 —— 业务代码十分钟完成

流式接口核心代码如下:

@GetMapping(value = "/chat/stream", produces = "text/event-stream;charset=UTF-8") public ResponseEntity<Flux<String>> chatStream(@RequestParam String q) { return ResponseEntity.ok(chatClient.prompt().user(q).stream().content()); }

关键知识点梳理

  1. call()和stream()本质区别不是返回对象,而是数据返回时机
    • call():同步阻塞,等待模型生成全部内容后一次性返回完整结果;
    • stream():流式响应,模型每生成一段内容,就立刻推送给客户端。
  2. 返回值变更:返回Flux<String>,代表源源不断陆续到达的字符串数据流;
  3. Controller 注解必须配置produces = "text/event-stream;charset=UTF-8",声明响应为 SSE(Server‑Sent Events)协议。

代码启动完成,接口可以正常推送 SSE 数据流,但浏览器直接访问出现中文乱码:

data: 我能 data:å¸®ä½ data:做的事

接下来两个小时全部用来解决这个中文乱码问题。

乱码围剿战:完整踩坑复盘与根治方案

核心结论:服务端输出字节本身是标准 UTF‑8,乱码根源是响应头缺少 charset,浏览器自动猜测编码;Safari 在中文环境会误猜为 GBK,Chrome 多数场景默认 UTF‑8,造成浏览器表现不一致的玄学现象。

三步排查流程

  1. 校验原始字节:使用curl ... | xxd查看原始响应字节,确认后端输出是合法 UTF‑8(例:e4 bd a0对应汉字「你」)。字节没问题,说明不是后端业务代码 bug。
  2. 定位问题根源:当text/event‑stream响应头没有携带charset属性时,Safari 会自动猜测编码,中文系统环境经常识别为 GBK;Chrome 大多识别 UTF‑8,就出现 “换浏览器就正常” 的迷惑现象。
  3. 根治方案:强制在Content‑Type响应头追加charset=UTF‑8,交给浏览器明确编码,消除自动猜测行为。

通用过滤器方案(全局所有 SSE 接口生效)

实现OncePerRequestFilter,包装 HttpServletResponse,拦截所有设置Content‑Type的入口,自动补全编码。一次配置,项目内全部 SSE 接口永久规避该问题。

⚠️ 重要提醒:@Component注解不能漏(踩坑经历),否则 Filter 不会被 Spring 容器注册,代码完全不执行。

import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponseWrapper; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @Component public class SseCharsetFilter extends OncePerRequestFilter { /** * 给 text/event‑stream 类型补充 charset=UTF‑8 */ private static String fixContentType(String value) { if (value != null && value.startsWith("text/event‑stream") && !value.toLowerCase().contains("charset")) { return value + ";charset=UTF‑8"; } return value; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { chain.doFilter(request, new HttpServletResponseWrapper(response) { @Override public void setContentType(String type) { super.setContentType(fixContentType(type)); } @Override public void setHeader(String name, String value) { if ("Content‑Type".equalsIgnoreCase(name)) { value = fixContentType(value); } super.setHeader(name, value); } @Override public void addHeader(String name, String value) { if ("Content‑Type".equalsIgnoreCase(name)) { value = fixContentType(value); } super.addHeader(name, value); } }); } }

重启服务后,执行curl -sD -校验响应头:
Content‑Type: text/event‑stream;charset=UTF‑8,Safari 中文展示恢复正常。

坑点彩蛋:Spring 6.2 底层行为

最容易踩的坑:一开始只重写setContentType(),过滤器完全无效。
反编译spring‑web‑6.2.8.jar中ServletServerHttpResponse.writeHeaders()源码发现:Spring6.2 设置 Content‑Type 底层调用的是addHeader(),并不会调用setContentType()。

启示:不要主观臆断框架调用链路,你以为的统一入口,框架底层可能根本不走。所以过滤器必须同时重写三个方法。

补充生产环境说明:原生前端EventSource对象,规范强制使用 UTF‑8 解码,因此前端标准消费 SSE 不会出现乱码;乱码问题只出现在浏览器直接访问接口地址裸看 SSE 数据流的调试场景。我写了简短 JS 聊天页面做验证,打字机流式效果,中文完全正常。

任务二:对话记忆 —— 大模型本身是失忆症患者

底层本质:ChatModel本身是无状态。每一次网络请求对模型来说都是全新会话;所谓记忆,本质是框架把历史对话消息追加到请求报文里传给大模型。

Spring AI 1.1.0 记忆组件四层链路:

ChatModel(无状态大模型) ↑ ChatClient(门面,挂载人设、Advisor拦截器) MessageChatMemoryAdvisor(拦截器:请求读取历史,响应后保存对话) ↑ ChatMemory = MessageWindowChatMemory(实现消息窗口截断,默认最多保存20条消息) ChatMemoryRepository(持久化存储,示例使用JDBC+H2数据库)

业务调用,通过会话 ID 隔离不同用户对话:

chatClient.prompt().user(q) .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, cid)) .call().content();

代码关键点说明

  1. param(ChatMemory.CONVERSATION_ID, cid):每次请求传入会话 ID,这个参数交给 Advisor,用来区分不同会话;
  2. Bean 上配置的defaultAdvisors决定是否具备记忆能力;请求时传入的CONVERSATION_ID参数,决定这条对话读写哪一份会话记录;
  3. CONVERSATION_ID是字符串常量,编译器不会做语法校验,写错不会抛异常,只会记忆静默失效,这是 Spring AI 高频踩坑点。

踩坑记录:记忆完全不生效

所有配置完成,但是无法记住上下文。排查发现:Controller 内部手动调用ChatClient.create(chatModel)构建全新的 ChatClient 对象,直接绕过 Spring 容器中装配好的 Bean。

记忆、人设全部挂载在容器托管的 ChatClient Bean,手动 new 创建的裸对象不会携带任何 Advisor。修复:直接构造器注入容器中的 ChatClient Bean。

硬核现场:识破 AI 助手编造的 API

向 AI 助手提问:如何设置记忆窗口最大消息条数。AI 给出示例:MessageChatMemoryAdvisor.builder().chatMemoryRetrieveSize(20)。
复制代码直接编译报错,该方法不存在。当我指出错误后,AI 直接反编译本地 maven jar 包核对字节码,确认该 API 是虚构,主动修正答案。

Jar 包字节码得到事实(Spring AI 1.1.0)

  1. MessageChatMemoryAdvisor.Builder只有 4 个方法:conversationId/order/scheduler/build,不存在chatMemoryRetrieveSize;
  2. ChatMemory接口已经移除RETRIEVE_SIZE常量(旧版本教程存在,1.1.0 版本删除);
  3. Advisor 的before()方法会全量读取会话历史,消息窗口截断逻辑不在 Advisor;
  4. 默认实现MessageWindowChatMemory默认最大消息数为 20;
  5. 消息截断发生在消息存入内存 / 数据库的时候;超过窗口大小,淘汰最早的消息;SystemMessage 系统人设消息永远不会被淘汰,所以对话再长,人设始终保留。

✅ Spring AI1.1.0 设置会话窗口大小正确写法:

@Bean public ChatMemory chatMemory(ChatMemoryRepository repository) { return MessageWindowChatMemory.builder() .chatMemoryRepository(repository) .maxMessages(30) // 修改最大消息条数,默认20 .build(); }

重要启示:AI 的知识库存在时间截止。框架版本迭代,API 会删除、重构,AI 可能输出已经不存在的接口。Day1 定下的铁律:AI 生成代码必须实测验证。今天相当于 AI 助手自己演示了一次编造 API 踩坑案例。AI 犯错不可怕,不加核验直接复制才会出问题。

Day2 踩坑汇总(7个,按实际遇到顺序)

  1. SSE 返回中文显示 Latin‑1 样式乱码:服务端字节正确,是客户端解码错误,可以用 xxd 校验原始字节;
  2. 增加 charset 配置后乱码样式发生变化:字节流没有改变,只是切换了错误的解码器;
  3. Safari 浏览器直接访问 SSE 接口出现乱码,Chrome 正常:缺少 charset 响应头,Safari 自动猜测编码为 GBK;
  4. Filter 代码正确,但是完全不执行:忘记添加@Component,过滤器没有注册到 Spring 容器;
  5. Filter 注册成功依旧乱码:Spring6.2 底层使用addHeader()写入 Content‑Type,只重写 setContentType 拦截不到;
  6. 对话记忆功能完全失效:Controller 内部手动创建 ChatClient,绕开容器装配好的 Bean;
  7. AI 生成虚构 APIchatMemoryRetrieveSize,编译报错:版本迭代 API 被删除,必须查看 jar 源码确认;

Day2 收获清单

  • ✅ SSE 流式接口/api/chat/stream完整跑通,配套前端聊天页面实现打字机逐字输出;
  • ✅ SSE 中文乱码根治方案:SseCharsetFilter,同时拦截三套设置响应头方法,适配 Spring6.2 底层实现;
  • ✅ 对话记忆链路完成:MessageWindowChatMemory设置窗口大小 + Advisor + 会话 ID 隔离,上下文记忆验证通过;
  • ✅ 三件核心能力集齐:给模型设置人设模板、模型记住对话上下文、流式输出结果给前端。

写在最后

今天实现的流式输出、对话记忆,业务代码都很短。真正消耗时间的,是代码之外的知识:HTTP 编码协商、框架底层实现路径、AI 输出内容的版本陷阱。

继续遵守我的转型纪律:每天完成一个闭环,宁可少学,不要断链。Day2 闭环完成。

明天 Day3:结构化输出 + Prompt 模板,让模型输出直接转为 Java 对象。也就是实现「模型说给程序听」,是 Demo 从 “可以聊天” 进化到 “业务可用” 的关键一步。

如果你也在做 Java 后端 AI 转型,欢迎评论区分享你踩过的坑,尤其是被 AI 一本正经输出错误代码的经历。


本文是「Java 后端 AI 转型实录」系列第 2 篇(共 8 篇)。项目后续开源在 GitHub(链接见主页),每周更新。

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

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

立即咨询