上期回顾 Day77我们从零手写了一个 ReAct Agent看清了思考 → 行动 → 观察的循环本质。但 Agent 再聪明对外暴露的也只是一个接口——接口范式选错用户照样被PPT 式体验劝退。这篇把同步、异步、流式三种 AI 接口范式讲透让你对号入座。AI 接口跟传统 CRUD 接口最大的不同在于大模型生成 500 字可能要 5~15 秒这期间用户干等着体验极差。根因往往是后端只用一个PostMapping调完chatClient.call()拿到完整字符串才 return——典型的同步范式用错了场景。解决它的根本办法不是让模型变快你改不了模型而是换接口范式。今天我把三种范式一次讲透每种都给你能跑的代码和适用场景。一、三种范式到底差在哪一张表先建立直觉在写代码前先用一张表把三者的本质区别钉死免得你后面看着代码还是分不清。维度同步Sync异步Async流式StreamingHTTP 模型请求→阻塞→一次性返回请求→立即返回 taskId→轮询/Webhook请求→持续推 token→直到结束用户感知干等转圈啪一下全出提交后干别的完成通知像打字一样逐字出现首字延迟TTFT等于总生成时间等于任务排队时间200~800ms连接占用短一个请求一个响应极短提交即断长全程不断开后端复杂度最低中任务表回调高WebFlux/SSE典型场景分类、抽取、短问答报告生成、图片生成聊天、长文写作记忆口诀短问答用同步长任务用异步聊天用流式。下面逐个上代码。二、同步范式最简单但最容易用错同步范式的本质是一个 HTTP 请求占用一个线程直到大模型把整段答案生成完才 return。它最简单但也是最容易踩坑的——很多人不管什么场景都套这一套结果就是开头老板抱怨的PPT 式体验。适用场景响应短2000 token、生成快5 秒、用户可接受等待。典型例子是情感分类、关键词抽取、SQL 优化建议、短 FAQ 问答。这类任务模型一两秒就吐完用户等得起。// SyncChatController.java — JDK 17 Spring Boot 3.3 Spring AI 1.0 // 依赖spring-boot-starter-web、spring-ai-openai-spring-boot-starter RestController RequestMapping(/api/chat) public class SyncChatController { ​ private final ChatClient chatClient; ​ public SyncChatController(ChatClient.Builder builder) { // 系统提示词固定角色避免每次重复传 this.chatClient builder .defaultSystem(你是金融领域的文本分析助手只返回JSON) .build(); } ​ PostMapping(/classify) public Result classify(RequestBody ClassifyRequest req) { // 同步调用线程阻塞到模型生成完整答案 String content chatClient.prompt() .user(u - u.text(判断以下文本的情感倾向返回{{positive|negative|neutral}}\n req.getText())) .call() .content(); // 一次性拿到完整字符串 ​ return Result.ok(Map.of(sentiment, content.trim())); } ​ public record ClassifyRequest(String text) {} public record Result(int code, Object data) { public static Result ok(Object d) { return new Result(0, d); } } }这段代码能跑但它有三个坑你必须知道坑 1线程被白白占着。Spring MVC 默认每个请求占一个 Tomcat 线程模型生成 3 秒这 3 秒线程啥也不干就干等。Tomcat 默认 max-threads200200 个并发就把线程池打满后续请求全排队。如果你确实要高并发同步调用要么上虚拟线程spring.threads.virtual.enabledtrueJDK 21要么限流。坑 2没设超时用户等到天荒地老。默认 OpenAI 客户端可能 60 秒才超时。生产必须显式设spring: ai: openai: chat: options: model: qwen-plus # 关键同步调用必须卡死超时否则一个卡住的请求吃掉一个线程 timeout: 15s # 连接读取总超时坑 3返回整段字符串前端没法做打字效果。同步拿到的是完整答案前端想做逐字显示只能靠前端 JS 模拟那是假流式后端该等多久还是等多久。所以同步范式的铁律是只在模型快、答案短的场景用长答案别用它。三、异步范式长任务的正确打开方式当任务本身就要跑 30 秒到 2 分钟比如生成一份 3000 字的行业报告、画一张图、做长文档摘要同步范式直接废掉——没有任何用户愿意盯着转圈等两分钟。这时候要用异步范式提交任务立即返回一个 taskId后台慢慢跑跑完了通过轮询或 Webhook 通知前端。适用场景生成时间长10 秒、用户可以去做别的事、结果可以稍后取。典型例子是报告生成、图片/视频生成、批量文档处理。// AsyncReportController.java — JDK 17 Spring Boot 3.3 // 依赖spring-boot-starter-web、spring-ai-openai-spring-boot-starter RestController RequestMapping(/api/report) public class AsyncReportController { ​ private final ChatClient chatClient; private final ReportTaskRepository taskRepo; // 任务表存 taskId/status/result ​ public AsyncReportController(ChatClient.Builder b, ReportTaskRepository r) { this.chatClient b.build(); this.taskRepo r; } ​ // ① 提交任务立即返回 taskId绝不阻塞 PostMapping(/submit) public Result submit(RequestBody ReportRequest req) { String taskId UUID.randomUUID().toString().replace(-, ); // 落库状态PENDING记录入参 taskRepo.save(new ReportTask(taskId, PENDING, req.getTopic(), null, Instant.now())); // 触发异步执行Async 见下 ApplicationContextHolder.getBean(ReportRunner.class).run(taskId, req.getTopic()); return Result.ok(Map.of(taskId, taskId)); } ​ // ② 轮询接口前端每隔 3~5 秒查一次 GetMapping(/status/{taskId}) public Result status(PathVariable String taskId) { ReportTask t taskRepo.findById(taskId).orElseThrow(); return Result.ok(Map.of( status, t.getStatus(), // PENDING / RUNNING / SUCCESS / FAILED result, t.getResult() // 成功才有值否则 null )); } } ​ // ③ 后台真正跑任务的组件Async 让它脱离 HTTP 线程 Component public class ReportRunner { private final ChatClient chatClient; private final ReportTaskRepository taskRepo; ​ Async(aiTaskExecutor) // 用独立线程池别跟 Tomcat 抢线程 public void run(String taskId, String topic) { try { taskRepo.updateStatus(taskId, RUNNING); String report chatClient.prompt() .user(u - u.text(请生成一份关于「 topic 」的行业分析报告约2000字)) .call() .content(); // 这里阻塞没关系因为跑在独立线程池 taskRepo.updateResult(taskId, SUCCESS, report); } catch (Exception e) { taskRepo.updateResult(taskId, FAILED, e.getMessage()); } } } ​ // 配置独立线程池跟 Web 线程隔离 Configuration EnableAsync class AsyncConfig { Bean(aiTaskExecutor) public ThreadPoolTaskExecutor aiTaskExecutor() { ThreadPoolTaskExecutor ex new ThreadPoolTaskExecutor(); ex.setCorePoolSize(5); // AI 任务 IO 密集核心线程不用多 ex.setMaxPoolSize(20); // 突发流量兜底 ex.setQueueCapacity(100); // 排队上限超过走拒绝策略 ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 兜底降速而非丢任务 ex.setThreadNamePrefix(ai-task-); return ex; } }异步范式有三个魔鬼细节踩一个就翻车细节 1必须有任务表做状态机。进程一重启内存里跑一半的任务就丢了。用 MySQL/Redis 存PENDING→RUNNING→SUCCESS/FAILED四态重启后能恢复。细节 2轮询 vs Webhook 怎么选。轮询简单但浪费请求90% 的轮询都返回 PENDINGWebhook 省流量但要前端能接收移动端 App 不好搞。经验法则Web 端用轮询配合指数退避服务端到服务端用 Webhook。下面是一个最小 Webhook 回调// 跑完后主动回调业务方提供的 URL PostMapping(/submit) public Result submit(RequestBody ReportRequest req) { String taskId UUID.randomUUID().toString().replace(-, ); taskRepo.save(new ReportTask(taskId, PENDING, req.getTopic(), req.getCallbackUrl(), Instant.now())); ApplicationContextHolder.getBean(ReportRunner.class).run(taskId, req.getTopic(), req.getCallbackUrl()); return Result.ok(Map.of(taskId, taskId)); } ​ // ReportRunner 末尾加回调 if (callbackUrl ! null) { restTemplate.postForObject(callbackUrl, Map.of(taskId, taskId, status, SUCCESS, result, report), String.class); }细节 3Async 的线程池必须独立。千万别用默认的SimpleAsyncTaskExecutor每次新建线程不回收高并发直接 OOM。上面的aiTaskExecutor配CallerRunsPolicy队列满了让提交线程自己跑相当于自动降速比丢任务安全。四、流式范式聊天场景的体验之王当用户在跟你聊天他要的是即时反馈——你说句话对方 0.5 秒内开始回哪怕慢一点一个字一个字蹦都行。这就是流式范式的天下后端用 SSEServer-Sent Events把模型生成的 token 一个个推给前端前端像打字机一样显示。适用场景对话式交互、长答案写作、用户需要看到进度的场景。典型例子就是 ChatGPT 那种对话框。SSE 比 WebSocket 更适合 AI 场景原因有二一是 AI 是服务端→客户端的单向推送不需要双向SSE 足够二是 SSE 走标准 HTTP过网关、过代理比 WebSocket 省心浏览器内置EventSource自动重连。// StreamChatController.java — JDK 17 Spring Boot 3.3 Spring AI 1.0 // 依赖spring-boot-starter-webflux注意是 webflux 不是 web、spring-ai-openai-spring-boot-starter RestController RequestMapping(/api/chat) public class StreamChatController { ​ private final ChatClient chatClient; ​ public StreamChatController(ChatClient.Builder builder) { this.chatClient builder.defaultSystem(你是一个简洁的技术助手).build(); } ​ // 返回 FluxServerSentEventSpring WebFlux 自动按 SSE 协议推 PostMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString stream(RequestBody ChatRequest req) { return chatClient.prompt() .user(req.getMessage()) .stream() // 关键stream() 而非 call() .content() // 拿到 token 流 .map(token - ServerSentEvent.Stringbuilder() .event(message) // 事件类型消息块 .data(token) .build()) .concatWith(Flux.defer(() - // 流结束补一个 done 事件 Flux.just(ServerSentEvent.Stringbuilder() .event(done).data([DONE]).build()))) .onErrorResume(e - Flux.just( // 异常也用事件推别让连接挂死 ServerSentEvent.Stringbuilder() .event(error).data(生成失败 e.getMessage()).build())); } ​ public record ChatRequest(String message) {} }注意三个点第一用stream()不是call()这是 Spring AI 流式的开关返回FluxString每个 emit 就是一个 token第二流结束必须发done事件前端靠它判断说完了好停止 loading用concatWith Flux.defer保证在主流完成后才发第三异常不能让连接挂死用onErrorResume把错误也包成 SSE 事件推出去否则前端 EventSource 会傻等。前端配合代码纯浏览器原生 API不依赖任何框架// chat.js — 浏览器原生 EventSource 不支持 POST改用 fetch ReadableStream async function streamChat(message) { const resp await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ message }) }); ​ const reader resp.body.getReader(); const decoder new TextDecoder(); let buffer ; ​ while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // SSE 是按 \n\n 分块的逐块解析 const blocks buffer.split(\n\n); buffer blocks.pop(); // 最后一块可能不完整留着下次拼 for (const block of blocks) { const event block.match(/event:(.)/)?.[1]?.trim(); const data block.match(/data:(.)/)?.[1]?.trim(); if (event done) { console.log(回答完毕); return; } if (event error) { console.error(出错:, data); return; } if (data) appendToUI(data); // 把 token 追加到对话框形成打字效果 } } }为什么不用原生EventSource因为它只支持 GET而聊天要 POST 请求体。所以用fetchReadableStream手动解析 SSE 格式。如果你用 GET 传参比如?messagexxx注意 URL 长度限制和编码可以直接用new EventSource(/api/chat/stream?message...)代码更短。流式范式的两个坑坑 1网关缓冲。Nginx 默认会缓冲响应导致 token 攒一批才发打字效果变一段一段。生产必须加proxy_buffering off;和X-Accel-Buffering: no响应头。坑 2超时。长答案可能生成 30 秒以上网关默认 60 秒超时可能掐断要么调高超时要么后端定期发心跳空 data 事件保活。五、选型决策矩阵别拍脑袋对号入座三种范式不是非此即彼同一个系统里经常混用。我给你一张实战决策矩阵按生成时长 用户是否需要即时反馈两个维度对号入座场景生成时长需要即时反馈推荐范式例子文本分类/抽取3 秒否同步情感分析、实体抽取短 FAQ 问答5 秒可选同步帮助中心自动回复聊天对话5~30 秒是流式智能客服、AI 助手长文写作30 秒~2 分否异步行业报告、营销文案图片/视频生成30 秒否异步文生图、视频生成实时翻译/同传持续是流式会议实时字幕一条贯穿性原则范式的选择本质是延迟与体验的权衡。同步牺牲体验换简单异步牺牲即时性换吞吐流式牺牲后端复杂度换体验。没有银弹只有对号入座。还有一种进阶玩法——异步 流式混合任务提交返回 taskId异步但生成过程通过 SSE 流式推送进度最后再把完整结果落库供后续查询。图片生成常用这套先流式推排队中→生成中→渲染中的进度条最后推图片 URL。这比纯轮询体验好太多。六、建议别让同步范式背所有锅。很多团队一上来所有 AI 接口都套同步PostMapping结果聊天界面慢成 PPT。先按上面的决策矩阵把场景分桶聊天和长文必须挪到流式或异步。同步只留给快且短的场景。流式接口必过压测。流式最怕的不是慢而是连接泄漏。1000 个并发 SSE 连接如果没正确关闭前端关页面、网关超时、异常未发 done后端 Flux 订阅不会自动取消连接越积越多直到打满。压测时重点看连接数曲线是不是平稳回收不是只看延迟。异步任务必须做幂等 状态机。异步范式最大的坑是任务跑了一半进程挂了。靠任务表的状态机PENDING→RUNNING→SUCCESS/FAILED 定时扫描超时 RUNNING 任务补偿重跑比任何花哨的技术都管用。幂等靠 taskId 做唯一键重复提交直接返回原 taskId别起两个一样的任务烧两份 Token 钱。接口范式选错再快的模型也救不回体验选对了哪怕慢一点用户也觉得它在认真想。下篇我们聊一个更扎心的话题——钱。大模型按 Token 收费一次调用几分钱听着不贵可一旦上量月账单能把你吓出冷汗。Day79 我们来精算 Token 成本讲 Prompt 压缩和输出长度控制让你的 AI 预算不再失控。往期回顾Day65-主流大模型横评GPT-4o/Claude/DeepSeek/通义千问该怎么选Day60-Serverless函数计算改写传统Spring BootDay31-数据层 × 中间件AI化篇:MySQL主从复制与读写分离延迟排查/故障切换