D07.实战|实现支持结构化数据的流式对话系统

一灰灰blogSpringAISpringSpringAI约 5935 字大约 20 分钟

一、引言

在传统的 AI 对话系统中,后端通常只能返回纯文本内容。然而,在实际应用场景中,我们往往需要展示更丰富的交互形式:天气卡片、知识问答题、数据图表、可点击选项等。

本文将介绍如何基于 Spring AI 实现一个简洁而强大的流式对话系统,该系统不仅支持文本的流式输出,还能通过 Prompt 工程让 AI 返回卡片、列表、选项等结构化数据,让对话框具备展示多样化 UI 的能力。

1.1 效果预览

我们的系统可以返回以下几种响应类型:

  • text: 纯文本(如:"今天天气不错")
  • card: 卡片结构(如:天气卡片,包含标题、副标题、描述等)
  • list: 列表结构(如:推荐书单,每本书有标题、描述)
  • options: 选项结构(如:选择题,用户可点击选项进行交互)
D07-show.gif
D07-show.gif

1.2 技术亮点

  1. 流式响应 (SSE): 使用 Server-Sent Events 实现实时数据推送
  2. Prompt 工程: 通过精心设计的 Prompt 引导 AI 返回结构化数据
  3. 简洁架构: 无需 Function Calling,降低实现复杂度
  4. 会话管理: 支持多轮对话上下文,实现连续交互

二、系统架构

2.1 整体结构

D06-ai-auto-chat/
├── src/main/
│   ├── java/com/git/hui/springai/app/
│   │   ├── dto/
│   │   │   ├── ChatRequest.java      # 请求 DTO
│   │   │   └── ChatResponse.java     # 响应 DTO
│   │   ├── mvc/
│   │   │   └── SimpleChatController.java   # 控制器
│   │   ├── service/
│   │   │   └── ChatService.java      # 服务层
│   │   └── D06Application.java                 # 启动类
│   └── resources/
│       ├── application.yml           # 配置文件
│       └── templates/simpleChat.html         # 前端页面
└── pom.xml                           # Maven 配置

2.2 核心组件

  • Controller: 处理 HTTP 请求,暴露 RESTful API
  • ChatService: 核心业务逻辑,处理会话管理和事件流
  • DTOs: 数据传输对象,定义请求/响应格式

三、环境准备

3.1 技术栈

  • JDK: 17+
  • Spring Boot: 3.5.4
  • Spring AI: 1.1.2
  • LLM Provider: OpenAI(底层模型使用轨迹流动)
  • 构建工具: Maven

3.2 项目依赖

pom.xml 核心依赖:

<dependencies>
    <!-- Spring Boot Starter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- Spring AI with OpenAI Compatible -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
    
    <!-- Thymeleaf (前端页面) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    
    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

3.3 配置 API Key

src/main/resources/application.yml 中配置 SiliconFlow(兼容 OpenAI 接口):

spring:
  ai:
    openai:
      api-key: ${silicon-api-key}  # 从环境变量读取
      base-url: https://api.siliconflow.cn
      chat:
        options:
          model: Qwen/Qwen3-8B  # 使用通义千问模型

server:
  port: 8080

启动应用:

mvn spring-boot:run -Dsilicon-api-key=your-api-key-here

四、核心实现详解

4.1 数据结构定义

首先定义请求和响应的数据结构:

ChatRequest.java:

@Data
public class ChatRequest {
    /**
     * 用户消息
     */
    private String message;
    
    /**
     * 会话 ID(可选,用于多轮对话)
     */
    private String conversationId;
    
    /**
     * 响应类型:text, card, list, options
     */
    private String responseType = "text";
}

ChatResponse.java:

