
1. 先说结论老项目接 AI别急着换框架最近公司把一个 2016 年的 Spring Boot 2.0 老项目交到我手上业务逻辑密密麻麻结构还是多模块的 War 包JDK 8。需求倒也不复杂在现有系统里加一个人机对话入口用户发消息AI 给回答体验要像现在的主流聊天工具一样逐字输出不能一直转圈。我一开始也在版上看到各种 Spring AI 的案例看起来挺美结果一引入依赖直接跟项目里的老 Spring 版本撞车一堆 jar 冲突和类加载异常。试了两天我发现这条路不适合老项目。后来我放弃“华丽方案”老老实实按一个四层递进的思路手写接入第一层先打通大模型 API 的同步调用第二层加上多轮上下文和异步处理第三层做 SSE 流式输出第四层再补配置、模型切换、日志监控这些工程化细节。整个改造过程没有改老项目的主框架没有引入重型组件代码量也不大但效果是立竿见影的。这套东西用到现在的项目里效果很稳所以我把完整过程整理出来做成一个系列。这篇是第一篇核心就盯在“基础对话”到“流式输出”这条主线上。文章的范围适合两类人一类是和我一样在维护老单体 Java 项目又想快速接 AI 能力的后端工程师另一类是刚接触大模型 API 集成想找一个不绕弯的 Spring Boot 落地方案的开发者。看这篇文章之前你需要一点 Java 和 Spring 的基础但对大模型本身不用有什么深研究我会把原理掰开揉碎讲。2. 四层递进的整体设计每一层解决什么2.1 L1 同步调用先让对话跑起来很多人一开始会问接入 AI 有什么难的不就是 HTTP 调一个接口再返回吗对第一层就是这个思路难就难在“先跑通”。你面对的 API 不是自己内部的服务它有自己的协议、鉴权、错误码和响应结构。老项目里各种历史包袱又多网络代理、SSL 证书、依赖冲突都可能成为拦路虎。L1 的核心目标只有一个能够稳定地发送一次请求拿到完整回复并把结果带回到业务里。这一层不追求多轮对话不追求打字机效果甚至不追求异步化就是把最基础的通路打通。只有跑通这一层后续所有优化才有意义。具体到技术选型老项目普遍是 JDK 8 Spring Boot 2.x很多新框架根本不兼容。JDK 8 自带的 HttpURLConnection 太难用RestTemplate 虽然能用但对流式响应支持比较弱。我最后选的是 OkHttp 3轻量、稳定、社区成熟也不会给老项目带来侵入式改造。如果你项目里已经有 Apache HttpClient 4 或 5使用体验也差不多不必刻意换。2.2 L2 异步与上下文从“玩具”到“能用”第一层跑通之后你会发现一个问题调用大模型 API 是非常耗时的几十毫秒到几十秒都可能。如果一个用户请求进来业务线程傻傻等着一旦并发上来线程池很快就被占满整个老项目都跟着卡。这不是危言耸听我在压测时就见过 20 个并发直接把接口拖死的案例。所以第二层要做两件事一是把 AI 调用放到独立的线程池里异步执行避免阻塞主业务线程二是引入会话上下文。所谓上下文本质就是把聊天历史一起发给大模型让它能记住前面说过的话而不是每次答非所问。旧项目里的登录用户、会话 ID 这些东西现成就有稍微包装一下就能和 AI 上下文打通。很多教程只告诉你“调接口、拿结果”但没告诉你上下文是要自己维护的。大模型 API 本身就是无状态的你发多少条历史消息它就基于多少条回答这个“记忆”完全由调用方管理。第二层如果不做你后续做流式输出也只是把一个没脑子的问答机变成打字机而已。2.3 L3 流式输出把等待变成阅读第三层是最出效果的一层。传统的同步返回是用户等上好几秒然后一次性看到全部内容。流式输出是服务端把大模型返回的文本分成一小块一小块像打字机一样持续推给前端用户第一秒就能看到开头体验完全不一样。大模型 API 的流式输出走的是 SSEServer-Sent Events协议。简单说就是请求发出后HTTP 响应连接不关闭服务端以多行data:格式持续推送内容最后以一个特定结束标记收尾。Java 端要做的就是把这个 SSE 流解析出来再通过 Spring 的 SseEmitter 转发给前端浏览器。这个环节的坑非常多后面我会详细写编码问题、超时设置、断线重连、事件格式不统一、旧前端怎么接每一项都足够让新手折腾半天。2.4 L4 工程化能上线、能运维才算完第四层是很多自嗨型教程忽略的部分却是真正决定能不能上生产的一层。你需要把 API 地址、密钥、模型名从代码里挪到配置文件或者配置中心需要支持不同业务线用不同模型需要在日志里能看到每次请求的 Token 用量、耗时、错误原因当 AI 服务不稳定时你要能优雅降级而不是把异常直接抛给用户。老项目最怕的是“牵一发动全身”所以我第四层的原则就是“配置化 隔离”。AI 相关的所有东西都收敛到一个独立的 service 包里和业务代码解耦。这样就算大模型服务商要切换或者模型升级你改一行配置就能完成不用去业务代码里东翻西找。3. 实操先跑通 L1 同步对话3.1 动手前必须先做的环境检查我建议你在写任何代码之前先花十分钟把环境摸清楚。老项目最坑的不是技术而是未知的依赖和历史债务。首先是确认 JDK 版本。如果是 JDK 8就别指望用 JDK 11 才有的java.net.http.HttpClient如果是 JDK 17 那选择就多很多。我的项目是 JDK 8所以出于稳妥考虑HTTP 客户端直接用 OkHttp。你可以在 pom.xml 里加这么一段dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version3.14.9/version /dependency版本号为什么要选 3.14.9 而不是最新的 4.x因为 4.x 是用 Kotlin 重写的虽然 JVM 上能跑但在一些老项目中会有意外的依赖链问题。如果你的项目里没有 Kotlin 运行环境用 3.x 最省事。这是我在实际项目中试出来的结论后面还会继续说这种选型上的细节。其次确认项目里已有的 JSON 库。fastjson 也好Jackson 也好选一个你熟悉的不要同时混用多个。下面的示例我统一用 Jackson这是 Spring Boot 自带的老朋友不会出错。环境检查的最后一步是网络。如果你的服务部署在公司内网或云服务器上先确认能不能访问大模型 API 的公网地址。如果不能找运维开通出口白名单或者配一个 HTTP 代理这个环节没搞定后面写多漂亮的代码都白搭。3.2 同步调用完整代码与解析环境准备好之后同步调用就非常简单了。以目前各家大模型平台基本都兼容的 OpenAI Chat Completions 接口格式为例请求体是一个 JSON里面包含模型名、消息列表和采样参数。下面这个是我在实际项目里抽出来的简化版代码public class LlmClient { private static final String API_URL https://your-gateway.example.com/v1/chat/completions; private static final String API_KEY System.getenv(AI_API_KEY); private final OkHttpClient client new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build(); private final ObjectMapper mapper new ObjectMapper(); public String chatSync(String prompt) throws Exception { MapString, Object body new HashMap(); body.put(model, your-model-name); body.put(messages, List.of(Map.of(role, user, content, prompt))); body.put(stream, false); body.put(temperature, 0.7); Request request new Request.Builder() .url(API_URL) .addHeader(Authorization, Bearer API_KEY) .addHeader(Content-Type, application/json) .post(RequestBody.create(application/json; charsetutf-8, mapper.writeValueAsString(body))) .build(); try (Response response client.newCall(request).execute()) { if (!response.isSuccessful()) { String errBody response.body() ! null ? response.body().string() : ; throw new RuntimeException(LLM API error: response.code() errBody); } JsonNode root mapper.readTree(response.body().string()); return root.path(choices).get(0).path(message).path(content).asText(); } } }这里有三个细节要特别说明。第一个readTimeout必须设置得足够长。大模型推理不像普通接口那么快尤其是长文本生成几十秒很常见。如果按照老项目里默认的 3 秒或 5 秒超时去调几乎每次都会失败。我最后定的是 120 秒因为绝大多数请求不会超过这个时间。第二个鉴权头里有个空格问题。Bearer后面一定要跟一个空格很多新人写BearerAPI_KEY少了这个空格服务端就会返回 401排查时又很难发现。第三个错误处理绝不能省。大模型的 API 可能在模型名称写错、Token 超出上限、服务端过载时返回各种非 200 状态码。你至少要能够把错误信息打出来否则生产环境出问题项目组会非常痛苦。3.3 密钥与模型配置不要写死在代码里上面那段代码把 API_URL 和 API_KEY 写死是不适合上生产的。我建议至少使用环境变量更理想的做法是放到 Spring Boot 的配置文件中打包时用不同 profile 区分。如果你的公司有配置中心比如 Nacos那就更省事了。ai: api-url: ${AI_API_URL:https://your-gateway.example.com/v1/chat/completions} api-key: ${AI_API_KEY:} default-model: ${AI_DEFAULT_MODEL:your-model-name}配合一个普通的配置类Component ConfigurationProperties(prefix ai) public class AiProperties { private String apiUrl; private String apiKey; private String defaultModel; private int connectTimeout 10; private int readTimeout 120; // getter/setter 省略 }这种写法的好处是以后换模型厂商或者切换模型时运维改配置就行开发不用发版。老项目里最怕为一个小功能改版本能配置化解决的问题尽量别碰代码。4. 实操L2 多轮上下文与异步化4.1 会话上下文设计L1 跑通后你会立刻发现第二个问题AI 不记得自己说过什么。你问“你好”它回“你好”你再问“我刚才说了什么”它就一脸懵。原因在于大模型 API 是无状态的你必须把历史消息一起发给它。我设计上很简单用 ConcurrentHashMap 把会话 ID 映射到消息列表一个用户一个上下文。消息体跟 API 协议对齐就是role和content两个字段。围观对话处理器把用户输入加进列表调完 API 再把助手回复追加进去。Service public class ChatContextService { private final ConcurrentHashMapString, ListMessage contexts new ConcurrentHashMap(); public ListMessage getOrCreate(String sessionId) { return contexts.computeIfAbsent(sessionId, k - new ArrayList()); } public void append(String sessionId, String role, String content) { ListMessage list getOrCreate(sessionId); list.add(new Message(role, content)); trimIfTooLong(list); } private void trimIfTooLong(ListMessage list) { // 避免无限膨胀 if (list.size() 60) { int from list.size() - 50; ListMessage subList new ArrayList(list.subList(from, list.size())); list.clear(); list.addAll(subList); } } }这种内存 Map 的方式严格来说在生产环境扛不住多节点部署。你如果有 Redis建议把 sessionId 当作 key存储经过序列化处理的历史消息。我这里为了讲清楚核心思路先用本地 Map实际项目里大家要结合自己已有中间件来改造。只要把存取方式替换掉其他逻辑都不用动。4.2 线程池异步改造同步调用如果放在 Controller 线程里执行很容易出问题。Spring MVC 的 Tomcat 线程池默认也就 200 个左右AI 接口一次卡几秒吞吐量直接崩。我习惯的做法是单独建一个线程池专门处理 AI 调用并且必须有名字、有界队列、拒绝策略这是老项目复用线程池最容易踩的三个雷。Configuration public class AiThreadPoolConfig { Bean(aiExecutor) public ExecutorService aiExecutor() { return new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(1000), new ThreadFactoryBuilder().setNameFormat(ai-chat-%d).build(), new ThreadPoolExecutor.AbortPolicy() ); } }这里我要解释一下为什么不用Executors.newFixedThreadPool。工厂方法创建的是无界队列任务越多排队越多一旦 AI 服务变慢积压任务能把你内存堆满。而且默认线程名是pool-1-thread-1等你排查问题时根本不知道是哪个线程在干 AI 的活。所以自建线程池线程名必须自定义拒绝策略必须明确。调用方用一个Future把任务交出去结果可以放在响应对象里返回给前端也可以做回调处理。异步并不是为了立刻拿到结果而是把“慢”和“快”隔离开。4.3 上下文长度控制多轮对话一旦跑起来消息列表会越来越长最终超过大模型 API 的 Token 上限。每个模型对 Token 长度都有严格限制你硬塞太多历史消息服务端直接报错。我的办法是控制轮数上限同时在发送给 API 前估算一下字符数量。如果字符数超了某个阈值就丢弃最早的部分历史只保留最近几轮。public ListMessage buildMessages(String sessionId, String userInput) { ListMessage history contextService.getOrCreate(sessionId); ListMessage all new ArrayList(history); all.add(new Message(user, userInput)); // 只保留最近 20 轮 if (all.size() 40) { all new ArrayList(all.subList(all.size() - 40, all.size())); } // 系统提示词如果有单独保留 return all; }这个阈值不是什么科学计算是我根据常用模型上下文窗口倒推出来的经验值。你完全可以根据自己用的模型调整。核心思路是宁可丢一点早期记忆也要保证请求不失败。5. 实操L3 流式输出的完整链路5.1 SSE 协议与流式返回的形态进入整篇文章最核心的部分。SSE 协议说白了就是服务端不关闭响应连接把数据分成多个片段推送出去。跟 WebSocket 的区别在于它是单向的只能服务端推给客户端但对“AI 实时聊天”这个场景完全够用。大模型 API 在流式模式下的响应长这样data: {choices:[{delta:{role:assistant},index:0}]} data: {choices:[{delta:{content:你},index:0}]} data: {choices:[{delta:{content:好},index:0}]} data: [DONE]每一行data:就是一个事件块delta.content是新增的内容片段[DONE]是结束标记。你要做的就是在这个流结束之前把内容片段转发给浏览器。5.2 后端 SseEmitter 实现Spring Boot 2.x 里有一个很好用的类叫 SseEmitter它专门用来做服务端推送给浏览器的事件流。你可以把它理解成一个管道后端往管道里塞数据前端管道口接数据。Controller 里这么写RestController RequestMapping(/api/ai) public class AiChatController { Autowired private AiStreamService aiStreamService; GetMapping(value /chat/stream, produces text/event-stream;charsetUTF-8) public SseEmitter streamChat(RequestParam String sessionId, RequestParam String prompt) { SseEmitter emitter new SseEmitter(120_000L); aiStreamService.streamChat(sessionId, prompt, emitter); return emitter; } }注意produces必须带text/event-stream;charsetUTF-8否则浏览器可能会把它当成普通 JSON 响应你的流接不起来。SseEmitter的第二个构造参数是超时时间我设置为 120 秒跟前面 OkHttp 的 readTimeout 保持一致。核心的流式调用逻辑在 Service 里。我用 OkHttp 的异步回调方式去读大模型的流式响应然后通过 SseEmitter 一份一份推出去Service public class AiStreamService { private final OkHttpClient client; private final ObjectMapper mapper new ObjectMapper(); public void streamChat(String sessionId, String prompt, SseEmitter emitter) { // 1. 构造包含上下文的消息体stream 置为 true MapString, Object body new HashMap(); body.put(model, your-model-name); body.put(messages, buildMessages(sessionId, prompt)); body.put(stream, true); Request request new Request.Builder() .url(https://your-gateway.example.com/v1/chat/completions) .addHeader(Authorization, Bearer System.getenv(AI_API_KEY)) .post(RequestBody.create(application/json; charsetutf-8, mapper.writeValueAsString(body))) .build(); client.newCall(request).enqueue(new Callback() { Override public void onResponse(Call call, Response response) throws IOException { // 2. 用 byteStream UTF-8 包装避免中文乱码 // 注意不要直接用 response.body().charStream()它可能会按 ISO-8859-1 解码 try (BufferedReader reader new BufferedReader( new InputStreamReader(response.body().byteStream(), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { if (!line.startsWith(data:)) { continue; } String data line.substring(5).trim(); if ([DONE].equals(data)) { emitter.complete(); return; } // 解析 delta 内容 String content extractDeltaContent(data); if (content ! null !content.isEmpty()) { // 转成 JSON 发送给前端 MapString, String payload Map.of(content, content); emitter.send(SseEmitter.event() .data(mapper.writeValueAsString(payload))); } } emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } } Override public void onFailure(Call call, IOException e) { emitter.completeWithError(e); } }); } }extractDeltaContent这个方法做的事情很简单拿到一段data: {...}之后用 Jackson 解析出choices[0].delta.content取不到就返回 null。代码如下private String extractDeltaContent(String data) throws Exception { JsonNode node mapper.readTree(data); if (node.hasNonNull(choices)) { JsonNode choice node.path(choices).get(0); if (choice ! null) { return choice.path(delta).path(content).asText(null); } } return null; }这里最容易犯的错误是直接把响应体的byteStream()和charStream()搞混。OkHttp 的charStream()会根据响应头里的 charset 来解码如果大模型服务端返回的 SSE 响应头没有明确charsetutf-8它默认就走 ISO-8859-1中文内容到你手上全变成乱码。我踩过这个坑之后一律用byteStream() UTF-8。5.3 前端接入EventSource 的坑与 fetch 方案前端如果用的是浏览器原生的 EventSource这个对象有一个硬伤它只能发 GET 请求而且不能设置自定义请求头。如果你的接口需要加 Authorization 之类的鉴权头用 EventSource 基本就走不通。更稳妥的方式是使用 fetch 配合 ReadableStream。代码也不复杂只要你理解了 SSE 是按行解析的就行async function streamChat(sessionId, prompt) { const resp await fetch(/api/ai/chat/stream?sessionId sessionId prompt encodeURIComponent(prompt), { headers: { Authorization: Bearer getToken() } }); 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 }); const lines buffer.split(\n); buffer lines.pop(); // 最后一行可能不完整留到下一次 for (const line of lines) { const trimmed line.trim(); if (!trimmed.startsWith(data:)) continue; const data trimmed.substring(5).trim(); if (data [DONE]) return; try { const obj JSON.parse(data); const content obj.content || ; if (content) { document.getElementById(output).textContent content; } } catch (e) { console.warn(解析失败:, data, e); } } } }这段代码做了两件事一是用buffer.split(\n)把一段流按行切分二是在循环解析时保留最后一行继续拼接。SSE 的数据在 TCP 层面可能会被拆包你拿到的value不一定恰好是一整行甚至一次推送可能包含多行。所以缓冲区的处理是必须的。5.4 流式输出中的三个隐藏细节第一个隐藏细节SseEmitter.event()方法。你可以只传一个字符串但在浏览器端解析时很难区分事件类型。我建议统一转成 JSON 传出去前端JSON.parse(data)一次就能拿到结构化内容。这样后续如果要在结束事件里带 token 用量之类的信息扩展起来也很容易。第二个隐藏细节长时间没有新数据的连接保活。有些网络代理或负载均衡设备会静默断开空闲连接导致用户看到输出到一半就卡死了。解决方式有两种一是让大模型 API 本身保持持续输出一般的模型每秒都会吐出几个字基本不会触发空闲问题二是在后端加一个心跳机制每隔 15 秒往 emitter 里发一个注释行。// 心跳定时器示例 ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() - { try { emitter.send(SseEmitter.event().comment(ping)); } catch (Exception e) { scheduler.shutdown(); } }, 15, 15, TimeUnit.SECONDS);第三个隐藏细节用户主动断开连接时一定要清理线程。SseEmitter 有onTimeout和onCompletion回调如果你不去注册底层连接可能一直不会被释放。我一般在创建 emitter 后立刻把回调挂上去记录日志并释放相关资源。6. 常见问题与踩坑记录6.1 OkHttp 连接池与超时设置老项目里用 OkHttp要特别注意连接池的问题。OkHttpClient 实例建议全局复用不要每次请求都new一个。每次 new 都会创建新的连接池和线程池在频繁调用 AI 接口的场景下内存和句柄会持续增长最后把老项目拖垮。超时的设置也很有讲究。大模型 API 的特点是“连接很快响应很慢”所以connectTimeout可以短一点10 秒足够readTimeout必须很长我设的是 120 秒。但 120 秒也不是死板不变的如果接入的模型非常慢你可以把 readTimeout 拉长到 180 秒。有一点要记住超时时间必须比 SseEmitter 的超时时间短否则后端都已经因为读超时抛异常了前端管子还没到时间两边就对不上。6.2 流式输出中途断开怎么办流式输出最怕的情况是用户已经看到了 50 个字连接突然断了。这个问题分两层看。第一层是大模型 API 和你的后端之间的连接断开。这可能是网络抖动、服务端负载过高也可能是你的 readTimeout 设置得太短。OkHttp 的onFailure回调会触发emitter.completeWithError前端会因为流中断而报错。我给这个场景加了一个简单的重试机制只允许重试一次并且只在“已经收到数据之前”重试。如果已经收到了大量数据还去重试会导致用户看到的内容重复。第二层是后端和浏览器之间的连接断开。SseEmitter 在客户端断开后你再往里写数据会抛出 IllegalStateException。所以你要在onCompletion回调里做资源清理避免线程一直挂着。6.3 老项目的前后端联动改造很多老项目的前端还停留在 jQuery 时代没有 fetch也没有 async/await。这时候让团队立刻改造成 React 不太现实。我的建议是后端在前端页面上引入一个独立的流式输出工具函数把 fetch ReadableStream 的逻辑封装成一个 JS 文件老页面只要在按钮点击时调用一个函数再在回调里往 DOM 追加文本就行其他业务代码不用动。这种渐进式改造的思路对老项目特别友好。你不用推倒重来而是把 AI 能力当成一个“可插拔”的模块部署上线后再逐步优化交互细节。6.4 避坑清单速查下面这些全是我实际跑项目时候的总结挨个核对一遍能帮你省下大量排查时间常见问题可能原因处理建议请求返回 401Authorization 头少了空格或密钥错误打印请求头检查格式响应乱码OkHttp charStream 按 ISO-8859-1 解码使用 byteStream UTF-8SseEmitter 连接提前关闭超时时间太短构造时传 120000并注册回调前端 EventSource 无法鉴权该对象只支持 GET 且无自定义头改用 fetch ReadableStreamAI 没有上下文记忆没有把历史消息带回请求检查 L2 上下文拼接逻辑线程池被占满无界队列或线程数太小自建带名称线程池有界队列拒绝策略请求超时readTimeout 设置过短至少设置 120 秒这张表我会持续补充后面系列文章里如果遇到新的坑也会继续加进来。7. 写完这套方案之后的一点个人体会四层递进的方案看起来是四个独立步骤其实背后是一条非常清晰的工程主线先用最笨的方式跑通整个链路再逐层加复杂度每一步都保证系统是可用的。这跟我们平时处理老项目其他需求是一样的思路别一上来就上过度设计。我现在维护的这个旧 Java 项目接入 AI 之后已经稳定跑了两三个月。同事反馈最多的不是代码多优雅而是“答得真快字幕一会儿就出来了”。这个反馈让我挺有底气的也证明了在旧项目里做轻量改造比强行引入新框架要实际得多。需要提醒的是L3 流式输出只是整个 AI 体验的地基。上下文太长、多轮记忆、模型切换、Function Calling、Agent 这些进阶玩法我打算在系列后面的文章里逐个展开。老项目的每一次升级都得像这样先找一条风险最小的路径踩稳了再继续往前探。希望这套方案能帮你少走点弯路。