【AI全栈后端12-09】Spring Boot 用流式输出做打字机对话:点下去不再干等

本文是「Spring Boot + AI 全栈后端」系列第 09 篇。前几篇我们解决了模型能不能对话、能不能省钱、能不能按格式输出、能不能查实时数据、能不能读私有文档、能不能自己串任务、能不能统一接入内部系统。这一篇回到用户体验本身:点发送之后那几秒的空窗,怎么变成一句一句往外蹦的打字机。示例基于 Spring AI 2.0 / Boot 4.1。

一个"等了六秒,用户关掉了页面"的场景

给客户做第一期 AI 助手上线后,产品经理甩过来一条用户反馈:用户问"帮我写一份下周的营销活动方案",页面上的按钮转圈转了六秒,用户以为卡死了,直接关掉。

六秒其实不算慢------模型要生成一份几百字的方案,本来就是这么久。问题不在速度,在感知:用户盯着一个转圈,没有任何反馈,六秒里他的耐心就耗完了。如果把生成过程换成一句一句往外蹦,用户第一秒就看到字在动,剩下五秒他是在"看内容",不是在"等结果"。

这个体验差别,背后是两种完全不同的接口形态:

维度 一次性返回(call) 流式返回(stream)
首字延迟 要等整段都生成完 模型吐出第一段就发出去
用户感知 转圈 → 突然一大段 打字机 → 跟着读
断线恢复 整段重来 已收到的部分还在
服务端内存 攒整段答案 边生成边发送,压力平摊
实现复杂度 一行 .content() 多几行,还要处理取消和异常

一句话:流式不改变"生成要多快",它改变"用户感觉有多快"。对长文生成、代码补全、报告写作这类场景,流式几乎是标配。

一、ChatClient 切到流式,其实只差一个方法

第 02 篇里,拿一次完整答案是这样写的:

java 复制代码
String answer = chatClient.prompt()
        .system("你是智能写作助手")
        .user(question)
        .call()          // 一次性:等整段
        .content();

切成流式,把 .call() 换成 .stream(),返回类型从 String 变成 Flux<String>:

java 复制代码
Flux<String> answerStream = chatClient.prompt()
        .system("你是智能写作助手")
        .user(question)
        .stream()        // 流式:一段一段来
        .content();

Flux<String> 是 Reactor 的响应式类型,代表"未来会一个接一个到来的字符串序列"。Spring AI 把 stream() 之后的三个出口都给你了:content() 只要文本片段,chatResponse() 要带元数据的完整响应,chatClientResponse() 要包括工具调用在内的全过程。做打字机,content() 就够。

这层抽象的好处是:业务代码不关心底层模型是 OpenAI 还是别的什么 。只要是 Spring AI 支持的模型,stream() 的行为一致,你换模型不用改这行代码。

二、光吐字不够:流式接口有四个必须处理的边角

很多人以为流式就是"把 .call() 换成 .stream() 就完了"。V哥 告诉你,真上线你会撞上四件事,每一件都能让这个功能翻车:

第一件:完整答案从哪来。 打字机是一段一段吐给前端的,但审计、计费、质量抽检要的是整段。你不能指望前端把碎片拼好了再回传给你------那不可靠。正确做法是服务端在吐的同时自己把碎片拼一份,流结束的时候归档。

第二件:迟迟不来第一段怎么办。 模型那边限流了、网络抖了,Flux 可能一直没元素。前端转圈事小,连接占着不释放事大。要给它加一个静默超时。

第三件:半路断了怎么收场。 模型生成到一半报错,如果你不做处理,SSE 连接会直接断,用户看到一半字停在那里,比没字更难受。要把异常翻译成一句人话,塞进流里再正常收尾。

第四件:用户关页面了,后端知道吗。 这是最容易被忽略、也最费钱的一点。用户看到一半关掉页面,如果后端收不到取消信号,模型还在为一份没人看的答案继续吐 token,钱照花。Flux 的取消信号必须被接住。

把这四件事写进一个 Service,长这样:

java 复制代码
@Service
public class StreamingChatService {

    private static final String SYSTEM_PROMPT = """
            你是智能写作助手,回答要口语化,一段话控制在三句以内,方便在打字机效果里阅读。
            """;

    private static final Duration SILENCE_TIMEOUT = Duration.ofSeconds(20);

    private final ChatClient chatClient;
    private final AnswerArchive archive;
    private final StreamMetrics metrics;

    public StreamingChatService(ChatModel chatModel, AnswerArchive archive, StreamMetrics metrics) {
        this.chatClient = ChatClient.builder(chatModel).build();
        this.archive = archive;
        this.metrics = metrics;
    }

