拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

老Java项目接入AI:四层递进实战与避坑指南

老Java项目接入AI:四层递进实战与避坑指南

我去年接了一个跑了好几年的 Java 老项目,Spring Boot 2.x、JDK 8、Tomcat 部署,数据库还是 MySQL 5.7 那一代的东西。产品提需求:页面上加一个 AI 助手,能回答客户问题。我当时的第一反应是直接上个 LangChain 或者 Spring AI 的框架,结果看了依赖树之后就放弃了——老项目的 jar 冲突问题经不起这种折腾。全重构成微服务?那更不现实。最后我走了一条四层递进的路线:基础对话、多轮记忆、流式输出、工程化兜底,每一层只在前一层基础上加一点点东西,不推翻旧代码,也不引入重框架。这篇把完整路径、关键代码、以及我实际踩过的坑都写出来。适合正在给遗留 Java 系统接 AI 的团队参考,也适合准备 Java 开发工程师面试时想把 Java + AI 调用链路彻底理清的开发者。

1. 第一层:先跑通基础对话接口

1.1 别急着上框架,先把它当成一次普通 HTTP 调用

老项目接入 AI 最容易犯的错,就是第一步想得太复杂。LangChain 那套东西本来是为 Python 生态设计的,Java 移植版要么功能残缺,要么和 Spring Boot 2.x 的版本兼容性成问题。我的建议是:第一层就当普通 HTTP 调用做,什么都不引入。

现在几乎所有主流大模型服务都提供 OpenAI 兼容的 Chat Completions 接口,格式是 POST 一个 JSON 过去,再拿一个 JSON 回来。你只需要在旧项目里写一个 Service,把消息发给模型接口,再把模型返回的文本取出来。这一步跑通之后,前端调用方式不变、Controller 结构不变、数据库表不用动,只是业务层多了一个 AI 能力入口。等到产品确认这是真的能用、值得继续投入,再考虑要不要引入更重的东西。

而且对于旧项目来说,最友好的地方在于:第一层几乎不需要加任何新依赖。Spring Boot 2.x 自带的spring-web里有 RestTemplate,Jackson 也是标配。只要项目本身能发 HTTP 请求、能解析 JSON,就能调大模型。

1.2 一个最小可用的 ChatClient

先看配置文件,我习惯把所有 AI 相关配置单独放到application.yml里,方便后续切换环境:

ai: endpoint: https://your-llm-gateway.example.com/v1/chat/completions api-key: ${AI_API_KEY} model: gpt-4o-mini

注意api-key不要直接写死在 yml 里,用${AI_API_KEY}的形式从环境变量读取。旧项目经常有开发、测试、生产三套配置,密钥一旦写死不换环境就是事故。

然后写一个最简的 ChatClient。我建议用构造器注入配置,不要用@Autowired一股脑塞进来:

@Component public class ChatClient { private final RestTemplate restTemplate; private final String endpoint; private final String apiKey; private final String model; public ChatClient(@Value("${ai.endpoint}") String endpoint, @Value("${ai.api-key}") String apiKey, @Value("${ai.model}") String model, RestTemplate restTemplate) { this.endpoint = endpoint; this.apiKey = apiKey; this.model = model; this.restTemplate = restTemplate; } public String chat(String userMessage) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(apiKey); Map<String, Object> body = new HashMap<>(); body.put("model", model); body.put("temperature", 0.7); body.put("messages", Arrays.asList( message("system", "你是一个严谨的技术支持助手,回答要简洁、准确。"), message("user", userMessage) )); HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers); ResponseEntity<JsonNode> response = restTemplate.postForEntity(endpoint, request, JsonNode.class); JsonNode choices = response.getBody().path("choices"); if (choices.isArray() && choices.size() > 0) { return choices.get(0).path("message").path("content").asText(); } throw new RuntimeException("AI 返回内容为空"); } private Map<String, String> message(String role, String content) { Map<String, String> m = new HashMap<>(); m.put("role", role); m.put("content", content); return m; } }

