上期回顾: Day77我们从零手写了一个 ReAct Agent,看清了"思考 → 行动 → 观察"的循环本质。但 Agent 再聪明,对外暴露的也只是一个接口——接口范式选错,用户照样被"PPT 式体验"劝退。这篇把同步、异步、流式三种 AI 接口范式讲透,让你对号入座。
AI 接口跟传统 CRUD 接口最大的不同在于:大模型生成 500 字可能要 5~15 秒,这期间用户干等着,体验极差。根因往往是后端只用一个@PostMapping,调完chatClient.call()拿到完整字符串才 return——典型的"同步范式用错了场景"。解决它的根本办法不是"让模型变快"(你改不了模型),而是换接口范式。今天我把三种范式一次讲透,每种都给你能跑的代码和适用场景。
一、三种范式到底差在哪:一张表先建立直觉
在写代码前,先用一张表把三者的本质区别钉死,免得你后面看着代码还是分不清。
| 维度 | 同步(Sync) | 异步(Async) | 流式(Streaming) |
|---|---|---|---|
| HTTP 模型 | 请求→阻塞→一次性返回 | 请求→立即返回 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-threads=200,200 个并发就把线程池打满,后续请求全排队。如果你确实要高并发同步调用,要么上虚拟线程(spring.threads.virtual.enabled=true,JDK 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% 的轮询都返回 PENDING);Webhook 省流量但要前端能接收(移动端 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); }细节 3:@Async 的线程池必须独立。千万别用默认的SimpleAsyncTaskExecutor(每次新建线程不回收,高并发直接 OOM)。上面的aiTaskExecutor配CallerRunsPolicy,队列满了让提交线程自己跑,相当于自动降速,比丢任务安全。
四、流式范式:聊天场景的体验之王
当用户在跟你"聊天",他要的是即时反馈——你说句话,对方 0.5 秒内开始回,哪怕慢一点一个字一个字蹦都行。这就是流式范式的天下:后端用 SSE(Server-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(); } // 返回 Flux<ServerSentEvent>,Spring WebFlux 自动按 SSE 协议推 @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> stream(@RequestBody ChatRequest req) { return chatClient.prompt() .user(req.getMessage()) .stream() // 关键:stream() 而非 call() .content() // 拿到 token 流 .map(token -> ServerSentEvent.<String>builder() .event("message") // 事件类型:消息块 .data(token) .build()) .concatWith(Flux.defer(() -> // 流结束补一个 done 事件 Flux.just(ServerSentEvent.<String>builder() .event("done").data("[DONE]").build()))) .onErrorResume(e -> Flux.just( // 异常也用事件推,别让连接挂死 ServerSentEvent.<String>builder() .event("error").data("生成失败:" + e.getMessage()).build())); } public record ChatRequest(String message) {} }注意三个点:第一,用stream()不是call(),这是 Spring AI 流式的开关,返回Flux<String>每个 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 请求体。所以用fetch+ReadableStream手动解析 SSE 格式。如果你用 GET 传参(比如?message=xxx,注意 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 Boot
- Day31-数据层 × 中间件AI化篇:MySQL主从复制与读写分离:延迟排查/故障切换