    public Flux<String> streamAnswer(String question) {
        StringBuilder full = new StringBuilder();

        return chatClient.prompt()
                .system(SYSTEM_PROMPT)
                .user(question)
                .stream()
                .content()
                .doOnNext(chunk -> {
                    metrics.onChunk();
                    full.append(chunk);
                })
                .timeout(SILENCE_TIMEOUT)
                .doOnCancel(metrics::onCancel)
                .doOnComplete(() -> archive.save(question, full.toString()))
                .doOnError(e -> metrics.onError())
                .onErrorResume(ex -> Flux.just("[生成中断:" + friendlyMessage(ex) + "]"));
    }

    public String answerAll(String question) {
        return streamAnswer(question).reduce(String::concat).block();
    }

    private static String friendlyMessage(Throwable ex) {
        String name = ex.getClass().getSimpleName();
        return switch (name) {
            case "TimeoutException" -> "等待模型响应超时,请重试";
            case "ResourceAccessException" -> "连接模型服务失败";
            default -> "后端生成异常";
        };
    }
}

几个 Reactor 算子,逐个说清楚它们为什么必须在这里:

  • doOnNext:每来一段就拼进 StringBuilder。这个变量是闭包捕获的,每个订阅者一份,流结束的时候就是完整答案;
  • timeout(20s):两段之间的静默超过 20 秒,就往下游发 TimeoutException。注意它测的是间隔,不是总时长------打字机本来就是慢慢吐的,只要一直在动就不该超时;
  • doOnComplete:正常吐完,把完整答案归档。这是"完整答案从哪来"的答案;
  • doOnCancel:前端断开时触发,这里只做了计数。真实项目里,这个信号应该再往上游传,让模型真正停下来------很多模型 SDK 的 stream() 都支持把取消传到网络层,那才是真省钱;
  • onErrorResume:把任何异常都翻译成一句 [生成中断:...],塞回流里正常收尾。前端拿到这句就知道"不是网络断了,是后端出了问题",展示体验完全不一样。

AnswerArchive 和 StreamMetrics 是配套的两个小类。归档用内存 Map 示意(生产换 MySQL),指标就是三个计数器:吐了多少段、取消几次、失败几次。这三个数是你判断流式接口健不健康的直接依据------取消率居高不下,说明要么太慢,要么用户根本不需要那么长的答案。

三、前端怎么接:两种姿势,各有用武之地

前端侧,最简单的是用浏览器原生的 EventSource:

javascript 复制代码
const es = new EventSource('/api/stream/ask-get?question=' + encodeURIComponent(question));
es.onmessage = (e) => {
    textArea.value += e.data;   // 一段一段往上拼
};
es.onerror = () => es.close();

不过 EventSource 只支持 GET,参数只能放 URL 里,长问题不合适。生产里 V哥 更推荐用 fetch 读流,能带 POST body,也能精确控制取消:

javascript 复制代码
const controller = new AbortController();
const resp = await fetch('/api/stream/ask', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ question }),
    signal: controller.signal,   // 用户点停止,就触发后端的 doOnCancel
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    textArea.value += decoder.decode(value, { stream: true });
}

AbortController 这条线要重点画出来:前端"停止"按钮调 controller.abort(),请求断开,后端 Flux 收到取消,doOnCancel 被触发------这才是取消链路真正闭环的地方。少了这一步,你做的取消只是一个前端动画,后端该怎么烧钱还怎么烧。

四、后端怎么返回:Flux 直返 vs SseEmitter

Controller 层,Spring Boot 给了两条路:

java 复制代码
@RestController
@RequestMapping("/api/stream")
public class StreamAskController {

    private final StreamingChatService service;

    public StreamAskController(StreamingChatService service) {
        this.service = service;
    }

    // 路线一:直接把 Flux 返回出去,框架自动按 text/event-stream 处理(推荐)
    @PostMapping(value = "/ask", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> ask(@Valid @RequestBody StreamAskRequest request) {
        return service.streamAnswer(request.question());
    }

    // 路线二:SseEmitter,适合还要手工塞心跳、自定义事件名的场景
    @PostMapping("/ask-emitter")
    public SseEmitter askEmitter(@Valid @RequestBody StreamAskRequest request) {
        var emitter = new SseEmitter(30_000L);
        service.streamAnswer(request.question())
                .subscribe(chunk -> {
                    try {
                        emitter.send(chunk);
                    } catch (IOException ex) {
                        emitter.completeWithError(ex);
                    }
                }, emitter::completeWithError, emitter::complete);
        return emitter;
    }
}

两条路的取舍很清晰:

