第4篇:《Stream流的浪漫:让AI像ChatGPT一样一个字一个字往外蹦》

承上:上一篇我们让AI乖乖返回了结构化JSON,接口终于像个正经API了。但体验上还差一口气------用户问一个问题,得盯着空白页面等8秒,然后突然蹦出一大段文字。这哪叫AI对话?这叫"开盲盒"。今天,我们要让AI学会"边想边写"。

1. 问题场景:同步调用的"假死感"

先回顾一下我们现在的调用方式:

java 复制代码
String response = chatClient.prompt()
        .user(message)
        .call()
        .content();  // 阻塞等待,直到AI全部写完才返回

调用链是这样的:

txt 复制代码
用户发请求 → 服务器等待 → AI思考3秒 → AI写5秒 → 返回完整结果
                                        ↑
                              这8秒里用户啥也看不到

这种同步调用在生成大段文字时体验极差。用户盯着空白页面等8秒,然后突然冒出来一篇800字的文章。这不叫AI对话,这叫"AI盲盒"------你不知道它在干嘛,也不知道要不要继续等。

后端老鸟的直觉反应:这不是和文件下载一个道理吗?大文件不能一次性读到内存里,要用流。

2. 核心代码:一句话切换到流式

2.1. Service实现

Spring AI的流式调用,改动量小到离谱:

java 复制代码
    /**
     * 同步调用
     */
    public String chatString(String message) {
        return chatClient.prompt()
                .system("你是一个专业的助手,可以回答用户的问题")
                .user(message)
                .call()
                .content();
    }
    /**
     * 流式调用
     */
    public Flux<String> chatFlux(String message) {
        return chatClient.prompt()
                .system("你是一个专业的助手,可以回答用户的问题")
                .user(message)
                .stream()
                .content();
    }

就一个变化:.call().stream(),返回类型从 String 变成 Flux<String>

Flux是啥? Project Reactor的核心类,和你在WebFlux里用的是同一套。简单理解:Flux<String> 就是一个"会随时间不断吐出字符串的流",每吐出一个Token,订阅者就能立刻收到。

2.2. Controller适配SSE

java 复制代码
package com.yunxi.ai.controller;

import com.yunxi.ai.service.ChatService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

@RestController
public class ChatController {

    private final ChatService chatService;

    public ChatController(ChatService chatService) {
        this.chatService = chatService;
    }

    /**
     * 流式对话接口
     * produces = TEXT_EVENT_STREAM_VALUE 是关键,告诉Spring返回SSE格式
     */
    @GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> chatStream(@RequestParam String message) {
        return chatService.chatFlux(message);
    }
}

关键注解produces = MediaType.TEXT_EVENT_STREAM_VALUE

这告诉Spring MVC:

  • 响应Content-Type是 text/event-stream
  • 数据格式遵循SSE规范:data: 内容\n\n

2.3. 测试效果

用curl测试(curl原生支持SSE,-N 参数禁用缓冲):

perl 复制代码
http://localhost:9999/chat/stream?message=%E5%86%99%E4%B8%80%E9%A6%96%E5%85%B3%E4%BA%8Ejava%E7%9A%84%E4%BA%8B

你会看到输出是逐词往外蹦的

txt 复制代码
data:我想

data:您说的"事

data:"

data:应该是"诗"

data:的笔误。

data:Java 作为编程

data:界最经典、

data:最长寿的语言之一

data:,

data:承载了无数程序

data:员的青春与汗水

data:。
data:
data:为您创作

data:了一首关于

data: Java 的现代诗

data: **《致Java

data::一杯不冷的

data:咖啡》**,

Token ≠ 汉字:中文Token有时一个字,有时一个词。这取决于模型的分词策略,不用纠结。

3. 前端消费

