
最近接了个实打实的活给一套跑了五年多的老 Java 后台管理系统接入 AI 问答能力。这个系统还是传统 Spring Boot 模板引擎 jQuery 的老三样业务方提的需求却一点不老——“页面上直接跟 AI 对话而且打字过程要像 ChatGPT 一样一个字一个字蹦出来”。接到需求的时候网上全是“五分钟接入大模型”的爽文真上手做才发现老项目接 AI 不是拉个依赖调个接口那么简单。鉴权、超时、编码、上下文、流式推送、线程池……每一层都有坑在等着。这篇文章把我实际落地时的完整思路和代码整理出来核心就是“四层递进”先打通基础对话再做多轮上下文然后上流式输出最后封装成老项目能直接用的统一能力。这一篇先讲前两层同时把后面两层的关键点交代清楚。如果你是拿老系统练手或者正在做企业内部的 AI 助手我的建议是别急着秀流式特效老项目迭代最怕一把梭。按下面这条路线一步步走每步都能独立上线验证出了问题也知道在哪层排查。1. 先想清楚为什么老项目接入 AI 要分四步走很多人在老项目里接入 AI 时犯的第一个错误就是把 AI 当成一个“数据库操作类”来写——来个请求调接口拿结果返回。纯同步调用在小流量下看着没问题一旦页面需要打字机效果、用户连续追问、并发一上来代码就得推倒重来。1.1 老项目的真实处境老项目和新项目的差别不在技术新旧而在“约束条件”。这套系统跑在客户内网权限体系是自己写的一套过滤器链页面全是服务端渲染加 jQuery 拼接 DOM前端根本不可能上 React/Vue 那套。而且整个项目还在持续交付业务需求我不能为了接 AI 把原有架构翻个底朝天。这其实是大多数旧 Java 项目的共同处境代码能跑、文档稀缺、维护的人少你不能因为一个新功能就引入重前端、换 HTTP 框架、改部署方式。接入 AI 必须像打补丁一样边界清晰、侵入性低。1.2 四层递进的规划逻辑“四层递进”的每一层都对应真实业务场景里的一道关卡第一层“基础对话”解决的是链路打通问题。让你手里的 Java 代码能和大模型服务说上话拿到回复这是所有上层能力的地基。第二层“多轮上下文”解决的是记忆问题。用户说“帮我查一下上个问题里的订单号”AI 得记得“上个问题”是什么这就需要对会话消息做管理和持久化。第三层“流式输出”解决的是体验问题。一次 HTTP 调用动辄二三秒甚至更久用户看着白屏心里慌流式输出能把首字延迟压到一秒内让用户“边看答案边生成”。第四层“统一网关与生产化”解决的是成本和质量问题。把前三层的能力收口成一个入口对外提供同步和流式两种模式里面做超时、重试、限流、落库。到了这层才算“能用”而不是“能演示”。这个顺序不是拍脑袋定的。每一层都在上一层的基础上增加一个变量出问题了也只在新增的那一层里找原因排查成本非常低。2. 第一层让旧项目先“能说话”——基础对话接入先别想什么流式、什么打字机第一版的目标只有一个用户在页面输入一句话后台调用大模型接口把完整回复返回给前端。链路通了其他都好说。2.1 最小可运行的 AI 调用封装当时考虑过直接用 Spring 的 RestTemplate后来放弃了。老项目里 RestTemplate 大多还是 3.x 的用法不支持流式响应的回调超时配置也粗糙。我选了 OkHttp理由有三个Java 8 兼容好、连接/读取/写入三段超时分开配置、能拿到原始 ResponseBody 做流式 IO对后面的第三层特别重要。依赖加到 pom.xml 里dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.12.0/version /dependency调用大模型走的是标准的 chat/completions 接口请求体和返回都是一个 JSON。我封装了一个最简单的客户端只暴露一个 chat 方法import okhttp3.*; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import java.io.IOException; import java.util.concurrent.TimeUnit; public class SimpleAiClient { private static final String API_URL https://ai-gateway.example.com/v1/chat/completions; private static final String API_KEY sk-你的密钥; private final OkHttpClient client new OkHttpClient.Builder() .connectTimeout(5, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build(); public String chat(String userInput) throws IOException { JSONObject requestBody buildRequestBody(userInput); Request request new Request.Builder() .url(API_URL) .header(Authorization, Bearer API_KEY) .header(Content-Type, application/json; charsetutf-8) .post(RequestBody.create( requestBody.toJSONString().getBytes(java.nio.charset.StandardCharsets.UTF_8), MediaType.parse(application/json; charsetutf-8))) .build(); try (Response response client.newCall(request).execute()) { if (!response.isSuccessful()) { throw new IOException(AI 接口返回异常: HTTP response.code() , body response.body().string()); } String respBody response.body().string(); JSONObject json JSONObject.parseObject(respBody); return json.getJSONArray(choices) .getJSONObject(0) .getJSONObject(message) .getString(content); } } private JSONObject buildRequestBody(String userInput) { JSONObject body new JSONObject(); body.put(model, your-model-name); JSONArray messages new JSONArray(); JSONObject userMsg new JSONObject(); userMsg.put(role, user); userMsg.put(content, userInput); messages.add(userMsg); body.put(messages, messages); body.put(temperature, 0.7); body.put(max_tokens, 1024); return body; } }这段代码看着简单里面有三个参数值得琢磨一下。temperature 控制回复的随机性0.7 是通用对话的不错起点做客服问答可以降到 0.2 以下让它更“一本正经”。max_tokens 限制的是“回答的最大长度”不是输入长度企业内部 QA 场景设 1024 够用如果业务要它写长文再调大。readTimeout 必须给足模型生成一个 500 字的回答慢的时候真能跑 60 秒设短了直接断流。2.2 关键参数与编码细节很多人在第一层就折在编码上。老项目里 response.body().string() 拿回来乱码绝大部分原因是客户端没有明确指定 UTF-8。我在 Header 里显式写了 charsetutf-8RequestBody.create 时也把字节数组直接转成 UTF-8双保险。另一个细节是 Response 要放进 try-with-resources 里。OkHttp 的 Response 持有底层连接不用的话很容易把连接池打满。我在最初版本里就是没用 try-with-resources压测到 50 并发直接报“Connection pool exhausted”排查了半天才发现是连接没释放。2.3 这一层最容易踩的坑第一个坑是模型名不对。内网网关和大模型厂商的模型名经常不一样报错信息又模棱两可我第一次调用返回“Model Not Found”一度怀疑是网络问题。建议把模型名配置放到 application.yml 里通过 ConfigurationProperties 注入方便排查时改。第二个坑是接口返回被截断。前几次调通之后我发现回答永远停在某个字数附近打开日志才发现 max_tokens 设得太小生成的回答被硬生生截断了。判断方法是看返回 JSON 里的 finish_reason如果值是 length 而不是 stop就是截断调大 max_tokens 即可。第三个坑最隐蔽网关返回了 200但 body 里是一个错误提示。大模型网关为了兼容老客户端有时候把业务错误包在 200 响应里代码里只判断 HTTP 状态码是不够的还要解析 body 里的 error 字段。我在 catch 里同时检查了这两层生产环境少挨了好几刀。3. 第二层让 AI “记住”聊了什么——多轮上下文管理基础对话跑通后业务方很快会提第二个需求“我接着问‘那这个订单呢’AI 怎么不知道我在说哪个订单”这就是上下文问题。大模型本身是无状态的它只看你这次请求里塞了哪些消息。要实现多轮就得把之前的对话历史也发过去。3.1 消息模型与数据结构设计OpenAI 兼容接口的消息体里role 有三种system 用于设定 AI 的角色和行为准则user 是用户输入assistant 是 AI 之前的回复。多轮对话的本质就是把“历史消息按时间顺序排成一个 messages 数组”一起发过去。我定义了一个会话历史的数据结构public class ChatMessage { private String role; // system / user / assistant private String content; // 省略 getter/setter }注意 assistant 消息必须用 AI 自己的原始回答不能是自己拼的模拟结果。有些实现图省事把上一轮 AI 回答截断成几十个字存起来这会导致上下文语义丢失模型越聊越糊涂。3.2 会话池与上下文裁剪多轮对话需要有地方存会话数据。我第一版用了 ConcurrentHashMapkey 是 sessionIdvalue 是一个 DequeComponent public class SessionManager { private final MapString, DequeChatMessage sessionStore new ConcurrentHashMap(); private static final int MAX_ROUNDS 10; public ListChatMessage getContext(String sessionId) { DequeChatMessage deque sessionStore .computeIfAbsent(sessionId, k - new ArrayDeque()); return new ArrayList(deque); } public void append(String sessionId, ChatMessage userMsg, ChatMessage assistantMsg) { DequeChatMessage deque sessionStore .computeIfAbsent(sessionId, k - new ArrayDeque()); deque.addLast(userMsg); deque.addLast(assistantMsg); while (deque.size() MAX_ROUNDS * 2) { deque.pollFirst(); } } }为什么做裁剪模型有上下文窗口限制比如某些模型窗口是 8K token中文字大约一个 token 一到两个字十几轮对话加进来很容易超限。与其等它报错不如主动控制历史轮数。我这边实测消息体大约 10 轮、每轮 200 字左右在 8K 窗口下是安全的。如果业务需要更长历史建议把历史摘要在 system 消息里概括一遍而不是无限堆原文。还有一种常见做法是“摘要式前缀”把较旧的对话用“用户询问了 A你答复了 B”这样的描述压缩成一条 system 消息再把最近几轮完整消息拼在后面。这个方案第一版可以先不做但数据结构上要预留位置。3.3 老项目中会话数据持久化的选型ConcurrentHashMap 存会话服务一重启就全没了而且多实例部署时请求打到不同节点上下文就串不起来。刚开始图省事在单机跑没问题一旦部署到集群就有人来反馈“换个服务器就不认识了”。老项目最稳的做法是直接落 MySQL。表结构很简单CREATE TABLE ai_chat_session ( session_id VARCHAR(64) PRIMARY KEY, user_id VARCHAR(64), title VARCHAR(200), created_time DATETIME, updated_time DATETIME ); CREATE TABLE ai_chat_message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, session_id VARCHAR(64), role VARCHAR(16), content TEXT, created_time DATETIME, KEY idx_session_id (session_id) );每次追加消息时先查库里最近的消息组装好请求消息体再把新消息写回去。这个方案在老项目里推进阻力最小因为 DBA 熟、备份策略现成、不用引入新的中间件。如果项目里已经有 Redis用 Redis List 存最近 N 条也行但要注意给 key 设置过期时间避免死会话占着内存。4. 第三层体验升级的命门——流式输出接入基础对话和多轮对话都跑通之后我拿给业务方看对方的反馈是“怎么点完按钮要等两秒才出结果转圈圈期间心里发慌。” 这就是流式输出要解决的核心问题——降低首字延迟让用户感觉系统“秒回”。4.1 从轮询等到边等边看流式输出的技术本质是客户端发起一个 HTTP 请求服务端不立刻返回完整结果而是通过一条保持打开的连接分多次把数据推给客户端。网上 Chat 页面那种一个字一个字蹦的效果靠的就是这个。实现流式有两种常见方案WebSocket 和 SSEServer-Sent Events。我在这个项目里选了 SSE。原因有三点第一SSE 是单向服务端推送AI 对话正好是“客户端提问一次、服务端连续推答案”的模式用的就是单向通道第二SSE 基于普通 HTTP老项目的前后端都不用改通信框架防火墙上也不用专门开 WebSocket 的通道第三SSE 自带断线重连机制前端 EventSource 对象挂了会自动重连省心。4.2 SSE 在 Spring Boot 里的落地Spring 框架里做 SSE 最方便的是 SseEmitter。Controller 方法返回一个 SseEmitterSpring 会把持住这个 HTTP 连接你在任意线程里调用 emitter.send()数据就实时推到前端。核心实现是这样RestController RequestMapping(/api/ai) public class AiStreamController { private final SimpleAiClient aiClient; private final SessionManager sessionManager; private final ExecutorService streamPool Executors.newFixedThreadPool(20); GetMapping(value /stream, produces text/event-stream;charsetutf-8) public SseEmitter stream(RequestParam String sessionId, RequestParam String question) { SseEmitter emitter new SseEmitter(180000L); streamPool.submit(() - { StringBuilder fullAnswer new StringBuilder(); try { ListChatMessage context sessionManager.getContext(sessionId); context.add(new ChatMessage(user, question)); // 发起流式请求OkHttp 在子线程里回调 aiClient.streamChat(context, new AiStreamCallback() { Override public void onDelta(String delta) { fullAnswer.append(delta); emitter.send(SseEmitter.event().name(delta).data(delta)); } Override public void onComplete() { sessionManager.append(sessionId, new ChatMessage(user, question), new ChatMessage(assistant, fullAnswer.toString())); emitter.complete(); } Override public void onError(Throwable t) { emitter.completeWithError(t); } }); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; } }这里有一个很多教程没讲的坑SseEmitter 默认有超时时间如果不设置Spring MVC 默认超时后会自动把连接断开前端会看到“ERR_CONNECTION_RESET”。我这边显式传了 180000L3 分钟和模型最长生成时间对齐。另一个容易出错的地方是产生 SseEmitter 的 HTTP 请求线程和真正去拉模型数据的线程不是同一个。Controller 方法里 submit 到线程池之后方法直接返回 emitter这时候 HTTP 连接被 Spring 保持住没有关闭。如果你在 sync 线程里直接去拉模型阻塞的是 Tomcat 的工作线程并发一高线程池就耗尽页面全部卡住。所以必须用独立的业务线程池去转发模型流Tomcat 线程只负责收前端连接。4.3 前端“打字机”效果的实现前端部分老项目不可能上 React 全家桶我用原生 fetch 也能读取 SSE 流。核心是拿到 reader 之后手动解析async function streamChat(sessionId, question) { const resp await fetch(/api/ai/stream?sessionId sessionId question encodeURIComponent(question)); const reader resp.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // SSE 数据帧以空行分隔 const frames buffer.split(\n\n); buffer frames.pop(); for (const frame of frames) { const delta frame .split(\n) .filter(line line.startsWith(data:)) .map(line line.slice(5)) .join(); updateAnswer(delta); // 追加到页面上 } } }有一个细节SSE 数据帧的边界是“两个连续换行”而 utf-8 的中文字符可能被拆在两次网络包里头所以必须维护一个 buffer把没拆完的半截帧留在下一次循环再处理。我第一次写的时候没管这个中文偶尔会蹦出一个乱码字符查了很久才定位到是分帧问题。还有一个更隐蔽的问题Chrome 对每分钟 EventSource 连接数有限制如果页面里同时开着多个会话卡片就可能有连接打不开的情况。实际落地时我给页面加了一个限制默认最多同时打开两个会话流用完立刻 close避开浏览器限制。5. 第四层把能力装进老代码——统一网关与生产化前三层跑通AI 功能已经“能看能聊”但离“能上线”还差一口气。老项目里这么多页面、这么多权限点不可能让每个 Controller 都自己去拼消息体、管理会话、处理异常。到了这层要做的是把 AI 能力收口成一个统一服务让老代码像调用普通 Service 一样使用 AI。5.1 统一 AI 服务入口我在项目里建了一个 AiChatService对外暴露两个方法chatSync同步完整回复和 chatStream流式回调。Controller 层不管底下是哪个模型厂商、是不是流式只管传 sessionId 和问题拿结果。Service public class AiChatService { public String chatSync(String sessionId, String question) { ListChatMessage context sessionManager.getContext(sessionId); context.add(new ChatMessage(user, question)); String answer aiClient.chat(context); sessionManager.append(sessionId, new ChatMessage(user, question), new ChatMessage(assistant, answer)); return answer; } public void chatStream(String sessionId, String question, AiStreamCallback callback) { // 实现见上文 Controller 中的逻辑 } }统一收口之后老项目原有的 Controller 里只需要注入 AiChatService 调一行代码。不同页面可以共享同一个会话也可以各自建会话全部由 sessionId 隔离。多轮上下文、落库逻辑都在 Service 里收敛了业务代码零感知。5.2 超时、重试、限流的配置生产环境的第一个杀手就是超时。模型服务再稳也有 1% 的请求会慢甚至卡住。我在 OkHttp client 上配了 connectTimeout 5 秒、readTimeout 120 秒、writeTimeout 30 秒。连接失败可以快速判断读取阶段给足缓冲。第二个杀手是重试风暴。老项目接了 AI 之后用户心存疑惑会狂点“发送”网关侧如果没兜底直接把模型服务打挂。我在 Service 层加了一个简单限流每用户每分钟最多 20 次对话超出直接抛业务异常。同时在 AI 调用层做了指数退避重试只对网络异常重试HTTP 4xx 不重试因为那多半是参数问题重试只会雪上加霜。第三个杀手是 token 超限。用户一次性粘贴大段文本进来加上历史消息很容易把上下文窗口撑爆。我在网关层加了一个预检把用户输入按 4 个字符约等于 1 个 token 粗算超过上限直接提示“内容过长请分段提问”不走模型调用。这个粗算方法在多数中文场景下比较保守能挡掉 90% 的问题。5.3 历史记录落库与回放前文提到建了 ai_chat_session 和 ai_chat_message 两张表到了第四层还要加一个回放能力。用户刷新页面之后如果能看到历史问答列表体验会好一大截。这个功能在老项目里实现起来很顺列表接口查 session 表详情接口查 message 表把消息按顺序渲染出来即可。这里有个处理经验的积累落库时机不要在模型回复结束后再一次性写而是流式开始之前先落一条空的 user 消息流式过程中实时把内容 append 到 assistant 消息里。万一中间断了至少用户的问题还在方便排查。我用 UPDATE ai_chat_message SET content CONCAT(content, ?) WHERE id ? 实现追加跑了两个月没有遇到性能问题因为这个表的写入量级跟业务主表完全不在一个数量级。6. 实测中的高频问题与排查指南最后把我在这个项目里见过的、自己和同事踩过的问题整理一下有些问题查了一两个小时写上排查思路希望大家少走弯路。6.1 高频问题清单现象根因排查/修复思路接口返回乱码客户端或服务端未指定 UTF-8Header 与 Body 均显式指定 charsetutf-8首字延迟低但字节蹦字很慢前端每帧都拿完整内容去渲染改为只 append 增量 delta不重复解析全文流式连接 3 分钟左右断SseEmitter 超时未设置显式 new SseEmitter(180000L) 对齐模型生成时长大量请求超时Tomcat 线程耗尽在 Controller 同步线程里等模型响应引入独立业务线程池立刻返回 SseEmitter多实例部署后上下文串台会话存本地内存迁移到 Redis 或 MySQL 存储回答固定被截断max_tokens 太小检查 finish_reason为 length 时调大 max_tokens重启后聊天记录消失未落库建立 session/message 表流式开始前先落 user 消息6.2 几个现场排查实录有一次线上反馈某个页面点了发送之后前端一直转圈。我看后台日志SseEmitter 已经创建但模型回调一直没触发。查到最后是网关侧把请求限流了返回了一个 429 状态码而 OkHttp 的流式回调在非 200 响应时不会进入正常的 delta 回调分支直接走到了 onError。由于当时 onError 里只打了日志没传给前端前端就卡在“连接已建立但无数据”的状态。修复方法是两层第一onError 里必须调 emitter.completeWithError()把错误信号传给前端第二OkHttp 回调里先检查 response.isSuccessful()非成功直接构造异常抛给 onError。这样前端收到 error 事件就渲染一个“模型服务繁忙请稍后重试”的友好提示而不是永远转圈。还有一次是流式输出到一半前端突然停住刷新后整段回答消失。回看代码发现assistant 消息落库是在 onComplete 回调里做的而连接中断时 onComplete 不会执行所以那半段回答压根没存。后来改成每收到一个 delta 就实时更新数据库里的 content 字段中断了也能保留部分内容用户刷新页面还能看到“半截回答”比什么都没有强得多。另有一个容易忽略的点生产环境日志里如果有大量“Broken pipe”异常多半是用户没等流式结束就关闭了页面此时 emitter.send() 会抛 IOException这是正常的不需要告警只需要在 catch 里区分一下异常类型把断连相关异常降级为 warn 日志否则告警平台天天晚上被吵醒。写在最后的一次实战体会这套四层方案从第一版基础对话到流式输出全部上线前后大约两周。我最大的感触是老项目接 AI 不缺少“技术炸点”缺的是“最小侵入”的落地节奏。每一层都能独立交付、独立验证业务方在每个阶段都能看到可用的东西这比憋一个大招然后翻车要踏实得多。如果你也要动手做类似的事情我的具体建议是——第一层别追求完美封装先让链路通起来第二层别急着上 RedisMySQL 一张表就能起步第三层流式用 SseEmitter 就好别一上来就 WebSocket 打通所有第四层再回头补限流、重试、监控这些生产要素。后续如果业务要继续深化可以在此基础上扩展知识库问答、历史会话搜索、甚至让 AI 调用老项目里的内部接口来做任务编排——到那时候这套四层结构依然不会过时它本身就是给这些能力预留的接缝。