ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

老Spring Boot项目接入AI:四层递进实现流式输出全链路改造

老Spring Boot项目接入AI:四层递进实现流式输出全链路改造 接手维护过一个七八年的老 Spring Boot 项目数据库表过百张连 HTTP 客户端都还是最原始的HttpURLConnection代码里跑着定时任务、消息队列、报表导出这些祖传逻辑。突然有一天产品说把 AI 接进来。当时我的第一反应不是“怎么接”而是“能不能别接”——老项目接大模型最怕的不是模型本身而是老项目能不能扛住流式响应那套新玩法。连接池、代理层、拦截器、响应缓冲区任何一个环节都可能把流式输出变成一次性等全部返回的假流式。这篇就是我从零把 AI 接入这个老项目的完整记录核心思路是四层递进基础对话、封装统一访问层、流式改造、全链路流式贯通。适合正在给老 Spring Boot 项目接入 AI 能力又不想推倒重来的同学参考。1. 先说清楚为什么要分四层老项目接入 AI 的真实约束老项目接入 AI绝不只是把官方 SDK 粘进 pom.xml 那么简单。最大的约束有两个依赖老、架构旧。1.1 依赖常年不升级第三方 SDK 直接引用容易触发依赖冲突很多老项目是 Java 8 时代的产物Spring Boot 版本可能停在 2.3 甚至 2.1。而现在 AI 厂商的官方 Java SDK比如 OpenAI Java SDK、通义千问 SDK普遍基于 Java 11 以上构建引入的时候极大概率会跟你现有的spring-boot-starter-web、mybatis-spring-boot-starter产生传递依赖冲突。我这边实测下来引入某大厂 SDK 后项目启动直接报NoSuchMethodError定位半天发现是 SDK 依赖的okhttp版本和项目里老旧的okhttp版本冲突把WebSocket相关类搞崩了。这种问题在接 AI 的第一天就出现特别消磨耐心。1.2 老代码对“同步阻塞”的执念和流式返回天生不对付老项目的接口风格基本都是同步阻塞请求进来Service 算完一次性把结果返回。而大模型的流式输出是持续“吐字”的过程要求服务端能边接收模型返回值边把内容往客户端转发——这是一种长连接 增量响应的模式。问题在于老项目的很多组件根本不了解长连接是什么概念代理层 Nginx 默认读到上游完整响应才转发不配proxy_buffering off用户永远等不到第一个字中间件层如果用了老的OncePerRequestFilter或HandlerInterceptor很可能悄悄把响应体缓存住项目里常用的FastJSON的JSONObject.toJSONString()序列化大模型流式分片时也会因为转义处理把本来就该严格按格式传输的 SSE 数据搞坏。所以我把接入过程拆成四层递进核心目的就是逐步验证、逐层打通避免一次性改造太多东西导致排查困难第一层基础对话打通模型能力验证账号、鉴权、网络第二层抽象统一访问层隐藏厂商差异让后续扩展可控第三层单接口流式输出在服务端把流式响应做出来第四层全链路流式把代理层、拦截器、前端全部对齐。后面每一层的坑我都会单独写一节尤其是流式相关的部分值得仔细看。2. 第一层不要急着引 SDK先用 HTTP 原生请求把对话跑通很多同学一上来就找官方 SDK我反而建议第一层先用老项目里现成的HttpURLConnection或者RestTemplate直接调一次大模型接口。这里目的不是生产可用而是最快验证三件事网络通不通、鉴权对不对、模型返回结构长什么样。2.1 我用的基础封装不引依赖把聊天接口调通我这边因为项目实在太老连RestTemplate都很少用所以我直接用HttpURLConnection写了个最小调用代码。如果你项目里有现成的RestTemplate也完全可以替代。核心代码如下public String callChat(String prompt) throws Exception { URL url new URL(https://api.xxxx.com/v1/chat/completions); HttpURLConnection conn (HttpURLConnection) url.openConnection(); conn.setRequestMethod(POST); conn.setRequestProperty(Content-Type, application/json; charsetutf-8); conn.setRequestProperty(Authorization, Bearer apiKey); conn.setConnectTimeout(30000); conn.setReadTimeout(60000); conn.setDoOutput(true); // 构造请求体不同厂商字段有细微差异这里是 OpenAI 兼容协议 MapString, Object body new HashMap(); body.put(model, 你的模型名称); body.put(messages, Arrays.asList( Collections.singletonMap(role, user), Collections.singletonMap(content, prompt) )); body.put(stream, false); conn.getOutputStream().write(JSON.toJSONString(body).getBytes(StandardCharsets.UTF_8)); // 读取返回结果 if (conn.getResponseCode() 200) { try (BufferedReader reader new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { String response reader.lines().collect(Collectors.joining(\\n)); return response; } } else { // 这里把错误流打印出来方便排查 401/403/429 等状态 try (BufferedReader reader new BufferedReader( new InputStreamReader(conn.getErrorStream(), StandardCharsets.UTF_8))) { throw new RuntimeException(调用失败: conn.getResponseCode() reader.lines().collect(Collectors.joining(\\n))); } } }逐行看下来没什么玄学就是一次标准 POST 请求。但有三点值得提醒鉴权头别写错。现在国内主流大模型厂商基本兼容 OpenAI 协议但鉴权方式五花八门有的用Authorization: Bearer有的用X-Api-Key还有的需要带api-key路径参数。第一层建议老老实实把官方文档的鉴权方式抄过来不然后续每一层报 401 你都会怀疑是自己的问题。超时时间要给足。非流式调用一个长文本生成任务经常十几秒甚至几十秒连接超时和读取超时都要放宽。我见过有同事把读超时设成 5 秒结果每次问稍微长一点的问题就报SocketTimeoutException折腾一上午才反应过来是这个原因。先不要动stream参数。第一层把stream设为false拿到一次性完整返回值即可。流式的坑太多了问题会留给第三层统一处理。2.2 第一层最容易翻车的地方模型名和请求体结构第一层还有个特别容易翻车的地方就是请求体字段名。虽然市面上大多兼容 OpenAI 协议但总有些细节不一样有些厂商的模型名不是model字段而是model_name或enginemessages里的role有的叫role有的叫author字段大小写也有的严格区分。这些差异在第二层统一抽象时会被消化掉但第一层如果你拿着一个模型名去调别家接口大概率得到一坨莫名其妙的报错。我的建议是第一层固定接一家模型厂商把这一家的细节完全摸清第二层再考虑多厂商。这里顺带提一句模型选型。如果你接入的是纯文本对话能力选那种通用对话模型就好别选带工具调用function calling或视觉理解的模型因为第一层只需要把对话链路通掉越少变量越好。3. 第二层抽象统一访问层把厂商差异和代码侵入锁死在接口后面第一层跑通之后你手上已经有了一份能用的调用代码。但如果直接把它撒进业务代码里后面会非常痛苦上线的模型要换版本、要接第二家厂商做容灾、要做 token 用量统计、要做模型切换开关——这些需求一旦产生散落在各 Service 里的原生调用代码会变成一场灾难。所以第二层的目标很明确定义一个不依赖任何具体厂商的 ChatClient 接口把厂商差异隔离在实现类里。3.1 接口设计只暴露业务方真正关心的语义我最终设计的接口不花哨核心就两个方法public interface ChatClient { String chat(String prompt); void chat(String prompt, StreamResponseHandler handler); }方法一是同步调用方法二是流式调用的占位第二层先只实现chat(String)StreamResponseHandler的完整逻辑留到第三层实现。为什么这么设计因为业务方的核心诉求只有“给我下面这句话的回复”他不需要知道你在背后调的是哪家模型、走的是 HTTP 还是 SDK、模型名是什么。把复杂性关在接口后面业务代码就不会被 AI 供应商绑架。同时我定义了一个配置类public class ChatConfig { private String provider; // 供应商标识aliyun/openai/baidu... private String apiBaseUrl; private String apiKey; private String model; private Integer timeout; // getter/setter 省略 }这个配置类从application.yml读取后面做多环境隔离、多厂商切换都靠它。3.2 基于 Ioc 的动态切换一个注解解决多厂商路由这里我觉得是比较关键的并发设计思路。用 Spring 的Autowired注入的是一个接口可能有多个实现所以一定要配合条件装配做路由。我在每个实现类上打了一个自定义注解Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface AiProvider { String value(); }实现类AiProvider(aliyun) Service public class AliyunChatClient implements ChatClient { ... }调用时根据配置路由Service public class ChatRouter { Autowired private ListChatClient chatClients; Autowired private ChatConfig chatConfig; public ChatClient route() { return chatClients.stream() .filter(client - client.getClass() .getAnnotation(AiProvider.class) .value().equals(chatConfig.getProvider())) .findFirst() .orElseThrow(() - new RuntimeException(no provider matched)); } }这一步做完后面想切模型、加备用厂商都只是加个实现类、改个配置的事业务代码一行都不用动。3.3 这一层容易忽略的细节Nacos 配置刷新生效如果你项目里用的是 Nacos 做配置中心配置变更能不能即时生效是个大坑。我的ChatConfig是ConfigurationProperties注解的属性类Spring Boot 默认不会在 Nacos 配置变更时自动重新绑定到单例 bean。解决办法是加一个刷新监听在 Nacos 配置变更回调里重新读取配置或者给配置类加上RefreshScopeSpring Cloud 体系支持。否则你改了provider配置想切换厂商结果客户端还是访问旧厂商会查得怀疑人生。4. 第三层从非流式到流式改造的不只是接口是响应语义第三层是整篇的核心也是标题中点名要打的硬仗流式输出。先明确一个前提流式输出在 HTTP 层其实不是什么新东西它就是 SSEServer-Sent Events。大模型服务端不断向客户端推送数据分片直到结束。4.1 SSE 到底是个啥一种基于文本的增量推送协议SSE 的响应头是Content-Type: text/event-stream正文是一行一行的文本基本格式如下data: {id:1,choices:[{delta:{content:你}}]}\\n\\n data: {id:2,choices:[{delta:{content:好}}]}\\n\\n data: [DONE]\\n\\n每条数据以data:开头两条数据之间用空行分隔。客户端靠这个格式可以按行解析、按事件累积内容。关键点SSE 不是标准 JSON 响应不能按 JSON 一次性解析。所以要逐行读取 HTTP 输入流把每一行data:前缀去掉再做 JSON 解析拿到增量内容。这里我建议直接把第三层的实现写成单独的工具类不要和第一层的callChat混在一起。两种模式在读取逻辑上是完全不同的一个是一次性读完整 ResponseBody一个是要一边拿到输入流一边逐行解析。4.2 核心代码基于 OKHttp 的流式解析封装老项目没有现成的 WebClient 或响应式依赖我还是要了一个okhttp的依赖进来因为流式这块的 Connection 管理、超时控制、响应流读取用 OkHttp 是最省事稳定的选择。public class OkHttpStreamChatClient implements ChatClient { private final OkHttpClient httpClient; private final ChatConfig config; Override public void chat(String prompt, StreamResponseHandler handler) { JSONObject requestBody new JSONObject(); requestBody.put(model, config.getModel()); requestBody.put(stream, true); JSONArray messages new JSONArray(); JSONObject message new JSONObject(); message.put(role, user); message.put(content, prompt); messages.add(message); requestBody.put(messages, messages); Request request new Request.Builder() .url(config.getApiBaseUrl() /chat/completions) .post(RequestBody.create( MediaType.parse(application/json; charsetutf-8), requestBody.toJSONString())) .addHeader(Authorization, Bearer config.getApiKey()) .build(); try (okhttp3.Response response httpClient.newCall(request).execute()) { if (!response.isSuccessful()) { handler.onError(new RuntimeException(http response.code())); return; } // 一次读入流逐行解析 try (BufferedReader reader new BufferedReader( new InputStreamReader(response.body().byteStream(), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { if (line.isEmpty() || !line.startsWith(data:)) { continue; } String data line.substring(5).trim(); if ([DONE].equals(data)) { handler.onComplete(); return; } // 解析增量数据提取 content 字段 JSONObject json JSON.parseObject(data); String delta json.getJSONArray(choices) .getJSONObject(0) .getJSONObject(delta) .getString(content); if (delta ! null !delta.isEmpty()) { handler.onMessage(delta); } } } } catch (IOException e) { handler.onError(e); } } }这段代码有几个地方值得展开讲讲。为什么用response.body().byteStream()而不是string()因为OkHttp的body().string()会一次性把整个响应体读进内存你要的流式就名存实亡了。只有拿到byteStream()流才能一边接收一边解析。这一点是整个流式改造的地基。delta字段的解析路径必须按厂商文档调整。OpenAI 兼容协议里增量内容在choices[0].delta.content。但有些厂商的流式响应里增量内容是choices[0].message.content还有的厂商没有delta这个概念而是直接给一个全量字段让你自己前后对比。所以这段解析代码必须针对你第一层确定的厂商做定制。超时设置要重新想。非流式时读超时 60 秒没问题流式时如果还设 60 秒读超时模型生成一个长回答可能超过几分钟中间没有数据到达就会触发超时。所以流式客户端的读超时通常设置为 0无限或者设一个较大的上限比如 10 分钟。4.3 流式改造中的隐藏大坑FastJSON 的 writeString 把 SSE 数据搞坏这一节值得单独拿出来讲。因为我最初实现时流式解析已经在本地单测中“成功”了但一接上 Web 层前端收到的数据永远是乱码。排查到最后发现问题出在FastJSON的JSON.toJSONString()上。当时我的 Controller 是这样写的ServletOutputStream out response.getOutputStream(); handler.onMessage content - { // 这里直接把数据转 JSON 字符串写出 String jsonData JSON.toJSONString(Collections.singletonMap(content, content)); out.print(data: jsonData \\n\\n); out.flush(); };FastJSON序列化字符串时默认会把中文转成\\uXXXX的 Unicode 转义形式。在 JSON 字符串内部这是合法的但在data:这块自定义的 SSE 文本里所有\\u转义序列会被 SSE 解析器当作普通文本处理前端拿到手的就是一坨\\u4f60\\u597d而不是正常的“你好”。解决办法有两个序列化时关闭 HTML 反转义/Unicode 转义用SerializerFeature.DisableCircularReferenceDetect之外再传SerializerFeature.BrowserCompatible或WriteNonStringValueAsString往往没用了最稳的是不用FastJSON序列化这块文本手拼字符串或者直接拼接data: {content:你好}这种 JSON 字符串文本避免中间层序列化。我当时是手拼的这样就绕开了转义问题handler.onMessage content - { String jsonData {\\\content\\\:\\\ escapeJson(content) \\\}; out.print(data: jsonData \\n\\n); out.flush(); };这里想提醒所有用 FastJSON 做流式输出的同学如果你是直接把输出对象序列化后写到 SSE 流里先检查转义配置。后续项目如果允许建议逐步换成 Jackson并且在序列化时也要设置JsonGenerator.Feature.ESCAPE_NON_ASCII为 false。4.4 Spring MVC 里的流式响应写法别用 ResponseEntity我见过很多初学者在 Spring MVC 里做流式响应时会尝试返回ResponseEntityStreamingResponseBody或者ResponseEntityFluxString。在老项目里这不一定是最省事的方案。因为老项目 Controller 基本都是RestController 返回值凭空引入响应式依赖反而可能是负担。我用的最原始的方式注入HttpServletResponse设置响应头直接拿ServletOutputStream写。GetMapping(value /api/ai/chat, produces text/event-stream;charsetutf-8) public void chat(RequestParam String prompt, HttpServletResponse response) throws IOException { response.setContentType(text/event-stream;charsetutf-8); response.setCharacterEncoding(UTF-8); ServletOutputStream out response.getOutputStream(); chatClient.chat(prompt, new StreamResponseHandler() { Override public void onMessage(String content) { String sseData data: {\\\content\\\:\\\ escapeJson(content) \\\}\\\\n\\\\n; out.write(sseData.getBytes(StandardCharsets.UTF_8)); out.flush(); } Override public void onComplete() { out.write(data: [DONE]\\\\n\\\\n.getBytes(StandardCharsets.UTF_8)); out.flush(); } Override public void onError(Throwable t) { t.printStackTrace(); try { out.write((data: {\\\error\\\:\\\ t.getMessage() \\\}\\\\n\\\\n) .getBytes(StandardCharsets.UTF_8)); out.flush(); } catch (IOException ignore) { } } }); }这里面的转义在 Java 源码里会比较绕建议实际开发时定义常量模板把拼接逻辑整理干净一点不然极其容易出错。5. 第四层从 Nginx 到前端全链路流式贯通Service 层的流式输出做完你以为就结束了远没有。真正的战场在全链路。5.1 代理层必须关缓冲Nginx 的 proxy_buffering off老项目的接口前面基本都有 Nginx 做反向代理。默认情况下proxy_buffering是 on 的意思是 Nginx 会等上游也就是你的 Spring Boot 服务把整个响应写完再一次性发给客户端。这种情况会把你的流式彻底变成假流式——用户看到的效果是等了好久然后一次性蹦出全文。解决方法是针对 SSE 接口单独把这个关掉location /api/ai/chat { proxy_buffering off; proxy_cache off; proxy_set_header Connection ; proxy_http_version 1.1; chunked_transfer_encoding on; proxy_read_timeout 300s; }我踩过的坑主要是proxy_read_timeout。默认 60 秒里如果上游没有任何数据输出Nginx 就会断开连接。但大模型有时候会先“思考”几秒再开始吐字所以一定要把这个调大。而且流式输出过程中即使有持续数据在推长时间无数据的情况也会存在所以我这里直接干到 300 秒再配合下游模型 API 的超时控制兜底。5.2 拦截器的 buffered 陷阱为什么你设置了 SSE 头前端还是等全部返回这个问题非常隐蔽。老项目里通常有个全局HandlerInterceptor或OncePerRequestFilter用来打印请求日志、做登录校验、或者统计耗时。这些组件本身没有恶意但某些实现会把response.getWriter()或response.getOutputStream()包装一层变成缓冲流。比如你 Controller 里已经out.flush()了但因为拦截器在 afterCompletion 阶段对这层缓冲流做了关闭前的整体 flush真正到达客户端的数据还是被聚合了。体现在现象上就是SSE 头是 text/event-stream前端也走对了 EventSource 解析但数据还是一批一批地到达完全没有流式的实时性。排查方法很直接抓响应头看有没有Content-Length。正常 SSE 应答是 chunked 传输不可能有确定的Content-Length。如果响应头里出现了Content-Length字段那十有八九是被哪个组件缓存了。我当时在ResponseWrapper里看到项目自己写了个BufferedResponseWrapper extends HttpServletResponseWrapper本来是为了给日志记录拿 body 用的。这个包装类把所有输出都缓存在内存里导致 SSE 完全没法工作。解决办法就是给这个包装类加白名单SSE 接口直接使用原生HttpServletResponseif (isStreamingRequest(request.getRequestURI())) { filterChain.doFilter(request, response); return; } // 非流式接口才走 buffered 逻辑这个坑尤其值得注意老项目里各种日志包装、性能统计包装特别多流式接口很容易被他们“贴心”地缓存掉。5.3 前端 EventSource 的使用与注意点前端用 SSE 其实非常简单浏览器原生支持。注意两点如果用/api/ai/chatEventSource 默认是 GET 请求不能带自定义 header。实际项目里鉴权信息放在 URL 参数或 Cookie 里别再想着用 header 传 token。const eventSource new EventSource(/api/ai/chat?prompt encodeURIComponent(prompt)); eventSource.onmessage function (event) { if (event.data [DONE]) { eventSource.close(); return; } const json JSON.parse(event.data); appendContent(json.content); };老项目前端如果用的是 jQuery 那一套EventSource 也能照用不算突兀。6. 全链路流式改造的排错现场一次完整的定位过程这部分我希望记录一个完整案例。当时我自认为第四层已经全部打通但前端实测时仍然出现“等待 5 秒后一次性出现全文”的现象。我把排查过程完整梳理出来给大家参考。6.1 定位链路从响应头一路抓到浏览器 Network前端是 EventSource 调用现象是迟迟不出字。我首先打开浏览器 Network 面板发现请求的状态一直是 pending直到最后几秒直接变成 complete并且响应大小为很大的一个值。这说明数据确实一次性到齐了。接着我用curl -N直接请求后端接口curl -N http://localhost:8080/api/ai/chat?promptHello本地服务是正常的一个字一个字地吐。这就把问题隔离到了 Nginx 层。然后我查看 Nginx 配置发现流式接口的 location 没有配置proxy_buffering off而是继承了全局默认的 on。改完之后curl -N走 Nginx 也正常了。6.2 第二次排错浏览器还是等全部返回问题出在拦截器现象回到前端发现仍然不是实时输出。此时再检查响应头——注意这里不能只看 Chrome 的 Network 面板概览要点进去看响应头里有没有Content-Length有没有Transfer-Encoding: chunked。我看到的响应头里根本没有Transfer-Encoding只有一个异常巨大的Content-Length。这就说明响应体被某个层缓存了。于是我把 Nginx 配置又加了一轮chunked_transfer_encoding on并没有解决因为在 Nginx 之前数据已经被 Spring 这边的 ResponseWrapper 缓存了。最后我在javax.servlet.Filter里加日志排查定位到项目里的StreamBodyWrapper把所有输出都写进了ByteArrayOutputStream再在 afterCompletion 统一刷出。SSE 接口加上白名单放行之后前端终于秒出字。6.3 这个坑为什么在老项目里格外常见老项目多有统一的响应包装比如把响应体转成{code: 0, data: ...}这种标准结构或者为日志记录响应内容。这些功能对普通 JSON 接口是好东西但对 SSE 接口是灾难。所以如果你在给老项目接流式先做一件事梳理项目里所有 Filter 和 Interceptor找到会动 response 实例的类评估它们对流式接口的影响。一个简单的排查判定方法流式接口如果响应头出现Content-Length而不是Transfer-Encoding: chunked基本可以断定被缓存了。7. 流式接口落地时的中断管理与其他实践细节全链路打通后还有一个很容易被忽视但生产环境必须解决的问题客户端断开连接怎么办。7.1 客户端断开后线程一直在耗资源假设用户在页面上等 AI 回复到一半直接关了浏览器。此时你的后端线程可能还在继续执行大模型的流式读取一路读到模型生成完毕才退出。这个过程浪费了模型调用费用也占用了 Tomcat 线程。所以流式接口需要注意“中断感知”。在 OkHttp 的execute过程中如果客户端连接断开底层 Socket 写入会失败抛出 IOException但你的代码可能还在response.body().byteStream()里 reading。实现上可以通过向输出流写出一个检测位或者利用 OkHttp 的Call.cancel()机制。比较粗糙但有效的做法是在写前检查response.getOutputStream().isClosed()能不能感知——实际上ServletOutputStream并没有可靠的isClosed()方法。更可靠的是处理好写出的异常try { out.write(...); out.flush(); } catch (IOException ex) { // 客户端已断开取消当前 OKHttp call call.cancel(); handler.shouldStop.set(true); }如果模型 API 调用用的是Call.execute()同步阻塞可以在onMessage回调中拿到当前Call引用一旦写出失败就cancel()。7.2 手动实现一个任务中断标记我在封装 ChatClient 时额外加了一个StopFlag的概念每当写出 SSE 数据失败就把这个线程的任务标记为中断。然后流式读取循环每拿到一块新数据先检查标记是否被置位AtomicBoolean stopped new AtomicBoolean(false); handler.onError(ex - { stopped.set(true); call.cancel(); }); ... while ((line reader.readLine()) ! null) { if (stopped.get()) { break; } ... }虽然“优雅中断”在老项目里很难做到完美但至少可以在用户离开页面时快速释放资源而不是傻等模型把话说完。7.3 心跳机制代理层长期空闲会被切断SSE 连接如果长时间没有数据中间任何一层网络设备都可能因为空闲把它断开。虽然大模型的响应一直在推但极端情况比如模型“卡住”思考了几分钟连接可能保不住。稳妥做法是给 SSE 加心跳。心跳在 Nginx 层可以靠proxy_read_timeout调大来缓解在应用层可以在 onMessage 之前设一个定时任务每隔 15 秒写一个注释行SSE 注释行是以冒号开头的行客户端会忽略// 心跳线程每 15 秒写一个冒号注释行 ScheduledExecutorService executor Executors.newSingleThreadScheduledExecutor(); executor.scheduleAtFixedRate(() - { try { out.write(: heartbeat\\\\n\\\\n.getBytes(StandardCharsets.UTF_8)); out.flush(); } catch (IOException ignored) { } }, 15, 15, TimeUnit.SECONDS); exitExecutor.onComplete(() - executor.shutdown());8. 个人经验总结与后续规划这套四层递进的接入方案核心思路可以浓缩成一句话让每一层都有独立的验证点让每个坑只在一层里出现。不要试图一步到位否则你会分不清 401 到底是鉴权问题还是网络问题流式不吐字到底是 Nginx 问题还是 FastJSON 转义问题。对我个人而言最有价值的一个调整是把原来“直接引官方 SDK”的方案改成“先原生化打通、再渐进式封装”。虽然官方 SDK 功能全但在老项目里你根本不清楚它的底层行为出了问题也很难排查。反而是我手写的那套HttpURLConnection/ OkHttp 调用每一行代码都在自己掌控之下出问题能很快定位。后续我的规划是把 ChatClient 的同步方法从HttpURLConnection升级为 OkHttp 异步调用提升并发能力引入对多轮会话历史的支持现在只是单轮 prompt 直出没有办法维护上下文把 token 计费和接入方流量控制加上毕竟 AI 调用不是免费的如果后续尝试的工具、其他 AI 能力接入顺利再考虑是否接第二家第三家厂商做自动降级。老项目接 AI 这事本质上不是技术炫技是一场边界清晰的工程改造。只要控制住变化范围、稳住核心链路老项目反而比新项目更有“接住 AI 能力”的底子和经验沉淀。希望我这套方案能给你省去几个加班的夜晚。
返回列表