AI图鉴

AI图鉴首页 › AI 资讯 › 给 Agent 装上对话框 —— AgentScope-Java 接入 Spri

给 Agent 装上对话框 —— AgentScope-Java 接入 Spring Boot 与 SSE 流式实战

来源:掘金 · 发布时间:10-25 · 由 AI图鉴每小时自动聚合

P5:给 Agent 装上对话框 —— AgentScope-Java 接入 Spring Boot 与 SSE 流式实战 你能获得什么 把 HarnessAgent 接进 Spring Boot 的

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图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。

相关 AI 工具

相关免费羊毛

同源资讯

阅读原文(juejin.cn)

标题、摘要与正文均来自该信源的公开页面,版权归原作者所有;本站自动聚合与翻译,仅作出处跳转。

← 返回 AI 资讯列表AI工具图鉴首页

返回 AI图鉴首页 · 提交工具 · 商务合作 · 关于本站 · 591 款 AI 工具分类导航 · 每日 AI 热门项目与动态