@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ChatResponse {
    /**
     * 响应类型
     */
    private String type;
    
    /**
     * 文本内容(流式返回片段或完整文本)
     */
    private String content;
    
    /**
     * 结构化数据(card/list/options)
     */
    private Object data;
    
    /**
     * 错误信息
     */
    private String error;
    
    /**
     * 会话 ID
     */
    private String conversationId;
    
    /**
     * 是否完成
     */
    private Boolean done;
    
    // 内部静态类:卡片、列表项、选项等数据结构
    @Data
    @Builder
    public static class CardData {
        private String title;
        private String subtitle;
        private String description;
    }
    
    @Data
    @Builder
    public static class ListItem {
        private String title;
        private String description;
    }
    
    @Data
    @Builder
    public static class OptionItem {
        private String label;
        private String value;
        private String description;
    }
}

4.2 核心服务:ChatService

这是整个系统的核心,负责处理流式聊天和 Prompt 工程。

4.2.1 服务初始化

@Service
public class ChatService {
    private static final Logger log = LoggerFactory.getLogger(ChatService.class);

    private final ChatModel chatModel;
    private final ChatClient chatClient;
    private final ObjectMapper objectMapper;
    private final ChatMemory chatMemory;

    public ChatService(ChatModel chatModel, ObjectMapper objectMapper, ChatMemory chatMemory) {
        this.chatMemory = chatMemory;
        this.chatModel = chatModel;
        this.chatClient = ChatClient.builder(chatModel)
                .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
                .build();
        this.objectMapper = objectMapper;
    }
}

关键点:

  • ChatMemory 接口支持多种实现(内存、Redis 等),默认使用内存进行存储
  • ObjectMapper 用于 JSON 解析

4.2.2 流式聊天主流程

public Flux<ChatResponse> streamChat(ChatRequest request) {
    String conversationId = getOrCreateConversationId(request.getConversationId());

    // 构建提示词,根据响应类型调整输出格式
    String prompt = buildPrompt(request.getMessage(), request.getResponseType(), conversationId);

    // 使用 ChatClient 进行流式调用
    StringBuilder context = new StringBuilder();

    return chatClient.prompt(prompt)
            .stream()
            .content()
            .map(content -> {
                context.append(content);
                return createStreamResponse(content, request.getResponseType(), conversationId);
            })
            .concatWith(Flux.defer(() -> {
                // 流式完成后,返回完整的结构化数据
                log.info("Stream completed, context length: {}", context.length());
                Object structuredData = RspExtractor.parseStructuredData(context.toString(), request.getResponseType());
                ChatResponse struct = null;
                if (structuredData != null) {
                    // 返回结构化的数据
                    struct = ChatResponse.builder()
                            .type(request.getResponseType())
                            .done(false)
                            .conversationId(conversationId)
                            .data(structuredData)
                            .build();
                }
                // 返回一个结束的标识
                ChatResponse finalResponse = ChatResponse.builder()
                        .type(request.getResponseType())
                        .conversationId(conversationId)
                        .done(true)
                        .build();
                if (struct != null) return Flux.just(struct, finalResponse);
                return Flux.just(finalResponse);
            }))
            .onErrorResume(error -> {
                log.error("Stream chat error", error);
                return Flux.just(ChatResponse.builder()
                        .type(request.getResponseType())
                        .error(error.getMessage())
                        .conversationId(conversationId)
                        .done(true)
                        .build());
            });
}

流程说明:

  1. 获取会话 ID: 如果未提供则创建新的 UUID
  2. 构建 Prompt: 根据响应类型动态调整提示词
  3. 流式调用: 使用 .stream().content() 获取文本流
  4. 实时返回: 每收到一个文本片段就立即返回给前端
  5. 结构化解析: 流式结束后,尝试从完整内容中解析结构化数据
  6. 返回结果: 先返回结构化数据,再返回完成标记

注意:我们这里是一个极其简单的实现方式,要求返回要么是文本,要么是结构化数据,对于混合返回的场景,请看下一篇的改造


4.2.3 Prompt 工程:根据响应类型构建提示词

这是实现结构化数据返回的关键:(完全通过提示词来限制返回)