这里有几个细节值得解释。messages是一个数组,模型就是靠它理解上下文的。system消息用来设定人设和行为规范,user消息是用户输入。返回体里真正有用的字段在choices[0].message.content,如果模型因为安全策略或者其他原因拒绝回答,content可能为空字符串,也可能返回一个refusal字段,最好在业务层判空处理。

Controller 层也很简单,旧项目不需要为 AI 单独造一套接口风格,保持原有的 REST 风格即可:

@RestController @RequestMapping("/api/chat") public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient = chatClient; } @PostMapping public Map<String, String> chat(@RequestBody Map<String, String> req) { String answer = chatClient.chat(req.get("message")); return Collections.singletonMap("answer", answer); } }

到这一步,前端已经可以拿到 AI 的回复了。虽然体验一般般——点击按钮后要等好几秒才出结果,但链路通了,这是最重要的。

1.3 调通之后先别急着往下走,把参数和异常搞清楚

第一层跑通后,我建议花一点时间把请求参数过一遍,省得后面排查问题时一头雾水。常用的参数就这几个:

参数作用我的建议
temperature控制随机性,0 到 2 之间,越大越"放飞"客服场景用 0.3 左右,写作场景用 0.7
max_tokens限制返回的最大 token 数按业务需要设置,建议至少 512
top_p核采样,控制候选词范围保持默认,先不调
stream是否流式返回第三层之前保持 false

异常处理是新手最容易忽略的。我见过同事写了一个多小时发现所有请求都报 401,结果发现 API Key 复制的时候多了个空格。还有 429 被当成普通错误处理导致前端看到一串报错堆栈,实际上 429 只是限流,后端可以做退避重试。常见的错误码要单独接一层判断:

  • 401:API Key 无效或已过期,检查密钥
  • 404:endpoint 路径不对,或者是模型名拼错了
  • 429:触发限流或余额不足,需要做重试
  • 400:请求体格式错误,多半是 messages 结构不对

第一个坑就出现了:中文乱码。RestTemplate 的StringHttpMessageConverter默认编码是 ISO-8859-1,如果接口走的是字符串解析,中文大概率变成问号。解决方式是在配置 RestTemplate 时把所有 String 转换器的默认编码改成 UTF-8:

@Bean public RestTemplate restTemplate() { RestTemplate restTemplate = new RestTemplate(); List<HttpMessageConverter<?>> converters = restTemplate.getMessageConverters(); for (HttpMessageConverter<?> converter : converters) { if (converter instanceof StringHttpMessageConverter) { ((StringHttpMessageConverter) converter).setDefaultCharset(StandardCharsets.UTF_8); } } return restTemplate; }

注意:如果你调第一层时用的是ResponseEntity<Map>而不是JsonNode,请确认 Jackson 依赖存在。Spring Boot 2.x 的 web 场景默认带 jackson-databind,但你手动 import 都会遇到,说明项目里肯定有。

2. 第二层:从一次性问答升级成多轮对话

2.1 大模型 API 本身是无状态的,多轮只是"把历史带上"

第一层跑通后,产品马上会提要求:AI 要能记住用户前面说了什么。比如用户先问"帮我看看这个报错日志",你又发了一条解释的指令,AI 需要知道"你指的是刚才那一段日志"。

很多人会问:大模型是不是自带记忆?不是。Chat Completions 接口本身是无状态的,同一个模型、同一个 API Key,每次调用都是独立的。所谓"多轮对话",实际上是调用方把历史消息拼成一个更长的messages数组,一起发给模型。比如第二轮的请求体应该是:

{ "model": "gpt-4o-mini", "messages": [ {"role": "system", "content": "你是技术支持助手"}, {"role": "user", "content": "我的接口报 500 错误"}, {"role": "assistant", "content": "先看服务日志,一般是空指针"}, {"role": "user", "content": "日志里没有异常啊"} ] }

