feat(XL13): 支持工作流大模型流式输出
- 增加文本与思考增量事件及取消终态收口 - 支持图片输入解析并修复死信定义查找空值 - 补充并发与模型流式回归测试
This commit is contained in:
@@ -778,7 +778,19 @@ public class Chain {
|
|||||||
if (state == null) {
|
if (state == null) {
|
||||||
throw new IllegalStateException("Unable to initialize chain state: " + stateInstanceId);
|
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) {
|
private boolean shouldSkipNode(Node node, String edgeId) {
|
||||||
@@ -1613,6 +1625,8 @@ public class Chain {
|
|||||||
if (changed.get()) {
|
if (changed.get()) {
|
||||||
notifyEvent(new ChainStatusChangeEvent(
|
notifyEvent(new ChainStatusChangeEvent(
|
||||||
this, ChainStatus.CANCELLED, before.get()));
|
this, ChainStatus.CANCELLED, before.get()));
|
||||||
|
// 取消属于工作流终态,统一发布结束事件供审计、清理等监听器收口。
|
||||||
|
notifyEvent(new ChainEndEvent(this));
|
||||||
}
|
}
|
||||||
return changed.get();
|
return changed.get();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com).
|
||||||
|
* <p>
|
||||||
|
* 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
|
||||||
|
* <p>
|
||||||
|
* http://www.gnu.org/licenses/lgpl-3.0.txt
|
||||||
|
* <p>
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.*;
|
import java.util.concurrent.*;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TinyFlow 最新 ChainExecutor
|
* TinyFlow 最新 ChainExecutor
|
||||||
@@ -397,8 +398,29 @@ public class ChainExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String executeAsync(String definitionId, Map<String, Object> variables) {
|
public String executeAsync(String definitionId, Map<String, Object> variables) {
|
||||||
|
return executeAsync(definitionId, variables, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步启动工作流,并在首个节点开始前暴露执行实例 ID。
|
||||||
|
*
|
||||||
|
* <p>回调用于提前注册流式事件接收器,保证高速工作流不会在调用方拿到执行 ID
|
||||||
|
* 之前丢失开始事件或首批输出。</p>
|
||||||
|
*
|
||||||
|
* @param definitionId 工作流定义 ID
|
||||||
|
* @param variables 工作流输入变量
|
||||||
|
* @param beforeStart 启动前回调;可为 {@code null}
|
||||||
|
* @return 执行实例 ID
|
||||||
|
*/
|
||||||
|
public String executeAsync(
|
||||||
|
String definitionId,
|
||||||
|
Map<String, Object> variables,
|
||||||
|
Consumer<String> beforeStart) {
|
||||||
Chain chain = createChain(definitionId);
|
Chain chain = createChain(definitionId);
|
||||||
try {
|
try {
|
||||||
|
if (beforeStart != null) {
|
||||||
|
beforeStart.accept(chain.getStateInstanceId());
|
||||||
|
}
|
||||||
chain.start(variables);
|
chain.start(variables);
|
||||||
return chain.getStateInstanceId();
|
return chain.getStateInstanceId();
|
||||||
} catch (RuntimeException | Error error) {
|
} catch (RuntimeException | Error error) {
|
||||||
@@ -962,9 +984,12 @@ public class ChainExecutor {
|
|||||||
return definition;
|
return definition;
|
||||||
}
|
}
|
||||||
ChainDefinition loaded = definitionSnapshotRepository.load(stateInstanceId);
|
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) {
|
if (loaded == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ public interface Llm {
|
|||||||
private String message;
|
private String message;
|
||||||
private String systemMessage;
|
private String systemMessage;
|
||||||
private List<String> images;
|
private List<String> images;
|
||||||
|
private List<Object> imageInputs;
|
||||||
|
|
||||||
public String getMessage() {
|
public String getMessage() {
|
||||||
return message;
|
return message;
|
||||||
@@ -74,6 +75,24 @@ public interface Llm {
|
|||||||
public void setImages(List<String> images) {
|
public void setImages(List<String> images) {
|
||||||
this.images = images;
|
this.images = images;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取尚未转换为模型图片 URL 的原始图片输入。
|
||||||
|
*
|
||||||
|
* @return 原始图片输入列表
|
||||||
|
*/
|
||||||
|
public List<Object> getImageInputs() {
|
||||||
|
return imageInputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置原始图片输入,供模型调用前按运行环境解析。
|
||||||
|
*
|
||||||
|
* @param imageInputs 原始图片输入列表
|
||||||
|
*/
|
||||||
|
public void setImageInputs(List<Object> imageInputs) {
|
||||||
|
this.imageInputs = imageInputs;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import com.easyagents.flow.core.llm.Llm;
|
|||||||
import com.easyagents.flow.core.llm.LlmManager;
|
import com.easyagents.flow.core.llm.LlmManager;
|
||||||
import com.easyagents.flow.core.util.*;
|
import com.easyagents.flow.core.util.*;
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
public class LlmNode extends BaseNode {
|
public class LlmNode extends BaseNode {
|
||||||
@@ -123,17 +122,21 @@ public class LlmNode extends BaseNode {
|
|||||||
Map<String, Object> filesMap =
|
Map<String, Object> filesMap =
|
||||||
chainState.resolveParameters(
|
chainState.resolveParameters(
|
||||||
this, images);
|
this, images);
|
||||||
List<String> imagesUrls = new ArrayList<>();
|
List<Object> imageInputs = new ArrayList<>(filesMap.size());
|
||||||
filesMap.forEach((s, o) -> {
|
filesMap.forEach((name, value) -> {
|
||||||
if (o instanceof String) {
|
if (value == null) {
|
||||||
imagesUrls.add((String) o);
|
return;
|
||||||
} else if (o instanceof File) {
|
|
||||||
byte[] bytes = IOUtil.readBytes((File) o);
|
|
||||||
String base64 = Base64.getEncoder().encodeToString(bytes);
|
|
||||||
imagesUrls.add(base64);
|
|
||||||
}
|
}
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import com.easyagents.flow.core.chain.ChainDefinition;
|
|||||||
import com.easyagents.flow.core.chain.ChainStatus;
|
import com.easyagents.flow.core.chain.ChainStatus;
|
||||||
import com.easyagents.flow.core.chain.Edge;
|
import com.easyagents.flow.core.chain.Edge;
|
||||||
import com.easyagents.flow.core.chain.ChainState;
|
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.ChainDefinitionSnapshotRepository;
|
||||||
import com.easyagents.flow.core.chain.repository.ChainStateField;
|
import com.easyagents.flow.core.chain.repository.ChainStateField;
|
||||||
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
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.repository.InMemoryNodeStateRepository;
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
import com.easyagents.flow.core.chain.runtime.InMemoryTriggerStore;
|
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.chain.runtime.TriggerScheduler;
|
||||||
import com.easyagents.flow.core.node.EndNode;
|
import com.easyagents.flow.core.node.EndNode;
|
||||||
import com.easyagents.flow.core.node.BaseNode;
|
import com.easyagents.flow.core.node.BaseNode;
|
||||||
@@ -36,6 +38,7 @@ import org.junit.Assert;
|
|||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
@@ -49,12 +52,100 @@ import java.util.concurrent.Future;
|
|||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link ChainExecutor} 并发同步执行测试。
|
* {@link ChainExecutor} 并发同步执行测试。
|
||||||
*/
|
*/
|
||||||
public class ChainExecutorConcurrencyTest {
|
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<String> 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 独立接收执行结果。
|
* 验证多个同步调用可以通过实例 ID 独立接收执行结果。
|
||||||
*
|
*
|
||||||
@@ -143,6 +234,7 @@ public class ChainExecutorConcurrencyTest {
|
|||||||
CountDownLatch ioStarted = new CountDownLatch(1);
|
CountDownLatch ioStarted = new CountDownLatch(1);
|
||||||
CountDownLatch allowIoCompletion = new CountDownLatch(1);
|
CountDownLatch allowIoCompletion = new CountDownLatch(1);
|
||||||
AtomicInteger downstreamExecutions = new AtomicInteger();
|
AtomicInteger downstreamExecutions = new AtomicInteger();
|
||||||
|
AtomicInteger chainEndEvents = new AtomicInteger();
|
||||||
ChainDefinition definition = createCancellationDefinition(
|
ChainDefinition definition = createCancellationDefinition(
|
||||||
ioStarted, allowIoCompletion, downstreamExecutions);
|
ioStarted, allowIoCompletion, downstreamExecutions);
|
||||||
ChainExecutor chainExecutor = new ChainExecutor(
|
ChainExecutor chainExecutor = new ChainExecutor(
|
||||||
@@ -150,6 +242,11 @@ public class ChainExecutorConcurrencyTest {
|
|||||||
chainStateRepository,
|
chainStateRepository,
|
||||||
new InMemoryNodeStateRepository(),
|
new InMemoryNodeStateRepository(),
|
||||||
triggerScheduler);
|
triggerScheduler);
|
||||||
|
chainExecutor.addEventListener((event, chain) -> {
|
||||||
|
if (event instanceof ChainEndEvent) {
|
||||||
|
chainEndEvents.incrementAndGet();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String instanceId = chainExecutor.executeAsync(
|
String instanceId = chainExecutor.executeAsync(
|
||||||
@@ -163,6 +260,7 @@ public class ChainExecutorConcurrencyTest {
|
|||||||
Assert.assertEquals(
|
Assert.assertEquals(
|
||||||
ChainStatus.CANCELLED,
|
ChainStatus.CANCELLED,
|
||||||
chainStateRepository.load(instanceId).getStatus());
|
chainStateRepository.load(instanceId).getStatus());
|
||||||
|
Assert.assertEquals(1, chainEndEvents.get());
|
||||||
} finally {
|
} finally {
|
||||||
allowIoCompletion.countDown();
|
allowIoCompletion.countDown();
|
||||||
triggerScheduler.shutdown();
|
triggerScheduler.shutdown();
|
||||||
|
|||||||
@@ -28,7 +28,11 @@
|
|||||||
<artifactId>easy-agents-core</artifactId>
|
<artifactId>easy-agents-core</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>junit</groupId>
|
||||||
|
<artifactId>junit</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -2,46 +2,240 @@ package com.easyagents.flow.support.provider;
|
|||||||
|
|
||||||
import com.easyagents.core.message.AiMessage;
|
import com.easyagents.core.message.AiMessage;
|
||||||
import com.easyagents.core.message.SystemMessage;
|
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.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.model.chat.response.AiMessageResponse;
|
||||||
import com.easyagents.core.prompt.SimplePrompt;
|
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.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.llm.Llm;
|
||||||
import com.easyagents.flow.core.node.LlmNode;
|
import com.easyagents.flow.core.node.LlmNode;
|
||||||
import com.easyagents.flow.core.util.StringUtil;
|
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.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于 Easy-Agents 聊天模型实现工作流 LLM 调用。
|
||||||
|
*/
|
||||||
public class EasyAgentsLlm implements Llm {
|
public class EasyAgentsLlm implements Llm {
|
||||||
|
|
||||||
private ChatModel chatModel;
|
private ChatModel chatModel;
|
||||||
|
private ImageInputResolver imageInputResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取聊天模型。
|
||||||
|
*
|
||||||
|
* @return 聊天模型
|
||||||
|
*/
|
||||||
public ChatModel getChatModel() {
|
public ChatModel getChatModel() {
|
||||||
return chatModel;
|
return chatModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置聊天模型。
|
||||||
|
*
|
||||||
|
* @param chatModel 聊天模型
|
||||||
|
*/
|
||||||
public void setChatModel(ChatModel chatModel) {
|
public void setChatModel(ChatModel chatModel) {
|
||||||
this.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
|
@Override
|
||||||
public String chat(MessageInfo messageInfo, ChatOptions options, LlmNode llmNode, Chain chain) {
|
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());
|
SimplePrompt prompt = new SimplePrompt(messageInfo.getMessage());
|
||||||
|
|
||||||
// 系统提示词
|
|
||||||
if (StringUtil.hasText(messageInfo.getSystemMessage())) {
|
if (StringUtil.hasText(messageInfo.getSystemMessage())) {
|
||||||
prompt.setSystemMessage(SystemMessage.of(messageInfo.getSystemMessage()));
|
prompt.setSystemMessage(SystemMessage.of(messageInfo.getSystemMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 图片
|
List<String> images = resolveImages(messageInfo);
|
||||||
List<String> images = messageInfo.getImages();
|
|
||||||
if (images != null && !images.isEmpty()) {
|
if (images != null && !images.isEmpty()) {
|
||||||
|
assertImageSupported();
|
||||||
for (String image : images) {
|
for (String image : images) {
|
||||||
prompt.addImageUrl(image);
|
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();
|
com.easyagents.core.model.chat.ChatOptions chatOptions = new com.easyagents.core.model.chat.ChatOptions();
|
||||||
chatOptions.setSeed(options.getSeed());
|
chatOptions.setSeed(options.getSeed());
|
||||||
chatOptions.setTemperature(options.getTemperature());
|
chatOptions.setTemperature(options.getTemperature());
|
||||||
@@ -49,21 +243,89 @@ public class EasyAgentsLlm implements Llm {
|
|||||||
chatOptions.setTopK(options.getTopK());
|
chatOptions.setTopK(options.getTopK());
|
||||||
chatOptions.setMaxTokens(options.getMaxTokens());
|
chatOptions.setMaxTokens(options.getMaxTokens());
|
||||||
chatOptions.setStop(options.getStop());
|
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()) {
|
List<String> resolvedImages = new ArrayList<>(inputs.size());
|
||||||
throw new RuntimeException("EasyAgentsLlm error: " + response.getErrorMessage());
|
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("当前模型不支持图片输入,请选择支持视觉能力的模型");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user