private String buildPrompt(String message, String responseType, String conversationId) {
    StringBuilder prompt = new StringBuilder();

    prompt.append(message);

    switch (responseType) {
        case "card":
            prompt.append("\n\n请以 JSON 格式返回一个卡片结构,包含 title(标题), subtitle(副标题), description(描述) 字段");
            break;
        case "list":
            prompt.append("\n\n请以 JSON 数组格式返回列表,每个列表项包含 title(标题), description(描述) 字段");
            break;
        case "options":
            prompt.append("\n\n请以 JSON 数组格式返回选项列表,每个选项包含 label(标签), value(值), description(描述) 字段");
            break;
        default:
            // text 类型不需要特殊处理
            prompt.append("\n\n请直接返回文本");
            break;
    }
    return prompt.toString();
}

关键点:

  • 根据 responseType 动态调整 Prompt
  • 明确告知 AI 需要返回的 JSON 格式
  • 保存对话历史用于多轮对话

4.2.4 结构化数据解析:RspExtractor

从 AI 返回的文本中提取结构化数据:

public class RspExtractor {
    
    private static final ObjectMapper objectMapper = new ObjectMapper();
    
    /**
     * 从文本中解析结构化数据
     */
    public static Object parseStructuredData(String content, String responseType) {
        try {
            // 尝试从文本中提取 JSON
            String json = extractJson(content);
            if (json == null || json.isEmpty()) {
                return null;
            }
            
            // 根据响应类型解析为对应的数据结构
            return switch (responseType) {
                case "card" -> objectMapper.readValue(json, Map.class);
                case "list" -> objectMapper.readValue(json, List.class);
                case "options" -> objectMapper.readValue(json, List.class);
                default -> null;
            };
        } catch (Exception e) {
            // 解析失败返回 null,作为文本处理
            return null;
        }
    }
    
    /**
     * 从文本中提取 JSON 字符串
     * 支持 ```json ... ``` 或纯 JSON 格式
     */
    private static String extractJson(String content) {
        // 尝试匹配 ```json 代码块
        Matcher matcher = Pattern.compile("```(?:json)?\\s*([\\s\\S]+?)```").matcher(content);
        if (matcher.find()) {
            return matcher.group(1);
        }
        
        // 尝试直接查找 JSON 对象
        int start = content.indexOf('{');
        int end = content.lastIndexOf('}');
        if (start != -1 && end > start) {
            return content.substring(start, end + 1);
        }
        
        return null;
    }
}

4.3 Controller 层:SimpleChatController

提供 RESTful API 接口:

@RestController
@RequestMapping("/api/chat")
@CrossOrigin(origins = "*")
public class SimpleChatController {
    private final ChatService chatService;
    
    public SimpleChatController(ChatService chatService) {
        this.chatService = chatService;
    }
    
    /**
     * 流式聊天接口
     * POST /api/chat/stream
     * Content-Type: application/json
     * Accept: text/event-stream
     *
     * Body: {
     *   "message": "用户消息",
     *   "conversationId": "会话 ID(可选)",
     *   "responseType": "text|card|list|options"
     * }
     */
    @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ChatResponse> streamChat(@RequestBody ChatRequest request) {
        log.info("Stream chat request: {}", request);
        return chatService.streamChat(request);
    }
    
    /**
     * 普通聊天接口(非流式)
     * POST /api/chat
     */
    @PostMapping
    public ChatResponse chat(@RequestBody ChatRequest request) {
        log.info("Chat request: {}", request);
        return chatService.chat(request);
    }
    
    /**
     * GET 方式的流式聊天接口(方便测试)
     * GET /api/chat/stream?message=xxx&conversationId=xxx&responseType=text
     */
    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ChatResponse> streamChatGet(
            @RequestParam String message,
            @RequestParam(required = false) String conversationId,
            @RequestParam(defaultValue = "text") String responseType) {
        
        ChatRequest request = new ChatRequest();
        request.setMessage(message);
        request.setConversationId(conversationId);
        request.setResponseType(responseType);
        
        log.info("Stream chat GET request: {}", request);
        return chatService.streamChat(request);
    }
}