模型看到的是一个完整的对话历史,所以才能理解"日志里没有异常啊"指的是什么。旧项目接入时,这里需要解决的只是:怎么保存和管理这些历史消息。

2.2 上下文窗口有限,怎么裁剪是最现实的问题

多轮对话的实现本身不复杂,真正麻烦的是上下文长度。每个模型都有上下文窗口限制,比如 4K token、8K token、128K token,超出部分会被直接截断或者报错。而且 token 是计费的,历史消息越长每次调用的成本越高。

最简单实用的策略是按消息条数裁剪。我当时的做法是:会话里保留一条 system 消息,然后只保留最近 N 轮对话(比如 12 轮),更早的消息直接丢掉。用一个SessionManager来管:

@Component public class SessionManager { private final ConcurrentHashMap<String, List<Map<String, String>>> sessions = new ConcurrentHashMap<>(); private static final int MAX_MESSAGES = 20; public void addMessage(String sessionId, String role, String content) { List<Map<String, String>> messages = sessions.computeIfAbsent(sessionId, k -> new ArrayList<>()); synchronized (messages) { messages.add(messageEntry(role, content)); trim(messages); } } public List<Map<String, String>> getMessages(String sessionId) { List<Map<String, String>> messages = sessions.get(sessionId); return messages == null ? new ArrayList<>() : new ArrayList<>(messages); } private void trim(List<Map<String, String>> messages) { while (messages.size() > MAX_MESSAGES) { if ("system".equals(messages.get(0).get("role"))) { messages.remove(1); } else { messages.remove(0); } } } private Map<String, String> messageEntry(String role, String content) { Map<String, String> m = new HashMap<>(); m.put("role", role); m.put("content", content); return m; } }

为什么用ConcurrentHashMap?因为单体应用里多个用户可能同时访问,不同 session 之间必须隔离。为什么在synchronized里操作列表?因为同一个用户可能连发多条消息,两个请求同时往一个 session 里追加消息时会产生并发覆盖。

按条数裁剪有一个痛点:你不知道每条消息实际占多少 token。如果用户每轮都贴了一大段日志,20 条消息可能直接打爆 4K 窗口。更稳的做法是估算 token 数,比如中英文混合内容大致每个汉字算 1.5 个 token、每 1 个英文单词算 1 个 token,设置一个总上限,超出就删最早的消息。这个估算公式不用很精确,够用就行。我后来在系统里加了这样一个方法:

public static int estimateTokens(String text) { if (text == null || text.isEmpty()) return 0; int chinese = 0, words = 0; // 简单估算,不区分大小写 return (int) (text.replaceAll("[\\u4e00-\\u9fa5]", "中").length() * 1.5 + text.split("\\s+").length * 0.6); }

这个方法的准确性无法和模型的 tokenizer 比,但用来做"超限就裁剪"足够。

2.3 别忘了边界:会话隔离要跟着部署架构走

旧项目迁移到多实例部署是常有的事。SessionManager 用ConcurrentHashMap在单实例里好用,但如果后来做负载均衡,两个实例各自存各自的 session,用户第一次请求落在 A 机器、第二次落在 B 机器,AI 就"失忆"了。

所以这一步要提前想清楚:如果你的项目单体规模不大、短期不会上多实例,用内存 Map 可以;如果早晚要水平扩展,应该直接把历史消息放到 Redis,用sessionId -> List<String>的形式存 JSON。我给同事的建议是:别在内存会话上花太多功夫,老项目如果已经用了 Redis 做缓存,那第二层顺手就存 Redis,session 不丢,代码也简单。

提示:在多轮对话接口里,前端必须把sessionId传回来。不要在每次请求时重新生成 sessionId,否则 AI 永远记不住你是谁。建议 sessionId 直接复用旧项目已有的用户会话 ID,或者前端生成后持久化。

多轮对话做完后,AI 的体验会有一个质变。用户会觉得"它在认真听我说话",这比第一层的纯问答有意义的得多。

3. 第三层:流式输出的完整落地

3.1 非流式为什么不够用

第一层和第二层都是非流式:用户发消息,后端等待模型生成完整回答,一次性返回 JSON,前端再渲染。这个模式最大的问题不是慢,而是"一无所有地等待"。大模型生成一段 200 字的回答可能需要 5 到 8 秒,更长的回答甚至二三十秒。用户点击发送之后,页面上没有任何反馈,连转圈都转得心虚——他不知道是卡了还是坏了。

流式输出解决的问题就是"从第一个字开始给用户反馈"。我们平时看到的 AI 网页版,回答是一个字一个字往外蹦的,这就是流式效果。技术上看也没那么玄乎:模型每生成一小段 token,服务端就立刻把它推给浏览器,前端边收边渲染。

把非流式改成流式,体感上像是把首屏等待时间从 8 秒降到了 1 秒——不是说生成变快了,是用户马上能看到"动静",耐心会成倍增加。

3.2 SSE 原理与 Java 端实现

流式输出的底层协议是 SSE,全称 Server-Sent Events。它本质上是 HTTP 长连接:服务端不断往同一个响应里写数据,每一条数据以data:开头,以空行结尾。客户端收到data: [DONE]就说明流结束了。举个例子,一次流式响应可能是这样的:

data: {"choices":[{"delta":{"role":"assistant"}}]} data: {"choices":[{"delta":{"content":"你"}}]} data: {"choices":[{"delta":{"content":"好"}}]} data: [DONE]

SSE 的每一帧都是独立 JSON,浏览器端用EventSource接口可以直接解析。有人会问:为什么不用 WebSocket?因为 AI 对话这个场景是"服务端单方向持续推给客户端",SSE 已经足够;而且 SSE 的鉴权方式和普通 HTTP 请求一样,旧项目不需要额外改造认证链路。WebSocket 是双向协议,还要讲究心跳、重连、代理配置,在遗留系统里引入它是给自己添乱。

Java 端要解析 SSE,RestTemplate 就不够用了,因为 RestTemplate 默认会等整个响应结束才返回。我换成了 OkHttp,它在依赖树里很轻,和老项目兼容性也好。只需要在 pom 里加一行:

<dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>3.14.9</version> </dependency>

注意版本,选 3.x 而不是 4.x,因为 4.x 要求 Kotlin 标准库,老项目加上去可能又是一个"依赖全家桶"。

关键代码如下,我在那篇项目里实际用过,稍微简化了下:

@Component public class StreamChatClient { private final OkHttpClient httpClient; private final String endpoint; private final String apiKey; private final String model; private final ObjectMapper objectMapper = new ObjectMapper(); public StreamChatClient(@Value("${ai.endpoint}") String endpoint, @Value("${ai.api-key}") String apiKey, @Value("${ai.model}") String model) { this.endpoint = endpoint; this.apiKey = apiKey; this.model = model; this.httpClient = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.MILLISECONDS) .build(); } public void streamChat(List<Map<String, String>> messages, Consumer<String> onToken, Runnable onDone, Consumer<Throwable> onError) { String payload = buildPayload(messages); RequestBody requestBody = RequestBody.create( MediaType.parse("application/json; charset=utf-8"), payload); Request request = new Request.Builder() .url(endpoint) .header("Authorization", "Bearer " + apiKey) .header("Accept", "text/event-stream") .post(requestBody) .build(); httpClient.newCall(request).enqueue(new Callback() { @Override public void onFailure(Call call, IOException e) { onError.accept(e); } @Override public void onResponse(Call call, Response response) throws IOException { if (!response.isSuccessful()) { onError.accept(new IOException("HTTP " + response.code())); return; } try (ResponseBody body = response.body()) { BufferedSource source = body.source(); while (!source.exhausted()) { String line = source.readUtf8Line(); if (line == null || !line.startsWith("data:")) { continue; } String data = line.substring(5).trim(); if ("[DONE]".equals(data)) { onDone.run(); return; } JsonNode node = objectMapper.readTree(data); JsonNode delta = node.path("choices").get(0).path("delta"); String token = delta.path("content").asText(null); if (token != null && !token.isEmpty()) { onToken.accept(token); } } onDone.run(); } } }); } }

