1. 先说结论:老项目接 AI,别急着换框架
最近公司把一个 2016 年的 Spring Boot 2.0 老项目交到我手上,业务逻辑密密麻麻,结构还是多模块的 War 包,JDK 8。需求倒也不复杂:在现有系统里加一个人机对话入口,用户发消息,AI 给回答,体验要像现在的主流聊天工具一样,逐字输出,不能一直转圈。我一开始也在版上看到各种 Spring AI 的案例,看起来挺美,结果一引入依赖,直接跟项目里的老 Spring 版本撞车,一堆 jar 冲突和类加载异常。试了两天,我发现这条路不适合老项目。
后来我放弃“华丽方案”,老老实实按一个四层递进的思路手写接入:第一层先打通大模型 API 的同步调用,第二层加上多轮上下文和异步处理,第三层做 SSE 流式输出,第四层再补配置、模型切换、日志监控这些工程化细节。整个改造过程没有改老项目的主框架,没有引入重型组件,代码量也不大,但效果是立竿见影的。这套东西用到现在的项目里,效果很稳,所以我把完整过程整理出来,做成一个系列。这篇是第一篇,核心就盯在“基础对话”到“流式输出”这条主线上。
文章的范围适合两类人:一类是和我一样在维护老单体 Java 项目,又想快速接 AI 能力的后端工程师;另一类是刚接触大模型 API 集成,想找一个不绕弯的 Spring Boot 落地方案的开发者。看这篇文章之前,你需要一点 Java 和 Spring 的基础,但对大模型本身不用有什么深研究,我会把原理掰开揉碎讲。
2. 四层递进的整体设计,每一层解决什么
2.1 L1 同步调用:先让对话跑起来
很多人一开始会问,接入 AI 有什么难的,不就是 HTTP 调一个接口再返回吗?对,第一层就是这个思路,难就难在“先跑通”。你面对的 API 不是自己内部的服务,它有自己的协议、鉴权、错误码和响应结构。老项目里各种历史包袱又多,网络代理、SSL 证书、依赖冲突都可能成为拦路虎。
L1 的核心目标只有一个:能够稳定地发送一次请求,拿到完整回复,并把结果带回到业务里。这一层不追求多轮对话,不追求打字机效果,甚至不追求异步化,就是把最基础的通路打通。只有跑通这一层,后续所有优化才有意义。
具体到技术选型,老项目普遍是 JDK 8 + Spring Boot 2.x,很多新框架根本不兼容。JDK 8 自带的 HttpURLConnection 太难用,RestTemplate 虽然能用,但对流式响应支持比较弱。我最后选的是 OkHttp 3,轻量、稳定、社区成熟,也不会给老项目带来侵入式改造。如果你项目里已经有 Apache HttpClient 4 或 5,使用体验也差不多,不必刻意换。
2.2 L2 异步与上下文:从“玩具”到“能用”
第一层跑通之后,你会发现一个问题:调用大模型 API 是非常耗时的,几十毫秒到几十秒都可能。如果一个用户请求进来,业务线程傻傻等着,一旦并发上来,线程池很快就被占满,整个老项目都跟着卡。这不是危言耸听,我在压测时就见过 20 个并发直接把接口拖死的案例。
所以第二层要做两件事:一是把 AI 调用放到独立的线程池里异步执行,避免阻塞主业务线程;二是引入会话上下文。所谓上下文,本质就是把聊天历史一起发给大模型,让它能记住前面说过的话,而不是每次答非所问。旧项目里的登录用户、会话 ID 这些东西现成就有,稍微包装一下就能和 AI 上下文打通。
很多教程只告诉你“调接口、拿结果”,但没告诉你上下文是要自己维护的。大模型 API 本身就是无状态的,你发多少条历史消息它就基于多少条回答,这个“记忆”完全由调用方管理。第二层如果不做,你后续做流式输出也只是把一个没脑子的问答机变成打字机而已。
2.3 L3 流式输出:把等待变成阅读
第三层是最出效果的一层。传统的同步返回是用户等上好几秒,然后一次性看到全部内容。流式输出是服务端把大模型返回的文本分成一小块一小块,像打字机一样持续推给前端,用户第一秒就能看到开头,体验完全不一样。
大模型 API 的流式输出走的是 SSE(Server-Sent Events)协议。简单说,就是请求发出后,HTTP 响应连接不关闭,服务端以多行data:格式持续推送内容,最后以一个特定结束标记收尾。Java 端要做的,就是把这个 SSE 流解析出来,再通过 Spring 的 SseEmitter 转发给前端浏览器。
这个环节的坑非常多,后面我会详细写:编码问题、超时设置、断线重连、事件格式不统一、旧前端怎么接,每一项都足够让新手折腾半天。
2.4 L4 工程化:能上线、能运维才算完
第四层是很多自嗨型教程忽略的部分,却是真正决定能不能上生产的一层。你需要把 API 地址、密钥、模型名从代码里挪到配置文件或者配置中心;需要支持不同业务线用不同模型;需要在日志里能看到每次请求的 Token 用量、耗时、错误原因;当 AI 服务不稳定时,你要能优雅降级,而不是把异常直接抛给用户。
老项目最怕的是“牵一发动全身”,所以我第四层的原则就是“配置化 + 隔离”。AI 相关的所有东西都收敛到一个独立的 service 包里,和业务代码解耦。这样就算大模型服务商要切换,或者模型升级,你改一行配置就能完成,不用去业务代码里东翻西找。
3. 实操:先跑通 L1 同步对话
3.1 动手前必须先做的环境检查
我建议你在写任何代码之前,先花十分钟把环境摸清楚。老项目最坑的不是技术,而是未知的依赖和历史债务。
首先是确认 JDK 版本。如果是 JDK 8,就别指望用 JDK 11 才有的java.net.http.HttpClient;如果是 JDK 17 那选择就多很多。我的项目是 JDK 8,所以出于稳妥考虑,HTTP 客户端直接用 OkHttp。你可以在 pom.xml 里加这么一段:
<dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>3.14.9</version> </dependency>版本号为什么要选 3.14.9 而不是最新的 4.x?因为 4.x 是用 Kotlin 重写的,虽然 JVM 上能跑,但在一些老项目中会有意外的依赖链问题。如果你的项目里没有 Kotlin 运行环境,用 3.x 最省事。这是我在实际项目中试出来的结论,后面还会继续说这种选型上的细节。
其次,确认项目里已有的 JSON 库。fastjson 也好,Jackson 也好,选一个你熟悉的,不要同时混用多个。下面的示例我统一用 Jackson,这是 Spring Boot 自带的老朋友,不会出错。
环境检查的最后一步是网络。如果你的服务部署在公司内网或云服务器上,先确认能不能访问大模型 API 的公网地址。如果不能,找运维开通出口白名单,或者配一个 HTTP 代理,这个环节没搞定,后面写多漂亮的代码都白搭。
3.2 同步调用完整代码与解析
环境准备好之后,同步调用就非常简单了。以目前各家大模型平台基本都兼容的 OpenAI Chat Completions 接口格式为例,请求体是一个 JSON,里面包含模型名、消息列表和采样参数。下面这个是我在实际项目里抽出来的简化版代码:
public class LlmClient { private static final String API_URL = "https://your-gateway.example.com/v1/chat/completions"; private static final String API_KEY = System.getenv("AI_API_KEY"); private final OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build(); private final ObjectMapper mapper = new ObjectMapper(); public String chatSync(String prompt) throws Exception { Map<String, Object> body = new HashMap<>(); body.put("model", "your-model-name"); body.put("messages", List.of(Map.of("role", "user", "content", prompt))); body.put("stream", false); body.put("temperature", 0.7); Request request = new Request.Builder() .url(API_URL) .addHeader("Authorization", "Bearer " + API_KEY) .addHeader("Content-Type", "application/json") .post(RequestBody.create("application/json; charset=utf-8", mapper.writeValueAsString(body))) .build(); try (Response response = client.newCall(request).execute()) { if (!response.isSuccessful()) { String errBody = response.body() != null ? response.body().string() : ""; throw new RuntimeException("LLM API error: " + response.code() + " " + errBody); } JsonNode root = mapper.readTree(response.body().string()); return root.path("choices").get(0).path("message").path("content").asText(); } } }这里有三个细节要特别说明。
第一个,readTimeout必须设置得足够长。大模型推理不像普通接口那么快,尤其是长文本生成,几十秒很常见。如果按照老项目里默认的 3 秒或 5 秒超时去调,几乎每次都会失败。我最后定的是 120 秒,因为绝大多数请求不会超过这个时间。
第二个,鉴权头里有个空格问题。Bearer后面一定要跟一个空格,很多新人写"Bearer"+API_KEY少了这个空格,服务端就会返回 401,排查时又很难发现。
第三个,错误处理绝不能省。大模型的 API 可能在模型名称写错、Token 超出上限、服务端过载时返回各种非 200 状态码。你至少要能够把错误信息打出来,否则生产环境出问题,项目组会非常痛苦。
3.3 密钥与模型配置不要写死在代码里
上面那段代码把 API_URL 和 API_KEY 写死是不适合上生产的。我建议至少使用环境变量,更理想的做法是放到 Spring Boot 的配置文件中,打包时用不同 profile 区分。如果你的公司有配置中心,比如 Nacos,那就更省事了。
ai: api-url: ${AI_API_URL:https://your-gateway.example.com/v1/chat/completions} api-key: ${AI_API_KEY:} default-model: ${AI_DEFAULT_MODEL:your-model-name}配合一个普通的配置类:
@Component @ConfigurationProperties(prefix = "ai") public class AiProperties { private String apiUrl; private String apiKey; private String defaultModel; private int connectTimeout = 10; private int readTimeout = 120; // getter/setter 省略 }这种写法的好处是,以后换模型厂商或者切换模型时,运维改配置就行,开发不用发版。老项目里最怕为一个小功能改版本,能配置化解决的问题,尽量别碰代码。
4. 实操:L2 多轮上下文与异步化
4.1 会话上下文设计
L1 跑通后,你会立刻发现第二个问题:AI 不记得自己说过什么。你问“你好”,它回“你好”;你再问“我刚才说了什么”,它就一脸懵。原因在于大模型 API 是无状态的,你必须把历史消息一起发给它。
我设计上很简单,用 ConcurrentHashMap 把会话 ID 映射到消息列表,一个用户一个上下文。消息体跟 API 协议对齐,就是role和content两个字段。围观对话处理器把用户输入加进列表,调完 API 再把助手回复追加进去。
@Service public class ChatContextService { private final ConcurrentHashMap<String, List<Message>> contexts = new ConcurrentHashMap<>(); public List<Message> getOrCreate(String sessionId) { return contexts.computeIfAbsent(sessionId, k -> new ArrayList<>()); } public void append(String sessionId, String role, String content) { List<Message> list = getOrCreate(sessionId); list.add(new Message(role, content)); trimIfTooLong(list); } private void trimIfTooLong(List<Message> list) { // 避免无限膨胀 if (list.size() > 60) { int from = list.size() - 50; List<Message> subList = new ArrayList<>(list.subList(from, list.size())); list.clear(); list.addAll(subList); } } }这种内存 Map 的方式,严格来说在生产环境扛不住多节点部署。你如果有 Redis,建议把 sessionId 当作 key,存储经过序列化处理的历史消息。我这里为了讲清楚核心思路,先用本地 Map,实际项目里大家要结合自己已有中间件来改造。只要把存取方式替换掉,其他逻辑都不用动。
4.2 线程池异步改造
同步调用如果放在 Controller 线程里执行,很容易出问题。Spring MVC 的 Tomcat 线程池默认也就 200 个左右,AI 接口一次卡几秒,吞吐量直接崩。我习惯的做法是单独建一个线程池,专门处理 AI 调用,并且必须有名字、有界队列、拒绝策略,这是老项目复用线程池最容易踩的三个雷。
@Configuration public class AiThreadPoolConfig { @Bean("aiExecutor") public ExecutorService aiExecutor() { return new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000), new ThreadFactoryBuilder().setNameFormat("ai-chat-%d").build(), new ThreadPoolExecutor.AbortPolicy() ); } }这里我要解释一下为什么不用Executors.newFixedThreadPool。工厂方法创建的是无界队列,任务越多排队越多,一旦 AI 服务变慢,积压任务能把你内存堆满。而且默认线程名是pool-1-thread-1,等你排查问题时根本不知道是哪个线程在干 AI 的活。所以自建线程池,线程名必须自定义,拒绝策略必须明确。
调用方用一个Future把任务交出去,结果可以放在响应对象里返回给前端,也可以做回调处理。异步并不是为了立刻拿到结果,而是把“慢”和“快”隔离开。
4.3 上下文长度控制
多轮对话一旦跑起来,消息列表会越来越长,最终超过大模型 API 的 Token 上限。每个模型对 Token 长度都有严格限制,你硬塞太多历史消息,服务端直接报错。
我的办法是控制轮数上限,同时在发送给 API 前估算一下字符数量。如果字符数超了某个阈值,就丢弃最早的部分历史,只保留最近几轮。
public List<Message> buildMessages(String sessionId, String userInput) { List<Message> history = contextService.getOrCreate(sessionId); List<Message> all = new ArrayList<>(history); all.add(new Message("user", userInput)); // 只保留最近 20 轮 if (all.size() > 40) { all = new ArrayList<>(all.subList(all.size() - 40, all.size())); } // 系统提示词如果有,单独保留 return all; }这个阈值不是什么科学计算,是我根据常用模型上下文窗口倒推出来的经验值。你完全可以根据自己用的模型调整。核心思路是:宁可丢一点早期记忆,也要保证请求不失败。
5. 实操:L3 流式输出的完整链路
5.1 SSE 协议与流式返回的形态
进入整篇文章最核心的部分。SSE 协议说白了就是服务端不关闭响应连接,把数据分成多个片段推送出去。跟 WebSocket 的区别在于,它是单向的,只能服务端推给客户端,但对“AI 实时聊天”这个场景完全够用。
大模型 API 在流式模式下的响应,长这样:
data: {"choices":[{"delta":{"role":"assistant"},"index":0}]} data: {"choices":[{"delta":{"content":"你"},"index":0}]} data: {"choices":[{"delta":{"content":"好"},"index":0}]} data: [DONE]每一行data:就是一个事件块,delta.content是新增的内容片段,[DONE]是结束标记。你要做的,就是在这个流结束之前,把内容片段转发给浏览器。
5.2 后端 SseEmitter 实现
Spring Boot 2.x 里有一个很好用的类叫 SseEmitter,它专门用来做服务端推送给浏览器的事件流。你可以把它理解成一个管道,后端往管道里塞数据,前端管道口接数据。Controller 里这么写:
@RestController @RequestMapping("/api/ai") public class AiChatController { @Autowired private AiStreamService aiStreamService; @GetMapping(value = "/chat/stream", produces = "text/event-stream;charset=UTF-8") public SseEmitter streamChat(@RequestParam String sessionId, @RequestParam String prompt) { SseEmitter emitter = new SseEmitter(120_000L); aiStreamService.streamChat(sessionId, prompt, emitter); return emitter; } }注意produces必须带text/event-stream;charset=UTF-8,否则浏览器可能会把它当成普通 JSON 响应,你的流接不起来。SseEmitter的第二个构造参数是超时时间,我设置为 120 秒,跟前面 OkHttp 的 readTimeout 保持一致。
核心的流式调用逻辑在 Service 里。我用 OkHttp 的异步回调方式去读大模型的流式响应,然后通过 SseEmitter 一份一份推出去:
@Service public class AiStreamService { private final OkHttpClient client; private final ObjectMapper mapper = new ObjectMapper(); public void streamChat(String sessionId, String prompt, SseEmitter emitter) { // 1. 构造包含上下文的消息体,stream 置为 true Map<String, Object> body = new HashMap<>(); body.put("model", "your-model-name"); body.put("messages", buildMessages(sessionId, prompt)); body.put("stream", true); Request request = new Request.Builder() .url("https://your-gateway.example.com/v1/chat/completions") .addHeader("Authorization", "Bearer " + System.getenv("AI_API_KEY")) .post(RequestBody.create("application/json; charset=utf-8", mapper.writeValueAsString(body))) .build(); client.newCall(request).enqueue(new Callback() { @Override public void onResponse(Call call, Response response) throws IOException { // 2. 用 byteStream + UTF-8 包装,避免中文乱码 // 注意不要直接用 response.body().charStream(),它可能会按 ISO-8859-1 解码 try (BufferedReader reader = new BufferedReader( new InputStreamReader(response.body().byteStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { if (!line.startsWith("data:")) { continue; } String data = line.substring(5).trim(); if ("[DONE]".equals(data)) { emitter.complete(); return; } // 解析 delta 内容 String content = extractDeltaContent(data); if (content != null && !content.isEmpty()) { // 转成 JSON 发送给前端 Map<String, String> payload = Map.of("content", content); emitter.send(SseEmitter.event() .data(mapper.writeValueAsString(payload))); } } emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } } @Override public void onFailure(Call call, IOException e) { emitter.completeWithError(e); } }); } }extractDeltaContent这个方法做的事情很简单:拿到一段data: {...}之后,用 Jackson 解析出choices[0].delta.content,取不到就返回 null。代码如下:
private String extractDeltaContent(String data) throws Exception { JsonNode node = mapper.readTree(data); if (node.hasNonNull("choices")) { JsonNode choice = node.path("choices").get(0); if (choice != null) { return choice.path("delta").path("content").asText(null); } } return null; }这里最容易犯的错误是直接把响应体的byteStream()和charStream()搞混。OkHttp 的charStream()会根据响应头里的 charset 来解码,如果大模型服务端返回的 SSE 响应头没有明确charset=utf-8,它默认就走 ISO-8859-1,中文内容到你手上全变成乱码。我踩过这个坑之后,一律用byteStream() + UTF-8。
5.3 前端接入:EventSource 的坑与 fetch 方案
前端如果用的是浏览器原生的 EventSource,这个对象有一个硬伤:它只能发 GET 请求,而且不能设置自定义请求头。如果你的接口需要加 Authorization 之类的鉴权头,用 EventSource 基本就走不通。
更稳妥的方式是使用 fetch 配合 ReadableStream。代码也不复杂,只要你理解了 SSE 是按行解析的就行:
async function streamChat(sessionId, prompt) { const resp = await fetch('/api/ai/chat/stream?sessionId=' + sessionId + '&prompt=' + encodeURIComponent(prompt), { headers: { 'Authorization': 'Bearer ' + getToken() } }); const reader = resp.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; // 缓冲不完整的行 while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); // 最后一行可能不完整,留到下一次 for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('data:')) continue; const data = trimmed.substring(5).trim(); if (data === '[DONE]') return; try { const obj = JSON.parse(data); const content = obj.content || ''; if (content) { document.getElementById('output').textContent += content; } } catch (e) { console.warn('解析失败:', data, e); } } } }这段代码做了两件事:一是用buffer.split('\n')把一段流按行切分,二是在循环解析时保留最后一行继续拼接。SSE 的数据在 TCP 层面可能会被拆包,你拿到的value不一定恰好是一整行,甚至一次推送可能包含多行。所以缓冲区的处理是必须的。
5.4 流式输出中的三个隐藏细节
第一个隐藏细节:SseEmitter.event()方法。你可以只传一个字符串,但在浏览器端解析时很难区分事件类型。我建议统一转成 JSON 传出去,前端JSON.parse(data)一次就能拿到结构化内容。这样后续如果要在结束事件里带 token 用量之类的信息,扩展起来也很容易。
第二个隐藏细节:长时间没有新数据的连接保活。有些网络代理或负载均衡设备会静默断开空闲连接,导致用户看到输出到一半就卡死了。解决方式有两种:一是让大模型 API 本身保持持续输出,一般的模型每秒都会吐出几个字,基本不会触发空闲问题;二是在后端加一个心跳机制,每隔 15 秒往 emitter 里发一个注释行。
// 心跳定时器示例 ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() -> { try { emitter.send(SseEmitter.event().comment("ping")); } catch (Exception e) { scheduler.shutdown(); } }, 15, 15, TimeUnit.SECONDS);第三个隐藏细节:用户主动断开连接时一定要清理线程。SseEmitter 有onTimeout和onCompletion回调,如果你不去注册,底层连接可能一直不会被释放。我一般在创建 emitter 后立刻把回调挂上去,记录日志并释放相关资源。
6. 常见问题与踩坑记录
6.1 OkHttp 连接池与超时设置
老项目里用 OkHttp,要特别注意连接池的问题。OkHttpClient 实例建议全局复用,不要每次请求都new一个。每次 new 都会创建新的连接池和线程池,在频繁调用 AI 接口的场景下,内存和句柄会持续增长,最后把老项目拖垮。
超时的设置也很有讲究。大模型 API 的特点是“连接很快,响应很慢”,所以connectTimeout可以短一点,10 秒足够;readTimeout必须很长,我设的是 120 秒。但 120 秒也不是死板不变的,如果接入的模型非常慢,你可以把 readTimeout 拉长到 180 秒。有一点要记住:超时时间必须比 SseEmitter 的超时时间短,否则后端都已经因为读超时抛异常了,前端管子还没到时间,两边就对不上。
6.2 流式输出中途断开怎么办
流式输出最怕的情况是,用户已经看到了 50 个字,连接突然断了。这个问题分两层看。
第一层是大模型 API 和你的后端之间的连接断开。这可能是网络抖动、服务端负载过高,也可能是你的 readTimeout 设置得太短。OkHttp 的onFailure回调会触发emitter.completeWithError,前端会因为流中断而报错。我给这个场景加了一个简单的重试机制:只允许重试一次,并且只在“已经收到数据之前”重试。如果已经收到了大量数据还去重试,会导致用户看到的内容重复。
第二层是后端和浏览器之间的连接断开。SseEmitter 在客户端断开后,你再往里写数据,会抛出 IllegalStateException。所以你要在onCompletion回调里做资源清理,避免线程一直挂着。
6.3 老项目的前后端联动改造
很多老项目的前端还停留在 jQuery 时代,没有 fetch,也没有 async/await。这时候让团队立刻改造成 React 不太现实。我的建议是后端在前端页面上引入一个独立的流式输出工具函数,把 fetch + ReadableStream 的逻辑封装成一个 JS 文件,老页面只要在按钮点击时调用一个函数,再在回调里往 DOM 追加文本就行,其他业务代码不用动。
这种渐进式改造的思路对老项目特别友好。你不用推倒重来,而是把 AI 能力当成一个“可插拔”的模块,部署上线后再逐步优化交互细节。
6.4 避坑清单速查
下面这些全是我实际跑项目时候的总结,挨个核对一遍,能帮你省下大量排查时间:
| 常见问题 | 可能原因 | 处理建议 |
|---|---|---|
| 请求返回 401 | Authorization 头少了空格或密钥错误 | 打印请求头检查格式 |
| 响应乱码 | OkHttp charStream 按 ISO-8859-1 解码 | 使用 byteStream + UTF-8 |
| SseEmitter 连接提前关闭 | 超时时间太短 | 构造时传 120000,并注册回调 |
| 前端 EventSource 无法鉴权 | 该对象只支持 GET 且无自定义头 | 改用 fetch + ReadableStream |
| AI 没有上下文记忆 | 没有把历史消息带回请求 | 检查 L2 上下文拼接逻辑 |
| 线程池被占满 | 无界队列或线程数太小 | 自建带名称线程池,有界队列+拒绝策略 |
| 请求超时 | readTimeout 设置过短 | 至少设置 120 秒 |
这张表我会持续补充,后面系列文章里如果遇到新的坑,也会继续加进来。
7. 写完这套方案之后的一点个人体会
四层递进的方案,看起来是四个独立步骤,其实背后是一条非常清晰的工程主线:先用最笨的方式跑通整个链路,再逐层加复杂度,每一步都保证系统是可用的。这跟我们平时处理老项目其他需求是一样的思路,别一上来就上过度设计。
我现在维护的这个旧 Java 项目,接入 AI 之后已经稳定跑了两三个月。同事反馈最多的不是代码多优雅,而是“答得真快,字幕一会儿就出来了”。这个反馈让我挺有底气的,也证明了在旧项目里做轻量改造,比强行引入新框架要实际得多。
需要提醒的是,L3 流式输出只是整个 AI 体验的地基。上下文太长、多轮记忆、模型切换、Function Calling、Agent 这些进阶玩法,我打算在系列后面的文章里逐个展开。老项目的每一次升级都得像这样,先找一条风险最小的路径,踩稳了再继续往前探。希望这套方案能帮你少走点弯路。