五、响应格式说明

5.1 流式响应格式

流式返回时,每个事件都是一个 ChatResponse JSON 对象:

文本片段流:

{"type":"text","content":"今","conversationId":"xxx","done":false}
{"type":"text","content":"天","conversationId":"xxx","done":false}
{"type":"text","content":"天","conversationId":"xxx","done":false}
...

结构化数据(流式结束后返回):

{
    "type": "card",
    "data": {
        "title": "李白",
        "subtitle": "唐代浪漫主义诗人",
        "description": "李白(701年-762年),字太白,号青莲居士,是唐代最著名的诗人之一,被后人誉为“诗仙”。他以豪放不羁的个性、丰富的想象力和奔放的浪漫主义风格著称,代表作有《将进酒》、《静夜思》、《望庐山瀑布》等。李白的诗歌题材广泛,包括山水、饮酒、离别、思乡等,语言优美,意境深远,对后世文学影响极大。他与杜甫并称“李杜”,是唐代诗歌的巅峰代表之一。",
        "imageUrl": null,
        "actions": null,
        "extra": null
    },
    "conversationId": "d2541c6d-967c-40d8-a91e-b87791cca59c",
    "done": false
}

完成标记:

{"type":"text","conversationId":"xxx","done":true}

5.2 完整响应格式

非流式请求返回完整的 JSON 对象:

文本响应:

{
  "type": "text",
  "content": "这是 AI 的回复内容",
  "conversationId": "xxx",
  "done": true
}

卡片响应:

{
  "type": "card",
  "data": {
    "title": "天气卡片",
    "subtitle": "北京",
    "description": "今天天气晴朗,气温 25°C,适合户外活动。"
  },
  "conversationId": "xxx",
  "done": true
}

列表响应:

{
  "type": "list",
  "data": [
    {
      "title": "推荐书籍 1",
      "description": "这是一本好书"
    },
    {
      "title": "推荐书籍 2",
      "description": "值得一读"
    }
  ],
  "conversationId": "xxx",
  "done": true
}

选项响应:

{
  "type": "options",
  "data": [
    {
      "label": "选项 A",
      "value": "option_a",
      "description": "选项 A 的描述"
    },
    {
      "label": "选项 B",
      "value": "option_b",
      "description": "选项 B 的描述"
    }
  ],
  "conversationId": "xxx",
  "done": true
}

六、使用示例

6.1 请求文本回复

请求:

POST /api/chat/stream
Content-Type: application/json
Accept: text/event-stream

{
  "message": "讲个笑话",
  "responseType": "text"
}
D07-o1.webp
D07-o1.webp

响应流:

data:{"type":"text","content":"有一天","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"text","content":",","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"text","content":"小","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"text","content":"明","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"text","content":"去","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"text","content":"理发","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
...
data:{"type":"text","content":"强","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"text","content":"’","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"text","content":"的","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"text","content":"发型","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"text","content":"!”","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"text","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":true}

6.2 请求卡片回复

请求:

POST /api/chat/stream
Content-Type: application/json
Accept: text/event-stream

{
  "message": "推荐一道川菜",
  "responseType": "card"
}
D07-o2.webp
D07-o2.webp

响应流:

data:{"type":"card","content":"```","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"card","content":"json","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"card","content":"\n","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"card","content":"{\n","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"card","content":" ","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"card","content":" \"","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"card","content":"title","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"card","content":"\":","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"card","content":" \"","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"card","content":"水煮鱼","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
data:{"type":"card","content":"\",\n","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
...(文本流式返回)...
{"type":"card","content":"```","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
{"type":"card","data":{"title":"水煮鱼","subtitle":"麻辣鲜香的川菜经典","description":"水煮鱼是川菜中一道极具代表性的菜肴,以新鲜的鱼片为主料,搭配特制的麻辣汤底,配以豆芽、木耳、青菜等食材,口感嫩滑鲜辣,令人回味无穷。","imageUrl":null,"actions":null,"extra":null},"conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":false}
{"type":"card","conversationId":"d2541c6d-967c-40d8-a91e-b87791cca59c","done":true}

AI 会先流式返回文本描述,然后在最后返回结构化的卡片数据。


6.3 请求列表回复

请求:

POST /api/chat/stream
Content-Type: application/json
Accept: text/event-stream

{
  "message": "推荐几本好看的科幻小说",
  "responseType": "list"
}
D07-o3.webp
D07-o3.webp

响应流:

data: {"type":"list","content":"《","conversationId":"abc123","done":false}
data: {"type":"list","content":"三","conversationId":"abc123","done":false}
data: {"type":"list","content":"体","conversationId":"abc123","done":false}
...(文本流式返回)...
data: {"type":"list","content":"","conversationId":"abc123","done":false}
data: {"type":"list","data":[{"title":"《三体》","description":"刘慈欣创作的长篇科幻小说,讲述了地球人类文明和三体文明的信息交流、生死搏杀及两个文明之间的冲突。"},{"title":"《流浪地球》","description":"刘慈欣短篇科幻小说,讲述了太阳即将毁灭,人类启动流浪地球计划的故事。"},{"title":"《北京折叠》","description":"郝景芳创作的中篇科幻小说,获得了第 74 届雨果奖中短篇小说奖。"}],"conversationId":"abc123","done":false}
data: {"type":"list","content":"","conversationId":"abc123","done":true}

6.4 请求选项回复

请求:

POST /api/chat/stream
Content-Type: application/json
Accept: text/event-stream

{
  "message": "我想学习编程,应该从哪种语言开始?",
  "responseType": "options"
}
D07-o4.webp
D07-o4.webp

响应流:

data: {"type":"options","content":"建","conversationId":"abc123","done":false}
data: {"type":"options","content":"议","conversationId":"abc123","done":false}
...(文本流式返回)...
data: {"type":"options","content":"","conversationId":"abc123","done":false}
data: {"type":"options","data":[{"label":"Python","value":"python","description":"语法简洁,适合初学者,广泛应用于数据分析、人工智能等领域"},{"label":"JavaScript","value":"javascript","description":"Web 开发必备,前端后端都能使用,生态丰富"},{"label":"Java","value":"java","description":"企业级应用广泛,学习曲线较陡,但就业市场需求大"}],"conversationId":"abc123","done":false}
data: {"type":"options","content":"","conversationId":"abc123","done":true}

七、前端集成指南

7.1 使用 EventSource 接收 SSE 流

// 使用 EventSource 进行 SSE 流式请求
const params = new URLSearchParams({
    message: message,
    responseType: currentResponseType
});
if (conversationId) {
    params.append('conversationId', conversationId);
}

const eventSource = new EventSource(`/api/chat/stream?${params.toString()}`);

eventSource.onmessage = function(event) {
  const chatResponse = JSON.parse(event.data);
  
  // 处理错误
  if (chatResponse.error) {
    console.error('Error:', chatResponse.error);
    return;
  }
  
  // 流式文本内容
  if (chatResponse.content && !chatResponse.done) {
    appendText(chatResponse.content);
  }
  
  // 结构化数据
  if (chatResponse.data && !chatResponse.done) {
    handleStructuredData(chatResponse.type, chatResponse.data);
  }
  
  // 完成标记
  if (chatResponse.done) {
    console.log('Chat completed');
    eventSource.close();
  }
};

// 处理结构化数据
function handleStructuredData(type, data) {
  switch (type) {
    case 'card':
      renderCard(data);
      break;
    case 'list':
      renderList(data);
      break;
    case 'options':
      renderOptions(data);
      break;
    default:
      console.log('Unknown type:', type);
  }
}

7.2 渲染卡片示例

function renderCard(data) {
  const cardHtml = `
        <div class="card-header">
            <h3>${data.data.title || ''}</h3>
            ${data.data.subtitle ? `<p class="subtitle">${data.data.subtitle}</p>` : ''}
        </div>
        <div class="card-body">
            <p>${data.data.description || ''}</p>
        </div>
    `;
  document.getElementById('chat-container').innerHTML += cardHtml;
}

7.3 渲染列表示例

function renderList(items) {
  const listHtml = data.data.map(item => `
        <div class="list-item">
            <div class="list-item-icon">📋</div>
            <div class="list-item-content">
                <h4>${item.title || ''}</h4>
                <p>${item.description || ''}</p>
            </div>
        </div>
    `).join('');
  document.getElementById('chat-container').innerHTML += listHtml;
}

7.4 渲染选项示例

function renderOptions(options) {
  const optionsHtml = `
        <p style="margin-bottom: 12px; color: #666;">${data.content || '请选择:'}</p>
        <div class="options-container">
            ${data.data.map((opt, index) => `
                <div class="option-card" onclick="selectOption('${opt.value}')">
                    <div class="option-icon">${String.fromCharCode(65 + index)}</div>
                    <div class="option-content">
                        <div class="option-label">${opt.label}</div>
                        ${opt.description ? `<div class="option-description">${opt.description}</div>` : ''}
                    </div>
                </div>
            `).join('')}
        </div>
    `;
  document.getElementById('chat-container').innerHTML += optionsHtml;
}

// 用户点击选项后的处理
function selectOption(value) {
    document.getElementById('chatInput').value = value;
    sendMessage();
}

7.5 完整的聊天界面示例

请直接到项目查看源码 https://github.com/liuyueyi/spring-ai-demo/tree/master/app-projects/D06-ai-auto-chatopen in new window

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>AI 智能对话</title>
  <style>
    .chat-container { max-width: 800px; margin: 0 auto; padding: 20px; }
    .message { margin: 10px 0; padding: 10px; border-radius: 5px; }
    .user-message { background-color: #e3f2fd; }
    .ai-message { background-color: #f5f5f5; }
    .card { border-left: 4px solid #2196F3; padding: 15px; margin: 10px 0; }
    .option-btn { display: block; width: 100%; padding: 10px; margin: 5px 0; cursor: pointer; }
  </style>
</head>
<body>
  <div class="chat-container">
    <div id="messages"></div>
    <input type="text" id="userInput" placeholder="输入消息..." />
    <button onclick="sendMessage()">发送</button>
  </div>

  <script>
    let conversationId = null;

    async function sendMessage(message) {
      const input = message || document.getElementById('userInput').value;
      if (!input) return;

      // 显示用户消息
      appendMessage('user', input);
      document.getElementById('userInput').value = '';

      // 创建 AI 消息容器
      const aiMessageDiv = appendMessage('ai', '');
      let fullContent = '';

      // 发起流式请求
      const response = await fetch('/api/chat/stream', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          message: input,
          conversationId: conversationId,
          responseType: 'text'
        })
      });

      const reader = response.body.getReader();
      const decoder = new TextDecoder();

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value);
        const lines = chunk.split('\n');

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            try {
              const data = JSON.parse(line.slice(6));
              
              // 累积文本
              if (data.content) {
                fullContent += data.content;
                aiMessageDiv.textContent = fullContent;
              }
              
              // 处理结构化数据
              if (data.data) {
                handleStructuredData(data.type, data.data, aiMessageDiv);
              }
              
              // 更新会话 ID
              if (data.conversationId && !conversationId) {
                conversationId = data.conversationId;
              }
            } catch (e) {
              console.error('Parse error:', e);
            }
          }
        }
      }
    }

    function appendMessage(type, content) {
      const messagesDiv = document.getElementById('messages');
      const messageDiv = document.createElement('div');
      messageDiv.className = `message ${type}-message`;
      messageDiv.textContent = content;
      messagesDiv.appendChild(messageDiv);
      return messageDiv;
    }

    function handleStructuredData(type, data, container) {
      // 根据类型渲染不同的 UI 组件
      // ...(参考前面的渲染函数)
    }
  </script>
</body>
</html>

八、进阶技巧

8.1 会话历史管理

当前使用内存存储会话,生产环境建议使用 Redis/数据库:

MySql 集成建议:

@Bean
public ChatMemory jdbcChatMemory(ChatMemoryRepository chatMemoryRepository) {
    return MessageWindowChatMemory.builder()
            .chatMemoryRepository(chatMemoryRepository)
            .build();
}

8.2 Prompt 优化技巧

8.2.1 Few-Shot Prompting(少样本提示)

在 Prompt 中提供示例,帮助 AI 理解需要的格式:

private String buildPrompt(String message, String responseType, String conversationId) {
    StringBuilder prompt = new StringBuilder();
    
    // 添加示例
    if ("card".equals(responseType)) {
        prompt.append("示例:\n");
        prompt.append("用户:推荐一道菜\n");
        prompt.append("助手:{\"title\": \"宫保鸡丁\", \"subtitle\": \"经典川菜\", \"description\": \"...\"}\n\n");
    }
    
    prompt.append(message);
    // ... 其他逻辑
    
    return prompt.toString();
}

8.2.2 明确 JSON Schema

更详细地描述期望的 JSON 结构:

case "card":
    prompt.append("\n\n请严格按照以下 JSON Schema 返回:");
    prompt.append("\n{");
    prompt.append("\n  \"title\": \"字符串 - 卡片标题\",");
    prompt.append("\n  \"subtitle\": \"字符串 - 卡片副标题(可选)\",");
    prompt.append("\n  \"description\": \"字符串 - 详细描述\"");
    prompt.append("\n}");
    break;

8.3 错误处理与降级策略

public Flux<ChatResponse> streamChat(ChatRequest request) {
    String conversationId = getOrCreateConversationId(request.getConversationId());
    String prompt = buildPrompt(request.getMessage(), request.getResponseType(), conversationId);

    StringBuilder context = new StringBuilder();

    return chatClient.prompt(prompt)
            .stream()
            .content()
            .map(content -> {
                context.append(content);
                return createStreamResponse(content, request.getResponseType(), conversationId);
            })
            .concatWith(Flux.defer(() -> {
                try {
                    Object structuredData = RspExtractor.parseStructuredData(
                        context.toString(), request.getResponseType());
                    
                    if (structuredData != null) {
                        return Flux.just(
                            ChatResponse.builder()
                                .type(request.getResponseType())
                                .data(structuredData)
                                .done(false)
                                .build(),
                            ChatResponse.builder()
                                .type(request.getResponseType())
                                .done(true)
                                .build()
                        );
                    }
                } catch (Exception e) {
                    log.warn("结构化数据解析失败,降级为文本响应", e);
                    // 降级:返回原始文本
                }
                
                return Flux.just(ChatResponse.builder()
                    .type(request.getResponseType())
                    .done(true)
                    .build());
            }))
            .onErrorResume(error -> {
                log.error("Stream chat error", error);
                return Flux.just(ChatResponse.builder()
                        .type(request.getResponseType())
                        .error("服务暂时不可用,请稍后重试")
                        .conversationId(conversationId)
                        .done(true)
                        .build());
            });
}

8.4 性能优化建议

  1. 使用连接池: 配置 HTTP 连接池减少握手开销
  2. 流式压缩: 启用 SSE 压缩减少带宽
  3. 缓存常用响应: 对常见问题缓存 AI 响应
  4. 异步处理: 使用异步 IO 提高并发能力
# application.yml - HTTP 连接池配置
spring:
  ai:
    openai:
      connection-pool:
        enabled: true
        max-total: 50
        max-per-route: 10

九、常见问题 FAQ

Q1: 为什么选择 SSE 而不是 WebSocket?

A:

  • 简单性: SSE 基于 HTTP,无需额外握手,实现更简单
  • 单向通信: 对话场景中主要是后端推前端,SSE 足够用
  • 浏览器支持: 现代浏览器原生支持,无需 polyfill
  • 断线重连: 自动重连机制,可靠性高

如果需要双向通信(如前端主动发送心跳),可以选择 WebSocket。


Q2: Prompt 工程和 Function Calling 有什么区别?

A:

  • Prompt 工程(本方案):

    • 优势:实现简单,无需额外配置,兼容性强
    • 适用:快速原型,简单的结构化需求
  • Function Calling:

    • 优势:结构严谨,类型安全,可执行实际业务逻辑
    • 适用:复杂场景,需要调用外部 API 或执行操作

最佳实践:简单场景用 Prompt 工程,复杂场景用 Function Calling。


Q3: 如何处理 AI 返回的非标准 JSON?

A:

  1. JSON 提取: 从文本中提取 JSON 片段(使用正则或字符串匹配)
  2. 容错解析: 尝试多种解析策略(代码块、直接查找等)
  3. 降级处理: 解析失败时返回原始文本
  4. Prompt 优化: 明确要求 AI 返回纯 JSON,不要额外说明

示例提取逻辑:

private static String extractJson(String content) {
    // 1. 尝试匹配 ```json 代码块
    Matcher matcher = Pattern.compile("```(?:json)?\\s*([\\s\\S]+?)```").matcher(content);
    if (matcher.find()) {
        return matcher.group(1);
    }
    
    // 2. 尝试直接查找 JSON 对象
    int start = content.indexOf('{');
    int end = content.lastIndexOf('}');
    if (start != -1 && end > start) {
        return content.substring(start, end + 1);
    }
    
    return null;
}

Q4: 如何扩展更多的响应类型?

A: 按照以下步骤添加新类型:

  1. 修改响应类型枚举:
// 在 ChatRequest 中
private String responseType = "text"; // text, card, list, options, timeline, ...
  1. 更新 Prompt 构建逻辑:
case "timeline":
    prompt.append("\n\n请以 JSON 数组格式返回时间线,每个事件包含 time(时间), event(事件), description(描述) 字段");
    break;
  1. 添加对应的数据结构:
@Data
@Builder
public static class TimelineItem {
    private String time;
    private String event;
    private String description;
}
  1. 前端渲染组件:
case 'timeline':
  renderTimeline(data);
  break;

十、总结与展望

10.1 本文要点

  1. 简洁架构: 无需 Function Calling,通过 Prompt 工程实现结构化数据返回
  2. 流式响应: 基于Spring AI + SSE 实现实时对话流
  3. 灵活扩展: 支持 text/card/list/options 多种响应类型
  4. 会话管理: 使用 ChatMemory 实现多轮对话上下文
  5. 易于集成: 前端使用标准 EventSource API 即可接入

10.2 技术选型对比

特性Prompt 工程方案Function Calling 方案
实现复杂度⭐⭐⭐⭐⭐ 简单⭐⭐⭐ 中等
灵活性⭐⭐⭐⭐ 高⭐⭐⭐⭐⭐ 极高
类型安全⭐⭐⭐ 一般⭐⭐⭐⭐⭐ 强
性能⭐⭐⭐⭐⭐ 优⭐⭐⭐⭐ 良
学习曲线⭐⭐⭐⭐⭐ 平缓⭐⭐⭐ 陡峭

10.3 后续优化方向

  1. 多模态支持: 集成图片识别、语音输入等多模态能力
  2. RAG 集成: 结合知识库实现检索增强生成
  3. 会话持久化: 使用 Redis 等存储会话历史
  4. 鉴权机制: 添加用户认证和权限控制
  5. 监控告警: 集成 Prometheus + Grafana 监控体系
  6. 性能优化: 响应缓存、连接池优化等

10.4 源码地址

完整代码已开源:


推荐阅读

零基础入门:

实战

参考资料


作者:一灰
项目地址https://github.com/liuyueyi/spring-ai-demoopen in new window
最后更新:2026-03-06
标签: #SpringAI #流式响应 #智能对话 #前后端分离 #结构化数据

欢迎关注我获取更多 AI 开发实战技巧! 🚀

Loading...