有两个细节我必须多说一句。第一个是readTimeout(0, TimeUnit.MILLISECONDS)。流式响应的持续时间完全取决于模型生成速度,有时能到一两分钟,如果设置固定 readTimeout,流还没结束连接就被掐断了。设成 0 表示不超时。第二个是Accept: text/event-stream头,有些网关会通过这个头识别流式请求,缺少它可能导致服务端一直缓冲不吐数据。

3.3 后端如何把流式数据转发给前端

后端拿到模型吐出来的 token 后,不能攒着,得立刻推给浏览器。Spring MVC 的SseEmitter就是干这个的。改造后的 Controller 大概长这样:

@RestController @RequestMapping("/api/chat") public class ChatController { private final StreamChatClient streamChatClient; private final SessionManager sessionManager; private final ExecutorService aiExecutor; @GetMapping(value = "/stream") public SseEmitter stream(@RequestParam("sessionId") String sessionId, @RequestParam("message") String message) { SseEmitter emitter = new SseEmitter(0L); List<Map<String, String>> history = sessionManager.getMessages(sessionId); history.add(messageEntry("user", message)); aiExecutor.execute(() -> { try { streamChatClient.streamChat(history, token -> { try { emitter.send(SseEmitter.event().data(token)); } catch (IOException e) { // 前端连接已经断开,停止后续发送 throw new RuntimeException(e); } }, () -> emitter.complete(), error -> emitter.completeWithError(error) ); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; } }

这里最值得注意的点:为什么不能让 Controller 方法直接调 streamChatClient 然后逐 token 写出去?因为这样做会占住 Tomcat 的一个请求处理线程长达几十秒。旧项目的 Tomcat 默认线程池上限一般 200 个左右,如果 20 个人同时在问 AI,其他所有普通接口都可能排队等待。正确做法是:Controller 立刻返回一个SseEmitter对象,Spring 把 HTTP 连接切换到异步模式;真正的上游读取和转发逻辑放到一个独立的线程池里去跑。在代码里就是aiExecutor.execute(() -> {...})这部分。

这个线程池我建议专门定义,不要用默认的Executors.newCachedThreadPool,避免线程无限膨胀。参考配置:

@Bean("aiExecutor") public ExecutorService aiExecutor() { return new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(200), r -> { Thread t = new Thread(r, "ai-worker-" + ThreadLocalRandom.current().nextInt(1000)); t.setDaemon(true); return t; }, new ThreadPoolExecutor.CallerRunsPolicy()); }

CallerRunsPolicy的意思是线程池满了之后,让提交任务的线程自己执行,相当于一个降级保护,总比直接丢任务好。

前端调用方式和非流式完全不同了。浏览器直接拿EventSource就能接,但注意需要额外的配置来识别事件流:

const es = new EventSource(`/api/chat/stream?sessionId=xxx&message=你好`); let answer = ''; es.onmessage = (event) => { if (event.data === '[DONE]') { es.close(); return; } answer += event.data; document.getElementById('answer').textContent = answer; };

如果前端用的是自己封装的 HTTP 库而不是原生 EventSource,要注意:它必须支持流式读取,不能等响应全部结束才回调。否则你做的就是"伪流式"。

3.4 前端断开、用户点停、中途报错

流式输出的坑比非流式多得多,我只挑最要命的讲。第一个是前端断开。用户问了一半关掉了页面,后端如果不感知,OkHttp 还在那儿继续读模型返回,线程池白占用。我用的处理方式是给 SseEmitter 注册一个onCompletion回调,在里面尝试 cancel 掉正在执行的 OkHttp Call:

final Call[] currentCall = new Call[1]; emitter.onCompletion(() -> { if (currentCall[0] != null) { currentCall[0].cancel(); } emitter.complete(); });

这个代码需要把streamChatClient里创建的 Call 暴露出来,或者用返回值。细节实现上可以再封装一下,但思路就是:连接断了,上游调用必须马上取消,不能让它跑到天荒地老。

第二个坑是"用户点了停止"。前端EventSource.close()之后,后端一般会收到 connection closed 事件,触发onCompletion,这个链路是通的。但如果前端只是页面跳转、不主动 close,部分浏览器可能需要等 TCP 超时才会触发服务端回调。这一点不要依赖前端自觉,后端可以在发送时检查emitter是否已经超时或完成,发现了就主动 cancel。

第三个坑是网络代理缓冲。很多旧项目前面挂着 Nginx。默认情况下 Nginx 会缓冲上游响应,攒够一定大小才发给客户端。流式输出遇到这种代理,效果就是前端等了十秒,页面突然蹦出一整段答案——流式效果完全没了。需要在 Nginx 配置里关掉缓冲:

location /api/chat/stream { proxy_pass http://your-backend; proxy_buffering off; proxy_cache off; chunked_transfer_encoding on; }

这个坑我当时排查了两天才发现,不是代码有问题,是 Nginx 把流"攒"住了。遇到这种情况别急着改 Java,先检查中间件。

4. 第四层:工程化兜底与隐患排查

4.1 线程模型与连接池:别让请求线程去等大模型

流式输出上线后,你的系统就同时存在两类请求:普通业务接口和 AI 流式接口。一个常见的隐患是:开发图省事,把 AI 调用直接写在 Controller 的同步方法里,结果高并发下 Tomcat 的线程池被打满。这也是我在第三层反复强调要用独立线程池的原因:AI 接口是高延迟、长占用、慢响应,绝对不能和普通接口共用请求线程。

还有一个连接池问题。OkHttp 内部有连接池,但前提是你复用了同一个 OkHttpClient 实例。我有一次看到代码里每次请求都new OkHttpClient.Builder(),连接池完全失效,大量 TIME_WAIT 连接堆积,最后把机器端口耗光了。正确姿势是像上面那样把 OkHttpClient 作为 Spring 单例 Bean 使用。RestTemplate 同理,尽量作为单例注入,不要每次 new。

4.2 成本、日志与监控要从第一天就做

AI 接口和普通接口最大的区别是:每次调用都在花钱。我见过项目上线两周后才开始统计成本,结果发现模型参数配置不合理,每天多烧了几千块。成本统计最好在第二层就顺手做掉。

非流式返回的 JSON 里有个usage字段,直接告诉你这次请求用了多少 prompt tokens、多少 completion tokens。流式响应通常在结束帧前的最后一条数据里也会带usage,但有的兼容接口不带,这时候只能自己按字符数粗估。我当时的做法是:读到一个 token 就累加一个字符计数器,最后按"4 个字符约等于 1 个 token"折算,虽然不精确但够用来做成本估算。

日志方面,我用 MDC 把requestId贯穿整个调用链,每个 AI 请求都会记录:

  • 会话 ID
  • 用户 ID
  • 模型名
  • 请求耗时
  • prompt token 数
  • completion token 数
  • 是否命中降级
  • 错误信息

这样后面用户反馈"AI 回答很慢"时,我可以直接从日志里把当时的链路还原出来,而不是靠猜。

注意:日志里不要记录完整对话内容,尤其不要记录用户输入中的个人信息。老项目对接 AI 时,数据安全边界要提前划清楚。输入接口传过来之前最好做过脱敏,比如把手机号、身份证号替换成掩码。

我建议从第一天就建一张 AI 调用汇总表,哪怕只有 MySQL 单表也行,字段大概是:id, session_id, user_id, model, question_length, answer_length, prompt_tokens, completion_tokens, duration_ms, success, error_code, create_time。后面做成本分析、异常诊断、甚至给产品看"AI 到底帮客户解决了什么问题",这张表都是基础。

4.3 降级与重试策略:别让 AI 挂了就全站瘫痪

AI 服务是外部依赖,它有概率抖动、限流、甚至宕机。如果 AI 挂了,你的业务接口也跟着挂,那等于引入了一个巨大的单点故障。我做的降级策略分三层:

第一层是重试。网络超时这种偶发抖动可以重试,但要注意 AI 接口不是幂等的——重试一次可能就多扣一次费用。我的原则是:当还没有收到任何 token、只是连接层失败时,可以重试一次;一旦已经接收过部分 token,就绝不重试,直接走降级。

第二层是熔断。用一个 1 分钟滑动窗口统计 AI 请求的错误率,错误率超过 50% 就打开熔断开关,接下来的 5 分钟所有 AI 请求直接走兜底文本,不再真正调用模型。5 分钟后尝试放行少量请求,如果成功就关闭熔断,如果还是失败就继续保持。这个逻辑用AtomicBoolean和环形数组就能实现,不需要引入重量级框架。

第三层是兜底回答。当 AI 不可用时,后端返回一个固定的文本,比如"智能助手暂时不可用,请稍后再试",并在 HTTP 响应头里加一个X-AI-Fallback: true,前端看到这个头可以改变展示样式,提示用户当前是降级状态。

这一步看起来简单,但价值极大。因为只有 AI 挂了不影响主业务流程,你才敢把这个功能放到生产环境里给真实用户用。

4.4 我整理的老项目 AI 接入问题速查表

我把实际踩过的坑按现象整理成一张表,排查时照着对就行:

现象可能原因解决办法
返回中文全是问号RestTemplate 默认编码 ISO-8859-1把 StringHttpMessageConverter 编码改为 UTF-8
前端一直在转圈,最后一次性出一大段文字Nginx 代理缓冲;或前端没有用流式接口Nginx 关proxy_buffering off;后端确认返回的是 SseEmitter
流式连接到一半突然断开readTimeout 设了固定值;或后端线程池满了被杀OkHttp 设readTimeout(0);检查线程池队列
偶发 429触发上游限流退避重试一次;加本地信号量控制并发
前端 EventSource 一直报错后端返回内容不是合法的 SSE 格式检查是否有\n\n分隔;不要用 GZip 压缩 SSE
多轮对话答非所问历史消息裁剪策略太激进了,把上下文丢了保留 system+最近几轮;打印 messages 实际内容排查

最后提一个老项目改造时容易被忽略的点:旧项目可能有统一响应的拦截器或者 Filter,会在 Controller 返回后对响应包一层包装。SseEmitter 一旦经过这些过滤器,可能被强制转成 JSON 导致流式失效。遇到"SseEmitter 看着没问题但浏览器收到的却是 JSON"的情况,先检查全局 ResponseBodyAdvice 和 Filter,把这个接口排除掉。

最后再分享一个小技巧

这套改造走下来,我对"旧项目接 AI"的感触就一句话:真正花时间的从来不是调模型接口,而是怎么把模型塞进旧系统已有的约束里——线程池、会话存储、日志规范、前端协议,每一项都要贴着现状来。

想给要把这套讲给面试官听的读者提个醒:面试官问"Java 怎么做 AI 流式输出",不要只背 SSE 和 SseEmitter 这两个名词。你给他画一条完整链路——用户请求进来,Controller 返回 SseEmitter 并立刻释放 Tomcat 线程,独立线程池调 OkHttp 读上游 SSE,逐 token 转发给前端,前端通过 EventSource 边收边渲染,最后说清楚断连怎么 cancel、Nginx 缓冲怎么处理、降级怎么做——这一套讲完,面试官基本上就知道你是真做过而不是背过概念。

我下一篇文章打算写老项目里的 RAG 落地方案:怎么让 AI 基于项目自己的旧文档、工单记录回答问题,而不是靠通用模型瞎编。如果你们项目也有"AI 回答不够准"的痛点,欢迎在评论区聊聊你的接入场景,踩坑细节我一个人总结不完。

返回列表