AI Chat 多类型流式事件处理方案(以AgentScope Java 2为例)

目录

    • 一、概述
    • 二、事件类型映射
    • [三、SSE 流时序](#三、SSE 流时序)
    • 四、后端实现
      • [4.1 核心思路](#4.1 核心思路)
      • [4.2 示例代码](#4.2 示例代码)
    • 五、前端实现
      • [5.1 fetch vs EventSource 对比](#5.1 fetch vs EventSource 对比)
      • [5.2 方案一:fetch + ReadableStream(POST 一步到位)](#5.2 方案一:fetch + ReadableStream(POST 一步到位))
      • [5.3 方案二:EventSource(GET 两步式)](#5.3 方案二:EventSource(GET 两步式))
        • 后端补充:两步式接口
        • [前端 EventSource 实现](#前端 EventSource 实现)
        • [EventSource 方案的优势](#EventSource 方案的优势)
        • [EventSource 方案的代价](#EventSource 方案的代价)
      • [5.4 方案三:GET + EventSource 一步到位(推荐)](#5.4 方案三:GET + EventSource 一步到位(推荐))
      • [5.5 方案选择建议](#5.5 方案选择建议)
    • [六、前端 UI 展示效果](#六、前端 UI 展示效果)
    • 七、关键设计要点

一、概述

AI Chat 采用 SSE(Server-Sent Events)实现流式响应,后端在调用 LLM Agent 的过程中会产生多种类型的事件(如 Agent 启动、文本生成、工具调用、Agent 结束等)。前端需要根据事件类型分别处理和展示,以实现丰富的交互体验。

本文档定义了 AgentScope 事件到 SSE 事件的映射规则,以及前后端的处理示例。

二、事件类型映射

AgentScope 框架在流式调用过程中产生以下事件,后端需将其映射为不同的 SSE event name 推送给前端:

AgentScope 事件 SSE event name 数据格式 说明
AgentStartEvent agent_start {"replyId": "xxx"} Agent 开始生成回复
ThinkingBlockDeltaEvent thinking 纯文本片段 AI 思考的文本增量
ToolCallStartEvent tool_call {"toolName": "xxx"} 开始调用某个工具
ToolResultEndEvent tool_result {"toolName": "xxx", "state": "SUCCESS"} 工具执行完成
TextBlockDeltaEvent reply 纯文本片段 AI 回复的文本增量
AgentEndEvent agent_end {"replyId": "xxx"} Agent 回复结束
(后端自定义) biz_result {"conversationId", "bizData"} 最终需要的业务数据,需根据LLM回复进行提取(这部分是可选的,前端可直接显示reply,仅当需要特殊处理时使用)
(后端自定义) error 错误文本 处理异常

三、SSE 流时序

一次完整的 AI Chat 流式交互,SSE 事件按以下时序推送:

以下为典型时序,实际 ReAct 多轮迭代中 thinking/tool_call/tool_result 可能交替出现多次

复制代码
agent_start   →  标记 AI 开始处理
thinking      →  AI 思考文本(多条,逐字/逐段推送)
tool_call     →  开始调用工具(可能多次)
tool_result   →  工具执行结果
thinking      →  AI 继续思考(工具调用后可能继续生成文本)
reply         →  LLM的回复文本(多条,逐字/逐段推送)
agent_end     →  标记 AI 处理结束
biz_result    →  推送最终完整结果(含 conversationId、从LLM回复中提取的业务数据)

实际示例:

复制代码
← agent_start   {"replyId":"r-001"}
← thinking      "让我分析"
← thinking      "一下这个问题"
← tool_call     {"toolName":"api_search"}
← tool_result   {"toolName":"api_search","state":"SUCCESS"}
← thinking      "找到了相关API,正在生成修复方案..."
← reply         "根据分析,"
← reply         "问题出在..."
← agent_end     {"replyId":"r-001"}
← biz_result    {"conversationId":"c-123","bizData":"{...}"}

四、后端实现

4.1 核心思路

  1. AiAgent 层 :返回原始 Flux<AgentEvent>,不过滤任何事件类型
  2. AiChatService 层:透传事件,同时收集文本内容用于后续校验
  3. Controller 层:遍历 AgentScope 事件,按类型映射为不同 SSE event name 推送

4.2 示例代码

AiAgent 接口
java 复制代码
import io.agentscope.core.event.AgentEvent;
import reactor.core.publisher.Flux;

public interface AiAgent {

    /**
     * 同步调用 LLM。
     */
    String chat(String userMessage, String systemPrompt, String sessionId, Object... tools);

    /**
     * 流式调用 LLM,返回原始 AgentScope 事件流(不过滤任何事件类型)。
     */
    Flux<AgentEvent> streamEvents(String userMessage, String systemPrompt, String sessionId, Object... tools);
}
AiAgent 实现
java 复制代码
import io.agentscope.core.ReActAgent;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.event.AgentEvent;
import io.agentscope.core.state.InMemoryAgentStateStore;
import io.agentscope.core.tool.Toolkit;
import reactor.core.publisher.Flux;

public class AgentScopeAiAgent implements AiAgent {

    private final InMemoryAgentStateStore stateStore = new InMemoryAgentStateStore();

    @Override
    public String chat(String userMessage, String systemPrompt, String sessionId, Object... tools) {
        try (ReActAgent agent = buildAgent(systemPrompt, tools)) {
            RuntimeContext ctx = RuntimeContext.builder().sessionId(sessionId).build();
            return agent.call(userMessage, ctx).block().getTextContent();
        }
    }

    @Override
    public Flux<AgentEvent> streamEvents(String userMessage, String systemPrompt, String sessionId, Object... tools) {
        ReActAgent agent = buildAgent(systemPrompt, tools);
        RuntimeContext ctx = RuntimeContext.builder().sessionId(sessionId).build();
        return agent.streamEvents(userMessage, ctx)
                .doFinally(signal -> agent.close());
        // 注意:不做任何 filter,保留全部事件类型
    }

    private ReActAgent buildAgent(String systemPrompt, Object... tools) {
        Toolkit toolkit = new Toolkit();
        for (Object tool : tools) {
            toolkit.registerTool(tool);
        }
        return ReActAgent.builder()
                .name("WebAgent")
                .sysPrompt(systemPrompt)
                .model(/* 模型配置 */)
                .stateStore(stateStore)
                .toolkit(toolkit)
                .build();
    }
}
AiChatService
java 复制代码
import io.agentscope.core.event.AgentEvent;
import io.agentscope.core.event.TextBlockDeltaEvent;
import java.util.function.Consumer;

@Service
public class AiChatService {

    private final AiAgent aiAgent;
    private final AiConversationService conversationService;

    /**
     * 流式聊天。
     * 通过 eventCallback 将每个 AgentScope 原始事件推送给调用方,
     * 同时内部收集文本内容用于后续校验。
     *
     * @param conversationId 对话ID(空则新建)
     * @param message        用户消息
     * @param eventCallback  事件回调(每个原始事件都会回调)
     * @return 聊天响应
     */
    public AiChatResponseDto chatStream(String conversationId, String message,
                                        Consumer<AgentEvent> eventCallback) {
        // 1. 确保会话存在
        if (conversationId == null || conversationId.isBlank()) {
            conversationId = conversationService.createConversation();
        }

        // 2. 流式调用 Agent,订阅事件流
        StringBuilder replyBuffer = new StringBuilder();

        Flux<AgentEvent> eventFlux = aiAgent.streamEvents(message, systemPrompt, sessionId);
        eventFlux
                .doOnNext(event -> {
                    // 回调原始事件给 Controller 层做 SSE 推送
                    eventCallback.accept(event);
                    // 收集回复文本(TextBlockDeltaEvent)用于后续提取业务数据
                    if (event instanceof TextBlockDeltaEvent delta) {
                        replyBuffer.append(delta.getDelta());
                    }
                    // 注意:ThinkingBlockDeltaEvent(思维链)无需收集,仅透传给前端展示
                })
                .blockLast();

        String llmReply = replyBuffer.toString();

        // 3. 从 LLM 回复中提取业务数据(可选,如 IR JSON 校验修复)
        // ...

        // 4. 保存消息 & 返回结果
        return AiChatResponseDto.builder()
                .conversationId(conversationId)
                .reply(llmReply)
                .bizData(extractedBizData)
                .build();
    }
}
AiChatController
java 复制代码
import io.agentscope.core.event.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@RestController
@RequestMapping("/api/v1/ai")
public class AiChatController {

    private final AiChatService aiChatService;
    private final ObjectMapper objectMapper;
    private final ExecutorService sseExecutor = Executors.newCachedThreadPool();

    @PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter chat(@RequestBody AiChatRequestDto requestDto) {
        SseEmitter emitter = new SseEmitter(120_000L);

        sseExecutor.execute(() -> {
            try {
                // 流式调用,通过回调处理每个 AgentScope 事件
                AiChatResponseDto response = aiChatService.chatStream(
                        requestDto.getConversationId(),
                        requestDto.getMessage(),
                        // 事件回调:按类型映射为不同 SSE event name
                        event -> {
                            try {
                                if (event instanceof AgentStartEvent start) {
                                    emitter.send(SseEmitter.event()
                                            .name("agent_start")
                                            .data(Map.of("replyId", start.getReplyId())));

                                } else if (event instanceof ThinkingBlockDeltaEvent thinking) {
                                    emitter.send(SseEmitter.event()
                                            .name("thinking")
                                            .data(thinking.getDelta()));

                                } else if (event instanceof TextBlockDeltaEvent delta) {
                                    emitter.send(SseEmitter.event()
                                            .name("reply")
                                            .data(delta.getDelta()));

                                } else if (event instanceof ToolCallStartEvent tc) {
                                    emitter.send(SseEmitter.event()
                                            .name("tool_call")
                                            .data(Map.of("toolName", tc.getToolCallName())));

                                } else if (event instanceof ToolResultEndEvent tr) {
                                    emitter.send(SseEmitter.event()
                                            .name("tool_result")
                                            .data(Map.of(
                                                    "toolName", tr.getToolCallName(),
                                                    "state", tr.getState())));

                                } else if (event instanceof AgentEndEvent end) {
                                    emitter.send(SseEmitter.event()
                                            .name("agent_end")
                                            .data(Map.of("replyId", end.getReplyId())));
                                }
                            } catch (IOException e) {
                                log.warn("Failed to send SSE event: {}", event.getClass().getSimpleName(), e);
                            }
                        }
                );

                // 推送最终业务数据(作为流结束信号始终推送,无业务数据时 bizData 为 null)
                Map<String, Object> bizResult = new HashMap<>();
                bizResult.put("conversationId", response.getConversationId());
                bizResult.put("bizData", response.getBizData());
                emitter.send(SseEmitter.event()
                        .name("biz_result")
                        .data(objectMapper.writeValueAsString(bizResult)));

                emitter.complete();
            } catch (Exception e) {
                log.error("AI chat error", e);
                try {
                    emitter.send(SseEmitter.event()
                            .name("error")
                            .data("AI处理失败: " + e.getMessage()));
                } catch (IOException ex) {
                    log.warn("Failed to send error event", ex);
                }
                emitter.completeWithError(e);
            }
        });

        return emitter;
    }
}

五、前端实现

5.1 fetch vs EventSource 对比

前端读取 SSE 事件流有两种方式:fetch + ReadableStreamEventSource API。二者在 SSE 事件接收层面是等效的,但在适用场景上有本质区别:

特性 fetch + ReadableStream EventSource
HTTP 方法 支持 POST/PUT 等任意方法 仅支持 GET
SSE 解析 需手动解析 event:/data: 格式 浏览器自动解析,直接拿到 event name 和 data
自动重连 无,需自行实现 内置断线自动重连
请求体 支持 body: JSON.stringify(...) 不支持,只能通过 URL query 传参
自定义 Header 支持(如 Content-Type, Authorization 不支持自定义 Header
代码复杂度 较高(需手动解析 SSE 协议) 较低(浏览器原生处理)

关键冲突 :AI Chat 接口需要 POST 方法 + JSON 请求体(传递 messagecontextData 等),而 EventSource 只支持 GET 且无法发送请求体。

解决方案概览(三种方案,按推荐度排序):

方案 请求次数 前端复杂度 后端改动
方案三:GET + EventSource 一步到位 1 次 最低(原生 EventSource) 改 POST 为 GET,参数走 query
方案一:fetch + ReadableStream 1 次 较高(手动解析 SSE 协议) 无改动
方案二:POST + GET 两步式 2 次 低(原生 EventSource) 新增 taskId 中转端点

下面分别给出三种方案的完整实现。

5.2 方案一:fetch + ReadableStream(POST 一步到位)

适用于 POST 接口直接返回 SSE 流的场景,无需额外 taskId 中转。

示例代码
html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>AI Chat 多事件流式展示</title>
    <style>
        .chat-container { max-width: 720px; margin: 0 auto; font-family: sans-serif; }
        .message { margin: 12px 0; padding: 12px 16px; border-radius: 12px; }
        .message.user { background: #e3f2fd; text-align: right; }
        .message.assistant { background: #f5f5f5; }
        .thinking-text { white-space: pre-wrap; color: #888; line-height: 1.6; font-size: 13px; }
        .reply-text { white-space: pre-wrap; color: #333; line-height: 1.6; margin-top: 8px; }
        .status-indicator { color: #888; font-size: 13px; margin: 4px 0; }
        .tool-badge {
            display: inline-block; padding: 4px 10px; margin: 4px 4px 4px 0;
            border-radius: 16px; font-size: 12px;
            background: #fff3e0; color: #e65100; border: 1px solid #ffcc80;
        }
        .tool-badge.done { background: #e8f5e9; color: #2e7d32; border-color: #a5d6a7; }
        .tool-badge.error { background: #ffebee; color: #c62828; border-color: #ef9a9a; }
        .cursor { display: inline-block; width: 2px; height: 1em; background: #333; animation: blink 0.8s infinite; vertical-align: text-bottom; }
        @keyframes blink { 0%,100% { opacity: 1; } 50% { opacity: 0; } }
    </style>
</head>
<body>
<div class="chat-container">
    <div id="chatMessages"></div>
</div>

<script>
class AiChatClient {
    constructor(containerEl) {
        this.containerEl = containerEl;
        this.currentAssistantEl = null;
        this.thinkingEl = null;
        this.replyEl = null;
        this.toolsEl = null;
        this.statusEl = null;
        this.cursorEl = null;
    }

    /**
     * 发送消息并处理 SSE 流式响应。
     */
    async sendMessage(message) {
        // 渲染用户消息
        this.appendMessage('user', message);

        // 创建助手消息容器
        this.currentAssistantEl = this.createAssistantBubble();

        const response = await fetch('/api/v1/ai/chat', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ message })
        });

        const reader = response.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 });
            const lines = buffer.split('\n');
            buffer = lines.pop(); // 保留未完成的行

            let currentEvent = 'message';
            for (const line of lines) {
                if (line.startsWith('event:')) {
                    currentEvent = line.slice(6).trim();
                } else if (line.startsWith('data:')) {
                    const data = line.slice(5).trim();
                    this.handleEvent(currentEvent, data);
                    currentEvent = 'message'; // 重置
                }
            }
        }
    }

    /**
     * 根据事件类型分发处理。
     */
    handleEvent(eventType, data) {
        switch (eventType) {
            case 'agent_start':
                this.onAgentStart(JSON.parse(data));
                break;
            case 'thinking':
                this.onThinking(data);
                break;
            case 'tool_call':
                this.onToolCall(JSON.parse(data));
                break;
            case 'tool_result':
                this.onToolResult(JSON.parse(data));
                break;
            case 'reply':
                this.onReply(data);
                break;
            case 'agent_end':
                this.onAgentEnd(JSON.parse(data));
                break;
            case 'biz_result':
                this.onBizResult(JSON.parse(data));
                break;
            case 'error':
                this.onError(data);
                break;
        }
    }

    /**
     * Agent 开始处理 ------ 显示状态指示器。
     */
    onAgentStart({ replyId }) {
        this.statusEl = document.createElement('div');
        this.statusEl.className = 'status-indicator';
        this.statusEl.textContent = '● AI 正在思考...';
        this.currentAssistantEl.appendChild(this.statusEl);

        // 思考文本区域
        this.thinkingEl = document.createElement('div');
        this.thinkingEl.className = 'thinking-text';
        this.currentAssistantEl.appendChild(this.thinkingEl);

        // 闪烁光标
        this.cursorEl = document.createElement('span');
        this.cursorEl.className = 'cursor';
        this.thinkingEl.appendChild(this.cursorEl);

        // 工具调用区域
        this.toolsEl = document.createElement('div');
        this.toolsEl.style.marginTop = '8px';
        this.currentAssistantEl.appendChild(this.toolsEl);
    }

    /**
     * 收到思考文本增量(思维链)------ 打字机效果追加到思考区。
     */
    onThinking(delta) {
        if (!this.thinkingEl) return;
        // 在光标前插入文本
        const textNode = document.createTextNode(delta);
        this.thinkingEl.insertBefore(textNode, this.cursorEl);
        // 自动滚动到底部
        this.currentAssistantEl.scrollTop = this.currentAssistantEl.scrollHeight;
    }

    /**
     * 收到回复文本增量 ------ 打字机效果追加到回复区。
     */
    onReply(delta) {
        // 首次收到 reply 时,将光标移到回复区
        if (!this.replyEl) {
            this.replyEl = document.createElement('div');
            this.replyEl.className = 'reply-text';
            this.currentAssistantEl.appendChild(this.replyEl);
            if (this.cursorEl) {
                this.replyEl.appendChild(this.cursorEl);
            }
            if (this.statusEl) {
                this.statusEl.textContent = '● AI 正在回复...';
            }
        }
        const textNode = document.createTextNode(delta);
        this.replyEl.insertBefore(textNode, this.cursorEl);
        this.currentAssistantEl.scrollTop = this.currentAssistantEl.scrollHeight;
    }

    /**
     * 工具开始调用 ------ 添加"运行中"状态的徽章。
     */
    onToolCall({ toolName }) {
        if (!this.toolsEl) return;
        const badge = document.createElement('span');
        badge.className = 'tool-badge';
        badge.dataset.toolName = toolName;
        badge.textContent = '🔧 ' + toolName + ' ⏳';
        this.toolsEl.appendChild(badge);

        // 更新状态文本
        if (this.statusEl) {
            this.statusEl.textContent = '● 正在调用 ' + toolName + '...';
        }
    }

    /**
     * 工具执行完成 ------ 更新徽章状态。
     */
    onToolResult({ toolName, state }) {
        if (!this.toolsEl) return;
        const badge = this.toolsEl.querySelector(`[data-tool-name="${toolName}"]`);
        if (badge) {
            const isSuccess = state === 'SUCCESS';
            badge.className = 'tool-badge ' + (isSuccess ? 'done' : 'error');
            badge.textContent = (isSuccess ? '✅ ' : '❌ ') + toolName;
        }

        if (this.statusEl) {
            this.statusEl.textContent = '● AI 正在思考...';
        }
    }

    /**
     * Agent 结束 ------ 隐藏状态指示器和光标。
     */
    onAgentEnd({ replyId }) {
        if (this.statusEl) {
            this.statusEl.textContent = '✅ 处理完成';
        }
        if (this.cursorEl) {
            this.cursorEl.remove();
            this.cursorEl = null;
        }
    }

    /**
     * 收到最终业务数据(可选事件)------ 前端据此做特殊处理。
     * 若无此事件,前端直接展示 reply 文本即可。
     */
    onBizResult({ conversationId, bizData }) {
        if (bizData) {
            console.log('收到业务数据:', bizData);
            // 例如:applyBizData(bizData);
        }
    }

    /**
     * 错误处理。
     */
    onError(errorMsg) {
        const errorEl = document.createElement('div');
        errorEl.style.color = '#c62828';
        errorEl.textContent = '❌ ' + errorMsg;
        this.currentAssistantEl.appendChild(errorEl);
    }

    // ---- DOM 辅助方法 ----

    appendMessage(role, text) {
        const el = document.createElement('div');
        el.className = 'message ' + role;
        el.textContent = text;
        this.containerEl.appendChild(el);
    }

    createAssistantBubble() {
        const el = document.createElement('div');
        el.className = 'message assistant';
        this.containerEl.appendChild(el);
        return el;
    }
}

// 使用
const chatClient = new AiChatClient(document.getElementById('chatMessages'));
chatClient.sendMessage('帮我修复这个 bug');
</script>
</body>
</html>

5.3 方案二:EventSource(GET 两步式)

适用于需要利用 EventSource 内置自动重连、自动 SSE 解析能力的场景。后端需额外提供一个 GET 流式端点。

后端补充:两步式接口
java 复制代码
@RestController
@RequestMapping("/api/v1/ai")
public class AiChatController {

    // 内存存储 taskId → 请求参数(生产环境可用 Redis)
    private final Map<String, AiChatRequestDto> taskStore = new ConcurrentHashMap<>();

    /**
     * 第一步:POST 提交聊天请求,返回 taskId。
     */
    @PostMapping("/chat")
    public Map<String, String> submitChat(@RequestBody AiChatRequestDto requestDto) {
        String taskId = UUID.randomUUID().toString();
        taskStore.put(taskId, requestDto);
        return Map.of("taskId", taskId);
    }

    /**
     * 第二步:GET 端点,EventSource 连接后开始推送事件流。
     */
    @GetMapping(value = "/chat/{taskId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter streamChat(@PathVariable String taskId) {
        SseEmitter emitter = new SseEmitter(120_000L);
        AiChatRequestDto requestDto = taskStore.remove(taskId);
        if (requestDto == null) {
            try {
                emitter.send(SseEmitter.event().name("error").data("taskId 无效或已过期"));
                emitter.complete();
            } catch (IOException e) {
                emitter.completeWithError(e);
            }
            return emitter;
        }

        sseExecutor.execute(() -> {
            try {
                AiChatResponseDto response = aiChatService.chatStream(
                        requestDto.getConversationId(),
                        requestDto.getMessage(),
                        event -> {
                            try {
                                if (event instanceof AgentStartEvent start) {
                                    emitter.send(SseEmitter.event()
                                            .name("agent_start")
                                            .data(Map.of("replyId", start.getReplyId())));
                                } else if (event instanceof ThinkingBlockDeltaEvent thinking) {
                                    emitter.send(SseEmitter.event()
                                            .name("thinking")
                                            .data(thinking.getDelta()));
                                } else if (event instanceof TextBlockDeltaEvent delta) {
                                    emitter.send(SseEmitter.event()
                                            .name("reply")
                                            .data(delta.getDelta()));
                                } else if (event instanceof ToolCallStartEvent tc) {
                                    emitter.send(SseEmitter.event()
                                            .name("tool_call")
                                            .data(Map.of("toolName", tc.getToolCallName())));
                                } else if (event instanceof ToolResultEndEvent tr) {
                                    emitter.send(SseEmitter.event()
                                            .name("tool_result")
                                            .data(Map.of(
                                                    "toolName", tr.getToolCallName(),
                                                    "state", tr.getState())));
                                } else if (event instanceof AgentEndEvent end) {
                                    emitter.send(SseEmitter.event()
                                            .name("agent_end")
                                            .data(Map.of("replyId", end.getReplyId())));
                                }
                            } catch (IOException e) {
                                log.warn("Failed to send SSE event", e);
                            }
                        }
                );

                // 推送最终业务数据(作为流结束信号始终推送,无业务数据时 bizData 为 null)
                Map<String, Object> bizResult = new HashMap<>();
                bizResult.put("conversationId", response.getConversationId());
                bizResult.put("bizData", response.getBizData());
                emitter.send(SseEmitter.event()
                        .name("biz_result")
                        .data(objectMapper.writeValueAsString(bizResult)));
                emitter.complete();
            } catch (Exception e) {
                log.error("AI chat error", e);
                try {
                    emitter.send(SseEmitter.event().name("error").data("AI处理失败: " + e.getMessage()));
                } catch (IOException ex) {
                    log.warn("Failed to send error event", ex);
                }
                emitter.completeWithError(e);
            }
        });

        return emitter;
    }
}
前端 EventSource 实现
html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>AI Chat - EventSource 方案</title>
    <style>
        .chat-container { max-width: 720px; margin: 0 auto; font-family: sans-serif; }
        .message { margin: 12px 0; padding: 12px 16px; border-radius: 12px; }
        .message.user { background: #e3f2fd; text-align: right; }
        .message.assistant { background: #f5f5f5; }
        .thinking-text { white-space: pre-wrap; color: #888; line-height: 1.6; font-size: 13px; }
        .reply-text { white-space: pre-wrap; color: #333; line-height: 1.6; margin-top: 8px; }
        .status-indicator { color: #888; font-size: 13px; margin: 4px 0; }
        .tool-badge {
            display: inline-block; padding: 4px 10px; margin: 4px 4px 4px 0;
            border-radius: 16px; font-size: 12px;
            background: #fff3e0; color: #e65100; border: 1px solid #ffcc80;
        }
        .tool-badge.done { background: #e8f5e9; color: #2e7d32; border-color: #a5d6a7; }
        .tool-badge.error { background: #ffebee; color: #c62828; border-color: #ef9a9a; }
        .cursor { display: inline-block; width: 2px; height: 1em; background: #333; animation: blink 0.8s infinite; vertical-align: text-bottom; }
        @keyframes blink { 0%,100% { opacity: 1; } 50% { opacity: 0; } }
    </style>
</head>
<body>
<div class="chat-container">
    <div id="chatMessages"></div>
</div>

<script>
class AiChatEventSourceClient {
    constructor(containerEl) {
        this.containerEl = containerEl;
        this.currentAssistantEl = null;
        this.thinkingEl = null;
        this.replyEl = null;
        this.toolsEl = null;
        this.statusEl = null;
        this.cursorEl = null;
        this.eventSource = null;
    }

    /**
     * 发送消息:先 POST 获取 taskId,再用 EventSource 连接流式端点。
     */
    async sendMessage(message) {
        // 渲染用户消息
        this.appendMessage('user', message);
        this.currentAssistantEl = this.createAssistantBubble();

        // 第一步:POST 提交请求,获取 taskId
        const submitResp = await fetch('/api/v1/ai/chat', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ message })
        });
        const { taskId } = await submitResp.json();

        // 第二步:EventSource 连接流式端点
        this.eventSource = new EventSource(`/api/v1/ai/chat/${taskId}/stream`);
        const es = this.eventSource;

        // 注册各事件类型监听器
        es.addEventListener('agent_start', (e) => {
            this.onAgentStart(JSON.parse(e.data));
        });

        es.addEventListener('thinking', (e) => {
            this.onThinking(e.data);
        });

        es.addEventListener('tool_call', (e) => {
            this.onToolCall(JSON.parse(e.data));
        });

        es.addEventListener('tool_result', (e) => {
            this.onToolResult(JSON.parse(e.data));
        });

        es.addEventListener('reply', (e) => {
            this.onReply(e.data);
        });

        es.addEventListener('agent_end', (e) => {
            this.onAgentEnd(JSON.parse(e.data));
        });

        es.addEventListener('biz_result', (e) => {
            this.onBizResult(JSON.parse(e.data));
            es.close(); // 收到最终业务数据,关闭连接
        });

        es.addEventListener('error', (e) => {
            // EventSource 的 error 事件有两种情况:
            // 1. 后端推送的自定义 error 事件(有 data)
            // 2. 连接异常(无 data,可能是断线,EventSource 会自动重连)
            if (e.data) {
                this.onError(e.data);
                es.close();
            } else {
                console.warn('SSE 连接异常,EventSource 将自动重连', e);
            }
        });
    }

    // ---- 事件处理方法(与 fetch 方案完全相同) ----

    onAgentStart({ replyId }) {
        this.statusEl = document.createElement('div');
        this.statusEl.className = 'status-indicator';
        this.statusEl.textContent = '● AI 正在思考...';
        this.currentAssistantEl.appendChild(this.statusEl);

        this.thinkingEl = document.createElement('div');
        this.thinkingEl.className = 'thinking-text';
        this.currentAssistantEl.appendChild(this.thinkingEl);

        this.cursorEl = document.createElement('span');
        this.cursorEl.className = 'cursor';
        this.thinkingEl.appendChild(this.cursorEl);

        this.toolsEl = document.createElement('div');
        this.toolsEl.style.marginTop = '8px';
        this.currentAssistantEl.appendChild(this.toolsEl);
    }

    onThinking(delta) {
        if (!this.thinkingEl) return;
        const textNode = document.createTextNode(delta);
        this.thinkingEl.insertBefore(textNode, this.cursorEl);
        this.currentAssistantEl.scrollTop = this.currentAssistantEl.scrollHeight;
    }

    onReply(delta) {
        // 首次收到 reply 时,将光标移到回复区
        if (!this.replyEl) {
            this.replyEl = document.createElement('div');
            this.replyEl.className = 'reply-text';
            this.currentAssistantEl.appendChild(this.replyEl);
            if (this.cursorEl) {
                this.replyEl.appendChild(this.cursorEl);
            }
            if (this.statusEl) {
                this.statusEl.textContent = '● AI 正在回复...';
            }
        }
        const textNode = document.createTextNode(delta);
        this.replyEl.insertBefore(textNode, this.cursorEl);
        this.currentAssistantEl.scrollTop = this.currentAssistantEl.scrollHeight;
    }

    onToolCall({ toolName }) {
        if (!this.toolsEl) return;
        const badge = document.createElement('span');
        badge.className = 'tool-badge';
        badge.dataset.toolName = toolName;
        badge.textContent = '🔧 ' + toolName + ' ⏳';
        this.toolsEl.appendChild(badge);
        if (this.statusEl) {
            this.statusEl.textContent = '● 正在调用 ' + toolName + '...';
        }
    }

    onToolResult({ toolName, state }) {
        if (!this.toolsEl) return;
        const badge = this.toolsEl.querySelector(`[data-tool-name="${toolName}"]`);
        if (badge) {
            const isSuccess = state === 'SUCCESS';
            badge.className = 'tool-badge ' + (isSuccess ? 'done' : 'error');
            badge.textContent = (isSuccess ? '✅ ' : '❌ ') + toolName;
        }
        if (this.statusEl) {
            this.statusEl.textContent = '● AI 正在思考...';
        }
    }

    onAgentEnd({ replyId }) {
        if (this.statusEl) {
            this.statusEl.textContent = '✅ 处理完成';
        }
        if (this.cursorEl) {
            this.cursorEl.remove();
            this.cursorEl = null;
        }
    }

    onBizResult({ conversationId, bizData }) {
        if (bizData) {
            console.log('收到业务数据:', bizData);
            // 例如:applyBizData(bizData);
        }
    }

    onError(errorMsg) {
        const errorEl = document.createElement('div');
        errorEl.style.color = '#c62828';
        errorEl.textContent = '❌ ' + errorMsg;
        this.currentAssistantEl.appendChild(errorEl);
    }

    // ---- DOM 辅助方法 ----

    appendMessage(role, text) {
        const el = document.createElement('div');
        el.className = 'message ' + role;
        el.textContent = text;
        this.containerEl.appendChild(el);
    }

    createAssistantBubble() {
        const el = document.createElement('div');
        el.className = 'message assistant';
        this.containerEl.appendChild(el);
        return el;
    }
}

// 使用
const chatClient = new AiChatEventSourceClient(document.getElementById('chatMessages'));
chatClient.sendMessage('帮我修复这个 bug');
</script>
</body>
</html>
EventSource 方案的优势

相比 fetch 方案,EventSource 方案的前端代码更简洁:

  1. 无需手动解析 SSE 协议 :不需要维护 buffer、逐行拆分、解析 event:/data: 前缀
  2. 内置自动重连 :网络断开时 EventSource 会自动尝试重新连接(可通过 es.close() 主动关闭)
  3. 事件监听更直观 :每种事件类型用 addEventListener 独立注册,代码结构清晰
EventSource 方案的代价
  1. 需要两步请求:先 POST 获取 taskId,再 GET 连接流
  2. 需要后端维护 taskId 状态:内存或 Redis 中暂存请求参数
  3. taskId 有生命周期:需要设置过期清理,避免内存泄漏

5.4 方案三:GET + EventSource 一步到位(推荐)

核心思路 :将接口改为 GET,参数通过 URL query 传递,HTTP 响应体为空,所有数据(包括最终结果)全部通过 SSE 事件推送。前端用原生 EventSource 一次连接,代码最简洁。

后端接口改造
java 复制代码
@RestController
@RequestMapping("/api/v1/ai")
public class AiChatController {

    private final AiChatService aiChatService;
    private final ObjectMapper objectMapper;
    private final ExecutorService sseExecutor = Executors.newCachedThreadPool();

    /**
     * AI 聊天接口(GET + SSE 纯事件流)。
     * 无 HTTP 响应体,所有数据通过 SSE 事件推送。
     *
     * @param conversationId 对话ID(空则新建)
     * @param message        用户消息
     * @param contextData    附加的上线文数据等
     */
    @GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter chat(
            @RequestParam(required = false) String conversationId,
            @RequestParam String message,
            @RequestParam(required = false) String contextData) {

        SseEmitter emitter = new SseEmitter(120_000L);

        sseExecutor.execute(() -> {
            try {
                AiChatResponseDto response = aiChatService.chatStream(
                        conversationId, message, contextData,
                        // 事件回调:按类型映射为不同 SSE event name
                        event -> {
                            try {
                                if (event instanceof AgentStartEvent start) {
                                    emitter.send(SseEmitter.event()
                                            .name("agent_start")
                                            .data(Map.of("replyId", start.getReplyId())));
                                } else if (event instanceof ThinkingBlockDeltaEvent thinking) {
                                    emitter.send(SseEmitter.event()
                                            .name("thinking")
                                            .data(thinking.getDelta()));
                                } else if (event instanceof TextBlockDeltaEvent delta) {
                                    emitter.send(SseEmitter.event()
                                            .name("reply")
                                            .data(delta.getDelta()));
                                } else if (event instanceof ToolCallStartEvent tc) {
                                    emitter.send(SseEmitter.event()
                                            .name("tool_call")
                                            .data(Map.of("toolName", tc.getToolCallName())));
                                } else if (event instanceof ToolResultEndEvent tr) {
                                    emitter.send(SseEmitter.event()
                                            .name("tool_result")
                                            .data(Map.of(
                                                    "toolName", tr.getToolCallName(),
                                                    "state", tr.getState())));
                                } else if (event instanceof AgentEndEvent end) {
                                    emitter.send(SseEmitter.event()
                                            .name("agent_end")
                                            .data(Map.of("replyId", end.getReplyId())));
                                }
                            } catch (IOException e) {
                                log.warn("Failed to send SSE event", e);
                            }
                        }
                );

                // 推送最终业务数据(作为流结束信号始终推送,无业务数据时 bizData 为 null)
                Map<String, Object> bizResult = new HashMap<>();
                bizResult.put("conversationId", response.getConversationId());
                bizResult.put("bizData", response.getBizData());
                emitter.send(SseEmitter.event()
                        .name("biz_result")
                        .data(objectMapper.writeValueAsString(bizResult)));

                emitter.complete();
            } catch (Exception e) {
                log.error("AI chat error", e);
                try {
                    emitter.send(SseEmitter.event()
                            .name("error")
                            .data("AI处理失败: " + e.getMessage()));
                } catch (IOException ex) {
                    log.warn("Failed to send error event", ex);
                }
                emitter.completeWithError(e);
            }
        });

        return emitter;
    }
}
前端 EventSource 实现
html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>AI Chat - GET + EventSource 一步到位</title>
    <style>
        .chat-container { max-width: 720px; margin: 0 auto; font-family: sans-serif; }
        .message { margin: 12px 0; padding: 12px 16px; border-radius: 12px; }
        .message.user { background: #e3f2fd; text-align: right; }
        .message.assistant { background: #f5f5f5; }
        .thinking-text { white-space: pre-wrap; color: #888; line-height: 1.6; font-size: 13px; }
        .reply-text { white-space: pre-wrap; color: #333; line-height: 1.6; margin-top: 8px; }
        .status-indicator { color: #888; font-size: 13px; margin: 4px 0; }
        .tool-badge {
            display: inline-block; padding: 4px 10px; margin: 4px 4px 4px 0;
            border-radius: 16px; font-size: 12px;
            background: #fff3e0; color: #e65100; border: 1px solid #ffcc80;
        }
        .tool-badge.done { background: #e8f5e9; color: #2e7d32; border-color: #a5d6a7; }
        .tool-badge.error { background: #ffebee; color: #c62828; border-color: #ef9a9a; }
        .cursor { display: inline-block; width: 2px; height: 1em; background: #333; animation: blink 0.8s infinite; vertical-align: text-bottom; }
        @keyframes blink { 0%,100% { opacity: 1; } 50% { opacity: 0; } }
    </style>
</head>
<body>
<div class="chat-container">
    <div id="chatMessages"></div>
</div>

<script>
class AiChatEventSourceClient {
    constructor(containerEl) {
        this.containerEl = containerEl;
        this.currentAssistantEl = null;
        this.thinkingEl = null;
        this.replyEl = null;
        this.toolsEl = null;
        this.statusEl = null;
        this.cursorEl = null;
        this.eventSource = null;
    }

    /**
     * 发送消息:一次 EventSource 连接,全部数据通过事件接收。
     */
    sendMessage(message, conversationId = '', contextData = '') {
        // 渲染用户消息
        this.appendMessage('user', message);
        this.currentAssistantEl = this.createAssistantBubble();

        // 构造 URL(参数通过 query 传递)
        const params = new URLSearchParams();
        params.set('message', message);
        if (conversationId) params.set('conversationId', conversationId);
        if (contextData) params.set('contextData', contextData);

        // 一次 EventSource 连接,搞定一切
        this.eventSource = new EventSource(`/api/v1/ai/chat?${params.toString()}`);
        const es = this.eventSource;

        // 注册各事件类型监听器
        es.addEventListener('agent_start', (e) => {
            this.onAgentStart(JSON.parse(e.data));
        });

        es.addEventListener('thinking', (e) => {
            this.onThinking(e.data);
        });

        es.addEventListener('tool_call', (e) => {
            this.onToolCall(JSON.parse(e.data));
        });

        es.addEventListener('tool_result', (e) => {
            this.onToolResult(JSON.parse(e.data));
        });

        es.addEventListener('reply', (e) => {
            this.onReply(e.data);
        });

        es.addEventListener('agent_end', (e) => {
            this.onAgentEnd(JSON.parse(e.data));
        });

        es.addEventListener('biz_result', (e) => {
            this.onBizResult(JSON.parse(e.data));
            es.close(); // 收到最终业务数据,主动关闭连接
        });

        es.addEventListener('error', (e) => {
            if (e.data) {
                // 后端推送的业务错误
                this.onError(e.data);
                es.close();
            } else {
                // 连接异常,EventSource 会自动重连
                console.warn('SSE 连接异常,将自动重连', e);
            }
        });
    }

    // ---- 事件处理方法 ----

    onAgentStart({ replyId }) {
        this.statusEl = document.createElement('div');
        this.statusEl.className = 'status-indicator';
        this.statusEl.textContent = '● AI 正在思考...';
        this.currentAssistantEl.appendChild(this.statusEl);

        this.thinkingEl = document.createElement('div');
        this.thinkingEl.className = 'thinking-text';
        this.currentAssistantEl.appendChild(this.thinkingEl);

        this.cursorEl = document.createElement('span');
        this.cursorEl.className = 'cursor';
        this.thinkingEl.appendChild(this.cursorEl);

        this.toolsEl = document.createElement('div');
        this.toolsEl.style.marginTop = '8px';
        this.currentAssistantEl.appendChild(this.toolsEl);
    }

    onThinking(delta) {
        if (!this.thinkingEl) return;
        const textNode = document.createTextNode(delta);
        this.thinkingEl.insertBefore(textNode, this.cursorEl);
        this.currentAssistantEl.scrollTop = this.currentAssistantEl.scrollHeight;
    }

    onReply(delta) {
        // 首次收到 reply 时,将光标移到回复区
        if (!this.replyEl) {
            this.replyEl = document.createElement('div');
            this.replyEl.className = 'reply-text';
            this.currentAssistantEl.appendChild(this.replyEl);
            if (this.cursorEl) {
                this.replyEl.appendChild(this.cursorEl);
            }
            if (this.statusEl) {
                this.statusEl.textContent = '● AI 正在回复...';
            }
        }
        const textNode = document.createTextNode(delta);
        this.replyEl.insertBefore(textNode, this.cursorEl);
        this.currentAssistantEl.scrollTop = this.currentAssistantEl.scrollHeight;
    }

    onToolCall({ toolName }) {
        if (!this.toolsEl) return;
        const badge = document.createElement('span');
        badge.className = 'tool-badge';
        badge.dataset.toolName = toolName;
        badge.textContent = '🔧 ' + toolName + ' ⏳';
        this.toolsEl.appendChild(badge);
        if (this.statusEl) {
            this.statusEl.textContent = '● 正在调用 ' + toolName + '...';
        }
    }

    onToolResult({ toolName, state }) {
        if (!this.toolsEl) return;
        const badge = this.toolsEl.querySelector(`[data-tool-name="${toolName}"]`);
        if (badge) {
            const isSuccess = state === 'SUCCESS';
            badge.className = 'tool-badge ' + (isSuccess ? 'done' : 'error');
            badge.textContent = (isSuccess ? '✅ ' : '❌ ') + toolName;
        }
        if (this.statusEl) {
            this.statusEl.textContent = '● AI 正在思考...';
        }
    }

    onAgentEnd({ replyId }) {
        if (this.statusEl) {
            this.statusEl.textContent = '✅ 处理完成';
        }
        if (this.cursorEl) {
            this.cursorEl.remove();
            this.cursorEl = null;
        }
    }

    onBizResult({ conversationId, bizData }) {
        if (bizData) {
            console.log('收到业务数据:', bizData);
            // 例如:applyBizData(bizData);
        }
    }

    onError(errorMsg) {
        const errorEl = document.createElement('div');
        errorEl.style.color = '#c62828';
        errorEl.textContent = '❌ ' + errorMsg;
        this.currentAssistantEl.appendChild(errorEl);
    }

    // ---- DOM 辅助方法 ----

    appendMessage(role, text) {
        const el = document.createElement('div');
        el.className = 'message ' + role;
        el.textContent = text;
        this.containerEl.appendChild(el);
    }

    createAssistantBubble() {
        const el = document.createElement('div');
        el.className = 'message assistant';
        this.containerEl.appendChild(el);
        return el;
    }
}

// 使用 ------ 只需一行代码
const chatClient = new AiChatEventSourceClient(document.getElementById('chatMessages'));
chatClient.sendMessage('帮我修复这个 bug');
</script>
</body>
</html>
方案三的优势
  1. 一次请求:GET + EventSource,无需 taskId 中转
  2. 代码最简洁 :前端无需手动解析 SSE 协议,addEventListener 直接注册各事件
  3. 内置自动重连:网络断开时 EventSource 自动重连
  4. 无后端状态:不需要维护 taskId → 请求参数的映射
方案三的注意事项
  1. URL 长度限制contextData(上线文数据JSON)可能较大,浏览器 URL 通常限制在 2048~8192 字符。如果画布数据较大,有两种应对策略:
    • 策略 A:首次对话不传 contextData(从零生成),后续多轮对话传 conversationId 即可(后端从数据库加载历史画布数据)
    • 策略 B :后端配置支持更长 URL(如 Tomcat 的 server.tomcat.max-http-header-size
  2. GET 幂等性:GET 语义上是幂等的,但此接口会创建会话、保存消息。实际项目中,如果严格遵循 REST 语义,可将"保存消息"逻辑拆到独立 POST 接口,GET 仅负责流式生成
  3. 参数编码contextData 包含 JSON 特殊字符,URLSearchParams 会自动编码,无需手动处理

5.5 方案选择建议

场景 推荐方案
通用场景(推荐) 方案三:GET + EventSource 一步到位
contextData 较大(超过 URL 长度限制) 方案一:fetch + ReadableStream(POST)
需要严格 REST 语义(POST 创建资源) 方案一或方案二
需要断线自动重连 + POST 方案二:两步式

六、前端 UI 展示效果

复制代码
┌──────────────────────────────────────────┐
│  💬 对话区域                               │
│                                          │
│  ┌────────────────────────────────────┐  │
│  │ 👤 帮我修复这个 bug                 │  │
│  └────────────────────────────────────┘  │
│                                          │
│  ┌────────────────────────────────────┐  │
│  │ 🤖                                 │  │
│  │                                    │  │
│  │  ● AI 正在回复...                   │  │ ← agent_start 状态
│  │                                    │  │
│  │  让我分析一下这个问题,               │  │ ← thinking 思考区(灰色小字,
│  │  需要查看相关的 API 定义...           │  │    打字机效果)
│  │                                    │  │
│  │  ✅ api_search   ✅ fix_code       │  │ ← tool_call + tool_result 徽章
│  │                                    │  │
│  │  已找到问题根因,正在生成修复方案。     │  │ ← 工具调用后的 thinking
│  │                                    │  │
│  │  根据分析,问题出在...               │  │ ← reply 回复区(正文样式,
│  │                                    │  │    打字机效果,光标迁移至此)
│  │  ✅ 处理完成                        │  │ ← agent_end 状态
│  └────────────────────────────────────┘  │
│                                          │
│  (收到 biz_result 后关闭连接;bizData      │
│    非空时前端据此做特殊处理,               │
│    如渲染画布,否则直接展示 reply 文本)      │
└──────────────────────────────────────────┘

七、关键设计要点

  1. 后端不做事件过滤 :AiAgent 层返回完整的 Flux<AgentEvent>,不做 filter,让 Controller 层负责映射
  2. 思考与回复分离thinking 映射自 ThinkingBlockDeltaEvent(模型思维链),reply 映射自 TextBlockDeltaEvent(正式回复),前端分别展示在思考区(灰色小字)和回复区(正文)
  3. 回复文本需收集 :Service 层在透传事件的同时,内部拼接 TextBlockDeltaEvent 的文本增量,用于后续提取业务数据(bizData);ThinkingBlockDeltaEvent 仅透传展示,无需收集
  4. biz_result 始终推送 :作为流结束信号,无论是否提取到业务数据都会推送(无业务数据时 bizData 为 null),前端收到后即可确定关闭连接的时机;bizData 非空时前端据此做特殊处理(如渲染画布),否则直接展示 reply 文本即可
  5. 前端打字机效果thinking/reply 事件携带的是文本增量(delta),前端逐次追加而非替换;首次收到 reply 时将光标从思考区迁移到回复区
  6. 工具状态可视化tool_calltool_result 配对使用,前端通过 toolName 关联,更新徽章状态
  7. 超时与错误兜底 :SSE 连接设置 2 分钟超时,异常时推送 error 事件通知前端
相关推荐
不才不才不不才1 小时前
Spring 源码系列(20): @SpringBootApplication 三注解拆解
java·后端·spring
aneasystone本尊1 小时前
学习 Headroom 的跨 agent 记忆与失败学习
人工智能
zhanghaha13141 小时前
Python进阶教程:5_XML 解析 —— 新手完全指南
java·前端·数据库
万亿少女的梦1681 小时前
基于Spring Boot、Vue与MySQL的私人健身教练预约管理系统设计
java·spring boot·mysql·vue·预约管理
a1122998211 小时前
GEO工具怎么选?电商、本地生活与B2B行业的选型差异
大数据·人工智能·生活
程序员黑豆1 小时前
Java字符串拼接全解析:6种方式性能对比与实战指南
java·前端·ai编程
大模型探索者1 小时前
重塑金融合规与效率:中关村科金AI+RTC智能双录系统解析
人工智能·金融·实时音视频
集萃智造机器人1 小时前
便携式焊接机器人品牌怎么选?高性价比品牌推荐
人工智能·机器人
程序员黑豆8 小时前
Java字符串详解
java·前端·ai编程