3.1. 上代码(AI写的)

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>流式输出示例</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            padding: 40px 20px;
        }

        .container {
            max-width: 800px;
            margin: 0 auto;
            background: white;
            border-radius: 12px;
            box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
            overflow: hidden;
        }

        .header {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            padding: 24px 32px;
            color: white;
        }

        .header h1 {
            font-size: 24px;
            font-weight: 600;
        }

        .header p {
            font-size: 14px;
            opacity: 0.9;
            margin-top: 4px;
        }

        .content {
            padding: 32px;
        }

        .input-group {
            display: flex;
            gap: 12px;
            margin-bottom: 24px;
        }

        .input-group input {
            flex: 1;
            padding: 12px 16px;
            border: 2px solid #e5e7eb;
            border-radius: 8px;
            font-size: 14px;
            outline: none;
            transition: border-color 0.2s;
        }

        .input-group input:focus {
            border-color: #667eea;
        }

        .input-group button {
            padding: 12px 24px;
            background: #667eea;
            color: white;
            border: none;
            border-radius: 8px;
            font-size: 14px;
            font-weight: 500;
            cursor: pointer;
            transition: background 0.2s;
        }

        .input-group button:hover {
            background: #5a6fd6;
        }

        .input-group button:disabled {
            background: #9ca3af;
            cursor: not-allowed;
        }

        .result-area {
            background: #f9fafb;
            border-radius: 8px;
            padding: 20px;
            min-height: 200px;
            font-size: 14px;
            line-height: 1.8;
            color: #374151;
            white-space: pre-wrap;
            word-break: break-all;
            border: 1px solid #e5e7eb;
        }

        .result-area:empty::before {
            content: '等待输入...';
            color: #9ca3af;
        }

        .streaming-indicator {
            display: inline-flex;
            align-items: center;
            gap: 4px;
            color: #667eea;
            font-size: 12px;
            margin-bottom: 8px;
        }

        .streaming-indicator span {
            display: inline-block;
            width: 6px;
            height: 6px;
            background: #667eea;
            border-radius: 50%;
            animation: pulse 1.4s infinite ease-in-out both;
        }

        .streaming-indicator span:nth-child(1) { animation-delay: -0.32s; }
        .streaming-indicator span:nth-child(2) { animation-delay: -0.16s; }

        @keyframes pulse {
            0%, 80%, 100% { transform: scale(0); }
            40% { transform: scale(1); }
        }

        .clear-btn {
            margin-top: 12px;
            padding: 8px 16px;
            background: #f3f4f6;
            color: #6b7280;
            border: none;
            border-radius: 6px;
            font-size: 13px;
            cursor: pointer;
            transition: background 0.2s;
        }

        .clear-btn:hover {
            background: #e5e7eb;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1>流式输出 Demo</h1>
            <p>基于 Server-Sent Events (SSE) 的实时响应</p>
        </div>
        <div class="content">
            <div class="input-group">
                <input type="text" id="prompt" placeholder="请输入问题,例如:介绍一下 Spring AI">
                <button id="sendBtn">发送</button>
            </div>
            <div id="streamingIndicator" class="streaming-indicator" style="display: none;">
                <span></span><span></span><span></span> 正在生成中...
            </div>
            <div id="result" class="result-area"></div>
            <button id="clearBtn" class="clear-btn" style="display: none;">清空结果</button>
        </div>
    </div>

    <script>
        const promptInput = document.getElementById('prompt');
        const sendBtn = document.getElementById('sendBtn');
        const resultArea = document.getElementById('result');
        const streamingIndicator = document.getElementById('streamingIndicator');
        const clearBtn = document.getElementById('clearBtn');

        sendBtn.addEventListener('click', () => {
            const prompt = promptInput.value.trim();
            if (!prompt) return;

            sendBtn.disabled = true;
            resultArea.textContent = '';
            streamingIndicator.style.display = 'flex';
            clearBtn.style.display = 'none';

            const encodedMessage = encodeURIComponent(prompt);
            const url = `/chat/stream?message=${encodedMessage}`;

            const eventSource = new EventSource(url);

            eventSource.onmessage = function(event) {
                if (event.data) {
                    resultArea.textContent += event.data;
                    resultArea.scrollTop = resultArea.scrollHeight;
                }
            };

            eventSource.onerror = function() {
                if (eventSource.readyState === EventSource.CLOSED) {
                    sendBtn.disabled = false;
                    streamingIndicator.style.display = 'none';
                    clearBtn.style.display = 'block';
                } else {
                    resultArea.textContent += '\n连接异常';
                    sendBtn.disabled = false;
                    streamingIndicator.style.display = 'none';
                    clearBtn.style.display = 'block';
                }
                eventSource.close();
            };
        });

        clearBtn.addEventListener('click', () => {
            resultArea.textContent = '';
            clearBtn.style.display = 'none';
        });

        promptInput.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') {
                sendBtn.click();
            }
        });
    </script>
</body>
</html>

3.2. 效果描述

用户点击"发送"后:

  1. 页面不转圈,不白屏
  2. 文字像打字机一样逐词出现在页面上

这就是ChatGPT同款体验。

4. 流式的工程价值(不只是"好看")

4.1. 首字响应时间对比

调用方式 首字到达时间 完整响应时间 用户体验
同步 .call() 8秒 8秒 盯白屏等8秒
流式 .stream() 0.3秒 8秒 立刻开始阅读

0.3秒 vs 8秒,这是"秒开"和"用户流失"的区别。

4.2. 支持中途取消

用户看到AI开始跑偏,可以立刻中断,不浪费Token:

java 复制代码
public Flux<String> streamChatWithCancel(String message) {
    return chatClient.prompt()
            .user(message)
            .stream()
            .content()
            .doOnCancel(() -> {
                // 用户关闭了连接,可以在这里记录日志或释放资源
                log.info("用户中断了对话,停止Token消耗");
            });
}

前端关闭EventSource → 后端 doOnCancel 触发 → Token不再消耗。真金白银省下来了。

4.3. 实时监控Token消耗

