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

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 又用它监听了委派过程。现在把它推给前端:

java 复制代码
agent.streamEvents(new UserMessage(message), ctx)
        .doOnNext(event -> { /* 每来一个事件就实时处理 */ })
        .blockLast();   // 控制台 demo 里用 blockLast 收尾

事件类型里和聊天界面相关的就四种:

事件 含义 前端怎么渲染
TEXT_BLOCK_DELTA 模型文本增量 追加进气泡 → 打字机效果
TOOL_CALL_START Agent 开始调工具 显示「调用 xxx」徽标
TOOL_RESULT_TEXT_DELTA 工具执行结果增量 同上,可折叠展示
TOOL_RESULT_END 工具执行结束 徽标收尾

其余事件(推理开始、轮次切换等)前端不关心,裁剪掉不推------这是第四节要讲的协议设计。

三、四层打通:从 Agent 到浏览器

整个链路四层,一层一层拆开。

第 1 层:Agent 提升为 Spring Bean

web/ChatAgentConfig.java:

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 的核心:

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 常识:

  1. subscribe() 是点火开关 。streamEvents() 返回的 Flux 在方法返回时还没开始跑,subscribe(...) 被调用的一瞬才真正启动推理循环------所以它必须写在 Controller 方法里,不能等 MVC 层帮你触发。
  2. SseEmitter 是 Spring MVC 的流式响应器 。方法 return 后 HTTP 响应保持打开,之后每次 emitter.send() 都往连接里写一条 SSE 帧(data: {...}\n\n 格式)。
  3. produces = TEXT_EVENT_STREAM_VALUE 让 Spring 按 SSE 协议处理响应,浏览器端的 EventSource 直接认这个 Content-Type。

三个回调参数分别对应:来一个事件 → 转发一条;流异常 → 通知前端断开;流正常结束 → 关闭连接。

第 3 层:事件裁剪------只推前端关心的

java 复制代码
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)。核心逻辑:

javascript 复制代码
// 具名事件:后端按事件类型设 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 帧逐条到达:

bash 复制代码
curl -N "http://localhost:8080/api/chat/stream?message=帮我记个笔记:周五要交P5稿"
text 复制代码
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 稿。"}

五、三条流式集成经验

  1. SseEmitter 的超时设 0(不限时)。默认超时(30 秒左右,取决于容器)会在 Agent 长回答或多轮工具调用中途掐断连接------用户看到的就是「回一半断了」。demo 和生成场景都应该显式禁用超时;如果必须设,也请设成分钟级以上。
  2. 错误必须传播到 emitter 的三个回调 。Agent 调用失败(模型 429、网络抖动)只发生在流的中途,页面会永远停在「半个句子」。我们的 completeWithError(e) 会让 EventSource.onerror 触发,前端至少能恢复按钮、提示重试。生产环境建议再加一层:把错误帧也按 {type: "ERROR", content} 推给前端,让用户看到具体原因。
  3. 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 不再传递依赖它。修复一行:

xml 复制代码
<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 本篇


相关推荐
付威20231 小时前
从 Loop 到 Runtime:rpi Agent Loop 与 Cordis 的边界、取舍与组合
后端
IT_陈寒1 小时前
Redis的LRU淘汰策略坑了我一整天
前端·人工智能·后端
考虑考虑3 小时前
JDK26的JEP 500属性
java·后端·java ee
Mia_墨黎3 小时前
2026 Windows 11 终端美化完全指南
前端·后端
foreverYoung3 小时前
给开源后台做等保适配,我发现了 5 个"看起来有、其实没用"的功能 从一个尴尬的现场测试说起
后端
程序猿阿越3 小时前
从 StatefulSet 到容器挂载:CSI 源码走读
后端·kubernetes·源码阅读
苏三说技术4 小时前
继PHP之后,Java也爆出了重大安全漏洞!
后端
程序猿乐锅5 小时前
【从0-1学习Python | 第七篇】Python 面向对象基础
开发语言·笔记·后端·python·学习·1024程序员节