尧图网站设计 尧图网站设计YAOTU DESIGN
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里有 RestTemplateJackson 也是标配。只要项目本身能发 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); MapString, Object body new HashMap(); body.put(model, model); body.put(temperature, 0.7); body.put(messages, Arrays.asList( message(system, 你是一个严谨的技术支持助手回答要简洁、准确。), message(user, userMessage) )); HttpEntityMapString, Object request new HttpEntity(body, headers); ResponseEntityJsonNode 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 MapString, String message(String role, String content) { MapString, 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 MapString, String chat(RequestBody MapString, String req) { String answer chatClient.chat(req.get(message)); return Collections.singletonMap(answer, answer); } }到这一步前端已经可以拿到 AI 的回复了。虽然体验一般般——点击按钮后要等好几秒才出结果但链路通了这是最重要的。1.3 调通之后先别急着往下走把参数和异常搞清楚第一层跑通后我建议花一点时间把请求参数过一遍省得后面排查问题时一头雾水。常用的参数就这几个参数作用我的建议temperature控制随机性0 到 2 之间越大越放飞客服场景用 0.3 左右写作场景用 0.7max_tokens限制返回的最大 token 数按业务需要设置建议至少 512top_p核采样控制候选词范围保持默认先不调stream是否流式返回第三层之前保持 false异常处理是新手最容易忽略的。我见过同事写了一个多小时发现所有请求都报 401结果发现 API Key 复制的时候多了个空格。还有 429 被当成普通错误处理导致前端看到一串报错堆栈实际上 429 只是限流后端可以做退避重试。常见的错误码要单独接一层判断401API Key 无效或已过期检查密钥404endpoint 路径不对或者是模型名拼错了429触发限流或余额不足需要做重试400请求体格式错误多半是 messages 结构不对第一个坑就出现了中文乱码。RestTemplate 的StringHttpMessageConverter默认编码是 ISO-8859-1如果接口走的是字符串解析中文大概率变成问号。解决方式是在配置 RestTemplate 时把所有 String 转换器的默认编码改成 UTF-8Bean public RestTemplate restTemplate() { RestTemplate restTemplate new RestTemplate(); ListHttpMessageConverter? converters restTemplate.getMessageConverters(); for (HttpMessageConverter? converter : converters) { if (converter instanceof StringHttpMessageConverter) { ((StringHttpMessageConverter) converter).setDefaultCharset(StandardCharsets.UTF_8); } } return restTemplate; }注意如果你调第一层时用的是ResponseEntityMap而不是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 ConcurrentHashMapString, ListMapString, String sessions new ConcurrentHashMap(); private static final int MAX_MESSAGES 20; public void addMessage(String sessionId, String role, String content) { ListMapString, String messages sessions.computeIfAbsent(sessionId, k - new ArrayList()); synchronized (messages) { messages.add(messageEntry(role, content)); trim(messages); } } public ListMapString, String getMessages(String sessionId) { ListMapString, String messages sessions.get(sessionId); return messages null ? new ArrayList() : new ArrayList(messages); } private void trim(ListMapString, String messages) { while (messages.size() MAX_MESSAGES) { if (system.equals(messages.get(0).get(role))) { messages.remove(1); } else { messages.remove(0); } } } private MapString, String messageEntry(String role, String content) { MapString, 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 - ListString的形式存 JSON。我给同事的建议是别在内存会话上花太多功夫老项目如果已经用了 Redis 做缓存那第二层顺手就存 Redissession 不丢代码也简单。提示在多轮对话接口里前端必须把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 端要解析 SSERestTemplate 就不够用了因为 RestTemplate 默认会等整个响应结束才返回。我换成了 OkHttp它在依赖树里很轻和老项目兼容性也好。只需要在 pom 里加一行dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version3.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(ListMapString, String messages, ConsumerString onToken, Runnable onDone, ConsumerThrowable onError) { String payload buildPayload(messages); RequestBody requestBody RequestBody.create( MediaType.parse(application/json; charsetutf-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); ListMapString, 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?sessionIdxxxmessage你好); 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 Callfinal 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 回答不够准的痛点欢迎在评论区聊聊你的接入场景踩坑细节我一个人总结不完。
返回列表