目录
-
- 一、概述
- 二、事件类型映射
- [三、SSE 流时序](#三、SSE 流时序)
- 四、后端实现
-
- [4.1 核心思路](#4.1 核心思路)
- [4.2 示例代码](#4.2 示例代码)
-
- [AiAgent 接口](#AiAgent 接口)
- [AiAgent 实现](#AiAgent 实现)
- AiChatService
- AiChatController
- 五、前端实现
-
- [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 核心思路
- AiAgent 层 :返回原始
Flux<AgentEvent>,不过滤任何事件类型 - AiChatService 层:透传事件,同时收集文本内容用于后续校验
- 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 + ReadableStream 和 EventSource 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 请求体(传递 message、contextData 等),而 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 方案的前端代码更简洁:
- 无需手动解析 SSE 协议 :不需要维护 buffer、逐行拆分、解析
event:/data:前缀 - 内置自动重连 :网络断开时 EventSource 会自动尝试重新连接(可通过
es.close()主动关闭) - 事件监听更直观 :每种事件类型用
addEventListener独立注册,代码结构清晰
EventSource 方案的代价
- 需要两步请求:先 POST 获取 taskId,再 GET 连接流
- 需要后端维护 taskId 状态:内存或 Redis 中暂存请求参数
- 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>
方案三的优势
- 一次请求:GET + EventSource,无需 taskId 中转
- 代码最简洁 :前端无需手动解析 SSE 协议,
addEventListener直接注册各事件 - 内置自动重连:网络断开时 EventSource 自动重连
- 无后端状态:不需要维护 taskId → 请求参数的映射
方案三的注意事项
- URL 长度限制 :
contextData(上线文数据JSON)可能较大,浏览器 URL 通常限制在 2048~8192 字符。如果画布数据较大,有两种应对策略:- 策略 A:首次对话不传 contextData(从零生成),后续多轮对话传 conversationId 即可(后端从数据库加载历史画布数据)
- 策略 B :后端配置支持更长 URL(如 Tomcat 的
server.tomcat.max-http-header-size)
- GET 幂等性:GET 语义上是幂等的,但此接口会创建会话、保存消息。实际项目中,如果严格遵循 REST 语义,可将"保存消息"逻辑拆到独立 POST 接口,GET 仅负责流式生成
- 参数编码 :
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 文本) │
└──────────────────────────────────────────┘
七、关键设计要点
- 后端不做事件过滤 :AiAgent 层返回完整的
Flux<AgentEvent>,不做filter,让 Controller 层负责映射 - 思考与回复分离 :
thinking映射自ThinkingBlockDeltaEvent(模型思维链),reply映射自TextBlockDeltaEvent(正式回复),前端分别展示在思考区(灰色小字)和回复区(正文) - 回复文本需收集 :Service 层在透传事件的同时,内部拼接
TextBlockDeltaEvent的文本增量,用于后续提取业务数据(bizData);ThinkingBlockDeltaEvent仅透传展示,无需收集 - biz_result 始终推送 :作为流结束信号,无论是否提取到业务数据都会推送(无业务数据时
bizData为 null),前端收到后即可确定关闭连接的时机;bizData非空时前端据此做特殊处理(如渲染画布),否则直接展示 reply 文本即可 - 前端打字机效果 :
thinking/reply事件携带的是文本增量(delta),前端逐次追加而非替换;首次收到reply时将光标从思考区迁移到回复区 - 工具状态可视化 :
tool_call和tool_result配对使用,前端通过toolName关联,更新徽章状态 - 超时与错误兜底 :SSE 连接设置 2 分钟超时,异常时推送
error事件通知前端