feat(XL13): 支持工作流大模型流式输出

- 增加文本与思考增量事件及取消终态收口

- 支持图片输入解析并修复死信定义查找空值

- 补充并发与模型流式回归测试
This commit is contained in:
2026-07-31 09:40:38 +08:00
parent e74d229de2
commit fcc36dc699
10 changed files with 907 additions and 26 deletions

View File

@@ -28,7 +28,11 @@
<artifactId>easy-agents-core</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -2,46 +2,240 @@ package com.easyagents.flow.support.provider;
import com.easyagents.core.message.AiMessage;
import com.easyagents.core.message.SystemMessage;
import com.easyagents.core.model.chat.BaseChatModel;
import com.easyagents.core.model.chat.ChatModel;
import com.easyagents.core.model.chat.StreamResponseListener;
import com.easyagents.core.model.client.StreamContext;
import com.easyagents.core.model.chat.response.AiMessageResponse;
import com.easyagents.core.prompt.SimplePrompt;
import com.easyagents.core.util.ImageUtil;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.event.ChainStatusChangeEvent;
import com.easyagents.flow.core.chain.event.LlmStreamEvent;
import com.easyagents.flow.core.chain.listener.ChainEventListener;
import com.easyagents.flow.core.llm.Llm;
import com.easyagents.flow.core.node.LlmNode;
import com.easyagents.flow.core.util.StringUtil;
import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicReference;
/**
* 基于 Easy-Agents 聊天模型实现工作流 LLM 调用。
*/
public class EasyAgentsLlm implements Llm {
private ChatModel chatModel;
private ImageInputResolver imageInputResolver;
/**
* 获取聊天模型。
*
* @return 聊天模型
*/
public ChatModel getChatModel() {
return chatModel;
}
/**
* 设置聊天模型。
*
* @param chatModel 聊天模型
*/
public void setChatModel(ChatModel chatModel) {
this.chatModel = chatModel;
}
/**
* 获取图片输入解析器。
*
* @return 图片输入解析器
*/
public ImageInputResolver getImageInputResolver() {
return imageInputResolver;
}
/**
* 设置图片输入解析器。
*
* @param imageInputResolver 图片输入解析器
*/
public void setImageInputResolver(ImageInputResolver imageInputResolver) {
this.imageInputResolver = imageInputResolver;
}
/**
* 调用聊天模型并返回文本结果。
*
* @param messageInfo 消息信息
* @param options 模型参数
* @param llmNode 当前 LLM 节点
* @param chain 工作流链
* @return 模型文本结果
*/
@Override
public String chat(MessageInfo messageInfo, ChatOptions options, LlmNode llmNode, Chain chain) {
SimplePrompt prompt = buildPrompt(messageInfo);
com.easyagents.core.model.chat.ChatOptions chatOptions = buildChatOptions(options);
CountDownLatch completion = new CountDownLatch(1);
AtomicReference<StreamContext> streamContext = new AtomicReference<>();
AtomicReference<String> result = new AtomicReference<>();
AtomicReference<Throwable> failure = new AtomicReference<>();
String streamId = UUID.randomUUID().toString();
ChainEventListener cancellationListener = (event, eventChain) -> {
if (!(event instanceof ChainStatusChangeEvent statusEvent)
|| statusEvent.getStatus() != ChainStatus.CANCELLED
|| !chain.getStateInstanceId().equals(eventChain.getStateInstanceId())) {
return;
}
StreamContext context = streamContext.get();
if (context != null) {
context.getClient().stop();
}
};
chain.getEventManager().addEventListener(
ChainStatusChangeEvent.class, cancellationListener);
try {
chatModel.chatStream(prompt, new StreamResponseListener() {
/**
* 记录流客户端,供工作流取消时立即关闭模型连接。
*
* @param context 流上下文
*/
@Override
public void onStart(StreamContext context) {
streamContext.set(context);
}
/**
* 将模型文本增量转发为工作流事件。
*
* @param context 流上下文
* @param response 本次模型响应
*/
@Override
public void onMessage(StreamContext context, AiMessageResponse response) {
AiMessage message = response == null ? null : response.getMessage();
if (message == null) {
return;
}
String reasoningDelta = message.getReasoningContent();
if (StringUtil.hasText(reasoningDelta)) {
chain.notifyEvent(new LlmStreamEvent(
chain,
llmNode,
streamId,
reasoningDelta,
LlmStreamEvent.ContentType.REASONING
));
}
String textDelta = message.getContent();
if (StringUtil.hasText(textDelta)) {
chain.notifyEvent(new LlmStreamEvent(
chain,
llmNode,
streamId,
textDelta,
LlmStreamEvent.ContentType.TEXT
));
}
}
/**
* 收集完整响应并结束同步节点等待。
*
* @param context 流上下文
*/
@Override
public void onStop(StreamContext context) {
try {
if (failure.get() == null) {
AiMessage message = context.getFullMessage();
if (message == null || StringUtil.noText(message.getFullContent())) {
failure.compareAndSet(
null,
new IllegalStateException(
"EasyAgentsLlm can not get aiMessage!"));
} else {
result.set(message.getFullContent());
}
}
} finally {
completion.countDown();
}
}
/**
* 记录模型流异常并结束等待。
*
* @param context 流上下文
* @param throwable 模型异常
*/
@Override
public void onFailure(StreamContext context, Throwable throwable) {
failure.compareAndSet(
null,
throwable == null
? new IllegalStateException("EasyAgentsLlm stream failed")
: throwable);
completion.countDown();
}
}, chatOptions);
awaitCompletion(completion, streamContext);
} finally {
chain.getEventManager().removeEventListener(
ChainStatusChangeEvent.class, cancellationListener);
}
Throwable throwable = failure.get();
if (throwable != null) {
throw new RuntimeException("EasyAgentsLlm stream failed", throwable);
}
if (StringUtil.noText(result.get())) {
throw new RuntimeException("EasyAgentsLlm can not get response!");
}
return result.get();
}
/**
* 构建模型提示词,并解析图片输入。
*
* @param messageInfo 消息信息
* @return 模型提示词
*/
private SimplePrompt buildPrompt(MessageInfo messageInfo) {
SimplePrompt prompt = new SimplePrompt(messageInfo.getMessage());
// 系统提示词
if (StringUtil.hasText(messageInfo.getSystemMessage())) {
prompt.setSystemMessage(SystemMessage.of(messageInfo.getSystemMessage()));
}
// 图片
List<String> images = messageInfo.getImages();
List<String> images = resolveImages(messageInfo);
if (images != null && !images.isEmpty()) {
assertImageSupported();
for (String image : images) {
prompt.addImageUrl(image);
}
}
return prompt;
}
/**
* 构建 Easy-Agents 模型参数。
*
* @param options 工作流模型参数
* @return Easy-Agents 模型参数
*/
private com.easyagents.core.model.chat.ChatOptions buildChatOptions(ChatOptions options) {
com.easyagents.core.model.chat.ChatOptions chatOptions = new com.easyagents.core.model.chat.ChatOptions();
chatOptions.setSeed(options.getSeed());
chatOptions.setTemperature(options.getTemperature());
@@ -49,21 +243,89 @@ public class EasyAgentsLlm implements Llm {
chatOptions.setTopK(options.getTopK());
chatOptions.setMaxTokens(options.getMaxTokens());
chatOptions.setStop(options.getStop());
return chatOptions;
}
AiMessageResponse response = chatModel.chat(prompt, chatOptions);
if (response == null) {
throw new RuntimeException("EasyAgentsLlm can not get response!");
/**
* 等待异步模型流结束。
*
* @param completion 完成信号
* @param streamContext 当前模型流上下文
*/
private void awaitCompletion(
CountDownLatch completion,
AtomicReference<StreamContext> streamContext) {
try {
completion.await();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
StreamContext context = streamContext.get();
if (context != null) {
context.getClient().stop();
}
throw new RuntimeException("EasyAgentsLlm stream interrupted", exception);
}
}
/**
* 将原始图片输入解析为模型图片 URL。
*
* @param messageInfo 消息信息
* @return 已解析图片 URL 列表
*/
List<String> resolveImages(MessageInfo messageInfo) {
if (messageInfo == null) {
return Collections.emptyList();
}
List<?> inputs = messageInfo.getImageInputs();
if (inputs == null || inputs.isEmpty()) {
inputs = messageInfo.getImages();
}
if (inputs == null || inputs.isEmpty()) {
return Collections.emptyList();
}
if (response.isError()) {
throw new RuntimeException("EasyAgentsLlm error: " + response.getErrorMessage());
List<String> resolvedImages = new ArrayList<>(inputs.size());
for (Object input : inputs) {
if (input == null) {
continue;
}
String resolvedImage = resolveImage(input);
if (StringUtil.noText(resolvedImage)) {
throw new IllegalArgumentException("Resolved image input must not be blank");
}
resolvedImages.add(resolvedImage);
}
return resolvedImages;
}
AiMessage aiMessage = response.getMessage();
if (aiMessage != null) {
return aiMessage.getTextContent();
/**
* 解析单个图片输入。
*
* @param imageInput 原始图片输入
* @return 图片 URL 或 Data URI
*/
private String resolveImage(Object imageInput) {
if (imageInputResolver != null) {
return imageInputResolver.resolve(imageInput);
}
if (imageInput instanceof String value) {
return value;
}
if (imageInput instanceof File file) {
return ImageUtil.imageFileToDataUri(file);
}
throw new IllegalArgumentException(
"Unsupported image input type: " + imageInput.getClass().getName());
}
throw new RuntimeException("EasyAgentsLlm can not get aiMessage!");
/**
* 校验当前聊天模型是否明确支持图片。
*/
private void assertImageSupported() {
if (chatModel instanceof BaseChatModel<?> baseChatModel
&& Boolean.FALSE.equals(baseChatModel.getConfig().getSupportImage())) {
throw new IllegalArgumentException("当前模型不支持图片输入,请选择支持视觉能力的模型");
}
}
}

View File

@@ -0,0 +1,16 @@
package com.easyagents.flow.support.provider;
/**
* 将工作流运行态图片输入解析为模型可消费的图片 URL 或 Data URI。
*/
@FunctionalInterface
public interface ImageInputResolver {
/**
* 解析单个图片输入。
*
* @param imageInput 原始图片输入
* @return 图片 URL 或带 MIME 的 Data URI
*/
String resolve(Object imageInput);
}

View File

@@ -0,0 +1,314 @@
package com.easyagents.flow.support.provider;
import com.easyagents.core.model.chat.BaseChatModel;
import com.easyagents.core.model.chat.ChatConfig;
import com.easyagents.core.model.chat.ChatModel;
import com.easyagents.core.model.chat.ChatOptions;
import com.easyagents.core.model.chat.StreamResponseListener;
import com.easyagents.core.model.chat.response.AiMessageResponse;
import com.easyagents.core.model.client.StreamClient;
import com.easyagents.core.model.client.StreamContext;
import com.easyagents.core.prompt.Prompt;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.EventManager;
import com.easyagents.flow.core.chain.event.LlmStreamEvent;
import com.easyagents.flow.core.llm.Llm;
import com.easyagents.flow.core.node.LlmNode;
import org.junit.Assert;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 工作流 LLM 图片输入解析测试。
*/
public class EasyAgentsLlmTest {
/**
* 验证结构化图片对象通过注入的解析器转换。
*/
@Test
public void shouldResolveRawImageInput() {
EasyAgentsLlm llm = new EasyAgentsLlm();
llm.setImageInputResolver(input -> {
Assert.assertTrue(input instanceof Map<?, ?>);
return "data:image/png;base64,AQID";
});
Llm.MessageInfo messageInfo = new Llm.MessageInfo();
messageInfo.setImageInputs(List.of(Map.of(
"sourceType", "upload",
"fileName", "image.png",
"filePath", "workflow/image.png")));
Assert.assertEquals(
List.of("data:image/png;base64,AQID"),
llm.resolveImages(messageInfo));
}
/**
* 验证旧版图片字符串仍然可直接传递。
*/
@Test
public void shouldKeepLegacyImageString() {
EasyAgentsLlm llm = new EasyAgentsLlm();
Llm.MessageInfo messageInfo = new Llm.MessageInfo();
messageInfo.setImages(List.of("https://example.com/image.png"));
Assert.assertEquals(
List.of("https://example.com/image.png"),
llm.resolveImages(messageInfo));
}
/**
* 验证 File 图片会补全 MIME 和 Data URI 前缀。
*
* @throws Exception 临时文件写入失败
*/
@Test
public void shouldConvertFileToCompleteDataUri() throws Exception {
Path image = Files.createTempFile("easy-agents-image-", ".png");
try {
Files.write(image, new byte[]{1, 2, 3});
EasyAgentsLlm llm = new EasyAgentsLlm();
Llm.MessageInfo messageInfo = new Llm.MessageInfo();
messageInfo.setImageInputs(List.of(image.toFile()));
Assert.assertEquals(
List.of("data:image/png;base64,AQID"),
llm.resolveImages(messageInfo));
} finally {
Files.deleteIfExists(image);
}
}
/**
* 验证明确不支持图片的模型会在发送请求前失败。
*/
@Test
public void shouldRejectImageForUnsupportedModel() {
ChatConfig config = new ChatConfig();
config.setSupportImage(Boolean.FALSE);
EasyAgentsLlm llm = new EasyAgentsLlm();
llm.setChatModel(new BaseChatModel<>(config) {
});
Llm.MessageInfo messageInfo = new Llm.MessageInfo();
messageInfo.setImages(List.of("data:image/png;base64,AQID"));
try {
llm.chat(messageInfo, new Llm.ChatOptions(), null, null);
Assert.fail("expected IllegalArgumentException");
} catch (IllegalArgumentException exception) {
Assert.assertEquals(
"当前模型不支持图片输入,请选择支持视觉能力的模型",
exception.getMessage());
}
}
/**
* 验证重复执行同一 LLM 节点时,每次调用拥有独立流标识且增量不会被覆盖。
*/
@Test
public void shouldEmitIndependentStreamForRepeatedNodeInvocation() {
EasyAgentsLlm llm = new EasyAgentsLlm();
llm.setChatModel(new DeterministicStreamChatModel());
Chain chain = new Chain(new ChainDefinition(), "stream-test");
EventManager eventManager = new EventManager();
chain.setEventManager(eventManager);
List<LlmStreamEvent> events = new ArrayList<>();
eventManager.addEventListener(
LlmStreamEvent.class,
(event, currentChain) -> events.add((LlmStreamEvent) event));
LlmNode node = new LlmNode();
node.setId("llm-1");
Llm.MessageInfo messageInfo = new Llm.MessageInfo();
messageInfo.setMessage("请回答");
Assert.assertEquals(
"你好",
llm.chat(messageInfo, new Llm.ChatOptions(), node, chain));
Assert.assertEquals(
"你好",
llm.chat(messageInfo, new Llm.ChatOptions(), node, chain));
Assert.assertEquals(4, events.size());
Assert.assertEquals(List.of("", "", "", ""), events.stream()
.map(LlmStreamEvent::getDelta)
.toList());
Assert.assertTrue(events.stream().noneMatch(LlmStreamEvent::isReasoning));
Assert.assertEquals(events.get(0).getStreamId(), events.get(1).getStreamId());
Assert.assertEquals(events.get(2).getStreamId(), events.get(3).getStreamId());
Assert.assertNotEquals(events.get(0).getStreamId(), events.get(2).getStreamId());
}
/**
* 验证模型思考与正式回答使用同一流标识并按内容类型分别发布。
*/
@Test
public void shouldEmitReasoningAndTextDeltasSeparately() {
EasyAgentsLlm llm = new EasyAgentsLlm();
llm.setChatModel(new ReasoningStreamChatModel());
Chain chain = new Chain(new ChainDefinition(), "reasoning-stream-test");
EventManager eventManager = new EventManager();
chain.setEventManager(eventManager);
List<LlmStreamEvent> events = new ArrayList<>();
eventManager.addEventListener(
LlmStreamEvent.class,
(event, currentChain) -> events.add((LlmStreamEvent) event));
LlmNode node = new LlmNode();
node.setId("llm-reasoning");
Llm.MessageInfo messageInfo = new Llm.MessageInfo();
messageInfo.setMessage("请回答");
Assert.assertEquals(
"答案",
llm.chat(messageInfo, new Llm.ChatOptions(), node, chain));
Assert.assertEquals(4, events.size());
Assert.assertEquals(
List.of("", "", "", ""),
events.stream().map(LlmStreamEvent::getDelta).toList());
Assert.assertEquals(
List.of(
LlmStreamEvent.ContentType.REASONING,
LlmStreamEvent.ContentType.REASONING,
LlmStreamEvent.ContentType.TEXT,
LlmStreamEvent.ContentType.TEXT
),
events.stream().map(LlmStreamEvent::getContentType).toList());
Assert.assertEquals(
1,
events.stream().map(LlmStreamEvent::getStreamId).distinct().count());
}
/**
* 固定输出两个文本增量的测试聊天模型。
*/
private static final class DeterministicStreamChatModel implements ChatModel {
/**
* 同步聊天接口不参与本测试。
*
* @param prompt 提示词
* @param options 模型参数
* @return 无
*/
@Override
public AiMessageResponse chat(Prompt prompt, ChatOptions options) {
throw new UnsupportedOperationException("sync chat is not used");
}
/**
* 连续发送两个增量及完整消息。
*
* @param prompt 提示词
* @param listener 流监听器
* @param options 模型参数
*/
@Override
public void chatStream(
Prompt prompt,
StreamResponseListener listener,
ChatOptions options) {
StreamContext context = new StreamContext(this, null, new NoopStreamClient());
listener.onStart(context);
for (String content : List.of("", "")) {
com.easyagents.core.message.AiMessage delta =
new com.easyagents.core.message.AiMessage();
delta.setContent(content);
listener.onMessage(context, new AiMessageResponse(null, content, delta));
}
context.setFullMessage(new com.easyagents.core.message.AiMessage("你好"));
listener.onStop(context);
}
}
/**
* 固定输出思考与回答增量的测试聊天模型。
*/
private static final class ReasoningStreamChatModel implements ChatModel {
/**
* 同步聊天接口不参与本测试。
*
* @param prompt 提示词
* @param options 模型参数
* @return 无
*/
@Override
public AiMessageResponse chat(Prompt prompt, ChatOptions options) {
throw new UnsupportedOperationException("sync chat is not used");
}
/**
* 依次发送思考增量、回答增量及完整消息。
*
* @param prompt 提示词
* @param listener 流监听器
* @param options 模型参数
*/
@Override
public void chatStream(
Prompt prompt,
StreamResponseListener listener,
ChatOptions options) {
StreamContext context = new StreamContext(this, null, new NoopStreamClient());
listener.onStart(context);
for (String reasoning : List.of("", "")) {
com.easyagents.core.message.AiMessage delta =
new com.easyagents.core.message.AiMessage();
delta.setReasoningContent(reasoning);
listener.onMessage(context, new AiMessageResponse(null, reasoning, delta));
}
for (String content : List.of("", "")) {
com.easyagents.core.message.AiMessage delta =
new com.easyagents.core.message.AiMessage();
delta.setContent(content);
listener.onMessage(context, new AiMessageResponse(null, content, delta));
}
com.easyagents.core.message.AiMessage fullMessage =
new com.easyagents.core.message.AiMessage("答案");
fullMessage.setFullReasoningContent("先想");
context.setFullMessage(fullMessage);
listener.onStop(context);
}
}
/**
* 测试使用的空流客户端。
*/
private static final class NoopStreamClient implements StreamClient {
/**
* 测试模型直接分发事件,无需启动网络请求。
*
* @param url 请求地址
* @param headers 请求头
* @param payload 请求体
* @param listener 客户端监听器
* @param config 模型配置
*/
@Override
public void start(
String url,
Map<String, String> headers,
String payload,
com.easyagents.core.model.client.StreamClientListener listener,
ChatConfig config) {
}
/**
* 测试客户端没有需要关闭的网络资源。
*/
@Override
public void stop() {
}
}
}