欢迎光临
我们一直在努力

Day78-AI接口设计三范式:同步、异步、流式到底怎么选

上期回顾: 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主从复制与读写分离:延迟排查/故障切换
    赞(0)
    未经允许不得转载:171主机测评 » Day78-AI接口设计三范式:同步、异步、流式到底怎么选
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址