fix: 统一工作流失败原因与执行状态
- 关联 EASY-2,补齐模型错误分类、节点归属与执行日志 - 覆盖流式失败、重试状态及模型错误映射回归
This commit is contained in:
@@ -5,12 +5,15 @@ 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.exception.ModelException;
|
||||
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.WorkflowErrorReason;
|
||||
import com.easyagents.flow.core.chain.WorkflowExecutionException;
|
||||
import com.easyagents.flow.core.chain.event.ChainStatusChangeEvent;
|
||||
import com.easyagents.flow.core.chain.event.LlmStreamEvent;
|
||||
import com.easyagents.flow.core.chain.listener.ChainEventListener;
|
||||
@@ -23,14 +26,25 @@ import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Set;
|
||||
import java.net.SocketException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 基于 Easy-Agents 聊天模型实现工作流 LLM 调用。
|
||||
*/
|
||||
public class EasyAgentsLlm implements Llm {
|
||||
|
||||
private static final Pattern MODEL_NOT_FOUND_MESSAGE = Pattern.compile(
|
||||
"^(?:the\\s+)?model(?:\\s+([`'\"])[^\\r\\n]+\\1)?\\s+(?:not found|does not exist)[.!]?$",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private ChatModel chatModel;
|
||||
private ImageInputResolver imageInputResolver;
|
||||
|
||||
@@ -163,7 +177,7 @@ public class EasyAgentsLlm implements Llm {
|
||||
if (message == null || StringUtil.noText(message.getFullContent())) {
|
||||
failure.compareAndSet(
|
||||
null,
|
||||
new IllegalStateException(
|
||||
new WorkflowExecutionException(WorkflowErrorReason.NODE_OUTPUT_INVALID,
|
||||
"EasyAgentsLlm can not get aiMessage!"));
|
||||
} else {
|
||||
result.set(message.getFullContent());
|
||||
@@ -191,6 +205,8 @@ public class EasyAgentsLlm implements Llm {
|
||||
}
|
||||
}, chatOptions);
|
||||
awaitCompletion(completion, streamContext);
|
||||
} catch (RuntimeException exception) {
|
||||
throw modelFailure(exception);
|
||||
} finally {
|
||||
chain.getEventManager().removeEventListener(
|
||||
ChainStatusChangeEvent.class, cancellationListener);
|
||||
@@ -198,14 +214,66 @@ public class EasyAgentsLlm implements Llm {
|
||||
|
||||
Throwable throwable = failure.get();
|
||||
if (throwable != null) {
|
||||
throw new RuntimeException("EasyAgentsLlm stream failed", throwable);
|
||||
throw modelFailure(throwable);
|
||||
}
|
||||
if (StringUtil.noText(result.get())) {
|
||||
throw new RuntimeException("EasyAgentsLlm can not get response!");
|
||||
throw new WorkflowExecutionException(WorkflowErrorReason.NODE_OUTPUT_INVALID, "EasyAgentsLlm can not get response!");
|
||||
}
|
||||
return result.get();
|
||||
}
|
||||
|
||||
static WorkflowExecutionException modelFailure(Throwable error) {
|
||||
Set<Throwable> seen = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
WorkflowErrorReason reason = WorkflowErrorReason.NODE_EXECUTION_FAILED;
|
||||
for (Throwable cause = error; cause != null && seen.add(cause); cause = cause.getCause()) {
|
||||
if (cause instanceof WorkflowExecutionException known) {
|
||||
return known;
|
||||
}
|
||||
if (cause instanceof ModelException model && model.getStatusCode() != null) {
|
||||
int status = model.getStatusCode();
|
||||
if (status == 429) reason = WorkflowErrorReason.MODEL_RATE_LIMITED;
|
||||
else if (status == 401 || status == 403) reason = WorkflowErrorReason.MODEL_AUTH_FAILED;
|
||||
else if (status == 408 || status == 504) reason = WorkflowErrorReason.MODEL_TIMEOUT;
|
||||
else {
|
||||
reason = reasonFromModelCode(model.getErrorCode());
|
||||
if (reason == WorkflowErrorReason.NODE_EXECUTION_FAILED) reason = reasonFromModelCode(model.getErrorType());
|
||||
if (reason == WorkflowErrorReason.NODE_EXECUTION_FAILED) {
|
||||
if ((status == 200 || status == 400 || status == 404) && isModelNotFound(model)) {
|
||||
reason = WorkflowErrorReason.MODEL_NOT_FOUND;
|
||||
} else if (status >= 500 && status <= 599) {
|
||||
reason = WorkflowErrorReason.MODEL_UNAVAILABLE;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (cause instanceof SocketTimeoutException || cause instanceof TimeoutException) {
|
||||
reason = WorkflowErrorReason.MODEL_TIMEOUT;
|
||||
} else if (cause instanceof SocketException || cause instanceof UnknownHostException) {
|
||||
reason = WorkflowErrorReason.MODEL_UNAVAILABLE;
|
||||
}
|
||||
if (reason != WorkflowErrorReason.NODE_EXECUTION_FAILED) break;
|
||||
}
|
||||
return new WorkflowExecutionException(reason, "EasyAgentsLlm stream failed", error);
|
||||
}
|
||||
|
||||
private static WorkflowErrorReason reasonFromModelCode(String code) {
|
||||
if (code == null) return WorkflowErrorReason.NODE_EXECUTION_FAILED;
|
||||
return switch (code.toLowerCase(java.util.Locale.ROOT)) {
|
||||
case "model_not_found" -> WorkflowErrorReason.MODEL_NOT_FOUND;
|
||||
case "rate_limit_exceeded", "rate_limit_error" -> WorkflowErrorReason.MODEL_RATE_LIMITED;
|
||||
case "invalid_api_key", "authentication_error", "permission_denied", "permission_error" -> WorkflowErrorReason.MODEL_AUTH_FAILED;
|
||||
case "service_unavailable", "overloaded_error" -> WorkflowErrorReason.MODEL_UNAVAILABLE;
|
||||
case "request_timeout", "timeout" -> WorkflowErrorReason.MODEL_TIMEOUT;
|
||||
default -> WorkflowErrorReason.NODE_EXECUTION_FAILED;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean isModelNotFound(ModelException error) {
|
||||
if ("model_not_found".equalsIgnoreCase(error.getErrorCode())) return true;
|
||||
String message = error.getErrorMessage();
|
||||
// 只识别模型服务结构化错误中的明确语义,普通路由 404 不等于模型不存在。
|
||||
return message != null && message.length() <= 512 && MODEL_NOT_FOUND_MESSAGE.matcher(message.trim()).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建模型提示词,并解析图片输入。
|
||||
*
|
||||
@@ -292,7 +360,8 @@ public class EasyAgentsLlm implements Llm {
|
||||
}
|
||||
String resolvedImage = resolveImage(input);
|
||||
if (StringUtil.noText(resolvedImage)) {
|
||||
throw new IllegalArgumentException("Resolved image input must not be blank");
|
||||
throw new WorkflowExecutionException(WorkflowErrorReason.INPUT_INVALID,
|
||||
"Resolved image input must not be blank");
|
||||
}
|
||||
resolvedImages.add(resolvedImage);
|
||||
}
|
||||
@@ -315,7 +384,7 @@ public class EasyAgentsLlm implements Llm {
|
||||
if (imageInput instanceof File file) {
|
||||
return ImageUtil.imageFileToDataUri(file);
|
||||
}
|
||||
throw new IllegalArgumentException(
|
||||
throw new WorkflowExecutionException(WorkflowErrorReason.INPUT_INVALID,
|
||||
"Unsupported image input type: " + imageInput.getClass().getName());
|
||||
}
|
||||
|
||||
@@ -325,7 +394,8 @@ public class EasyAgentsLlm implements Llm {
|
||||
private void assertImageSupported() {
|
||||
if (chatModel instanceof BaseChatModel<?> baseChatModel
|
||||
&& Boolean.FALSE.equals(baseChatModel.getConfig().getSupportImage())) {
|
||||
throw new IllegalArgumentException("当前模型不支持图片输入,请选择支持视觉能力的模型");
|
||||
throw new WorkflowExecutionException(WorkflowErrorReason.INPUT_INVALID,
|
||||
"当前模型不支持图片输入,请选择支持视觉能力的模型");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ 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.WorkflowErrorReason;
|
||||
import com.easyagents.flow.core.chain.WorkflowExecutionException;
|
||||
import com.easyagents.flow.core.chain.event.LlmStreamEvent;
|
||||
import com.easyagents.flow.core.llm.Llm;
|
||||
import com.easyagents.flow.core.node.LlmNode;
|
||||
@@ -101,14 +103,41 @@ public class EasyAgentsLlmTest {
|
||||
|
||||
try {
|
||||
llm.chat(messageInfo, new Llm.ChatOptions(), null, null);
|
||||
Assert.fail("expected IllegalArgumentException");
|
||||
} catch (IllegalArgumentException exception) {
|
||||
Assert.fail("expected WorkflowExecutionException");
|
||||
} catch (WorkflowExecutionException exception) {
|
||||
Assert.assertEquals(WorkflowErrorReason.INPUT_INVALID, exception.getReason());
|
||||
Assert.assertEquals(
|
||||
"当前模型不支持图片输入,请选择支持视觉能力的模型",
|
||||
exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldClassifyExplicitImageInputValidation() {
|
||||
EasyAgentsLlm llm = new EasyAgentsLlm();
|
||||
Llm.MessageInfo message = new Llm.MessageInfo();
|
||||
message.setImageInputs(List.of(123));
|
||||
WorkflowExecutionException invalidType = Assert.assertThrows(WorkflowExecutionException.class,
|
||||
() -> llm.resolveImages(message));
|
||||
Assert.assertEquals(WorkflowErrorReason.INPUT_INVALID, invalidType.getReason());
|
||||
|
||||
llm.setImageInputResolver(input -> " ");
|
||||
WorkflowExecutionException blank = Assert.assertThrows(WorkflowExecutionException.class,
|
||||
() -> llm.resolveImages(message));
|
||||
Assert.assertEquals(WorkflowErrorReason.INPUT_INVALID, blank.getReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotReclassifyUnknownImageResolverFailures() {
|
||||
EasyAgentsLlm llm = new EasyAgentsLlm();
|
||||
IllegalStateException original = new IllegalStateException("synthetic resolver failure");
|
||||
llm.setImageInputResolver(input -> { throw original; });
|
||||
Llm.MessageInfo message = new Llm.MessageInfo();
|
||||
message.setImageInputs(List.of("image"));
|
||||
Assert.assertSame(original, Assert.assertThrows(IllegalStateException.class,
|
||||
() -> llm.resolveImages(message)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证重复执行同一 LLM 节点时,每次调用拥有独立流标识且增量不会被覆盖。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.easyagents.flow.support.provider;
|
||||
|
||||
import com.easyagents.core.model.exception.ModelException;
|
||||
import com.easyagents.flow.core.chain.WorkflowErrorReason;
|
||||
import com.easyagents.flow.core.chain.WorkflowExecutionException;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import java.net.ConnectException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.SocketException;
|
||||
|
||||
public class WorkflowModelFailureTest {
|
||||
@Test
|
||||
public void shouldClassifyTypedModelFailuresWithoutParsingMessages() {
|
||||
assertReason(WorkflowErrorReason.MODEL_RATE_LIMITED, new ModelException(429, "opaque", null));
|
||||
assertReason(WorkflowErrorReason.MODEL_AUTH_FAILED, new ModelException(401, "opaque", null));
|
||||
assertReason(WorkflowErrorReason.MODEL_AUTH_FAILED, new ModelException(403, "opaque", null));
|
||||
assertReason(WorkflowErrorReason.MODEL_UNAVAILABLE, new ModelException(503, "opaque", null));
|
||||
assertReason(WorkflowErrorReason.MODEL_TIMEOUT, new ModelException(408, "opaque", null));
|
||||
assertReason(WorkflowErrorReason.MODEL_TIMEOUT, new ModelException(504, "opaque", null));
|
||||
assertReason(WorkflowErrorReason.MODEL_TIMEOUT, new RuntimeException(new SocketTimeoutException()));
|
||||
assertReason(WorkflowErrorReason.MODEL_UNAVAILABLE, new ConnectException());
|
||||
assertReason(WorkflowErrorReason.MODEL_UNAVAILABLE, new SocketException("Connection reset"));
|
||||
assertReason(WorkflowErrorReason.NODE_EXECUTION_FAILED, new RuntimeException("429 timeout 鉴权失败"));
|
||||
assertReason(WorkflowErrorReason.NODE_OUTPUT_INVALID, new WorkflowExecutionException(WorkflowErrorReason.NODE_OUTPUT_INVALID, "empty"));
|
||||
}
|
||||
@Test
|
||||
public void shouldRecognizeMissingModelWithoutMisclassifyingRoute404() {
|
||||
assertReason(WorkflowErrorReason.MODEL_NOT_FOUND,
|
||||
new ModelException(404, "raw response", null, "404", null, "Model not found"));
|
||||
assertReason(WorkflowErrorReason.MODEL_NOT_FOUND,
|
||||
new ModelException(404, "raw response", null, null, null, "The model `test` does not exist."));
|
||||
assertReason(WorkflowErrorReason.MODEL_NOT_FOUND,
|
||||
new ModelException(400, "raw response", null, "model_not_found", null, "opaque"));
|
||||
assertReason(WorkflowErrorReason.NODE_EXECUTION_FAILED,
|
||||
new ModelException(404, "raw response", null, "404", null, "Not Found"));
|
||||
assertReason(WorkflowErrorReason.NODE_EXECUTION_FAILED,
|
||||
new ModelException(404, "raw response", null, "404", null, "Model endpoint not found"));
|
||||
assertReason(WorkflowErrorReason.MODEL_AUTH_FAILED,
|
||||
new ModelException(403, "raw response", null, "model_not_found", null, "Model not found"));
|
||||
}
|
||||
private void assertReason(WorkflowErrorReason reason, Throwable error) {
|
||||
Assert.assertEquals(reason, EasyAgentsLlm.modelFailure(error).getReason());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.easyagents.flow.support.provider;
|
||||
|
||||
import com.easyagents.core.model.chat.ChatConfig;
|
||||
import com.easyagents.core.model.chat.OpenAICompatibleChatModel;
|
||||
import com.easyagents.flow.core.chain.*;
|
||||
import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
|
||||
import com.easyagents.flow.core.chain.repository.InMemoryNodeStateRepository;
|
||||
import com.easyagents.flow.core.llm.LlmManager;
|
||||
import com.easyagents.flow.core.llm.Llm;
|
||||
import com.easyagents.flow.core.llm.LlmProvider;
|
||||
import com.easyagents.flow.core.node.LlmNode;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
|
||||
/** 真实 HTTP/SSE 到 LlmNode 的分类回归,不直接注入原因码。 */
|
||||
public class WorkflowModelHttpFailureTest {
|
||||
@Test
|
||||
public void shouldClassifyHttpErrorsAtModelBoundary() throws Exception {
|
||||
assertFailure(429, "{}", false, WorkflowErrorReason.MODEL_RATE_LIMITED);
|
||||
assertFailure(401, "{}", false, WorkflowErrorReason.MODEL_AUTH_FAILED);
|
||||
assertFailure(503, "{}", false, WorkflowErrorReason.MODEL_UNAVAILABLE);
|
||||
assertFailure(408, "{}", false, WorkflowErrorReason.MODEL_TIMEOUT);
|
||||
assertFailure(504, "{}", false, WorkflowErrorReason.MODEL_TIMEOUT);
|
||||
assertFailure(404, "{\"error\":{\"message\":\"Model not found\",\"code\":404,\"type\":\"NotFound\"}}",
|
||||
false, WorkflowErrorReason.MODEL_NOT_FOUND);
|
||||
assertFailure(404, "<html>Not Found</html>", false, WorkflowErrorReason.NODE_EXECUTION_FAILED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void streamErrorAfterPartialOutputMustFailInsteadOfSucceeding() throws Exception {
|
||||
for (String[] error : new String[][]{
|
||||
{"rate_limit_exceeded", "MODEL_RATE_LIMITED"},
|
||||
{"authentication_error", "MODEL_AUTH_FAILED"},
|
||||
{"overloaded_error", "MODEL_UNAVAILABLE"},
|
||||
{"request_timeout", "MODEL_TIMEOUT"},
|
||||
{"model_not_found", "MODEL_NOT_FOUND"}}) {
|
||||
String body = "data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n"
|
||||
+ "data: {\"error\":{\"code\":\"upstream_error\",\"type\":\"" + error[0] + "\",\"message\":\"private upstream body\"}}\n\n"
|
||||
+ "data: [DONE]\n\n";
|
||||
assertFailure(200, body, false, WorkflowErrorReason.valueOf(error[1]));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void blankOrInvalidJsonOutputMustHaveOutputReason() throws Exception {
|
||||
assertFailure(200, "data: [DONE]\n\n", false, WorkflowErrorReason.NODE_OUTPUT_INVALID);
|
||||
for (String output : new String[]{"not-json", "```json\\n\\n```", "null"}) {
|
||||
assertFailure(200, "data: {\"choices\":[{\"delta\":{\"content\":\"" + output
|
||||
+ "\"}}]}\n\ndata: [DONE]\n\n", true, WorkflowErrorReason.NODE_OUTPUT_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertFailure(int status, String body, boolean json, WorkflowErrorReason reason) throws Exception {
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/chat", exchange -> {
|
||||
exchange.getRequestBody().readAllBytes();
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", status == 200 ? "text/event-stream" : "application/json");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
try (var output = exchange.getResponseBody()) { output.write(bytes); }
|
||||
});
|
||||
server.start();
|
||||
String id = UUID.randomUUID().toString();
|
||||
ChatConfig config = new ChatConfig();
|
||||
config.setEndpoint("http://127.0.0.1:" + server.getAddress().getPort());
|
||||
config.setRequestPath("/chat"); config.setModel("missing-test"); config.setApiKey("synthetic");
|
||||
config.setLogEnabled(false); config.setObservabilityEnabled(false); config.setRetryEnabled(false);
|
||||
EasyAgentsLlm llm = new EasyAgentsLlm();
|
||||
llm.setChatModel(new OpenAICompatibleChatModel<>(config));
|
||||
LlmProvider provider = modelId -> id.equals(modelId) ? llm : null;
|
||||
LlmManager.getInstance().registerProvider(provider);
|
||||
try {
|
||||
Chain chain = new Chain(new ChainDefinition(), id);
|
||||
chain.setEventManager(new EventManager());
|
||||
chain.setChainStateRepository(new InMemoryChainStateRepository());
|
||||
chain.setNodeStateRepository(new InMemoryNodeStateRepository());
|
||||
chain.initializeState();
|
||||
LlmNode node = new LlmNode(); node.setId("llm"); node.setLlmId(id); node.setUserPrompt("test");
|
||||
node.setChatOptions(new Llm.ChatOptions());
|
||||
node.setOutType(json ? "json" : "text");
|
||||
WorkflowExecutionException failure = Assert.assertThrows(WorkflowExecutionException.class, () -> node.execute(chain));
|
||||
Assert.assertEquals(reason, failure.getReason());
|
||||
} finally {
|
||||
LlmManager.getInstance().removeProvider(provider);
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user