前阵子接了个活,把一个跑了七八年的旧 Java 项目接入 AI 能力,需求从一开始的基础对话,一路做到流式输出。系统不算新,Spring Boot 2.3、JDK 8,前端还有一坨 JSP,API 部分倒是 REST 风格。刚接到需求时,团队里有人提议直接升 JDK 17、上 WebFlux,再引一个大模型的官方 SDK,说这样“一步到位”。我赶紧拦住了:老项目接 AI,最忌讳的就是先动地基。旧系统最值钱的属性是“稳定”,不是“新潮”。
我最终用的思路是四层递进:先裸跑 HTTP 对话,再收敛成领域服务,然后补上会话上下文管理,最后才做流式输出。每一层都建立在前一层的稳定输出之上,出了问题能快速定位,不需要动原有业务架构。这篇就把整个改造过程、关键代码、以及我实际踩过的坑完整写出来,给同样在维护老 Java 系统、又想接 AI 能力的同学一个可以直接抄的作业。
1. 为什么旧系统接入 AI 不能一把梭:先想清楚改造边界
接手一个老项目,第一步永远不是写代码,而是盘家底。你得先搞清楚:项目跑在什么版本上,依赖了哪些库,哪些代码碰不得,哪些接口已经没人用但还在线上跑着。接入 AI 之前,这些问题没想明白,后面每一步都是雷。
1.1 先盘家底:老项目到底老在哪
我这次面对的系统算是比较典型的“老而不死”型:Spring Boot 2.3,用的是传统 Spring MVC 与嵌入式 Tomcat,JDK 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); Map<String, 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); HttpEntity<String> entity = new HttpEntity<>(json, headers); ResponseEntity<String> 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) { List<Map<String, 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(List<Map<String, String>> messages) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("Authorization", "Bearer " + props.getApiKey()); Map<String, Object> body = new HashMap<>(); body.put("model", props.getModel()); body.put("temperature", props.getTemperature()); body.put("max_tokens", props.getMaxTokens()); body.put("messages", messages); ResponseEntity<String> 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 ResponseEntity<String> postWithRetry(HttpEntity<String> 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 List<Map<String, 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 List<Map<String, String>> getMessages() { return messages; } public long getLastAccessTime() { return lastAccessTime; } } @Component public class InMemoryChatSessionManager { private final ConcurrentHashMap<String, ChatSession> sessions = new ConcurrentHashMap<>(); private final int maxSessionMinutes = 30; public List<Map<String, 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 是 sessionId,field 是 role 或 index,value 是内容。但即使要换,我仍然建议先从内存版跑起来,真实压测到瓶颈之后再迁移,别做超前设计。
5. 第四层:SSE 流式输出,把“打字机”效果搬进老项目
第四层是整个系列改造里最复杂、也是最出效果的一层。前面三层解决的是“能用”,这一层解决的是“好用”。
5.1 为什么同步等待在 AI 场景下不可接受
大模型生成一段回答,长的时候可能达到几十秒。如果在老项目的同步接口里等回应,用户看到的只有一个转圈,体验极其糟糕,而且对服务端来说,占着 Tomcat 线程几十秒不释放,并发稍微一上来,线程池直接就耗尽了。
流式输出的价值在于:大模型每生成一小段内容,后端就立刻推给前端,用户 1 秒内就能看到第一个字开始往外蹦,体感上快很多。从技术角度看,也就是把“等全部结果再返回”改成“边生成边推送”。
5.2 老项目实现流式输出的技术选型
我见过很多人一上来就推荐 Spring WebFlux,因为它原生支持响应式流,功能很强。但这里要泼一盆冷水:老项目基本都是 Spring MVC + Tomcat 的同步模型,强行引入 WebFlux 会带来两个直接问题:
- WebFlux 与 Web MVC 需要做排斥配置,否则两个容器同时启动,行为不可预期;
- 你原有的大多数同步代码、拦截器、过滤器,都需要重新适配,这已经不是“接入 AI”的范畴,而是重构项目了。
所以在传统 Servlet 栈里,我选的是:SseEmitter(Spring 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 接收前端请求,创建 SseEmitter(0 表示不超时,或者设一个足够长的时间),把任务丢给线程池:
@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(); List<Map<String, String>> messages = new ArrayList<>(); if (sessionId != null) { messages.addAll(sessionManager.getOrCreateSession(sessionId)); } messages.add(Map.of("role", "user", "content", userMessage)); Map<String, 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.protocols=TLSv1.2解决。在 2025 年连这个问题都能遇到,说明老系统环境里真的什么都有可能发生。
关于依赖冲突,这里也说一下我的原则:新增功能时,第三方库能不加就不加,必须加的时候,优先选择与现有依赖树兼容的版本。比如流式读取用 JDK 自带的 HttpURLConnection,就是为了避开 Http 客户端版本冲突。
另外我还要提醒一点,所有新增配置(模型名、超时时间、重试次数、会话窗口大小)都应该做成可配置项,并且提供环境差异的默认值。这样从测试到生产只需要改配置,不用动代码。
收尾:这套改造方案还能怎么继续深入
这是系列的第一篇,我写了四层递进的全部落地过程:连通 API、服务化封装、上下文管理、流式输出。这一套做完,你的老 Java 系统已经具备了最基础的 AI 对话能力,并且每一步都可以单独回滚和排查。
我个人在实际改造中最深的体会是:老项目接入 AI 最大的障碍不是技术,而是“克制”。克制住升 JDK 的冲动,克制住引入新框架的冲动,克制住第一版就想把所有 AI 能力塞满的冲动。四层递进这个节奏,本质上是在给你留出一个随时回头的余地。
后续这个系列我想接着聊几块:一是大模型的 Function Calling 在 Java 里的落地,让 AI 能真正调用你现有系统的业务方法;二是用向量数据库给老项目做企业知识库的私域问答;三是多个 AI 角色协同工作的编排思路。如果你也在改造类似的系统,遇到本文没有覆盖到的坑,欢迎直接在评论里把现象和堆栈贴出来,我们下一期接着拆。