中文资讯 掘金 AI Sun, 25 Oct 2026 正文 ok 844 words

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

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

本文介绍了将 AgentScope-Java 接入 Spring Boot Web 体系并实现 SSE 流式输出的端到端方案。通过将 HarnessAgent 注册为 Spring 单例 Bean,结合 Reactor Flux 的 `streamEvents()` 与 Spring MVC 的 `SseEmitter`,开发者仅需约 60 行 Java 与 100 行 HTML 即可搭建具备打字机动效和工具调用状态可视化的 Web 对话界面。文章进一步梳理了协议事件裁剪、会话隔离与长连接超时等关键工程实践,并针对 Spring Boot 4 依赖拆分与响应式陷阱给出了具体的避坑指南,为 Java 开发者将 Agent 落地为生产级 Web 服务提供了高度可复用的实践参考。
打开原文相关 3 · 机会 1
media3.2 KB media589 B media52.7 KB media3.8 KB

中文

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
  • 三条流式集成才踩得到的工程经验(超时、错误传播、会话隔离)

WeChat_official_account_cover__2026-10-25T08-06-49.png

一、流式输出不是可选项

先看体感差异。假设 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();

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

事件 含义 前端怎么渲染
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) {
    
    SseEmitter emitter = new SseEmitter(0L);

    RuntimeContext ctx = RuntimeContext.builder()
            .sessionId(sessionId)
            .userId("mk")
            .build();

    
    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 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稿"

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 org.springframework.boot spring-boot-starter-json

升级 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 本篇


English / 原文

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
  • 三条流式集成才踩得到的工程经验(超时、错误传播、会话隔离)

WeChat_official_account_cover__2026-10-25T08-06-49.png

一、流式输出不是可选项

先看体感差异。假设 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();   

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

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

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

三、四层打通:从 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) {
        
        SseEmitter emitter = new SseEmitter(0L);

        RuntimeContext ctx = RuntimeContext.builder()
                .sessionId(sessionId)
                .userId("mk")
                .build();

        
        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 层:事件裁剪——只推前端关心的

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)。核心逻辑:

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 稿。"}

五、三条流式集成经验

  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 不再传递依赖它。修复一行:

<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 本篇