Langchain4j + Flux 实现高可用 LLM 流式对话系统 教你如何接入AI

用 Flux + Langchain4j 构建一个可恢复的流式响应系统。

使用到的技术栈:SpringBoot、Langchain、Elasticsearch

Langchain现在被广泛用于实现AI Agent,而流式响应是大模型主流的交互方式。在生产环境中,网络环境复杂,可能出现模型响应慢等情况。于是为了优化用户体验,解决断流问题,我通过实践构建了一个可恢复的流式响应系统,支持断线后自动重连。起因是作者接到了一个与智慧校园、信息查询有关的AI Agent后端需求,所以将实践过程分享给大家。

Langchain4j 介绍

Langchain 大家应该都耳熟能详了,它是一个能够将 LLM 集成到 Java 应用程序的框架。我的使用的后端框架是 SpringBoot,由于 Spring AI 更新速度较慢,跟不上 Langchain4j 的版本,所以我没有使用 SpringAI,而是直接在Maven中引入了 langchain4j。

Langchain4j 集成了15+个 LLM 提供商,集成了向量储存、图像生成、评分模型等,支持多模态、消息持久化、流式传输、Tool calling,还有 RAG(增强检索)功能。这次主要使用到的是它的 Tool calling 与流式传输。

Langchain4j 的基本使用

首先简要介绍一下我是如何在 SpringBoot 中使用 Langchain4j的:

  1. 为大模型提供可调用的 tool(工具)
java 复制代码
@Component
public class LLMTools {
    @Tool("Tool Description")
    public String function() {
      // Do something...
    }
}

这样就创建了一个包含工具的 Bean,其中注解里可以写上工具的介绍,但方法名也是包括在上下文中的。返回值可以是String类型,其它类型的返回值会在发送给 LLM 之前转换为 JSON 字符串。而方法参数类型可以有很多选择:

  • 基本类型:int、double 等
  • 对象类型:String、Integer、Double 等
  • 自定义 POJO(可以包含嵌套 POJO)
  • enum(枚举)
  • List<T>/Set<T>,其中 T 是上述类型之一
  • Map<K,V>(需要使用 @P 描述 K 和 V 的类型)

举一个例子,如果我想让大模型可以获得我的 Elasticsearch 中的所有索引,我应该怎么写?

java 复制代码
@Tool("Get field mappings for a specific Elasticsearch index")
public String getMapping(@P("Name of the Elasticsearch index to get mappings for") String index) throws IOException {
    GetMappingResponse response = esClient.indices().getMapping(r -> r.index(index));

    ObjectNode node = objectMapper.createObjectNode();
    ArrayNode content = node.putArray("content");
    ObjectNode n1 = objectMapper.createObjectNode();
    n1.put("type", "text");
    n1.put("text", "Mappings for index: " + index);
    content.add(n1);

    ObjectNode n2 = objectMapper.createObjectNode();
    n2.put("type", "text");
    n2.put("text", "Mappings for index " + index + ": " + response.toString());
    content.add(n2);

    return objectMapper.writeValueAsString(node);
}

其中 esClient 是 Elasticsearch 官方 sdk 提供的类。

  1. 定义一个 Agent
java 复制代码
interface Agent {
    TokenStream ask(@UserMessage String question);
}

其中 question 是用户发送的字符串。

接着,为你的 agent 写一个提示词,有了大模型的api key,就可以注册一个 Agent 了:

java 复制代码
String prompt = "提示词";
StreamingChatLanguageModel model = OpenAiOfficialStreamingChatModel.builder()
  			.baseUrl("https://api.deepseek.com")
  			.apiKey("your key here")
  			.modelName("deepseek-chat")
			  .build();

Agent agent = AiServices.builder(Agent.class)
				.streamingChatLanguageModel(model)
				.tools(llmTools)
			  .systemMessageProvider(s -> prompt)
			  .build();
  1. 暴露一个接口来对话

考虑到场景需要,在这里使用了 SSE 来流式传输大模型的回答。实际上也可以用 WebSocket 实现。

java 复制代码
public SseEmitter chat(String message) {
	TokenStream tokenStream = agent.ask(memoryId, message);

  SseEmitter sseEmitter = new SseEmitter(0L);
  tokenStream.onPartialResponse(s -> {
		sseEmitter.send(s);
	});

	return sseEmitter;
}

这样,就实现了一个最基本的拥有 tool calling 和对话功能的 AI Agent。

单机中断恢复

直接上分布式的方案过于复杂,单体架构对于并发量不高的需求也足够使用,所以首先介绍单机中断恢复的方案。

首先,不管是用户主动还是被动断开连接,对于 LLM 侧,处理的方法有几种:

  • 继续保持 LLM 连接,直到输出结束
  • 直接断开LLM 连接

