数字分身
这个项目做了什么
这个项目用Spring Boot接入Dify,把用户的问题转发给Dify应用,再把AI生成的内容通过SSE一段一段推给前端。
简单理解:
text
用户浏览器
->Nginx
->Spring Boot后端
->Dify API
->LLM大模型它不是普通的一问一答接口,而是偏“流式问答后端”。用户不用等AI完整生成完,前端可以边接收边显示,效果类似打字机。
核心流程
text
前端提交问题query
->后端进入/api/bot/chat
->没有sessionId就生成一个UUID
->WebClient调用Dify的/chat-messages接口
->Dify返回SSE流式数据
->后端解析每一段chunk
->只取message或text_chunk事件中的answer
->通过SseEmitter推给前端
->收到message_end或workflow_finished后关闭连接我觉得难的地方
难点主要是流式响应。
普通HTTP接口是后端处理完后一次性返回结果,但AI回答是逐步生成的。如果后端等Dify全部生成完再返回,用户体验会比较差。所以这里需要后端一直保持连接,Dify返回一段,后端就解析一段,再推给前端一段。
另一个难点是限流。AI接口不能被无限刷,所以用自定义@RateLimit注解标记接口,再用AOP在真正执行接口前做访问频率检查。
用到的技术
SseEmitter:实现后端向浏览器持续推送数据WebClient:调用Dify流式接口ObjectMapper:解析Dify返回的JSON片段Redisson RRateLimiter:按IP限制访问次数Spring AOP:把限流逻辑从Controller中抽出来Nginx:部署时转发前端请求到后端服务
关键实现
接口入口:
java
@GetMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8")
@RateLimit(rate = 5, ratelnterval = 1)
public SseEmitter chat(@RequestParam String query,
@RequestParam(required = false) String sessionId) {
if (sessionId == null || sessionId.isEmpty()) {
sessionId = UUID.randomUUID().toString();
}
return difyService.streamChat(query, sessionId);
}限流核心:
java
String ip = request.getHeader("X-FORWARDED-FOR");
if (ip == null || ip.isEmpty()) {
ip = request.getRemoteAddr();
}
String key = "rate_limit:chat:" + ip;
RRateLimiter limiter = redissonClient.getRateLimiter(key);
limiter.trySetRate(RateType.OVERALL, rateLimit.rate(),
rateLimit.ratelnterval(), RateIntervalUnit.MINUTES);
if (!limiter.tryAcquire()) {
throw new RuntimeException("提问频繁");
}
return joinPoint.proceed();遇到的问题
Nginx缓存SSE数据
SSE需要服务端实时推送,但Nginx可能会把响应先缓存起来,导致前端不是一段一段收到,而是等一批数据后才显示。
解决方式是在Nginx配置里关闭代理缓存:
nginx
proxy_buffering off;代理后拿不到真实IP
部署到Nginx后,后端直接拿request.getRemoteAddr()可能拿到的是代理服务器地址,导致限流不准确。
所以优先读取X-Forwarded-For请求头,拿不到时再使用getRemoteAddr()。
目前效果
目前主要投喂了Dify相关笔记,后续可以继续补充更多知识库内容。

网络架构图:

可以继续优化
- 把Dify的API Key放到环境变量中,避免写死在配置文件
- 限流异常可以返回更友好的提示
- 前端可以保存历史对话
- 后续可以加用户登录,用用户维度做限流