  • Flux 直返:代码最省,框架帮你把响应式流桥接到 HTTP,取消信号天然透传。日常做打字机,V哥 默认用这条;
  • SseEmitter :传统的 Servlet 异步模型,好处是你可以完全掌控发什么。比如每隔 15 秒手工 emitter.send(SseEmitter.event().comment("")) 发个心跳,防止网关把空闲连接掐断;或者用 event().name("meta") 发自定义事件,把"预计还要 10 秒"这种提示单独推给前端。

入参还是老规矩,空问题直接 400:

java 复制代码
public record StreamAskRequest(
        @NotBlank(message = "问题不能为空") String question) {
}

五、离线环境怎么验证这条链

流式接口的难点在"时间"和"信号",这两样用桩都能模拟。桩只要覆写 stream(Prompt),把一段固定答案切成几片往外吐:

java 复制代码
public class StreamingStubChatModel extends StubChatModel {

    private final List<String> chunks;

    public StreamingStubChatModel(List<String> chunks) {
        super(String.join("", chunks));
        this.chunks = chunks;
    }

    @Override
    public Flux<ChatResponse> stream(Prompt prompt) {
        return Flux.fromIterable(chunks)
                .map(text -> ChatResponse.builder()
                        .generations(List.of(new Generation(new AssistantMessage(text))))
                        .build());
    }
}

再配一个故障桩,stream() 直接 Flux.error(...),用来验证异常翻译:

java 复制代码
public class FailingStreamStubChatModel extends StubChatModel {
    private final RuntimeException failure;

    public FailingStreamStubChatModel(RuntimeException failure) {
        super("不可用");
        this.failure = failure;
    }

    @Override
    public Flux<ChatResponse> stream(Prompt prompt) {
        return Flux.error(failure);
    }
}

有了这两个桩,就能离线断言下面这些关键点:片段按顺序到达、拼起来是完整答案、正常结束后归档完整答案、取消时归档保持旧值 (半截答案不该落库)、异常变成 [生成中断:...] 而不是断流、answerAll() 返回整段、HTTP 端点返回 text/event-stream 且内容里带生成的文本、空问题返回 400。这些覆盖的是"流式接口的工程正确性",跟模型聪明不聪明无关,而前者才是上线前必须锁死的部分。

尤其"取消时归档保持旧值"这一条,V哥 每次都会单独写一个测试:流式答案必须在完整到达后才落库,中途取消绝不能把半截答案存进去,否则下一次质检、审计拿到的就是一份残废数据。

六、几个上线必踩的坑

现象 原因 处理
前端收到的是一整坨,不是打字机 中间有代理/网关(Nginx)开了缓冲 关掉 proxy_buffering,或给响应头加 X-Accel-Buffering: no
用户关页面后 token 还在烧 取消信号没传到模型层 从 doOnCancel 一路把取消传进 SDK 的流,并加取消计数监控
半路断了用户看到半截 异常没翻译就直接断流 onErrorResume 塞一句人话,正常收尾
首字等了 8 秒 模型冷启动 / 首 token 慢 监控首字延迟,必要时上缓存或更快的档位(呼应第 03 篇路由)
完整答案归档重复 业务层又 collect 了一遍 归档只做一次,统一在 Service 里 doOnComplete 完成

关于第一条,单独多说一句:流式上线后第一件事不是看正确率,是看"首字延迟"和"取消率"两个指标。首字延迟决定了用户会不会关页面,取消率决定了你在为一个没人看的答案烧多少钱。这两条曲线稳了,再谈生成质量。


最后一句 :流式输出的本质不是让模型吐得更快,而是让用户每一秒都拿到反馈 ------把 .call() 换成 .stream() 只是第一行代码,真正值钱的是那四件边角事:完整答案要落库、静默要超时、异常要说人话、取消要能收到;把这四件做扎实,打字机才不是一个花架子。下一篇(10)带你把"看字"升级成"看图":让模型同时读图片和文字,做商品图审核和发票识别。

相关推荐
lisw051 小时前
大数据对大数据挖掘及其处理框架带来的挑战
大数据·人工智能·数据挖掘
223糖1 小时前
AI 助手架构设计:Python Agent 和 Spring Boot 如何分工?
人工智能
数智工坊1 小时前
视觉SLAM第4讲|李群与李代数:位姿优化的数学基石
人工智能·深度学习·线性代数·cnn
落798.1 小时前
ChatGPT 的回答,也能被“结构化抓取”?
人工智能·chatgpt·亮数据
步行cgn1 小时前
Spring 事务传播行为详解
java·数据库·spring
你就是答案 -1481 小时前
多轨道怎么按时间戳编辑
人工智能
海宇服务1 小时前
零信任架构实战:基于海宇车辆vin码查车辆信息详版构建自动化汽配云仓参配核验网关
java·人工智能·架构·自动化
wno7041 小时前
Spring Boot整合Flyway
java·spring boot·后端
红姐跨境书1 小时前
亚马逊广告接入 ChatGPT 后,卖家该如何布局 AI 电商营销?
人工智能·chatgpt