对于断开连接后是否需要保存已经输出的内容,这就根据需求而决定了,实现起来难度也不大,不过似乎 langchain4j 也没有提供主动断开连接的方法。

那么如何在用户中断后恢复连接呢?首先可以想到的是,后端与 LLM 的连接和后端与用户的连接是两个不同的连接,用户与后端连接中断并不影响后端与 LLM 的连接,所以方案是可行的。接下来就是思考如何重连。前端尝试与后端建立连接时有两种可能的状态:

  1. LLM 正在回复前一条信息
  2. 没有正在回复的信息

对于第二种情况很好处理,只要做了消息记录,那么不管有没有中断,消息结束后都会被持久化。而对于第一种情况,则需要考虑一些特殊的处理办法。目前我经过尝试后可行的思路如下:

封装一个ResumableSseEmitter,其中包含了一个可以推送事件的 Flux,并通过 AtomicReference 持有 sink,用于主动推送事件。注意 Flux 创建时调用了 replay(),表示这个 Flux 在连接时会将已传输的数据重新传输一遍。subscribe() 方法返回一个SseEmitter,通过订阅 Flux 来为 SseEmitter 传输数据。构造函数有一个 onComplete 回调,用于在传输完毕时修改状态。

java 复制代码
public class ResumableSseEmitter {

    @Getter
    private final AtomicReference<FluxSink<SseEmitter.SseEventBuilder>> sink = new AtomicReference<>();
    private final Flux<SseEmitter.SseEventBuilder> flux = Flux.create(sink::set).replay().autoConnect();

    public ResumableSseEmitter(TokenStream tokenStream, Runnable onComplete) {
        tokenStream.onPartialResponse(s -> {
            sink.get().next(new SseEventBuilderImpl().data(s));
        });
        tokenStream.onCompleteResponse(c -> {
            sink.get().complete();
            onComplete.run();
        });
        tokenStream.onError(throwable -> {
            sink.get().next(new SseEventBuilderImpl().name("error").data("发生了错误,请稍后再试"));
            // sink.get().error(throwable);
            sink.get().complete();
            onComplete.run();
        });
        tokenStream.start();
    }

    public SseEmitter subscribe() {
        SseEmitter sseEmitter = new SseEmitter(0L);
        flux.subscribe(s -> {
            try {
                sseEmitter.send(s);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }, sseEmitter::completeWithError, sseEmitter::complete);
        return sseEmitter;
    }
}

这样,就可以使用一个 Map 来储存 ResumableSseEmitter,实现一个 resume() 接口,用于续传数据:

java 复制代码
public static final Map<String, ResumableSseEmitter> EMITTER_MAP = new ConcurrentHashMap<>();

public SseEmitter resume() {
      String memoryId = //...
      ResumableSseEmitter emitter = EMITTER_MAP.get(memoryId);
      if (emitter == null) {
          throw new BusinessException(ErrorEnum.NO_CHAT_RUNNING);
      }
      return emitter.subscribe();
  }

当然,每个用户还需要有唯一标识来区分。

这种方案最好配套持久化消息记录使用,这里使用 Redis 举例,大家可以自己实现 langchain4j 提供的 ChatMemoryStore接口:

java 复制代码
@Component
public class RedisChatMemoryStore implements ChatMemoryStore {

    @Resource
    private RedisCache redisCache;
    private final ObjectMapper objectMapper;

    public RedisChatMemoryStore() {
        this.objectMapper = new ObjectMapper();
        SimpleModule module = new SimpleModule();
        module.addSerializer(ChatMessage.class, new ChatMessageSerializer());
        module.addDeserializer(ChatMessage.class, new ChatMessageDeserializer());
        objectMapper.registerModule(module);
    }

    @SneakyThrows
    @Override
    public List<ChatMessage> getMessages(Object memoryId) {
        String json = redisCache.get("chat:message:" + memoryId, String.class);
        if (json == null) {
            return new ArrayList<>();
        }
        List<ChatMessage> messages = objectMapper.readValue(json, new TypeReference<>() {
        });
        if (messages == null) {
            return new ArrayList<>();
        }
        return messages;
    }

    @SneakyThrows
    @Override
    public void updateMessages(Object memoryId, List<ChatMessage> messages) {
        String json = objectMapper.writeValueAsString(messages);
        redisCache.set("chat:message:" + memoryId, json, 7, TimeUnit.DAYS);
    }

    @Override
    public void deleteMessages(Object memoryId) {
        redisCache.remove("chat:message:" + memoryId);
    }

