前阵子接了个活把一个跑了七八年的旧 Java 项目接入 AI 能力需求从一开始的基础对话一路做到流式输出。系统不算新Spring Boot 2.3、JDK 8前端还有一坨 JSPAPI 部分倒是 REST 风格。刚接到需求时团队里有人提议直接升 JDK 17、上 WebFlux再引一个大模型的官方 SDK说这样“一步到位”。我赶紧拦住了老项目接 AI最忌讳的就是先动地基。旧系统最值钱的属性是“稳定”不是“新潮”。我最终用的思路是四层递进先裸跑 HTTP 对话再收敛成领域服务然后补上会话上下文管理最后才做流式输出。每一层都建立在前一层的稳定输出之上出了问题能快速定位不需要动原有业务架构。这篇就把整个改造过程、关键代码、以及我实际踩过的坑完整写出来给同样在维护老 Java 系统、又想接 AI 能力的同学一个可以直接抄的作业。1. 为什么旧系统接入 AI 不能一把梭先想清楚改造边界接手一个老项目第一步永远不是写代码而是盘家底。你得先搞清楚项目跑在什么版本上依赖了哪些库哪些代码碰不得哪些接口已经没人用但还在线上跑着。接入 AI 之前这些问题没想明白后面每一步都是雷。1.1 先盘家底老项目到底老在哪我这次面对的系统算是比较典型的“老而不死”型Spring Boot 2.3用的是传统 Spring MVC 与嵌入式 TomcatJDK 8打包方式还是 fat jar依赖管理用的是 Maven。前端页面有一部分还是 JSP另外一部分前后端分离但都走同一个 8080 端口。这类系统的普遍特点有三个依赖树非常敏感。你随便加一个新框架都有可能和老的 Jackson、Guava、HttpClient 版本打架一旦版本冲突问题不会出现在启动时而是出现在某个深夜里一个诡异的内存溢出或序列化异常。数据库连接池、线程池、HTTP 连接池等基础设施一般没有统一管理都是各自为政。上线流程偏重测试环境有限出了问题回滚成本不低。所以说接入 AI 的第一原则是最小侵入。所有 AI 相关代码全部收敛到一个独立模块里业务代码只管调接口不要去碰框架层。1.2 四层递进的分层策略我把这次改造拆成四个层次每层解决一个具体问题第一层基础对话。用最原始的方式调用大模型 API验证网络、密钥、参数、数据格式是否通。第二层服务化封装。把裸调用收敛成一个 AiChatService 接口让业务方只对接口编程屏蔽 API 细节。第三层上下文管理。大模型接口本身是无状态的你需要自己维护会话历史才能实现多轮对话。第四层流式输出。把“等结果一次性返回”改成“边生成边推送”让用户体验从转圈变成打字机效果。这个顺序不能乱。我见过有人一上来就做流式输出结果 API 鉴权都没通排查问题的时候根本分不清是网络断了、密钥错了还是解析代码写错了。四层递进的本质是每一层完成后都处于“可上线、可回滚、可排查”的状态再做下一层。2. 第一层裸 HTTP 调用大模型 API先把对话跑起来第一层非常简单但从这层开始所有的核心结论都依赖一个事实大模型 API 从 HTTP 协议的角度看就是一个普通的 POST 接口没有任何玄学。2.1 为什么第一层只做“裸调用”很多大模型供应商都会提供官方 SDK看起来很好用但我不建议在老项目里直接用。原因很现实SDK 通常会依赖 OkHttp、WebFlux 或者其他较新的 HTTP 客户端而这些依赖很可能与老项目的既有版本冲突。我之前就见过有项目因为引入某个 SDK 的 OkHttp 版本直接导致现有的 HTTP 调用全部乱掉排查了一整天。所以第一层我直接用 Spring Boot 自带的 RestTemplate加上 JDK 自带的能力一个外部依赖都不加。目标只有一个打通链路。2.2 十行代码跑通一次对话以业界广泛使用的 Chat Completions 接口格式为例核心请求就两件事请求头和请求体。请求头里放 API 密钥请求体里放模型名和消息列表。Service public class ChatService { private final RestTemplate restTemplate new RestTemplate(); private final ObjectMapper objectMapper new ObjectMapper(); public String chat(String userMessage) throws Exception { String apiKey System.getenv(AI_API_KEY); String apiUrl System.getenv(AI_API_URL); // 例如 https://api.example.com/v1/chat/completions HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set(Authorization, Bearer apiKey); MapString, Object requestBody new HashMap(); requestBody.put(model, System.getenv(AI_MODEL)); // 例如 qwen-turbo / deepseek-chat 等 requestBody.put(messages, List.of( Map.of(role, user, content, userMessage) )); String json objectMapper.writeValueAsString(requestBody); HttpEntityString entity new HttpEntity(json, headers); ResponseEntityString response restTemplate.postForEntity(apiUrl, entity, String.class); if (!response.getStatusCode().is2xxSuccessful()) { throw new IllegalStateException(AI API 返回异常: response.getStatusCode()); } JsonNode root objectMapper.readTree(response.getBody()); return root.path(choices).path(0).path(message).path(content).asText(); } }这段代码什么都不管不处理重试、不处理流式、不处理上下文甚至连日志都很少但它的价值在于只要这段代码跑通了后面所有层都建立在一个可靠的通路之上。我把这段代码部署到测试环境用一个最简单的请求“你好介绍一下自己”大概两三秒后拿到了返回那一刻基本就可以确定网络通、密钥对、模型名对、数据格式没问题。2.3 第一层最容易犯的两个错误第一层的代码虽然简单但我在经历过不少团队的代码后发现有两个低级错误反复出现把密钥硬编码在代码里。这个不用多说一旦提交到 Git哪怕后边删了历史记录里还是能找到。正确做法是放到环境变量或配置中心。不设置超时。RestTemplate 默认的超时行为很大很随缘一旦 API 地址不可达请求可能卡住几分钟把 Tomcat 工作线程全部占满。建议至少设置连接超时和读取超时HttpComponentsClientHttpRequestFactory factory new HttpComponentsClientHttpRequestFactory(); factory.setConnectTimeout(5000); // 连接超时 5 秒 factory.setReadTimeout(30000); // 读取超时 30 秒 this.restTemplate new RestTemplate(factory);第一层做到这个程度就够了不要追求完美先确保通路。3. 第二层把 AI 调用收敛成领域服务真正的“接入”第一层代码跑通后接下来最大的威胁不是技术而是业务团队会开始到处 copy 那段 RestTemplate 代码。你今天写了一个 ChatService明天他们就会在订单模块、用户模块、日志模块里各写一个自己的版本后端直接变成分布式负债现场。3.1 从“能跑”到“能维护”的转化第二层的核心任务是做收敛把 AI 调用封装成唯一的领域接口让所有调用方只面对一个方法签名内部怎么请求、怎么解析、怎么错误处理由实现类自己负责。我设计了这样一个接口public interface AiChatService { String chat(String userMessage); String chat(String systemPrompt, String userMessage); }接口方法很朴素但注意右上角两个关键点一调用方不用知道模型名、API 地址、密钥这些细节二如果你后面要换模型供应商只需要替换实现类所有业务代码一行都不用改。实现类则承接了配置化的工作。我把所有大模型 API 参数放到 application.yml 中ai: provider: example base-url: https://api.example.com/v1/chat/completions api-key: ${AI_API_KEY} model: example-chat temperature: 0.7 max-tokens: 2048然后用一个配置属性类接住ConfigurationProperties(prefix ai) Component public class AiProperties { private String provider; private String baseUrl; private String apiKey; private String model; private double temperature; private int maxTokens; // getter / setter 省略 }这时候再让原来的裸调用代码下沉为 AiChatServiceImpl所有逻辑统一管理。3.2 基于接口的模型抽象为更换供应商做准备说到“换供应商”这里有个经验值得多说一句。目前市面上接近兼容同一协议的大模型 API 很多你只要按标准 Chat Completions 格式请求就行。但为了万一哪天需要切换到别的协议或做特殊处理我在实现类里加了一层 provider 判断Component public class DefaultAiChatService implements AiChatService { private final RestTemplate restTemplate; private final AiProperties props; private final ObjectMapper objectMapper; Override public String chat(String systemPrompt, String userMessage) { ListMapString, String messages new ArrayList(); if (StringUtils.hasText(systemPrompt)) { messages.add(Map.of(role, system, content, systemPrompt)); } messages.add(Map.of(role, user, content, userMessage)); return doChat(messages); } private String doChat(ListMapString, String messages) { HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set(Authorization, Bearer props.getApiKey()); MapString, Object body new HashMap(); body.put(model, props.getModel()); body.put(temperature, props.getTemperature()); body.put(max_tokens, props.getMaxTokens()); body.put(messages, messages); ResponseEntityString response restTemplate.postForEntity( props.getBaseUrl(), new HttpEntity(toJson(body), headers), String.class); return extractContent(response.getBody()); } private String extractContent(String responseBody) { try { JsonNode root objectMapper.readTree(responseBody); if (root.has(error)) { throw new IllegalStateException(AI 接口返回错误: root.get(error).asText()); } return root.path(choices).path(0).path(message).path(content).asText(null); } catch (JsonProcessingException e) { throw new RuntimeException(解析 AI 响应失败, e); } } }封装之后业务代码变成这样清爽很多public String generateWelcomeMessage(String userName) { String prompt 你是一位用户运营专家请为用户生成一段 30 字以内的欢迎语。用户昵称 userName; return aiChatService.chat(prompt); }3.3 错误处理、重试与基础观测封装接口的同时要顺带把错误处理策略做掉。不要把异常直接抛给前端也不能静默吞掉。我的做法是对 429限流、5xx服务端异常做指数退避重试最多重试 3 次退避间隔依次为 1 秒、2 秒、4 秒。对 401、400 这类确定性错误不重试直接抛出业务异常并告警。每次调用记录日志耗时、tokens 数量如果响应里有、错误信息。这里不引入任何第三方重试框架JDK 自带的线程休眠加上循环就能搞定减少依赖永远是老项目的第一优先级。private int maxAttempts 3; private long baseDelayMs 1000; private ResponseEntityString postWithRetry(HttpEntityString entity) { Throwable lastError null; for (int attempt 1; attempt maxAttempts; attempt) { try { return restTemplate.postForEntity(props.getBaseUrl(), entity, String.class); } catch (HttpStatusCodeException e) { if (e.getStatusCode().value() 429 || e.getStatusCode().is5xxServerError()) { if (attempt maxAttempts) { throw e; } waitBeforeRetry(attempt); } else { throw e; } } catch (ResourceAccessException e) { if (attempt maxAttempts) { throw e; } waitBeforeRetry(attempt); } } throw new IllegalStateException(重试后仍然失败, lastError); } private void waitBeforeRetry(int attempt) { long delay baseDelayMs * (long) Math.pow(2, attempt - 1); try { Thread.sleep(delay); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new IllegalStateException(重试等待被中断, ie); } }跑完这层之后调用方完全不感知背后的网络细节和供应商切换改造的边界已经划定成功。4. 第三层会话上下文管理让 AI 从“失忆”变成“有记忆”大模型 API 本身是无状态的你不传历史消息下次对话它就完全不记得。但业务上用户不可能接受你每问一句都要把上一句重复一次。4.1 为什么要在第二层之后才做会话很多团队会把上下文管理当成接入 AI 的“第一件事”上来就设计 session、设计记忆存储结果连最基础的接口调用都没跑通代码写了一大堆全是空中楼阁。我的顺序是反过来的先把 ChatService 的封装做稳定再在它之上加会话层。这样会话管理变成一种“增强”而不是“前提”。出问题时可以随时摘掉会话层回到第二层的稳定状态。4.2 会话存储与会话窗口的取舍第三层我实现了两个核心类ChatSession 和 ChatSessionManager。public class ChatSession { private String sessionId; private ListMapString, String messages; private long lastAccessTime; public ChatSession(String sessionId) { this.sessionId sessionId; this.messages new ArrayList(); this.lastAccessTime System.currentTimeMillis(); } public void addMessage(String role, String content) { messages.add(Map.of(role, role, content, content)); lastAccessTime System.currentTimeMillis(); } public ListMapString, String getMessages() { return messages; } public long getLastAccessTime() { return lastAccessTime; } } Component public class InMemoryChatSessionManager { private final ConcurrentHashMapString, ChatSession sessions new ConcurrentHashMap(); private final int maxSessionMinutes 30; public ListMapString, String getOrCreateSession(String sessionId) { ChatSession session sessions.computeIfAbsent(sessionId, ChatSession::new); // 惰性清理过期会话 if (System.currentTimeMillis() - session.getLastAccessTime() maxSessionMinutes * 60_000L) { sessions.remove(sessionId); session new ChatSession(sessionId); } return session.getMessages(); } public void appendMessage(String sessionId, String role, String content) { ChatSession session sessions.get(sessionId); if (session ! null) { session.addMessage(role, content); } } }实现逻辑不复杂但有两个细节值得注意。一是会话窗口长度。大模型输入上下文是有限制的你不能无限叠历史消息。我采用“最后 N 条 Token 截断”的策略默认只携带最近 20 条消息同时估算 token 数超过 4000 token 时从最旧的消息开始丢弃。估算 token 不需要引入复杂依赖中文字符算 1.5 个 token英文算 1 个 token这个经验值对多数场景足够用。二是内存泄漏问题。如果只用内存存储又不做清理时间长了 ConcurrentHashMap 会越涨越大。我的做法是每次访问时都检查 lastAccessTime超过 30 分钟直接丢弃重建。这种做法叫惰性清理虽不精确但能有效控制内存边界。如果后续要支持大量用户再把实现换成 Redis接口不变业务代码也不会动。4.3 多用户隔离与 Redis 扩展会话管理最关键的一条底线是一个用户的会话绝不能串到另一个用户头上。sessionId 我统一用业务侧的用户 ID 加随机数组合例如orderId:userId:uuid这样既能关联业务上下文又能保证横向扩展时不同用户不会互相污染。如果会话量上去了内存版撑不住很自然会把 ChatSession 的存储换成 Redis。这里给出一个最小改造方向把 ChatSessionManager 定义一个接口内存实现与 Redis 实现并行Redis 实现直接操作 Hash 结构key 是 sessionIdfield 是 role 或 indexvalue 是内容。但即使要换我仍然建议先从内存版跑起来真实压测到瓶颈之后再迁移别做超前设计。5. 第四层SSE 流式输出把“打字机”效果搬进老项目第四层是整个系列改造里最复杂、也是最出效果的一层。前面三层解决的是“能用”这一层解决的是“好用”。5.1 为什么同步等待在 AI 场景下不可接受大模型生成一段回答长的时候可能达到几十秒。如果在老项目的同步接口里等回应用户看到的只有一个转圈体验极其糟糕而且对服务端来说占着 Tomcat 线程几十秒不释放并发稍微一上来线程池直接就耗尽了。流式输出的价值在于大模型每生成一小段内容后端就立刻推给前端用户 1 秒内就能看到第一个字开始往外蹦体感上快很多。从技术角度看也就是把“等全部结果再返回”改成“边生成边推送”。5.2 老项目实现流式输出的技术选型我见过很多人一上来就推荐 Spring WebFlux因为它原生支持响应式流功能很强。但这里要泼一盆冷水老项目基本都是 Spring MVC Tomcat 的同步模型强行引入 WebFlux 会带来两个直接问题WebFlux 与 Web MVC 需要做排斥配置否则两个容器同时启动行为不可预期你原有的大多数同步代码、拦截器、过滤器都需要重新适配这已经不是“接入 AI”的范畴而是重构项目了。所以在传统 Servlet 栈里我选的是SseEmitterSpring MVC 4.2 引入的类Spring Boot 2.x 完全支持加 JDK 原生 HttpURLConnection 做流式读取。这个组合的好处就是零新增依赖兼容性最好。5.3 核心实现Controller SseEmitter 流式 HTTP 调用实现思路分三段Controller 创建并返回 SseEmitter异步线程池负责调大模型并逐块推送SseEmitter 负责把数据通过 HTTP 长连接推给前端。第一段配置一个专门的异步线程池避免把流式任务直接丢到 Tomcat 的工作线程里执行Configuration public class AsyncConfig { Bean(aiStreamExecutor) public Executor aiStreamExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(100); executor.setThreadNamePrefix(ai-stream-); executor.initialize(); return executor; } }第二段Controller 接收前端请求创建 SseEmitter0 表示不超时或者设一个足够长的时间把任务丢给线程池RestController public class AiStreamController { Resource(name aiStreamExecutor) private Executor aiStreamExecutor; Resource private AiStreamService aiStreamService; GetMapping(value /api/ai/chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream(RequestParam String message, RequestParam(required false) String sessionId) { SseEmitter emitter new SseEmitter(180_000L); aiStreamExecutor.execute(() - { try { aiStreamService.streamChat(sessionId, message, new StreamCallback() { Override public void onDelta(String delta) { try { emitter.send(SseEmitter.event().name(message).data(delta)); } catch (IOException e) { throw new RuntimeException(SSE 推送失败, e); } } Override public void onDone() { emitter.complete(); } Override public void onError(Throwable t) { emitter.completeWithError(t); } }); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; } }第三段也是核心中的核心用 HttpURLConnection 发起流式请求逐行读取 ndjson 格式的数据流。为什么不用 RestTemplate 呢因为 RestTemplate 默认会把整个响应体读完才返回那还“流式”个啥。要做真正的流式必须拿到原始的 InputStream 边读边推。Service public class AiStreamService { private final AiProperties props; private final InMemoryChatSessionManager sessionManager; public void streamChat(String sessionId, String userMessage, StreamCallback callback) { String apiUrl props.getBaseUrl(); String apiKey props.getApiKey(); ListMapString, String messages new ArrayList(); if (sessionId ! null) { messages.addAll(sessionManager.getOrCreateSession(sessionId)); } messages.add(Map.of(role, user, content, userMessage)); MapString, Object body new HashMap(); body.put(model, props.getModel()); body.put(messages, messages); body.put(stream, true); HttpURLConnection conn null; try { conn (HttpURLConnection) new URL(apiUrl).openConnection(); conn.setRequestMethod(POST); conn.setRequestProperty(Content-Type, application/json); conn.setRequestProperty(Authorization, Bearer apiKey); conn.setDoOutput(true); conn.setConnectTimeout(5000); conn.setReadTimeout(0); // 流式读取不设读超时 try (OutputStream os conn.getOutputStream()) { os.write(new ObjectMapper().writeValueAsBytes(body)); } if (conn.getResponseCode() ! 200) { callback.onError(new IllegalStateException(AI 接口错误: conn.getResponseCode())); return; } StringBuilder fullContent new StringBuilder(); try (BufferedReader reader new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { if (line.startsWith(data:)) { String data line.substring(5).trim(); if ([DONE].equals(data)) { break; } JsonNode root new ObjectMapper().readTree(data); String delta root.path(choices).path(0).path(delta).path(content).asText(null); if (delta ! null !delta.isEmpty()) { fullContent.append(delta); callback.onDelta(delta); } } } } if (sessionId ! null) { sessionManager.appendMessage(sessionId, user, userMessage); sessionManager.appendMessage(sessionId, assistant, fullContent.toString()); } callback.onDone(); } catch (Exception e) { callback.onError(e); } finally { if (conn ! null) { conn.disconnect(); } } } }这里的换行解析逻辑很容易踩坑。大模型流式返回的数据格式大约是这样的data: {choices:[{delta:{content:你}}]} data: {choices:[{delta:{content:好}}]} data: [DONE]每个 data 行以data:前缀开头然后是一个 JSON 对象最后一行是data: [DONE]标记结束。解析时一定要移除前缀两端的空格并且用标准 JSON 解析器去读 delta别自己去做字符串切割找 content因为 JSON 中的转义字符会让你欲哭无泪。5.4 前端怎么接EventSource 的边界与 fetch 流式方案SSE 的天然搭档是浏览器自带的 EventSource。后端接口的 content-type 是text/event-stream前端这样写即可const eventSource new EventSource(/api/ai/chat/stream?message你好); eventSource.addEventListener(message, (event) { console.log(收到增量:, event.data); }); eventSource.addEventListener(error, () { eventSource.close(); });EventSource 的优点是足够简单不用引任何第三方库但它只支持 GET 请求且无法自定义请求头。如果你的 API 密钥是放在 Header 里的后端接口做了鉴权那就用 fetch 加 ReadableStream 来做流式读取const response await fetch(/api/ai/chat/stream?message你好, { headers: { X-Auth-Token: token } }); const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { value, done } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n\n); buffer lines.pop(); lines.forEach(line { if (line.startsWith(data:)) { const payload line.substring(5).trim(); if (payload ! [DONE]) { console.log(流式增量:, JSON.parse(payload)); } } }); }这个方案在老前端项目里也完全可用因为纯 JS 语法就能实现不需要脚手架支持。5.5 流式改造后最容易踩的三个坑第一反向代理缓冲。如果老项目前面挂了 Nginx 一类的反代必须关闭对 SSE 接口的缓冲否则数据会被攒着到最后一次性返回流式效果直接消失。对应配置大致是这样location /api/ai/ { proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_set_header Connection ; chunked_transfer_encoding on; }第二线程池不能随意设。流式接口的长连接数量会导致线程池被长期占用如果核心线程数设太小并发用户一多后面的请求全部排队。但也不要无脑调大老项目的内存有限建议先用 4~8 核心线程加一个有界队列压测后按实际水位调整。第三写 SseEmitter 时一定要在 finally 或者 onError 里保证 complete/completeWithError 一定会被调用。否则客户端连接会一直挂着Tomcat 的连接数会被一点点打满。你可以在日志里埋点一旦发现“SSE 连接未关闭”的告警优先检查是不是某个分支漏了 complete。6. 改造过程中我实际踩过的坑一份排查清单四层都跑通之后我在测试环境压了两周中间踩了不少坑这里挑几个最有代表性的整理成一份清单。很多问题不是 AI 本身的问题而是老项目与新流量之间的摩擦。现象根因解决方案请求 AI API 偶发握手失败JDK 8 默认 TLS 版本较低部分新供应商只支持 TLS 1.2 以上在启动参数或代码中指定 TLSv1.2 或 TLSv1.3并检查证书链是否完整高并发下 AI 调用耗时飙升RestTemplate 默认没有连接池每次请求都新建连接使用 Apache HttpClient 作为 RestTemplate 的底层连接工厂并配置连接池上限返回内容中文乱码使用默认字符集读取流所有读写统一指定 UTF-8包括 BufferedReader 的构造参数Nginx 下流式变成一次返回反代缓存了响应体关闭 SSE 接口的 proxy_buffering会话历史越攒越多内存上涨明显没有清理过期会话惰性清理 定时清理双保险部分请求 401 但密钥确实没问题请求头 Authorization 带了多余空格严格使用Bearer apiKey不要手动拼接 String其中 TLS 的问题最隐蔽。我遇到的情况是接口在测试环境用 Postman 调得好好的服务一启动就偶发握手失败。最后抓了 SSL 日志才看到是 JDK 8 与供应商服务端的 cipher suite 协商失败通过显式指定-Dhttps.protocolsTLSv1.2解决。在 2025 年连这个问题都能遇到说明老系统环境里真的什么都有可能发生。关于依赖冲突这里也说一下我的原则新增功能时第三方库能不加就不加必须加的时候优先选择与现有依赖树兼容的版本。比如流式读取用 JDK 自带的 HttpURLConnection就是为了避开 Http 客户端版本冲突。另外我还要提醒一点所有新增配置模型名、超时时间、重试次数、会话窗口大小都应该做成可配置项并且提供环境差异的默认值。这样从测试到生产只需要改配置不用动代码。收尾这套改造方案还能怎么继续深入这是系列的第一篇我写了四层递进的全部落地过程连通 API、服务化封装、上下文管理、流式输出。这一套做完你的老 Java 系统已经具备了最基础的 AI 对话能力并且每一步都可以单独回滚和排查。我个人在实际改造中最深的体会是老项目接入 AI 最大的障碍不是技术而是“克制”。克制住升 JDK 的冲动克制住引入新框架的冲动克制住第一版就想把所有 AI 能力塞满的冲动。四层递进这个节奏本质上是在给你留出一个随时回头的余地。后续这个系列我想接着聊几块一是大模型的 Function Calling 在 Java 里的落地让 AI 能真正调用你现有系统的业务方法二是用向量数据库给老项目做企业知识库的私域问答三是多个 AI 角色协同工作的编排思路。如果你也在改造类似的系统遇到本文没有覆盖到的坑欢迎直接在评论里把现象和堆栈贴出来我们下一期接着拆。