☰
Day78-AI接口设计三范式:同步、异步、流式到底怎么选
2026/9/27 22:48:48 网站建设 项目流程

上期回顾: 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。这比纯轮询体验好太多。

六、建议

  1. 别让同步范式背所有锅。很多团队一上来所有 AI 接口都套同步@PostMapping,结果聊天界面慢成 PPT。先按上面的决策矩阵把场景分桶,聊天和长文必须挪到流式或异步。同步只留给"快且短"的场景。

  2. 流式接口必过压测。流式最怕的不是慢,而是连接泄漏。1000 个并发 SSE 连接如果没正确关闭(前端关页面、网关超时、异常未发 done),后端 Flux 订阅不会自动取消,连接越积越多直到打满。压测时重点看连接数曲线是不是平稳回收,不是只看延迟。

  3. 异步任务必须做幂等 + 状态机。异步范式最大的坑是"任务跑了一半,进程挂了"。靠任务表的状态机(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主从复制与读写分离:延迟排查/故障切换

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询