    private static class ChatMessageSerializer extends JsonSerializer<ChatMessage> {
        @Override
        public void serialize(ChatMessage chatMessage, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
            jsonGenerator.writeStartObject();
            if (chatMessage instanceof ToolExecutionResultMessage toolExecutionResultMessage) {
                jsonGenerator.writeStringField("id", toolExecutionResultMessage.id());
                jsonGenerator.writeStringField("toolName", toolExecutionResultMessage.toolName());
            }
            jsonGenerator.writeStringField("type", chatMessage.type().name());
            jsonGenerator.writeStringField("text", chatMessage.text());
            jsonGenerator.writeEndObject();
        }
    }

    private static class ChatMessageDeserializer extends JsonDeserializer<ChatMessage> {
        @Override
        public ChatMessage deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException {
            ObjectMapper objectMapper = (ObjectMapper) jsonParser.getCodec();
            JsonNode node = objectMapper.readTree(jsonParser);
            ChatMessageType type = ChatMessageType.valueOf(node.get("type").asText());
            String text = node.get("text").asText();
            return switch (type) {
                case SYSTEM -> new SystemMessage(text);
                case USER -> new UserMessage(text);
                case AI -> new AiMessage(text);
                case TOOL_EXECUTION_RESULT ->
                        new ToolExecutionResultMessage(node.get("id").asText(), node.get("toolName").asText(), text);
                default -> throw new IllegalArgumentException("Unknown chat message type: " + type);
            };
        }
    }
}

然后,在构造 Agent 时,传入这个 ChatMemoryStore 就可以了。

java 复制代码
this.agent = AiServices.builder(Agent.class)
        .streamingChatLanguageModel(model)
        .tools(elasticsearchTools)
  			// 传入 ChatMemoryProvider
        .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder()
                .id(memoryId)
                .maxMessages(10)
                .chatMemoryStore(chatMemoryStore)
                .build())
        .systemMessageProvider(s -> prompt)
        .build();

分布式系统中断恢复

在生产环境中,部署在单节点上的服务往往存在单点风险,而分布式系统(例如 K8s 容器集群 + 网关负载均衡)可以实现更好的可用性和扩展性。但相应地,也带来了一些复杂性,尤其是在处理流式连接、状态一致性、用户重连等问题时。

与单机不同,分布式环境中用户请求可能会被负载均衡器随机分发到不同节点上,而每个节点的内存和状态又是独立的,这就带来了一个挑战:如何让任意节点都能接管和恢复一个"中断的流式会话"?

由此,我们可以提出我们的目标:

  1. 无状态服务设计:服务本身不保存对话状态,状态持久化到共享中间件。
  2. 幂等重连接口:支持用户在任意节点请求恢复流式对话。
  3. 全局唯一会话 ID:标识每一轮对话,用于索引状态与控制重连。

在多实例部署或微服务架构中,仅靠 Map 实现的 emitterRegistry 是无法满足分布式要求的。消息队列是解决跨节点推送 token 的核心组件,可以将 Langchain4j 生成的 token 数据广播至多个 Web 前端连接消费者,并实现了两条信息流的解耦。

  1. Langchain4j 节点推送 token 到 MQ

在 onPartialResponse(token -> {}) 中:

java 复制代码
kafkaTemplate.send("chat-stream-" + memoryId, token);
  1. SSE 节点消费 MQ 并转发给前端
java 复制代码
@KafkaListener(topics = "chat-stream-${memoryId}", groupId = "sse-${memoryId}")
public void onTokenReceived(String token) {
    emitterRegistry.push(memoryId, token); // 或直接 emitter.send(...)
}

Kafka 也可以用其它例如 Redis Stream 等中间件代替。这样就实现了一套高可用的流式传输方案。

如果你看懂了这篇文章,不妨点个赞或者转发一下;如果还有疑问,也欢迎评论交流。

相关推荐
王国强20093 小时前
LangChain 设计原理分析³ | 从零实现一个自定义 Runnable 模块
langchain
喵王叭3 小时前
【大模型核心技术】Agent 理论与实战
人工智能·langchain
Nero4 小时前
SpringBoot对接LangChain4J四件套
langchain
都叫我大帅哥6 小时前
幽默深度指南:LangChain中的RunnableParallel - 让AI任务像交响乐团般协同工作
python·langchain·ai编程
GetcharZp17 小时前
别再只知道 ChatGPT 了!用 LangChain 撸了个“AI 智能体”,自动化工作不是梦!
langchain·agent
GetcharZp1 天前
RAG 应用进阶指南:别再“一次性”加载了!教你构建可分离、可维护的动态 AI 知识库
langchain·llm·deepseek
都叫我大帅哥1 天前
当数据流经LangChain时,RunnablePassthrough如何成为“最懒却最聪明”的快递员?
python·langchain