
1. 为什么老项目接 AI 不能一上来就上流式我手头这套系统是 2018 年搭的JDK 8 Spring Boot 2.3.x跑在一台 4C8G 的云主机上业务是给几家中小客户做订单和库存管理。去年年底老板提了个需求在订单详情页加一个智能助手能根据当前订单数据回答客户的问题。听起来简单但真动手才发现老项目接 AI 这件事坑比想象中多得多。大多数人的第一反应是找个大模型 API写个 HTTP 请求把返回的 JSON 解析出来塞到页面上完事。我一开始也是这么想的半天就写完了第一版。结果上线第二天就出问题了——用户问帮我分析一下这个订单为什么延迟发货模型思考了十几秒前端页面一直转圈用户以为卡死了直接刷新请求重复发送账单直接翻倍。这就是为什么我把这个系列的第一篇定在四层递进上。从基础对话到流式输出不是技术炫技而是老项目在真实约束下必须走过的四个阶段。每一层解决一个具体问题跳过任何一层都会在某个时刻被反噬。先把这个系列的定位说清楚面向的是存量 Java 项目不是新起一个 AI 中台。这意味着几个硬约束你必须接受JDK 版本大概率是 8升不上去很多新库用不了Spring Boot 版本在 2.x 区间WebFlux 能用但团队不熟服务器资源有限不能为了一个 AI 功能单独扩一套集群团队里没人做过 AI 集成容错空间小在这四个约束下我总结出的四层递进是这样的层级核心目标解决什么问题典型耗时第一层基础对话打通验证链路可行性半天第二层上下文与结构化让回答有业务价值2-3 天第三层异步与超时治理防止请求堆积拖垮服务1-2 天第四层流式输出解决长回答的体验问题2-3 天这个顺序不能乱。我见过有人直接跳到第四层结果连基础的错误处理都没做流式输出到一半模型报错前端拿到半截 JSON 直接崩了。也见过有人卡在第一层就想着做 RAG最后发现连 API Key 的轮换都没搞定。提示如果你现在还在第一层之前先别急着看流式。把基础对话跑通、把错误码摸清楚、把计费逻辑理明白这三件事做完再往下走。接下来我按这四层把每一层的具体做法、踩过的坑、以及为什么这么设计完整拆一遍。代码都是 JDK 8 能直接跑的依赖也尽量用 Spring Boot 2.3.x 自带的不引入额外框架。2. 第一层用 RestTemplate 把对话链路跑通2.1 为什么不用 WebClient 而选 RestTemplate第一层最容易纠结的就是 HTTP 客户端选型。Spring Boot 2.3.x 里有两个选择RestTemplate 和 WebClient。网上很多文章推荐 WebClient说它支持响应式、非阻塞、性能好。但我实测下来在老项目里第一层用 RestTemplate 是更稳的选择原因有三个。第一团队熟悉度。RestTemplate 是同步阻塞的写法直观出问题好排查。WebClient 的 Mono/Flux 对没接触过响应式的同学来说调试成本很高一个block()用错地方就可能死锁。第二第一层的目标是验证链路不是压测性能。这个阶段 QPS 可能就个位数同步阻塞完全够用。等到了第三层做异步治理时再考虑要不要换。第三依赖干净。RestTemplate 是 spring-web 自带的WebClient 需要额外引入 spring-boot-starter-webflux在老项目里加这个依赖有可能和现有的 MVC 配置打架。所以第一层的技术栈就是RestTemplate Jackson 一个配置类。不引入任何 AI 相关的 SDK因为大部分厂商的 Java SDK 都要求 JDK 11JDK 8 用不了。2.2 配置类怎么写才不会被同事骂先看配置。很多人直接把 API Key 硬编码在代码里这是大忌。我的做法是抽一个配置类Key 走环境变量超时时间可配。Configuration public class AiClientConfig { Value(${ai.api.key:}) private String apiKey; Value(${ai.api.endpoint:https://api.example.com/v1/chat}) private String endpoint; Value(${ai.api.connect-timeout:5000}) private int connectTimeout; Value(${ai.api.read-timeout:60000}) private int readTimeout; Bean(aiRestTemplate) public RestTemplate aiRestTemplate() { SimpleClientHttpRequestFactory factory new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(connectTimeout); factory.setReadTimeout(readTimeout); RestTemplate restTemplate new RestTemplate(factory); restTemplate.setMessageConverters( Collections.singletonList(new MappingJackson2HttpMessageConverter()) ); return restTemplate; } }这里有几个细节值得说。readTimeout 设成 60 秒是因为第一层用的是非流式接口模型生成完整回答可能要几十秒设太短会频繁超时。connectTimeout 设 5 秒连接都连不上就没必要等。单独命名 Bean避免和项目里已有的 RestTemplate 冲突这个坑我踩过——老项目里往往已经有一个全局 RestTemplate直接注入会拿到错的。配置文件里这样写ai: api: key: ${AI_API_KEY:} endpoint: https://api.example.com/v1/chat connect-timeout: 5000 read-timeout: 60000Key 用环境变量注入本地开发时在 IDE 的运行配置里设生产环境在启动脚本里 export。绝对不要把 Key 提交到 Git哪怕是私有仓库。我见过有人图省事写在 application.yml 里结果仓库权限一放开就泄露了。2.3 一次完整对话的请求与响应长什么样请求体用 Map 拼就行不用建一堆 DTO 类第一层追求的是快。Service public class BasicChatService { Resource(name aiRestTemplate) private RestTemplate restTemplate; Value(${ai.api.key}) private String apiKey; Value(${ai.api.endpoint}) private String endpoint; public String chat(String userMessage) { HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set(Authorization, Bearer apiKey); MapString, Object body new HashMap(); body.put(model, default-model); body.put(messages, Collections.singletonList( buildMessage(user, userMessage) )); body.put(temperature, 0.7); HttpEntityMapString, Object entity new HttpEntity(body, headers); try { ResponseEntityMap response restTemplate.postForEntity( endpoint, entity, Map.class ); return extractContent(response.getBody()); } catch (HttpClientErrorException e) { // 4xx 错误通常是 Key 或参数问题 throw new BizException(AI 服务调用失败 e.getResponseBodyAsString()); } catch (ResourceAccessException e) { // 超时或网络问题 throw new BizException(AI 服务响应超时请稍后重试); } } private MapString, String buildMessage(String role, String content) { MapString, String msg new HashMap(); msg.put(role, role); msg.put(content, content); return msg; } SuppressWarnings(unchecked) private String extractContent(Map body) { if (body null) return ; ListMapString, Object choices (ListMapString, Object) body.get(choices); if (choices null || choices.isEmpty()) return ; MapString, Object message (MapString, Object) choices.get(0).get(message); return message null ? : String.valueOf(message.get(content)); } }这段代码看着简单但每一处异常处理都是踩出来的。HttpClientErrorException 和 ResourceAccessException 必须分开捕获因为前者是参数问题重试没用后者是网络问题可以重试。混在一起处理会导致要么疯狂重试无效请求要么该重试的时候直接放弃。2.4 第一层最容易忽略的三件事跑通 Demo 只要半小时但要让它在生产环境不出事还有三件事必须做。第一件日志脱敏。请求和响应都要打日志但 API Key 和用户敏感内容不能明文打。我的做法是打请求时把 Authorization 头替换成Bearer ***打响应时只打前 200 个字符。第二件计费监控。每次调用都要记录 token 消耗。响应体里通常有usage字段包含 prompt_tokens 和 completion_tokens。把这些数据落到一张表里按天统计一旦发现异常增长能立刻定位。我见过有人被刷了几千块才发现就是因为没做这个监控。第三件降级开关。在配置中心加一个ai.enabled开关AI 服务出问题时能一键关掉让业务回到没有 AI 的状态。这个开关在第一次线上故障时救了我——模型服务商那边挂了我直接关开关用户侧无感知。注意第一层不要做任何缓存。有人想着把相同问题的回答缓存起来省钱但 AI 回答本身有随机性缓存会导致用户体验不一致而且缓存命中率通常很低得不偿失。3. 第二层让 AI 回答真正带上业务上下文3.1 从能聊天到有用的鸿沟第一层跑通后你会发现一个尴尬的事实AI 能聊天但聊的都是废话。用户问我这个订单什么时候到AI 回答请您提供订单号我帮您查询——它根本不知道订单号是什么因为它看不到你的数据库。这就是第二层要解决的问题把业务数据喂给模型。但怎么喂喂多少喂什么格式这里面门道很多。最朴素的做法是把整个订单对象序列化成 JSON 塞进 prompt。我试过一个订单对象序列化出来 3000 多字符加上系统提示词一次请求的 prompt token 就上万了。成本高不说模型还容易被无关字段干扰回答质量反而下降。正确的做法是按需裁剪 结构化组织。只把和当前问题相关的字段喂进去并且用清晰的格式标注每个字段的含义。3.2 上下文注入的三种粒度我实践下来上下文注入分三种粒度对应不同的场景。粒度一单实体注入。用户问的是某个具体订单就只注入这个订单的关键字段。比如订单号、状态、下单时间、预计发货时间、商品名称、数量。其他字段一律不喂。粒度二实体加关联。用户问这个订单的物流怎么这么慢除了订单本身还要注入物流轨迹。这时候需要根据问题类型动态决定注入哪些关联数据。粒度三实体加历史。用户问我上次买的那个东西什么时候能再买需要注入该用户的历史订单。这种场景 token 消耗大要谨慎使用。实现上我抽了一个ContextBuilder根据问题类型决定注入哪些数据Component public class ContextBuilder { public String buildOrderContext(Order order, String userQuestion) { StringBuilder sb new StringBuilder(); sb.append(当前订单信息\n); sb.append(- 订单号).append(order.getOrderNo()).append(\n); sb.append(- 状态).append(order.getStatusDesc()).append(\n); sb.append(- 下单时间).append(formatTime(order.getCreateTime())).append(\n); sb.append(- 预计发货).append(formatTime(order.getExpectShipTime())).append(\n); // 只有问题涉及商品时才注入商品明细 if (containsAny(userQuestion, 商品, 东西, 货, 买)) { sb.append(- 商品).append(order.getItemName()).append(\n); sb.append(- 数量).append(order.getQuantity()).append(\n); } // 只有问题涉及物流时才注入物流信息 if (containsAny(userQuestion, 物流, 快递, 发货, 到)) { sb.append(- 物流状态).append(order.getLogisticsStatus()).append(\n); } return sb.toString(); } private boolean containsAny(String text, String... keywords) { if (text null) return false; for (String kw : keywords) { if (text.contains(kw)) return true; } return false; } }这个containsAny的判断很粗糙但实测下来比全量注入效果好很多。关键思路是让模型只看到它需要的信息而不是所有信息。这既省钱又提质量。3.3 系统提示词怎么写才不像机器人上下文有了还得告诉模型怎么用。这就是系统提示词system prompt的作用。很多人写系统提示词就是一句你是一个智能助手这等于没写。我的系统提示词模板是这样的你是一个订单管理系统的智能助手。你的职责是帮助用户解答订单相关问题。 规则 1. 只根据下面提供的订单信息回答不要编造信息。 2. 如果订单信息里没有用户问的内容直接说当前订单信息中没有这项数据不要猜测。 3. 回答要简洁控制在 100 字以内。 4. 涉及时间的问题用预计大约等词不要给绝对承诺。 5. 不要输出订单号、手机号等敏感信息的完整内容。 订单信息 {context}这五条规则每一条都有来历。规则 1 防幻觉模型很爱编造不存在的物流信息。规则 2 防瞎猜比编造更隐蔽的问题是模型根据不完整信息推断。规则 3 控长度不限制的话模型能写 500 字。规则 4 防承诺物流延迟是常事模型给绝对承诺会引发投诉。规则 5 防泄露这个不用解释。3.4 多轮对话的状态怎么存第二层还有一个绕不开的问题多轮对话。用户问这个订单什么时候到AI 回答后用户接着问那能改地址吗第二个问题依赖第一个问题的上下文。最直接的做法是把历史消息全部带上。但这样 token 会线性增长聊到第十轮prompt 就爆了。我的做法是滑动窗口 摘要。保留最近 3 轮完整对话更早的对话用一句话摘要代替。摘要由模型生成在每轮对话结束后异步更新。public class ConversationSession { private String sessionId; private ListMessage recentMessages; // 最近 3 轮 private String historySummary; // 更早对话的摘要 private long lastActiveTime; }Session 存在 Redis 里设置 30 分钟过期。为什么是 30 分钟因为实测下来用户超过 30 分钟不说话的基本不会再回到同一个话题保留上下文没意义还占内存。提示Session 的 key 一定要带上用户 ID不能只用 sessionId。否则用户 A 的对话可能被用户 B 读到这是严重的安全问题。4. 第三层异步化与超时治理别让 AI 拖垮整个服务4.1 同步调用的雪崩是怎么发生的第二层做完功能是完整了但性能问题开始暴露。AI 接口的响应时间波动极大快的时候 2 秒慢的时候 30 秒。如果用户请求直接同步调用 AI那么 Tomcat 的工作线程就会被长时间占用。Tomcat 默认最大线程数是 200。假设 AI 平均响应 10 秒那么理论上每秒只能处理 20 个请求。一旦并发超过这个数请求就开始排队排队导致响应更慢最终整个服务的所有接口都被拖垮——包括那些和 AI 无关的订单查询接口。这就是典型的线程池耗尽型雪崩。我压测过一次50 并发的情况下订单查询接口的 P99 从 80ms 涨到了 8 秒就是因为线程都被 AI 调用占着。4.2 用 Async 做异步化的正确姿势解决方案是异步化用户请求进来后立刻返回一个任务 ID后台线程池去调 AI前端拿任务 ID 轮询结果。Spring Boot 的Async用起来简单但有几个坑必须避开。首先是线程池配置。绝对不能用默认的 SimpleAsyncTaskExecutor它每次调用都新建线程高并发下会创建海量线程直接 OOM。必须自定义线程池Configuration EnableAsync public class AsyncConfig implements AsyncConfigurer { Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(100); executor.setThreadNamePrefix(ai-task-); executor.setRejectedExecutionHandler( new ThreadPoolExecutor.CallerRunsPolicy() ); executor.initialize(); return executor; } }参数怎么定corePoolSize 8是根据 AI 接口的并发能力定的模型服务商通常有并发限制开太多会被限流。queueCapacity 100是缓冲超过就触发拒绝策略。CallerRunsPolicy是关键——队列满了之后让调用线程自己执行这样会阻塞用户请求形成天然的背压避免任务无限堆积。然后是任务状态管理。异步任务的结果要存起来供前端轮询Service public class AsyncChatService { Resource private BasicChatService chatService; private final MapString, TaskResult taskCache new ConcurrentHashMap(); Async public void executeAsync(String taskId, String question, String context) { try { String answer chatService.chatWithContext(question, context); taskCache.put(taskId, TaskResult.success(answer)); } catch (Exception e) { taskCache.put(taskId, TaskResult.fail(e.getMessage())); } } public TaskResult queryResult(String taskId) { return taskCache.get(taskId); } }这里用 ConcurrentHashMap 只是演示生产环境要换成 Redis否则多实例部署时轮询会打到没有结果的实例上。4.3 超时、重试、熔断三件套异步化解决了线程占用问题但 AI 接口本身的不稳定还得治。三件套超时、重试、熔断。超时前面配过了readTimeout 60 秒。但异步任务还要加一层总超时防止任务永远卡着。我用一个 ScheduledExecutorService 做兜底任务超过 90 秒还没结果就标记为超时。重试要谨慎。AI 调用不是幂等的每次回答可能不同而且重试会翻倍消耗 token。我的策略是只对网络类错误连接超时、5xx重试且最多重试 1 次重试间隔 2 秒。参数类错误4xx绝不重试。熔断用 Resilience4jJDK 8 兼容。配置大概是10 秒内失败率超过 50% 就熔断熔断 30 秒后进入半开状态试探。CircuitBreakerConfig config CircuitBreakerConfig.custom() .failureRateThreshold(50) .waitDurationInOpenState(Duration.ofSeconds(30)) .slidingWindowSize(10) .build();熔断触发后直接返回降级话术智能助手暂时不可用请稍后再试。这比让用户等 60 秒然后报错体验好得多。4.4 前端轮询的节奏怎么定异步化后前端要轮询结果。轮询间隔定多少太短浪费请求太长体验差。我的方案是递增间隔第一次 500ms之后每次翻倍最大 3 秒。这样快速任务能快速拿到结果慢任务也不会疯狂请求。let interval 500; function poll(taskId) { fetch(/ai/result?taskId taskId) .then(res res.json()) .then(data { if (data.status DONE) { render(data.answer); } else { interval Math.min(interval * 2, 3000); setTimeout(() poll(taskId), interval); } }); }实测下来大部分请求在 3-5 秒内完成用户感知就是稍微等一下比转圈 30 秒好太多。5. 第四层流式输出把等待变成打字机5.1 为什么流式是体验的分水岭第三层做完功能稳定了但体验还是差。用户问一个需要长回答的问题比如帮我分析这个月订单延迟的原因模型要生成 500 字非流式的话用户要盯着 loading 看 20 秒然后答案一次性蹦出来。流式输出解决的就是这个让答案一个字一个字地出现用户看到第一个字的时间从 20 秒缩短到 1 秒。虽然总时长没变但感知完全不同——就像打字机一样用户知道系统在工作。这是体验的分水岭。我做过对比测试同样的功能流式版本的满意度比非流式高出一大截用户投诉卡死的比例几乎归零。5.2 SSE 还是 WebSocket老项目怎么选流式输出有两条技术路线SSEServer-Sent Events和 WebSocket。SSE 的优势基于 HTTP实现简单Spring MVC 原生支持返回 SseEmitter 即可自动重连单向推送够用。劣势只能服务端推客户端浏览器兼容性在老旧环境可能有问题。WebSocket 的优势双向通信兼容性好。劣势需要额外配置老项目里加 WebSocket 可能和现有安全配置冲突实现复杂度高。我的选择是SSE。因为 AI 对话是典型的单向推送场景用户发一次请求服务端持续推答案不需要双向。而且 Spring Boot 2.3.x 的 SseEmitter 用起来非常顺手。GetMapping(/ai/stream) public SseEmitter streamChat(RequestParam String question) { SseEmitter emitter new SseEmitter(120000L); emitter.onTimeout(() - emitter.complete()); emitter.onError(e - emitter.complete()); aiStreamService.streamChat(question, emitter); return emitter; }超时设 120 秒比非流式的 60 秒长因为流式任务总时长可能更久。onTimeout 和 onError 都要处理否则连接泄漏。5.3 用 OkHttp 消费上游的流式响应服务端要消费模型服务商的流式接口RestTemplate 就不够用了它不支持流式读取。这里我引入了 OkHttpJDK 8 兼容API 也简单。public void streamChat(String question, SseEmitter emitter) { OkHttpClient client new OkHttpClient.Builder() .connectTimeout(5, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS) .build(); MapString, Object body new HashMap(); body.put(model, default-model); body.put(messages, buildMessages(question)); body.put(stream, true); Request request new Request.Builder() .url(endpoint) .header(Authorization, Bearer apiKey) .post(RequestBody.create( MediaType.parse(application/json), JSON.toJSONString(body) )) .build(); client.newCall(request).enqueue(new Callback() { Override public void onFailure(Call call, IOException e) { emitter.completeWithError(e); } Override public void onResponse(Call call, Response response) throws IOException { try (BufferedReader reader new BufferedReader( new InputStreamReader(response.body().byteStream()))) { String line; while ((line reader.readLine()) ! null) { if (line.startsWith(data: )) { String data line.substring(6); if ([DONE].equals(data)) break; String content parseDelta(data); if (content ! null !content.isEmpty()) { emitter.send(SseEmitter.event().data(content)); } } } emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } } }); }这段代码有几个关键点。readTimeout 设 120 秒因为流式连接会保持很久。逐行读取模型返回的是 SSE 格式每行以data:开头。[DONE]是结束标志收到就 break。每个 delta 单独 send前端就能逐字渲染。5.4 前端 EventSource 的坑与处理前端用 EventSource 接收const es new EventSource(/ai/stream?question encodeURIComponent(q)); let buffer ; es.onmessage (event) { buffer event.data; document.getElementById(answer).textContent buffer; }; es.onerror (err) { es.close(); if (buffer.length 0) { showError(连接失败请重试); } };这里有个坑EventSource 默认会自动重连如果服务端已经 complete 了前端可能还在重连导致重复请求。解决办法是服务端 complete 后前端在 onmessage 里判断是否收到结束标志主动 close。另一个坑是中文乱码。SSE 默认用 UTF-8但如果服务端没设置正确的 Content-Type浏览器可能用错编码。Spring 的 SseEmitter 默认是 UTF-8一般没问题但如果你的项目里配了全局的字符编码过滤器要确认它没把 SSE 的响应也改了。还有一个体验细节流式输出时前端要自动滚动到底部否则用户看到一半就看不到新内容了。这个用scrollIntoView或者直接设置scrollTop就行。注意流式输出和第三层的异步任务不能混用。流式本身就是异步的再套一层异步任务会导致前端不知道该轮询还是该监听 SSE。二选一我推荐长回答用流式短回答用异步轮询。6. 四层走完后的复盘与几个反直觉的结论四层走完这套 AI 助手在线上跑了三个月日均调用 2000 多次没出过大故障。回过头看有几个结论和最初的预期完全相反值得记下来。第一个反直觉流式不是越早做越好。我一开始觉得流式是核心体验应该优先做。但实际是如果前三层没做好流式的调试会非常痛苦——你分不清是流式实现的问题还是上下文注入的问题还是超时配置的问题。分层递进的价值就在于每一层都建立在前一层稳定的基础上。第二个反直觉省钱的关键不在模型选型而在上下文裁剪。我一开始想着换个便宜模型能省不少钱后来发现上下文裁剪带来的 token 节省远超模型差价。一个订单对象全量注入 3000 token裁剪后 300 token差了 10 倍。模型再便宜也补不回这个差距。第三个反直觉异步化之后用户投诉反而变多了。因为异步化让用户要主动轮询有些用户不知道要等以为没反应就重复点击。后来加了正在思考中的提示和自动轮询投诉才降下来。技术方案解决的是系统问题用户体验问题还得靠交互设计。第四个反直觉熔断降级的话术比熔断本身更重要。熔断触发后返回什么直接决定用户感受。返回系统错误用户会恐慌返回智能助手正在休息请稍后再试用户就能接受。同样一个技术动作话术不同效果天差地别。最后分享一个实操小技巧在开发环境把 AI 响应 mock 掉。模型接口又慢又贵开发时频繁调用既浪费时间又烧钱。我写了一个 MockAiService根据问题关键词返回预设答案本地开发默认走 mock需要真实调用时加个开关。这个小改动让团队的开发效率提升了不少也避免了开发阶段的意外扣费。这套方案不是最优解但是在 JDK 8 Spring Boot 2.3.x 这个约束下的可行解。如果你的项目条件类似可以直接参考如果条件更好比如能上 JDK 17 和 Spring Boot 3那 WebFlux 和官方 SDK 会让实现更优雅。但无论技术栈怎么变分层递进、先稳后快这个思路是不变的。