From fcc36dc699635389cae36a015010853cd3127a65 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com>
Date: Fri, 31 Jul 2026 09:40:38 +0800
Subject: [PATCH] =?UTF-8?q?feat(XL13):=20=E6=94=AF=E6=8C=81=E5=B7=A5?=
=?UTF-8?q?=E4=BD=9C=E6=B5=81=E5=A4=A7=E6=A8=A1=E5=9E=8B=E6=B5=81=E5=BC=8F?=
=?UTF-8?q?=E8=BE=93=E5=87=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 增加文本与思考增量事件及取消终态收口
- 支持图片输入解析并修复死信定义查找空值
- 补充并发与模型流式回归测试
---
.../com/easyagents/flow/core/chain/Chain.java | 16 +-
.../flow/core/chain/event/LlmStreamEvent.java | 126 +++++++
.../core/chain/runtime/ChainExecutor.java | 29 +-
.../com/easyagents/flow/core/llm/Llm.java | 19 ++
.../easyagents/flow/core/node/LlmNode.java | 23 +-
.../test/ChainExecutorConcurrencyTest.java | 98 ++++++
easy-agents-support/pom.xml | 6 +-
.../flow/support/provider/EasyAgentsLlm.java | 286 +++++++++++++++-
.../support/provider/ImageInputResolver.java | 16 +
.../support/provider/EasyAgentsLlmTest.java | 314 ++++++++++++++++++
10 files changed, 907 insertions(+), 26 deletions(-)
create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/LlmStreamEvent.java
create mode 100644 easy-agents-support/src/main/java/com/easyagents/flow/support/provider/ImageInputResolver.java
create mode 100644 easy-agents-support/src/test/java/com/easyagents/flow/support/provider/EasyAgentsLlmTest.java
diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Chain.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Chain.java
index df5c58e..9fc1fb1 100644
--- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Chain.java
+++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Chain.java
@@ -778,7 +778,19 @@ public class Chain {
if (state == null) {
throw new IllegalStateException("Unable to initialize chain state: " + stateInstanceId);
}
- return state;
+ if (StringUtil.hasText(state.getChainDefinitionId())
+ || definition == null
+ || StringUtil.noText(definition.getId())) {
+ return state;
+ }
+ // 定义 ID 必须先于可重放入口触发器持久化,避免恢复时无法定位定义快照。
+ return updateStateSafely(current -> {
+ if (StringUtil.hasText(current.getChainDefinitionId())) {
+ return null;
+ }
+ current.setChainDefinitionId(definition.getId());
+ return EnumSet.of(ChainStateField.CHAIN_DEFINITION_ID);
+ });
}
private boolean shouldSkipNode(Node node, String edgeId) {
@@ -1613,6 +1625,8 @@ public class Chain {
if (changed.get()) {
notifyEvent(new ChainStatusChangeEvent(
this, ChainStatus.CANCELLED, before.get()));
+ // 取消属于工作流终态,统一发布结束事件供审计、清理等监听器收口。
+ notifyEvent(new ChainEndEvent(this));
}
return changed.get();
}
diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/LlmStreamEvent.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/LlmStreamEvent.java
new file mode 100644
index 0000000..bbcdc9f
--- /dev/null
+++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/LlmStreamEvent.java
@@ -0,0 +1,126 @@
+/**
+ * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com).
+ *
+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.gnu.org/licenses/lgpl-3.0.txt
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.easyagents.flow.core.chain.event;
+
+import com.easyagents.flow.core.chain.Chain;
+import com.easyagents.flow.core.chain.Node;
+
+/**
+ * LLM 节点生成文本时发布的增量事件。
+ */
+public class LlmStreamEvent extends BaseEvent {
+
+ private final Node node;
+ private final String streamId;
+ private final String delta;
+ private final ContentType contentType;
+
+ /**
+ * LLM 流式内容类型。
+ */
+ public enum ContentType {
+ /**
+ * 模型正式回答。
+ */
+ TEXT,
+ /**
+ * 模型思考过程。
+ */
+ REASONING
+ }
+
+ /**
+ * 创建 LLM 文本增量事件。
+ *
+ * @param chain 当前工作流
+ * @param node 当前 LLM 节点
+ * @param streamId 当前节点本次调用的流标识
+ * @param delta 本次新增文本
+ */
+ public LlmStreamEvent(Chain chain, Node node, String streamId, String delta) {
+ this(chain, node, streamId, delta, ContentType.TEXT);
+ }
+
+ /**
+ * 创建指定内容类型的 LLM 增量事件。
+ *
+ * @param chain 当前工作流
+ * @param node 当前 LLM 节点
+ * @param streamId 当前节点本次调用的流标识
+ * @param delta 本次新增内容
+ * @param contentType 增量内容类型
+ */
+ public LlmStreamEvent(
+ Chain chain,
+ Node node,
+ String streamId,
+ String delta,
+ ContentType contentType
+ ) {
+ super(chain);
+ this.node = node;
+ this.streamId = streamId;
+ this.delta = delta;
+ this.contentType = contentType == null
+ ? ContentType.TEXT
+ : contentType;
+ }
+
+ /**
+ * 获取当前 LLM 节点。
+ *
+ * @return 当前节点
+ */
+ public Node getNode() {
+ return node;
+ }
+
+ /**
+ * 获取当前节点本次调用的流标识。
+ *
+ * @return 流标识
+ */
+ public String getStreamId() {
+ return streamId;
+ }
+
+ /**
+ * 获取本次新增文本。
+ *
+ * @return 文本增量
+ */
+ public String getDelta() {
+ return delta;
+ }
+
+ /**
+ * 获取本次增量的内容类型。
+ *
+ * @return 内容类型
+ */
+ public ContentType getContentType() {
+ return contentType;
+ }
+
+ /**
+ * 判断本次增量是否为模型思考内容。
+ *
+ * @return {@code true} 表示思考内容
+ */
+ public boolean isReasoning() {
+ return contentType == ContentType.REASONING;
+ }
+}
diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainExecutor.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainExecutor.java
index 130d1a8..c2491e2 100644
--- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainExecutor.java
+++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainExecutor.java
@@ -28,6 +28,7 @@ import org.slf4j.LoggerFactory;
import java.io.Serializable;
import java.util.*;
import java.util.concurrent.*;
+import java.util.function.Consumer;
/**
* TinyFlow 最新 ChainExecutor
@@ -397,8 +398,29 @@ public class ChainExecutor {
}
public String executeAsync(String definitionId, Map variables) {
+ return executeAsync(definitionId, variables, null);
+ }
+
+ /**
+ * 异步启动工作流,并在首个节点开始前暴露执行实例 ID。
+ *
+ * 回调用于提前注册流式事件接收器,保证高速工作流不会在调用方拿到执行 ID
+ * 之前丢失开始事件或首批输出。
+ *
+ * @param definitionId 工作流定义 ID
+ * @param variables 工作流输入变量
+ * @param beforeStart 启动前回调;可为 {@code null}
+ * @return 执行实例 ID
+ */
+ public String executeAsync(
+ String definitionId,
+ Map variables,
+ Consumer beforeStart) {
Chain chain = createChain(definitionId);
try {
+ if (beforeStart != null) {
+ beforeStart.accept(chain.getStateInstanceId());
+ }
chain.start(variables);
return chain.getStateInstanceId();
} catch (RuntimeException | Error error) {
@@ -962,9 +984,12 @@ public class ChainExecutor {
return definition;
}
ChainDefinition loaded = definitionSnapshotRepository.load(stateInstanceId);
- if (loaded == null) {
+ String definitionId = state.getChainDefinitionId();
+ if (loaded == null
+ && definitionId != null
+ && !definitionId.isBlank()) {
// 兼容升级前已经启动、尚未持久化定义快照的实例。
- loaded = definitionRepository.getChainDefinitionById(state.getChainDefinitionId());
+ loaded = definitionRepository.getChainDefinitionById(definitionId);
}
if (loaded == null) {
return null;
diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/Llm.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/Llm.java
index d681115..1bcf699 100644
--- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/Llm.java
+++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/Llm.java
@@ -50,6 +50,7 @@ public interface Llm {
private String message;
private String systemMessage;
private List images;
+ private List imageInputs;
public String getMessage() {
return message;
@@ -74,6 +75,24 @@ public interface Llm {
public void setImages(List images) {
this.images = images;
}
+
+ /**
+ * 获取尚未转换为模型图片 URL 的原始图片输入。
+ *
+ * @return 原始图片输入列表
+ */
+ public List getImageInputs() {
+ return imageInputs;
+ }
+
+ /**
+ * 设置原始图片输入,供模型调用前按运行环境解析。
+ *
+ * @param imageInputs 原始图片输入列表
+ */
+ public void setImageInputs(List imageInputs) {
+ this.imageInputs = imageInputs;
+ }
}
/**
diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LlmNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LlmNode.java
index e62e31b..f2e3e0f 100644
--- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LlmNode.java
+++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LlmNode.java
@@ -23,7 +23,6 @@ import com.easyagents.flow.core.llm.Llm;
import com.easyagents.flow.core.llm.LlmManager;
import com.easyagents.flow.core.util.*;
-import java.io.File;
import java.util.*;
public class LlmNode extends BaseNode {
@@ -123,17 +122,21 @@ public class LlmNode extends BaseNode {
Map filesMap =
chainState.resolveParameters(
this, images);
- List imagesUrls = new ArrayList<>();
- filesMap.forEach((s, o) -> {
- if (o instanceof String) {
- imagesUrls.add((String) o);
- } else if (o instanceof File) {
- byte[] bytes = IOUtil.readBytes((File) o);
- String base64 = Base64.getEncoder().encodeToString(bytes);
- imagesUrls.add(base64);
+ List imageInputs = new ArrayList<>(filesMap.size());
+ filesMap.forEach((name, value) -> {
+ if (value == null) {
+ return;
}
+ if (!(value instanceof String)
+ && !(value instanceof java.io.File)
+ && !(value instanceof Map, ?>)) {
+ throw new IllegalArgumentException(
+ "Unsupported image input for parameter '" + name + "': "
+ + value.getClass().getName());
+ }
+ imageInputs.add(value);
});
- messageInfo.setImages(imagesUrls);
+ messageInfo.setImageInputs(imageInputs);
}
diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainExecutorConcurrencyTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainExecutorConcurrencyTest.java
index f1547eb..b9d402d 100644
--- a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainExecutorConcurrencyTest.java
+++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainExecutorConcurrencyTest.java
@@ -20,6 +20,7 @@ import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.Edge;
import com.easyagents.flow.core.chain.ChainState;
+import com.easyagents.flow.core.chain.event.ChainEndEvent;
import com.easyagents.flow.core.chain.repository.ChainDefinitionSnapshotRepository;
import com.easyagents.flow.core.chain.repository.ChainStateField;
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
@@ -28,6 +29,7 @@ import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
import com.easyagents.flow.core.chain.repository.InMemoryNodeStateRepository;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.easyagents.flow.core.chain.runtime.InMemoryTriggerStore;
+import com.easyagents.flow.core.chain.runtime.Trigger;
import com.easyagents.flow.core.chain.runtime.TriggerScheduler;
import com.easyagents.flow.core.node.EndNode;
import com.easyagents.flow.core.node.BaseNode;
@@ -36,6 +38,7 @@ import org.junit.Assert;
import org.junit.Test;
import java.lang.reflect.Field;
+import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
@@ -49,12 +52,100 @@ import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
/**
* {@link ChainExecutor} 并发同步执行测试。
*/
public class ChainExecutorConcurrencyTest {
+ /**
+ * 验证实例初始化会在入口触发器创建前持久化工作流定义 ID。
+ *
+ * @throws Exception 执行器初始化或异步启动失败时抛出
+ */
+ @Test
+ public void shouldPersistDefinitionIdBeforeStartingWorkflow() throws Exception {
+ ScheduledExecutorService schedulerPool = Executors.newSingleThreadScheduledExecutor();
+ ExecutorService workerPool = Executors.newFixedThreadPool(2);
+ TriggerScheduler triggerScheduler = new TriggerScheduler(
+ new InMemoryTriggerStore(), schedulerPool, workerPool, 1000L);
+ ChainDefinition definition = createDefinition();
+ InMemoryChainStateRepository stateRepository =
+ new InMemoryChainStateRepository();
+ ChainExecutor chainExecutor = new ChainExecutor(
+ id -> definition,
+ stateRepository,
+ new InMemoryNodeStateRepository(),
+ triggerScheduler);
+ AtomicReference persistedDefinitionId = new AtomicReference<>();
+
+ try {
+ chainExecutor.executeAsync(
+ definition.getId(),
+ Collections.emptyMap(),
+ executeId -> persistedDefinitionId.set(
+ stateRepository.load(executeId).getChainDefinitionId())
+ );
+
+ Assert.assertEquals(
+ definition.getId(),
+ persistedDefinitionId.get());
+ } finally {
+ triggerScheduler.shutdown();
+ }
+ }
+
+ /**
+ * 验证历史异常实例缺少定义 ID 时,死信触发器仍能收敛为失败终态。
+ *
+ * @throws Exception 反射调用死信收口逻辑失败时抛出
+ */
+ @Test
+ public void shouldFailDeadLetteredWorkflowWithoutDefinitionId()
+ throws Exception {
+ ScheduledExecutorService schedulerPool = Executors.newSingleThreadScheduledExecutor();
+ ExecutorService workerPool = Executors.newFixedThreadPool(2);
+ TriggerScheduler triggerScheduler = new TriggerScheduler(
+ new InMemoryTriggerStore(), schedulerPool, workerPool, 1000L);
+ InMemoryChainStateRepository stateRepository =
+ new InMemoryChainStateRepository();
+ AtomicInteger definitionLoadCount = new AtomicInteger();
+ ChainExecutor chainExecutor = new ChainExecutor(
+ id -> {
+ definitionLoadCount.incrementAndGet();
+ return null;
+ },
+ stateRepository,
+ new InMemoryNodeStateRepository(),
+ triggerScheduler);
+ String instanceId = "dead-letter-missing-definition";
+ stateRepository.create(instanceId);
+ Trigger trigger = new Trigger();
+ trigger.setId("dead-letter-trigger");
+ trigger.setStateInstanceId(instanceId);
+ Method failDeadLetteredTrigger = ChainExecutor.class.getDeclaredMethod(
+ "failDeadLetteredTrigger",
+ Trigger.class,
+ Throwable.class);
+ failDeadLetteredTrigger.setAccessible(true);
+
+ try {
+ boolean finalized = (boolean) failDeadLetteredTrigger.invoke(
+ chainExecutor,
+ trigger,
+ new IllegalStateException("delivery attempts exhausted"));
+
+ Assert.assertTrue(finalized);
+ Assert.assertEquals(
+ ChainStatus.FAILED,
+ stateRepository.load(instanceId).getStatus());
+ Assert.assertEquals(0, definitionLoadCount.get());
+ } finally {
+ triggerScheduler.shutdown();
+ }
+ }
+
/**
* 验证多个同步调用可以通过实例 ID 独立接收执行结果。
*
@@ -143,6 +234,7 @@ public class ChainExecutorConcurrencyTest {
CountDownLatch ioStarted = new CountDownLatch(1);
CountDownLatch allowIoCompletion = new CountDownLatch(1);
AtomicInteger downstreamExecutions = new AtomicInteger();
+ AtomicInteger chainEndEvents = new AtomicInteger();
ChainDefinition definition = createCancellationDefinition(
ioStarted, allowIoCompletion, downstreamExecutions);
ChainExecutor chainExecutor = new ChainExecutor(
@@ -150,6 +242,11 @@ public class ChainExecutorConcurrencyTest {
chainStateRepository,
new InMemoryNodeStateRepository(),
triggerScheduler);
+ chainExecutor.addEventListener((event, chain) -> {
+ if (event instanceof ChainEndEvent) {
+ chainEndEvents.incrementAndGet();
+ }
+ });
try {
String instanceId = chainExecutor.executeAsync(
@@ -163,6 +260,7 @@ public class ChainExecutorConcurrencyTest {
Assert.assertEquals(
ChainStatus.CANCELLED,
chainStateRepository.load(instanceId).getStatus());
+ Assert.assertEquals(1, chainEndEvents.get());
} finally {
allowIoCompletion.countDown();
triggerScheduler.shutdown();
diff --git a/easy-agents-support/pom.xml b/easy-agents-support/pom.xml
index b25b315..0dfe000 100644
--- a/easy-agents-support/pom.xml
+++ b/easy-agents-support/pom.xml
@@ -28,7 +28,11 @@
easy-agents-core
-
+
+ junit
+ junit
+ test
+
diff --git a/easy-agents-support/src/main/java/com/easyagents/flow/support/provider/EasyAgentsLlm.java b/easy-agents-support/src/main/java/com/easyagents/flow/support/provider/EasyAgentsLlm.java
index 9ab2422..d09d295 100644
--- a/easy-agents-support/src/main/java/com/easyagents/flow/support/provider/EasyAgentsLlm.java
+++ b/easy-agents-support/src/main/java/com/easyagents/flow/support/provider/EasyAgentsLlm.java
@@ -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 = new AtomicReference<>();
+ AtomicReference result = new AtomicReference<>();
+ AtomicReference 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 images = messageInfo.getImages();
+ List 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) {
+ 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 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 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("当前模型不支持图片输入,请选择支持视觉能力的模型");
+ }
}
}
diff --git a/easy-agents-support/src/main/java/com/easyagents/flow/support/provider/ImageInputResolver.java b/easy-agents-support/src/main/java/com/easyagents/flow/support/provider/ImageInputResolver.java
new file mode 100644
index 0000000..9bdf14d
--- /dev/null
+++ b/easy-agents-support/src/main/java/com/easyagents/flow/support/provider/ImageInputResolver.java
@@ -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);
+}
diff --git a/easy-agents-support/src/test/java/com/easyagents/flow/support/provider/EasyAgentsLlmTest.java b/easy-agents-support/src/test/java/com/easyagents/flow/support/provider/EasyAgentsLlmTest.java
new file mode 100644
index 0000000..ea51d0e
--- /dev/null
+++ b/easy-agents-support/src/test/java/com/easyagents/flow/support/provider/EasyAgentsLlmTest.java
@@ -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 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 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 headers,
+ String payload,
+ com.easyagents.core.model.client.StreamClientListener listener,
+ ChatConfig config) {
+ }
+
+ /**
+ * 测试客户端没有需要关闭的网络资源。
+ */
+ @Override
+ public void stop() {
+ }
+ }
+}