java 复制代码
public Flux<String> streamChatWithMonitoring(String message) {
    AtomicReference<Usage> usageRef = new AtomicReference<>();
    AtomicReference<StringBuilder> fullContent = new AtomicReference<>(new StringBuilder());

    return chatClient.prompt()
            .user(message)
            .stream()
            .chatResponse()
            .doOnNext(response -> {
                // 提取内容(根据实际框架调整)
                String chunk = extractContent(response);
                if (chunk != null) {
                    fullContent.get().append(chunk);
                }
                
                // 尝试获取 token(通常在最后一块才有)
                Usage usage = response.getMetadata().getUsage();
                if (usage != null && usage.getTotalTokens() > 0) {
                    usageRef.set(usage);
                }
            })
            .doOnComplete(() -> {
                // 流结束后记录 token 和完整内容
                if (usageRef.get() != null) {
                    log.info("=== Token Usage for message: {} ===", 
                        message.length() > 50 ? message.substring(0, 50) + "..." : message);
                    log.info("Prompt Tokens: {}", usageRef.get().getPromptTokens());
                    log.info("Completion Tokens: {}", usageRef.get().getCompletionTokens());
                    log.info("Total Tokens: {}", usageRef.get().getTotalTokens());
                    log.info("Response length: {} characters", fullContent.get().length());
                } else {
                    log.warn("No token usage data available for this response");
                }
            })
            .map(response -> extractContent(response)); // 直接返回内容字符串
}

// 辅助方法:安全提取内容
private String extractContent(ChatResponse response) {
    if (response == null || response.getResult() == null) {
        return "";
    }
    
    Object output = response.getResult().getOutput();
    if (output == null) {
        return "";
    }
    
    // 根据实际类型处理
    if (output instanceof String) {
        return (String) output;
    } else if (output instanceof AssistantMessage) {
        AssistantMessage msg = (AssistantMessage) output;
        return msg.getText() != null ? msg.getText() : "";
    } else {
        // 其他类型,尝试 toString
        return output.toString();
    }
}

注意 :部分模型在流式模式下,Token统计只在最后一条响应中返回。中间过程可能为null,要判空。

yaml 复制代码
=== Token Usage for message: 你好,做一首诗 ===
Prompt Tokens: 15
Completion Tokens: 913
Total Tokens: 928
Response length: 275 characters

5. 本篇避坑指南

5.1. 坑1:浏览器直接访问流式接口,页面一直转圈

浏览器地址栏不支持SSE渲染。用curl测试,或者写一个简单的HTML页面。

bash 复制代码
# 正确测试方式
curl -N http://localhost:8080/chat/stream?message=你好

5.2. 坑2:Nginx反向代理截断了流式效果

如果Nginx开启了响应缓冲,会等后端全部返回后再一次性发给前端。

解决:在Nginx配置中关闭缓冲:

ini 复制代码
location /chat/stream {
    proxy_buffering off;
    proxy_cache off;
    proxy_pass http://backend;
}

5.3. 坑3:Spring Security拦截了SSE请求

java 复制代码
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(auth -> auth
        .requestMatchers("/chat/stream/**").permitAll()  // 放行SSE
        // ...
    );
    return http.build();
}

6. 本篇小结

这一篇我们解决了"AI盲盒"问题:

改动 效果
.call().stream() 一字之差,体验天翻地覆
StringFlux<String> 返回类型变了,编程模型不变
produces = TEXT_EVENT_STREAM_VALUE 告诉Spring这是SSE
前端 EventSource 原生支持,零依赖

现在我们拥有了:

  • ✅ 工程化的项目结构(第1篇)
  • ✅ 配置管理的提示词系统(第2篇)
  • ✅ 结构化的JSON响应(第3篇)
  • ✅ 流式打字机体验(本篇)

但AI依然是"嘴强王者"------说得天花乱坠,但查不了数据库、调不了接口。下一篇,我们给AI装上手脚,让它能真正干活。Function Calling,启动!


本文与DeepSeek协作完成

相关推荐
win4r2 小时前
🚀Graph Engineering范式:Codex Multi-agent V2支持Kimi、MiniMax、GPT多模型混用+动态派生subagent,并行执行、Pi Agent工具调用,效率倍增
aigc·ai编程·vibecoding
花椒技术5 小时前
原本要 2 天的服务端冒烟前置审查,为什么 3 分半就能出报告?|QA 质量交付实践(三)
后端·ai编程·测试
东小西6 小时前
第3篇:《AI的JSON强迫症:我让大模型只回答能解析的格式》
openai·ai编程
ServBay6 小时前
Gemini 3.6 Flash 正式发布:省了 Token,但智商没跟上?
aigc·ai编程·gemini
AI大模型-小雄6 小时前
Codex 长任务总要重新开始?买 Credits 还是升级 ChatGPT Pro
人工智能·chatgpt·程序员·ai编程·codex·ai办公·chatgpt pro
Shirley~~6 小时前
Code-Review-Graph:面向 AI 辅助代码审查的结构化上下文引擎
前端·ai编程
Onesoft%J1ao7 小时前
【2026年7月份有感】VibeCoding的入门到免费API的精通
aigc·免费api·ai编程·vibecoding
未秃头的程序猿7 小时前
给公司做了个AI客服Agent,用的Spring AI 1.0,3天上线领导拍板了
java·后端·ai编程
Ai拆代码的曹操7 小时前
opencode 源码调试环境搭建:bun install → F5 断点全流程
ai编程·opencode·源码拆解