P5:给 Agent 装上对话框 —— AgentScope-Java 接入 Spring Boot 与 SSE 流式实战
系列第五篇。前四篇 Agent 都活在终端里,这一篇把它接上 Web:一个 SSE 接口,让浏览器里的聊天框像 ChatGPT 一样逐字吐出回复。
- 基于 agentscope 2.0.1 + Spring Boot 4.1.1(MVC)
你能获得什么
- 把 HarnessAgent 接进 Spring Boot 的完整四层链路:Agent Bean → SSE 接口 → 前端渲染
- 学会把 AgentScope 的事件流(streamEvents())转成浏览器可消费的 SSE 协议
- 一份可直接跑的前后端 demo:约 60 行 Java + 约 100 行 HTML
- 三条流式集成才踩得到的工程经验(超时、错误传播、会话隔离)
一、流式输出不是可选项
先看体感差异。假设 Agent 的回答需要 8 秒:
- 非流式:页面转了 8 秒圈,然后整段文字突然出现。用户的第一反应是「卡了」,第二反应是想关掉。
- 流式(打字机):第一个字 300 毫秒内就出现,之后逐字吐出。等待被拆成了上千个「有反馈」的瞬间,8 秒显得没那么长——ChatGPT 把这个认知打进了所有用户的预期里,流式从此不是加分项,是及格线。
好在 AgentScope-Java 从 P1 起就是流式设计的:agent.streamEvents() 返回一个 Reactor Flux,推理的每一个增量都以事件形式实时发出。我们要做的只是「把事件流转发到浏览器」。
二、事件流:P1 埋的伏笔
P1 里我们见过这个 API(控制台打印增量),P3 又用它监听了委派过程。现在把它推给前端:
agent.streamEvents(new UserMessage(message), ctx)
.doOnNext(event -> { /* 每来一个事件就实时处理 */ })
.blockLast(); // 控制台 demo 里用 blockLast 收尾
事件类型里和聊天界面相关的就四种:
其余事件(推理开始、轮次切换等)前端不关心,裁剪掉不推——这是第四节要讲的协议设计。
三、四层打通:从 Agent 到浏览器
整个链路四层,一层一层拆开。
第 1 层:Agent 提升为 Spring Bean
web/ChatAgentConfig.java:
@Configuration
public class ChatAgentConfig {
@Bean
public HarnessAgent chatAgent() {
return HarnessAgent.builder()
.name("note-taker")
.sysPrompt("你是一个帮助用户记录笔记的助手,需要记住用户提到的关键信息。")
.model(ModelFactory.build())
.workspace(Paths.get(".agentscope/workspace"))
.compaction(CompactionConfig.builder()
.triggerMessages(30)
.keepMessages(10)
.build())
.build();
}
}
和 P1 的裸 Agent 构建完全同一份代码,只是用 @Bean 包了一层。两个收益:构建成本(workspace 初始化、21 个内置工具扫描)只付一次;更重要的是 Agent 本身是无会话态的——会话状态按 RuntimeContext 里的 sessionId/userId 存在 stateStore 里,所以这一个单例可以安全地同时服务所有用户,用户 A 和用户 B 的会话天然隔离。
第 2 层:SSE 接口,把事件流转给浏览器
web/ChatController.java 的核心:
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final HarnessAgent agent;
private final ObjectMapper objectMapper;
public ChatController(HarnessAgent agent, ObjectMapper objectMapper) { /* ... */ }
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@RequestParam String message,
@RequestParam(defaultValue = "web-session") String sessionId) {
// 0 表示不设超时:长回答 + 多轮工具调用可能跑很久
SseEmitter emitter = new SseEmitter(0L);
RuntimeContext ctx = RuntimeContext.builder()
.sessionId(sessionId)
.userId("mk")
.build();
// 关键一行:Agent 的 Reactor 事件流直接订阅转发给 SSE emitter
agent.streamEvents(new UserMessage(message), ctx)
.subscribe(
event -> {
try {
emitter.send(SseEmitter.event()
.name(event.getType().name())
.data(toJson(event)));
} catch (IOException e) {
emitter.completeWithError(e);
}
},
emitter::completeWithError,
emitter::complete);
return emitter;
}
}
看懂这段代码只需要三个 Reactor/Servlet 常识:
- subscribe() 是点火开关。streamEvents() 返回的 Flux 在方法返回时还没开始跑,subscribe(...) 被调用的一瞬才真正启动推理循环——所以它必须写在 Controller 方法里,不能等 MVC 层帮你触发。
- SseEmitter 是 Spring MVC 的流式响应器。方法 return 后 HTTP 响应保持打开,之后每次 emitter.send() 都往连接里写一条 SSE 帧(data: {...}\n\n 格式)。
- produces = TEXT_EVENT_STREAM_VALUE 让 Spring 按 SSE 协议处理响应,浏览器端的 EventSource 直接认这个 Content-Type。
三个回调参数分别对应:来一个事件 → 转发一条;流异常 → 通知前端断开;流正常结束 → 关闭连接。
第 3 层:事件裁剪——只推前端关心的
private String toJson(AgentEvent event) throws IOException {
String content = switch (event.getType()) {
case TEXT_BLOCK_DELTA -> ((TextBlockDeltaEvent) event).getDelta();
case TOOL_CALL_START -> ((ToolCallStartEvent) event).getToolCallName();
case TOOL_CALL_END -> ((ToolCallEndEvent) event).getToolCallName();
case TOOL_RESULT_TEXT_DELTA -> ((ToolResultTextDeltaEvent) event).getDelta();
case TOOL_RESULT_END -> ((ToolResultEndEvent) event).getToolCallName();
default -> null;
};
if (content == null) {
return "{}"; // 该事件类型前端不关心,不推无谓数据
}
return objectMapper.writeValueAsString(new ChatEvent(event.getType().name(), content));
}
public record ChatEvent(String type, String content) {
}
这里有一个协议设计决策:原始 AgentEvent 带着框架内部字段(replyId、toolCallId、TaskId、各种 state),直接序列化推给前端既臃肿又泄露内部结构。裁剪成 {type, content} 的最小结构后,前端渲染逻辑极简,框架升级换事件内部结构也不影响协议。record 让 Jackson 零配置序列化,这是 JDK 21 的小红利。
第 4 层:前端 100 行收工
src/main/resources/static/chat.html(Spring Boot 静态资源默认目录,启动即访问 http://localhost:8080/chat.html)。核心逻辑:
// 具名事件:后端按事件类型设 name,前端按 name 分发
const es = new EventSource(
'/api/chat/stream?message=' + encodeURIComponent(msg) + '&sessionId=' + session);
es.addEventListener('TEXT_BLOCK_DELTA', ev => {
const data = JSON.parse(ev.data);
reply.textContent += data.content; // 打字机追加
});
['TOOL_CALL_START', 'TOOL_CALL_END', 'TOOL_RESULT_END'].forEach(type => {
es.addEventListener(type, ev => {
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = type.replace(/_/g, ' ') + ' · ' + JSON.parse(ev.data).content;
reply.appendChild(badge); // 工具调用显示为气泡内的徽标
});
});
es.onerror = () => { es.close(); btn.disabled = false; }; // 流结束/异常
两个技巧:
- 具名事件分发:后端 SseEmitter.event().name(eventType) 给每个帧打了类型名,前端用 addEventListener 按类型分流,比 onmessage 里 if-else 清晰得多。
- session 每次打开页面随机生成(web-xxxxxx),天然演示「多会话隔离」——你可以在两个标签页分别聊,互不串味。
四、实测
启动应用(记得 export DEEPSEEK_API_KEY=sk-xxx),两种方式验证。
方式一:浏览器(主推的演示效果)。打开 http://localhost:8080/chat.html,输入「帮我记一下:明天下午三点和周会」。你会看到:气泡逐字出现;若 Agent 触发工具,气泡里实时长出徽标。
方式二:命令行(方便放进文章里的纯文本证据)。curl 的 -N 关闭缓冲,SSE 帧逐条到达:
curl -N "http://localhost:8080/api/chat/stream?message=帮我记个笔记:周五要交P5稿"
event:TEXT_BLOCK_DELTA
data:{"type":"TEXT_BLOCK_DELTA","content":"好的,"}
event:TEXT_BLOCK_DELTA
data:{"type":"TEXT_BLOCK_DELTA","content":"已记下:"}
event:TEXT_BLOCK_DELTA
data:{"type":"TEXT_BLOCK_DELTA","content":"周五要交 P5 稿。"}
五、三条流式集成经验
- SseEmitter 的超时设 0(不限时)。默认超时(30 秒左右,取决于容器)会在 Agent 长回答或多轮工具调用中途掐断连接——用户看到的就是「回一半断了」。demo 和生成场景都应该显式禁用超时;如果必须设,也请设成分钟级以上。
- 错误必须传播到 emitter 的三个回调。Agent 调用失败(模型 429、网络抖动)只发生在流的中途,页面会永远停在「半个句子」。我们的 completeWithError(e) 会让 EventSource.onerror 触发,前端至少能恢复按钮、提示重试。生产环境建议再加一层:把错误帧也按 {type: "ERROR", content} 推给前端,让用户看到具体原因。
- sessionId 从前端传,别在后端写死。这是多用户接入的分水岭:@RequestParam String sessionId 让不同浏览器标签页天然隔离(各带各的随机 session);换成登录态后,把 userId 换成真实用户 ID,同一套接口直接支撑多租户。
六、踩坑记录
坑 0(启动第一个炸的):Spring Boot 4 把 Jackson 自动配置「拆」没了。
接口写完信心满满启动,结果 Parameter 1 of constructor in ChatController required a bean of type 'com.fasterxml.jackson.databind.ObjectMapper' that could not be found。排查链路:classpath 上明明有 jackson-databind(webflux starter 传递引入的),查 spring-boot-autoconfigure 的 AutoConfiguration.imports 注册表——里面已经没有 JacksonAutoConfiguration 了。真相是 Spring Boot 4 的模块化拆分:Jackson 自动配置被从核心 autoconfigure 剥离到独立模块 spring-boot-jackson(由 spring-boot-starter-json 聚合),而 spring-boot-starter-webmvc 不再传递依赖它。修复一行:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
升级 Boot 4 的同学留个神:「classpath 上有类」≠「有自动配置 bean」,中间还隔着一个注册表。
坑 1:webflux 和 webmvc starter 同时存在,应用默认按 MVC 启动。
项目里两个 starter 都在(webflux 是 Spring Boot 自动带的,webmvc 是 IDEA 建 Spring Boot 工程默认勾的)。Spring Boot 的规则是两者并存时默认 servlet(MVC)——这正好是我们想要的(SseEmitter 是 servlet 世界的 API),但也意味着别混用 Flux 返回值写 Controller:那个需要把应用切成 reactive 启动(spring.main.web-application-type=reactive)。两种风格二选一,别混。
坑 2:streamEvents() 不会自己跑起来,subscribe() 才是点火开关。
第一次写完接口,浏览器挂到超时也没见一个字节——Flux 是冷的,没人订阅它就不动。加了 .subscribe(...) 三回调后立刻通。这个坑 Reactor 新手 100% 会踩一次,记住:blockLast() 和 subscribe() 都是点火,区别只是同步/异步。
坑 3:emitter.send() 抛的 IOException 不能咽掉。
SSE 连接可能在中途被用户关页面掐断,此时 send 抛 IOException。若不当场 completeWithError,底层线程会反复重试往死连接里写,异常日志刷满控制台。正确做法就是 demo 里那样:try-catch 住,异常即终止。
坑 4(IDEA 用户专属):Key 别填进 Active profiles 字段。
这个坑不在框架在 IDE:IDEA 的 Spring Boot 运行配置模板有个「Active profiles」输入框,和「Environment variables」长得一样是文本框。把 DEEPSEEK_API_KEY=sk-... 误填进前者,IDEA 会转成 -Dspring.profiles.active=... 传给 JVM,Spring Boot 启动校验 profile 名合法性,报一个和 Key 八竿子打不着的 Invalid profile property value——而且你的 API Key 会整个明文打在错误日志里。填之前看清字段名,填错一次泄露一把钥匙。
七、下一篇预告
最后一篇 P6:把前五篇的所有踩坑做一次总盘点,聊聊生产化清单——成本控制、并发、限流、安全边界(prompt 注入、工具权限),以及这个系列之后我会用这个项目做什么。
仓库地址:github.com/525128633/a… 系列文章:P1 上手实测 · P2 工具调用实战 · P3 多智能体编排 · P4 工程化 · P5 本篇
本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。
P5: Loading dialogue box for Agent - AgentScope-Java access Spring Boot and SSE flow realities
Book V of the series. The first four Agents live in the terminal, and this one connects it to Web: a SSE interface that allows the chat box in the browser to spit out word for word like ChatGPT.
- based on angentscope 2.0.1 + Spring Boot 4.1.1 (MVC)
What do you get?
- Full four-storey link linking HarnessAgent to Spring Boot: Agent Bean →SSE interface → front-end rendering
- Learn to convert AgentScope's event stream (streamEvents()) to a consumption SSE protocol for browsers
- A direct running back and forth demo: about 60 lines Java + about 100 lines HTML
- Project experience from three streams of integration (timeout, mistransmission, isolation of sessions)
I. Stream output is not optional
Let's look at the differences. Assuming that Agent's answer takes 8 seconds:
- Non-flow: The page turns eight seconds and the whole text suddenly appears. The user's first reaction was "calculated" and the second response was to shut it down.
- Fluid (typist): The first word appears within 300 milliseconds, then it comes out word by word. Waiting for thousands of "responding" moments, eight seconds didn't seem that long -- ChatGPT put that knowledge into the expectations of all users, and the flow was never more detailed, it was a pass line.
Fortunately, AgentScope-Java was designed from P1 to flow: angent.streamEvents() returns a Reactor Flux, and every increment of reasoning is sent in real time in the form of an event. All we have to do is "transmit the event to the browser."
II. Incident flows: P1 ambushes
P1 has seen this API, and P3 has been listening to the assignment. Now push it to the front:
agent.streamEvents(new UserMessage(message), ctx)
.doOnNext (event- > / * processes every event in real time*/})
. blockLast(); / / console demo end with blockLast
There are four types of event types associated with chat interfaces:
The rest of the events (start of reasoning, rotations, etc.) are not concerned at the front end and cut off - This is the design of the agreement that is to be discussed in section IV.
3rd and 4th floor access: from Agent to browser
Four floors of the entire chain, one floor open.
Layer 1: Agent to Spring Bean
web/ChatAgentConfig.java:
@Configuration
public class ChatAgentConfig {
@Bean
public HarnessAgent chatAgent() {
return HarnessAgent.builder()
.name("note-taker")
.sysPrompt("You're an assistant who helps users to record notes, needs to remember the key messages that users refer to. " )
.model(ModelFactory.build())
.workspace(Paths.get(".agentscope/workspace"))
.compaction(CompactionConfig.builder()
.triggerMessages(30)
.keepMessages(10)
.build())
.build();
}
}
And P1's nudity Agent built exactly the same code, only wrapped one layer with @Bean. Two benefits: construction costs (initiation of workspace, 21 built-in tool scans) are paid only once; and, more importantly, Agent itself is non-talky - the session status is in the RuntimeContext session Id/userId status, so this single case can safely serve all users, user A and user B in a natural isolation.
Layer 2: SSE interface, transfer event stream to browser
Web/ChatController.java's core:
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final HarnessAgent agent;
private final ObjectMapper objectMapper;
public ChatController(HarnessAgent agent, ObjectMapper objectMapper) { /* ... */ }
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@RequestParam String message,
@RequestParam(defaultValue = "web-session") String sessionId) {
/ 0 means no timeout: Long answer + multi-wheel tool call can run long
SseEmitter emitter = new SseEmitter(0L);
RuntimeContext ctx = RuntimeContext.builder()
.sessionId(sessionId)
.userId("mk")
.build();
/ / Key Line: Reactor Current Direct Subscription to SSE emit
agent.streamEvents(new UserMessage(message), ctx)
.subscribe(
event -> {
try {
emitter.send(SseEmitter.event()
.name(event.getType().name())
.data(toJson(event)));
} catch (IOException e) {
emitter.completeWithError(e);
}
},
emitter::completeWithError,
emitter::complete);
return emitter;
}
}
Understand this code only takes three Reactor/Servlet common senses:
- Subscribe() is the ignition switch. The returned Flux() has not started running on the way back, subscribe(...) has been called for a moment to actually start the reasoning loop -- so it has to be written in the Controller method, not waiting for the MVC layer to trigger you.
- SseEmitter is Spring MVC's flower. The HTTP response remains open after the method returns, and every time thereafter emiter.send() writes a SSE frame (data: {n\n format).
-Produces = TEXT EVENT STREAM VALUE allows Spring to process a response by SSE. EventSource on the browser side recognizes this Content-Type.
The three echo parameters correspond to: one event forward one; flow abnormality notification front end disconnected; flow normal end → closed connection.
Layer 3: Incident shearing - Only front-end concern
private String toJson(AgentEvent event) throws IOException {
String content = switch (event.getType()) {
case TEXT_BLOCK_DELTA -> ((TextBlockDeltaEvent) event).getDelta();
case TOOL_CALL_START -> ((ToolCallStartEvent) event).getToolCallName();
case TOOL_CALL_END -> ((ToolCallEndEvent) event).getToolCallName();
case TOOL_RESULT_TEXT_DELTA -> ((ToolResultTextDeltaEvent) event).getDelta();
case TOOL_RESULT_END -> ((ToolResultEndEvent) event).getToolCallName();
default -> null;
};
if (content == null) {
return "{}";// does not concern the front end of this event type and does not push unnecessary data
}
return objectMapper.writeValueAsString(new ChatEvent(event.getType().name(), content));
}
public record ChatEvent(String type, String content) {
}
Here's a protocol design decision: the original AgentEvent, with field inside the frame (replyId, ToolCallId, TaskId, various state), was pushed directly into the front end with a bloated and leaking internal structure. When the minimum structure of {type, condition} is circumscribed, the front-end rendering logic is extremely simple, and the framework upgrades the internal structure of the event do not affect the protocol. Record sequences Jackson zero configuration, a small dividend for JDK 21.
Level 4: Front end 100 closed lines
src/main/resources/static/chat.html (Spring Boot Static Resource Default Directory, accessed at http://localhost:808080/chat.html). Core logic:
/ Event by name: backend set name by type of event, frontend issued by name
const es = new EventSource(
'/api/chat/stream?message=' + encodeURIComponent(msg) + '&sessionId=' + session);
es.addEventListener('TEXT_BLOCK_DELTA', ev => {
const data = JSON.parse(ev.data);
Other Organiser
});
['TOOL_CALL_START', 'TOOL_CALL_END', 'TOOL_RESULT_END'].forEach(type => {
es.addEventListener(type, ev => {
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = type.replace(/_/g, ' ') + ' · ' + JSON.parse(ev.data).content;
/ /tool call displays the logo inside the bubble
});
});
es. onerror =() = >es.close(); btn.disabled = false;}; / / / flow end/ anomaly
Two techniques:
- Event distribution by name: the backend SseEmiter.eventType has given a type name to each frame, with the front end diverting by type, much clearer than if-else in the context.
- The session generated randomly (web-xxxx) every time you open a page, the natural presentation " Multi-session Segregation" -- you can talk separately on two tabs, and you don't smell each other.
IV. EFFECTS
Starts the application (remember export DEEPSEEK API KEY=sk-xx), validates in two ways.
Mode I: Browser (presentation effect of main push). Open http://localhost:8080/chat.html and enter "Record for me: 3 p.m. and Mondays tomorrow." You'll see: bubbles appear word for word; if Agent triggers, the logo grows in real time.
Mode two: command line (for simple text evidence to facilitate placement in the article). Curl's -N close buffer, SSE frame by article:
curl-N "http://localhost:8080/api/chat/stream?message = take my notes: P5 on Friday"
event:TEXT_BLOCK_DELTA
Data:
event:TEXT_BLOCK_DELTA
Data:
event:TEXT_BLOCK_DELTA
Data: "type": "TEXT BLONK DELTA", "content": "P5 on Friday."
V. THREE THROUGH INTEGRATION EXPERIENCES
- timeout set-up for SseEmitter (not limited-time). The default timeout (30 seconds or so, depending on the container) will be cut off by either the Agent long answer or multiple rounds of tool call midway — the user sees the "half-way out." Demo and generate scenes should both visibly disable timeout; if necessary, please set more than a minute level.
- Errors must be spread to three echoes of emitter. Agent's call failure (model 429, network shaking) occurs only in the middle of the stream, and the page will stop at half a sentence forever. Our CompleteWithError(e) will trigger EventSource.onerror, at least the front end recovers the button, hint retry. The production environment suggests an additional layer: also push the wrong frame to the front to show the user the specific reason.
- Report Id forward, don't write dead back. This is the watershed of multi-user access: @RequestParam String sessionId allows different browser tabs to be naturally isolated (random session of each band); in exchange for login, replace userId with real user ID, and support the multi-tenant directly with the same interface.
VI. VICTIMS RECORDS
Pit 0 (starts first): Spring Boot 4 has lost Jackson 'disassembly'.
Parameter 1 of confidence in ChatController completed a bean of type 'com.fasterxml.jackson.databind.ObjectMapper' that could not be found. Queuelink: there is a jackson-databind on the classpath (webflux transmission introduced) and a check of AutoConfiguration.imports registration forms for spring-boot-autoconfigure - there is no Jackson AutoConfigration anymore. The truth is that the modular split:Jackson configuration of Spring Boot 4 was removed from the core of autoconfigure to an independent module spring-boot-jackson (aggregated by spring-boot-starter-json), and spring-boot-start-webmvc no longer transmits dependent on it. Fix a line:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
The students who upgraded Boot 4 paid attention to: "Classpath has a class "Auto-configured bean" and a separate registration form.
Pit 1: Webflux and Webmvc starter exist simultaneously, using default to start with MVC.
Both Starters are in the project (webflux is a Spring Boot automatic, webmvc is a default tick for the IDA construction Spring Boot project). Spring Boot's rule is the default servlet (MVC) when the two exist together - which is exactly what we want (SseEmitter is the API of the world), but it also means not to mix Flux back to value Controller: the application needs to be cut to active (spring.main.web-application-type=real). Two styles, one and the other.
Pit 2: StraamEvents() won't run by themselves. Subscribe() is the ignition switch.
The first time the interface was written, the browser hung up to timeout and did not see a byte-Flux, which was cold, and it was not signed up. Add.subscribe(...) will be sent as soon as three calls. This pothole Reactor beginner will step on it 100% once, remember: blackLast() and subscribe() are all ignitions, the difference is only sync/discrete.
Pit 3: Emitter.send() throw IOExcaption cannot swallow.
The SSE connection may be cut off by the user close page in the middle of the trip, and send the IOException. In the event of an inappropriate scene, the bottom line will repeat retry into the dead link, with the anomaly log full of the console. The right thing to do is demo, like: Terry-Catch lives, the anomaly ends.
Pipe 4 (IDEA user-specific): Key does not fill in Active products field.
This pit is not part of the "Active projects" input box in the Spring Boot running configuration template of IDE:IDE, which is as long as "Environvalables". DESEPSEEK API KEY=sk-- By mistake, IDA will be converted to -Dspring.profiles.active=... to JVM, Spring Boot to start verifying the validity of the profile, for an Invalid profile property value — and your API Key will be spelled in the entire error log. The name of field was identified before filling in, and a key was leaked at the wrong time.
VII. NEXT WARNING
Last P6: make a master count of all the pedestals in the first five pages and talk about the production list - cost control, co-opting, rate limit, the safe boundary (prompt injection, tool permission) and what I would do with this project after this series.
Warehouse address: Github.com/52518633/a... Series: P1 hands-on measurements P2 tool call field operations P3 multi Z42Z organization P4 Engineering P5
Auto-fetched and translated by AI Atlas. All rights belong to the original author.