feat: 增强智能体模型调用兼容能力
- 增加模型流式开关和 HTTP 传输策略配置 - 使用 AgentScope 执行基础连接、流式与 VLM 双阶段验证 - 固定多模态校验图片并统一验证状态展示
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
package tech.easyflow.agent.runtime;
|
||||
|
||||
import com.easyagents.agent.runtime.model.AgentHttpVersionPolicy;
|
||||
import com.easyagents.agent.runtime.model.AgentModelProviderType;
|
||||
import com.easyagents.agent.runtime.model.AgentModelSpec;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 将 EasyFlow 模型配置映射为智能体运行时模型声明。
|
||||
*/
|
||||
public final class AgentModelSpecMapper {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AgentModelSpecMapper.class);
|
||||
|
||||
/**
|
||||
* 禁止实例化工具类。
|
||||
*/
|
||||
private AgentModelSpecMapper() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按模型持久化配置创建运行时模型声明。
|
||||
*
|
||||
* @param model 已补齐供应商默认配置的模型
|
||||
* @return 运行时模型声明
|
||||
* @throws IllegalArgumentException 模型为空时抛出
|
||||
*/
|
||||
public static AgentModelSpec fromModel(Model model) {
|
||||
return fromModel(model, Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按模型配置和 Agent 快照覆盖项创建运行时模型声明。
|
||||
*
|
||||
* @param model 已补齐供应商默认配置的模型
|
||||
* @param overrides Agent 发布快照中的模型覆盖项
|
||||
* @return 运行时模型声明
|
||||
* @throws IllegalArgumentException 模型为空时抛出
|
||||
*/
|
||||
public static AgentModelSpec fromModel(Model model, Map<String, Object> overrides) {
|
||||
if (model == null) {
|
||||
throw new IllegalArgumentException("模型配置不能为空");
|
||||
}
|
||||
Map<String, Object> safeOverrides = overrides == null ? Map.of() : overrides;
|
||||
AgentModelSpec spec = new AgentModelSpec();
|
||||
String providerType = stringValue(
|
||||
safeOverrides,
|
||||
"providerType",
|
||||
model.getModelProvider() == null ? null : model.getModelProvider().getProviderType());
|
||||
spec.setProviderType(parseProviderType(providerType));
|
||||
spec.setModelName(stringValue(safeOverrides, "modelName", model.getModelName()));
|
||||
spec.setBaseUrl(stringValue(safeOverrides, "baseUrl", model.getEndpoint()));
|
||||
spec.setEndpointPath(stringValue(safeOverrides, "endpointPath", model.getRequestPath()));
|
||||
spec.setApiKey(stringValue(safeOverrides, "apiKey", model.getApiKey()));
|
||||
spec.setSupportImage(Boolean.TRUE.equals(model.getSupportImage()));
|
||||
spec.setSupportImageBase64Only(Boolean.TRUE.equals(model.getSupportImageB64Only()));
|
||||
spec.setHttpVersionPolicy(parseHttpVersionPolicy(model));
|
||||
spec.getMetadata().put("modelId", model.getId());
|
||||
if (providerType != null && !providerType.isBlank()) {
|
||||
spec.getMetadata().put("sourceProviderType", providerType);
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型配置中的 Agent HTTP 版本策略。
|
||||
*
|
||||
* @param model 模型配置
|
||||
* @return HTTP 版本策略,缺失或非法时返回 AUTO
|
||||
*/
|
||||
private static AgentHttpVersionPolicy parseHttpVersionPolicy(Model model) {
|
||||
Object rawPolicy = model.getOptions() == null
|
||||
? null
|
||||
: model.getOptions().get("agentHttpVersionPolicy");
|
||||
if (rawPolicy == null || String.valueOf(rawPolicy).isBlank()) {
|
||||
return AgentHttpVersionPolicy.AUTO;
|
||||
}
|
||||
String normalizedPolicy = String.valueOf(rawPolicy).trim().toUpperCase(Locale.ROOT);
|
||||
try {
|
||||
return AgentHttpVersionPolicy.valueOf(normalizedPolicy);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
LOG.warn("Invalid Agent HTTP version policy '{}' for model {}, fallback to AUTO",
|
||||
rawPolicy, model.getId());
|
||||
return AgentHttpVersionPolicy.AUTO;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 AgentScope 支持的模型供应商类型。
|
||||
*
|
||||
* @param providerType 供应商类型
|
||||
* @return 运行时供应商类型,未知值按 OpenAI-compatible 处理
|
||||
*/
|
||||
private static AgentModelProviderType parseProviderType(String providerType) {
|
||||
if (providerType == null || providerType.isBlank()) {
|
||||
return AgentModelProviderType.OPENAI_COMPATIBLE;
|
||||
}
|
||||
try {
|
||||
return AgentModelProviderType.valueOf(providerType.trim().toUpperCase(Locale.ROOT));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return AgentModelProviderType.OPENAI_COMPATIBLE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取字符串覆盖项。
|
||||
*
|
||||
* @param values 配置映射
|
||||
* @param key 字段名
|
||||
* @param defaultValue 默认值
|
||||
* @return 覆盖值或默认值
|
||||
*/
|
||||
private static String stringValue(Map<String, Object> values, String key, String defaultValue) {
|
||||
Object value = values.get(key);
|
||||
return value == null ? defaultValue : String.valueOf(value);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import com.easyagents.agent.runtime.memory.AgentMemoryType;
|
||||
import com.easyagents.agent.runtime.mcp.McpSpec;
|
||||
import com.easyagents.agent.runtime.mcp.McpTransportType;
|
||||
import com.easyagents.agent.runtime.model.AgentGenerationOptions;
|
||||
import com.easyagents.agent.runtime.model.AgentModelProviderType;
|
||||
import com.easyagents.agent.runtime.model.AgentModelSpec;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolCategory;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolResult;
|
||||
@@ -104,18 +103,7 @@ public class AgentRuntimeCompiler {
|
||||
if (model == null) {
|
||||
throw new BusinessException("Agent 模型不存在");
|
||||
}
|
||||
Map<String, Object> config = agent.getModelConfigJson();
|
||||
AgentModelSpec spec = new AgentModelSpec();
|
||||
String providerType = stringValue(config, "providerType", model.getModelProvider() == null ? null : model.getModelProvider().getProviderType());
|
||||
spec.setProviderType(parseProviderType(providerType));
|
||||
spec.setModelName(stringValue(config, "modelName", model.getModelName()));
|
||||
spec.setBaseUrl(stringValue(config, "baseUrl", model.getEndpoint()));
|
||||
spec.setEndpointPath(stringValue(config, "endpointPath", model.getRequestPath()));
|
||||
spec.setApiKey(stringValue(config, "apiKey", model.getApiKey()));
|
||||
spec.setSupportImage(Boolean.TRUE.equals(model.getSupportImage()));
|
||||
spec.setSupportImageBase64Only(Boolean.TRUE.equals(model.getSupportImageB64Only()));
|
||||
spec.getMetadata().put("modelId", model.getId());
|
||||
return spec;
|
||||
return AgentModelSpecMapper.fromModel(model, agent.getModelConfigJson());
|
||||
}
|
||||
|
||||
private AgentGenerationOptions buildGenerationOptions(Map<String, Object> config) {
|
||||
@@ -756,17 +744,6 @@ public class AgentRuntimeCompiler {
|
||||
return description == null ? "" : description;
|
||||
}
|
||||
|
||||
private AgentModelProviderType parseProviderType(String providerType) {
|
||||
if (providerType == null || providerType.isBlank()) {
|
||||
return AgentModelProviderType.OPENAI_COMPATIBLE;
|
||||
}
|
||||
try {
|
||||
return AgentModelProviderType.valueOf(providerType.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return AgentModelProviderType.OPENAI_COMPATIBLE;
|
||||
}
|
||||
}
|
||||
|
||||
private AgentMemoryType memoryTypeValue(Map<String, Object> map, String key) {
|
||||
String value = stringValue(map, key, AgentMemoryType.AUTO_CONTEXT.name());
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package tech.easyflow.agent.runtime;
|
||||
|
||||
import com.easyagents.agent.runtime.agentscope.AgentHttpTransportProvider;
|
||||
import com.easyagents.agent.runtime.agentscope.AgentScopeMessageAdapter;
|
||||
import com.easyagents.agent.runtime.agentscope.AgentScopeModelFactory;
|
||||
import com.easyagents.agent.runtime.message.AgentContentBlock;
|
||||
import com.easyagents.agent.runtime.message.AgentMediaBlock;
|
||||
import com.easyagents.agent.runtime.message.AgentMessage;
|
||||
import com.easyagents.agent.runtime.message.AgentMessageRole;
|
||||
import com.easyagents.agent.runtime.message.AgentTextBlock;
|
||||
import com.easyagents.agent.runtime.model.AgentGenerationOptions;
|
||||
import com.easyagents.agent.runtime.model.AgentModelFactory;
|
||||
import com.easyagents.agent.runtime.model.AgentModelProviderType;
|
||||
import com.easyagents.agent.runtime.model.AgentModelSpec;
|
||||
import io.agentscope.core.message.ContentBlock;
|
||||
import io.agentscope.core.message.Msg;
|
||||
import io.agentscope.core.message.TextBlock;
|
||||
import io.agentscope.core.model.ChatResponse;
|
||||
import io.agentscope.core.model.ExecutionConfig;
|
||||
import io.agentscope.core.model.GenerateOptions;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.service.support.VlmVerificationImage;
|
||||
import tech.easyflow.ai.service.verification.ChatModelConnectivityVerifier;
|
||||
import tech.easyflow.ai.service.verification.ChatModelVerificationResult;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 使用 AgentScope 真实运行链路验证 Chat Model 与 VLM 连通性。
|
||||
*/
|
||||
@Component
|
||||
public class AgentScopeChatModelConnectivityVerifier implements ChatModelConnectivityVerifier {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AgentScopeChatModelConnectivityVerifier.class);
|
||||
/** 为未识别关闭思考扩展参数的推理模型保留足够的小额输出预算。 */
|
||||
private static final int MAX_TOKENS = 256;
|
||||
private static final Duration DEFAULT_PHASE_TIMEOUT = Duration.ofSeconds(60);
|
||||
|
||||
/** AgentScope 模型工厂。 */
|
||||
private final AgentModelFactory<io.agentscope.core.model.Model> modelFactory;
|
||||
/** EasyAgents 与 AgentScope 的消息适配器。 */
|
||||
private final AgentScopeMessageAdapter messageAdapter;
|
||||
/** 单阶段最大等待时间。 */
|
||||
private final Duration phaseTimeout;
|
||||
|
||||
/**
|
||||
* 使用生产运行时组件创建验证器。
|
||||
*/
|
||||
public AgentScopeChatModelConnectivityVerifier() {
|
||||
this(new AgentScopeModelFactory(), new AgentScopeMessageAdapter(), DEFAULT_PHASE_TIMEOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定依赖创建验证器,供测试和受控运行环境使用。
|
||||
*
|
||||
* @param modelFactory AgentScope 模型工厂
|
||||
* @param messageAdapter 消息适配器
|
||||
* @param phaseTimeout 单阶段最大等待时间
|
||||
*/
|
||||
AgentScopeChatModelConnectivityVerifier(
|
||||
AgentModelFactory<io.agentscope.core.model.Model> modelFactory,
|
||||
AgentScopeMessageAdapter messageAdapter,
|
||||
Duration phaseTimeout) {
|
||||
this.modelFactory = modelFactory;
|
||||
this.messageAdapter = messageAdapter;
|
||||
this.phaseTimeout = phaseTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 依次验证非流式基础连接与流式响应能力。
|
||||
*
|
||||
* @param model 已补齐供应商默认配置的模型
|
||||
* @return 双阶段验证结果
|
||||
* @throws BusinessException 非流式基础连接失败时抛出
|
||||
*/
|
||||
@Override
|
||||
public ChatModelVerificationResult verify(Model model) {
|
||||
AgentModelSpec modelSpec = AgentModelSpecMapper.fromModel(model);
|
||||
String effectiveHttpVersion = AgentHttpTransportProvider.resolveEffectivePolicy(
|
||||
modelSpec.getHttpVersionPolicy(), modelSpec.getBaseUrl()).name();
|
||||
AgentMessage verificationMessage = buildVerificationMessage(modelSpec.isSupportImage());
|
||||
|
||||
try {
|
||||
verifyPhase(modelSpec, verificationMessage, false);
|
||||
} catch (BusinessException exception) {
|
||||
LOG.error("AgentScope model base connectivity verification failed, modelId={}, httpPolicy={}",
|
||||
model.getId(), effectiveHttpVersion, exception);
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
LOG.error("AgentScope model base connectivity verification failed, modelId={}, httpPolicy={}",
|
||||
model.getId(), effectiveHttpVersion, exception);
|
||||
throw new BusinessException(400, 1, "模型基础连接验证失败,请查看后端日志", exception);
|
||||
}
|
||||
|
||||
try {
|
||||
verifyPhase(modelSpec, verificationMessage, true);
|
||||
return ChatModelVerificationResult.passed(effectiveHttpVersion);
|
||||
} catch (Exception exception) {
|
||||
LOG.warn("AgentScope model streaming verification failed, modelId={}, httpPolicy={}",
|
||||
model.getId(), effectiveHttpVersion, exception);
|
||||
return ChatModelVerificationResult.streamingUnavailable(effectiveHttpVersion);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次指定流式模式的模型请求并校验响应。
|
||||
*
|
||||
* @param modelSpec 运行时模型声明
|
||||
* @param verificationMessage 验证消息
|
||||
* @param stream 是否启用流式响应
|
||||
* @throws BusinessException 响应为空或 VLM 图片识别错误时抛出
|
||||
*/
|
||||
private void verifyPhase(AgentModelSpec modelSpec,
|
||||
AgentMessage verificationMessage,
|
||||
boolean stream) {
|
||||
AgentGenerationOptions generationOptions = new AgentGenerationOptions();
|
||||
generationOptions.setStream(stream);
|
||||
generationOptions.setThinkingEnabled(false);
|
||||
disableOpenAiCompatibleThinking(modelSpec, generationOptions);
|
||||
generationOptions.setMaxTokens(MAX_TOKENS);
|
||||
io.agentscope.core.model.Model agentScopeModel = modelFactory.create(modelSpec, generationOptions);
|
||||
|
||||
Msg message = messageAdapter.toMsg(verificationMessage);
|
||||
GenerateOptions requestOptions = GenerateOptions.builder()
|
||||
.stream(stream)
|
||||
.maxTokens(MAX_TOKENS)
|
||||
.executionConfig(ExecutionConfig.builder()
|
||||
.timeout(phaseTimeout)
|
||||
.maxAttempts(1)
|
||||
.build())
|
||||
.build();
|
||||
List<ChatResponse> responses = agentScopeModel
|
||||
.stream(List.of(message), List.of(), requestOptions)
|
||||
.timeout(phaseTimeout)
|
||||
.collectList()
|
||||
.block(phaseTimeout.plusSeconds(1));
|
||||
String responseText = aggregateText(responses);
|
||||
validateResponse(modelSpec.isSupportImage(), responseText);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为支持该扩展字段的 OpenAI-compatible 服务显式关闭思考。
|
||||
*
|
||||
* <p>AgentScope 的通用 OpenAI Formatter 不会读取中立的
|
||||
* {@code thinkingEnabled} 字段,需通过扩展请求体传递;原生 OpenAI、
|
||||
* DeepSeek 等服务不接收该非标准字段,因此仅对已知兼容入口设置。</p>
|
||||
*
|
||||
* @param modelSpec 运行时模型声明
|
||||
* @param generationOptions 验证生成参数
|
||||
*/
|
||||
private void disableOpenAiCompatibleThinking(
|
||||
AgentModelSpec modelSpec,
|
||||
AgentGenerationOptions generationOptions) {
|
||||
AgentModelProviderType providerType = modelSpec.getProviderType();
|
||||
if (providerType == AgentModelProviderType.OPENAI_COMPATIBLE
|
||||
|| providerType == AgentModelProviderType.CUSTOM
|
||||
|| providerType == AgentModelProviderType.SILICONFLOW) {
|
||||
generationOptions.getAdditionalBodyParams().put("enable_thinking", false);
|
||||
}
|
||||
Object sourceProviderType = modelSpec.getMetadata().get("sourceProviderType");
|
||||
if (sourceProviderType != null
|
||||
&& "gpustack".equalsIgnoreCase(String.valueOf(sourceProviderType))) {
|
||||
// GPUStack 托管的 vLLM 模型通过 chat_template_kwargs 控制 Qwen 思考模式。
|
||||
generationOptions.getAdditionalBodyParams().put(
|
||||
"chat_template_kwargs",
|
||||
Map.of("enable_thinking", false));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文字模型或 VLM 的最小验证消息。
|
||||
*
|
||||
* @param supportImage 是否验证图片理解能力
|
||||
* @return 验证消息
|
||||
*/
|
||||
private AgentMessage buildVerificationMessage(boolean supportImage) {
|
||||
if (!supportImage) {
|
||||
return AgentMessage.text(
|
||||
AgentMessageRole.USER,
|
||||
"请直接回复“你好”,不要补充其他内容。");
|
||||
}
|
||||
List<AgentContentBlock> blocks = new ArrayList<>();
|
||||
blocks.add(new AgentTextBlock("请直接输出图片中的内容,不要补充其他内容。"));
|
||||
AgentMediaBlock image = new AgentMediaBlock("image");
|
||||
image.setMimeType("image/png");
|
||||
image.setData(Base64.getEncoder().encodeToString(VlmVerificationImage.pngBytes()));
|
||||
blocks.add(image);
|
||||
AgentMessage message = new AgentMessage();
|
||||
message.setRole(AgentMessageRole.USER);
|
||||
message.setContentBlocks(blocks);
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合流式响应中的全部文本增量。
|
||||
*
|
||||
* @param responses AgentScope 响应片段
|
||||
* @return 聚合后的文本
|
||||
*/
|
||||
private String aggregateText(List<ChatResponse> responses) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
if (responses == null) {
|
||||
return result.toString();
|
||||
}
|
||||
for (ChatResponse response : responses) {
|
||||
if (response == null || response.getContent() == null) {
|
||||
continue;
|
||||
}
|
||||
for (ContentBlock block : response.getContent()) {
|
||||
if (block instanceof TextBlock textBlock && textBlock.getText() != null) {
|
||||
result.append(textBlock.getText());
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验模型返回内容。
|
||||
*
|
||||
* @param supportImage 是否执行 VLM 图片识别校验
|
||||
* @param responseText 聚合后的响应文本
|
||||
* @throws BusinessException 响应为空或图片识别结果不匹配时抛出
|
||||
*/
|
||||
private void validateResponse(boolean supportImage, String responseText) {
|
||||
if (responseText == null || responseText.isBlank()) {
|
||||
throw new BusinessException("模型未返回有效内容");
|
||||
}
|
||||
if (supportImage
|
||||
&& !normalizeVerificationText(responseText)
|
||||
.contains(VlmVerificationImage.VERIFICATION_CODE)) {
|
||||
LOG.warn("VLM verification response did not contain expected code, responseSummary={}",
|
||||
responseSummary(responseText));
|
||||
throw new BusinessException("多模态校验未通过,模型未正确识别验证图片");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化 VLM 对固定验证码的常见排版输出。
|
||||
*
|
||||
* <p>模型可能将连续数字输出为 {@code 5 8 3 9} 或 {@code 5,8,3,9},
|
||||
* 这不影响图片识别结论,因此移除非字母数字字符后再校验。</p>
|
||||
*
|
||||
* @param responseText 模型验证回复
|
||||
* @return 仅保留字母与数字的响应文本
|
||||
*/
|
||||
private String normalizeVerificationText(String responseText) {
|
||||
return responseText == null
|
||||
? ""
|
||||
: responseText.replaceAll("[^\\p{L}\\p{N}]", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成固定验证回复的安全日志摘要。
|
||||
*
|
||||
* @param responseText 模型验证回复
|
||||
* @return 移除控制字符且最长 160 字符的摘要
|
||||
*/
|
||||
private String responseSummary(String responseText) {
|
||||
String normalized = responseText == null
|
||||
? ""
|
||||
: responseText.replaceAll("[\\p{Cntrl}]", " ").trim();
|
||||
return normalized.length() <= 160
|
||||
? normalized
|
||||
: normalized.substring(0, 160) + "...";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package tech.easyflow.agent.runtime;
|
||||
|
||||
import com.easyagents.agent.runtime.model.AgentGenerationOptions;
|
||||
import com.easyagents.agent.runtime.model.AgentHttpVersionPolicy;
|
||||
import com.easyagents.agent.runtime.model.AgentModelSpec;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Agent 模型生成和 HTTP 传输配置编译测试。
|
||||
*/
|
||||
public class AgentRuntimeCompilerModelConfigTest {
|
||||
|
||||
/**
|
||||
* 验证缺少 stream 时默认开启,显式关闭时保持关闭。
|
||||
*
|
||||
* @throws Exception 反射调用失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void generationStreamShouldDefaultToTrueAndAllowFalse() throws Exception {
|
||||
AgentRuntimeCompiler compiler = new AgentRuntimeCompiler();
|
||||
|
||||
AgentGenerationOptions defaultOptions = invokeGenerationOptions(compiler, Map.of());
|
||||
AgentGenerationOptions disabledOptions = invokeGenerationOptions(compiler, Map.of("stream", false));
|
||||
|
||||
Assert.assertTrue(defaultOptions.getStream());
|
||||
Assert.assertFalse(disabledOptions.getStream());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证模型 options 中的 HTTP 策略会编译到中立模型声明。
|
||||
*
|
||||
* @throws Exception 反射调用失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void modelHttpPolicyShouldCompileFromOptions() throws Exception {
|
||||
Model model = model(Map.of("agentHttpVersionPolicy", "HTTP_1_1"));
|
||||
AgentRuntimeCompiler compiler = compiler(model);
|
||||
|
||||
AgentModelSpec spec = invokeModelSpec(compiler);
|
||||
|
||||
Assert.assertEquals(AgentHttpVersionPolicy.HTTP_1_1, spec.getHttpVersionPolicy());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证未知 HTTP 策略安全回退到 AUTO。
|
||||
*
|
||||
* @throws Exception 反射调用失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void unknownModelHttpPolicyShouldFallbackToAuto() throws Exception {
|
||||
Model model = model(Map.of("agentHttpVersionPolicy", "HTTP_3"));
|
||||
AgentRuntimeCompiler compiler = compiler(model);
|
||||
|
||||
AgentModelSpec spec = invokeModelSpec(compiler);
|
||||
|
||||
Assert.assertEquals(AgentHttpVersionPolicy.AUTO, spec.getHttpVersionPolicy());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建已注入模型服务的编译器。
|
||||
*
|
||||
* @param model 模型
|
||||
* @return 编译器
|
||||
* @throws Exception 注入失败时抛出
|
||||
*/
|
||||
private AgentRuntimeCompiler compiler(Model model) throws Exception {
|
||||
AgentRuntimeCompiler compiler = new AgentRuntimeCompiler();
|
||||
ModelService modelService = (ModelService) java.lang.reflect.Proxy.newProxyInstance(
|
||||
ModelService.class.getClassLoader(),
|
||||
new Class<?>[]{ModelService.class},
|
||||
(proxy, method, args) -> "getModelInstance".equals(method.getName()) ? model : null);
|
||||
Field field = AgentRuntimeCompiler.class.getDeclaredField("modelService");
|
||||
field.setAccessible(true);
|
||||
field.set(compiler, modelService);
|
||||
return compiler;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试模型。
|
||||
*
|
||||
* @param options 模型扩展配置
|
||||
* @return 测试模型
|
||||
*/
|
||||
private Model model(Map<String, Object> options) {
|
||||
Model model = new Model();
|
||||
model.setId(BigInteger.TEN);
|
||||
model.setModelName("test-model");
|
||||
model.setOptions(options);
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用私有生成参数编译方法。
|
||||
*
|
||||
* @param compiler 编译器
|
||||
* @param config 生成配置
|
||||
* @return 生成参数
|
||||
* @throws Exception 反射调用失败时抛出
|
||||
*/
|
||||
private AgentGenerationOptions invokeGenerationOptions(AgentRuntimeCompiler compiler,
|
||||
Map<String, Object> config) throws Exception {
|
||||
Method method = AgentRuntimeCompiler.class.getDeclaredMethod("buildGenerationOptions", Map.class);
|
||||
method.setAccessible(true);
|
||||
return (AgentGenerationOptions) method.invoke(compiler, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用私有模型声明编译方法。
|
||||
*
|
||||
* @param compiler 编译器
|
||||
* @return 模型声明
|
||||
* @throws Exception 反射调用失败时抛出
|
||||
*/
|
||||
private AgentModelSpec invokeModelSpec(AgentRuntimeCompiler compiler) throws Exception {
|
||||
Agent agent = new Agent();
|
||||
agent.setModelId(BigInteger.TEN);
|
||||
Method method = AgentRuntimeCompiler.class.getDeclaredMethod("buildModelSpec", Agent.class);
|
||||
method.setAccessible(true);
|
||||
return (AgentModelSpec) method.invoke(compiler, agent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package tech.easyflow.agent.runtime;
|
||||
|
||||
import com.easyagents.agent.runtime.agentscope.AgentScopeMessageAdapter;
|
||||
import com.easyagents.agent.runtime.model.AgentGenerationOptions;
|
||||
import com.easyagents.agent.runtime.model.AgentModelFactory;
|
||||
import com.easyagents.agent.runtime.model.AgentModelSpec;
|
||||
import io.agentscope.core.message.Base64Source;
|
||||
import io.agentscope.core.message.ImageBlock;
|
||||
import io.agentscope.core.message.Msg;
|
||||
import io.agentscope.core.message.TextBlock;
|
||||
import io.agentscope.core.model.ChatResponse;
|
||||
import io.agentscope.core.model.GenerateOptions;
|
||||
import io.agentscope.core.model.ToolSchema;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import reactor.core.publisher.Flux;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.entity.ModelProvider;
|
||||
import tech.easyflow.ai.service.support.VlmVerificationImage;
|
||||
import tech.easyflow.ai.service.verification.ChatModelVerificationResult;
|
||||
import tech.easyflow.ai.service.verification.ModelVerificationStatus;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AgentScope 双阶段模型连通性验证测试。
|
||||
*/
|
||||
public class AgentScopeChatModelConnectivityVerifierTest {
|
||||
|
||||
/**
|
||||
* 验证非流式与流式阶段按顺序执行并返回通过状态。
|
||||
*/
|
||||
@Test
|
||||
public void shouldPassWhenBothPhasesReturnText() {
|
||||
RecordingModelFactory factory = new RecordingModelFactory(
|
||||
Flux.just(response("你好")),
|
||||
Flux.just(response("你"), response("好")));
|
||||
AgentScopeChatModelConnectivityVerifier verifier = verifier(factory);
|
||||
|
||||
ChatModelVerificationResult result = verifier.verify(model(false));
|
||||
|
||||
Assert.assertEquals(ModelVerificationStatus.PASSED, result.getStatus());
|
||||
Assert.assertEquals("HTTP_1_1", result.getEffectiveHttpVersion());
|
||||
Assert.assertEquals(List.of(false, true), factory.getFactoryStreams());
|
||||
Assert.assertEquals(List.of(false, true), factory.getRequestStreams());
|
||||
Assert.assertEquals(List.of(false, false), factory.getEnableThinkingValues());
|
||||
Assert.assertEquals(List.of(false, false), factory.getChatTemplateThinkingValues());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证基础连接通过而流式阶段失败时返回部分通过结果。
|
||||
*/
|
||||
@Test
|
||||
public void shouldReturnPartialWhenStreamingPhaseFails() {
|
||||
RecordingModelFactory factory = new RecordingModelFactory(
|
||||
Flux.just(response("你好")),
|
||||
Flux.error(new IllegalStateException("stream failed")));
|
||||
|
||||
ChatModelVerificationResult result = verifier(factory).verify(model(false));
|
||||
|
||||
Assert.assertEquals(ModelVerificationStatus.PARTIAL, result.getStatus());
|
||||
Assert.assertEquals(ModelVerificationStatus.PASSED, result.getNonStreaming());
|
||||
Assert.assertEquals(ModelVerificationStatus.FAILED, result.getStreaming());
|
||||
Assert.assertTrue(result.getMessage().contains("流式响应不可用"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证基础连接失败时立即终止且返回业务失败。
|
||||
*/
|
||||
@Test
|
||||
public void shouldStopWhenNonStreamingPhaseFails() {
|
||||
RecordingModelFactory factory = new RecordingModelFactory(
|
||||
Flux.error(new IllegalStateException("connection failed")));
|
||||
|
||||
try {
|
||||
verifier(factory).verify(model(false));
|
||||
Assert.fail("Expected base connectivity verification failure");
|
||||
} catch (BusinessException exception) {
|
||||
Assert.assertTrue(exception.getMessage().contains("基础连接验证失败"));
|
||||
Assert.assertFalse(exception.getMessage().contains("connection failed"));
|
||||
}
|
||||
Assert.assertEquals(List.of(false), factory.getFactoryStreams());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证流式阶段超时被归类为部分可用且不暴露底层异常。
|
||||
*/
|
||||
@Test
|
||||
public void shouldReturnPartialWhenStreamingPhaseTimesOut() {
|
||||
RecordingModelFactory factory = new RecordingModelFactory(
|
||||
Flux.just(response("你好")),
|
||||
Flux.never());
|
||||
AgentScopeChatModelConnectivityVerifier verifier = new AgentScopeChatModelConnectivityVerifier(
|
||||
factory,
|
||||
new AgentScopeMessageAdapter(),
|
||||
Duration.ofMillis(20));
|
||||
|
||||
ChatModelVerificationResult result = verifier.verify(model(false));
|
||||
|
||||
Assert.assertEquals(ModelVerificationStatus.PARTIAL, result.getStatus());
|
||||
Assert.assertEquals("连接成功,流式响应不可用,可关闭智能体的模型流式响应。",
|
||||
result.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 VLM 使用 Base64 图片,并能聚合流式文本增量。
|
||||
*/
|
||||
@Test
|
||||
public void shouldVerifyVlmImageAndAggregateStreamingChunks() {
|
||||
RecordingModelFactory factory = new RecordingModelFactory(
|
||||
Flux.just(response("图片中的内容是“" + VlmVerificationImage.VERIFICATION_CODE + "”。")),
|
||||
Flux.just(response("识别结果:58"), response("39")));
|
||||
|
||||
ChatModelVerificationResult result = verifier(factory).verify(model(true));
|
||||
|
||||
Assert.assertEquals(ModelVerificationStatus.PASSED, result.getStatus());
|
||||
Msg message = factory.getMessages().get(0);
|
||||
ImageBlock image = message.getContent().stream()
|
||||
.filter(ImageBlock.class::isInstance)
|
||||
.map(ImageBlock.class::cast)
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
Assert.assertTrue(image.getSource() instanceof Base64Source);
|
||||
byte[] imageBytes = Base64.getDecoder().decode(((Base64Source) image.getSource()).getData());
|
||||
Assert.assertEquals((byte) 0x89, imageBytes[0]);
|
||||
Assert.assertEquals((byte) 0x50, imageBytes[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建待验证模型。
|
||||
*
|
||||
* @param supportImage 是否支持图片
|
||||
* @return 测试模型
|
||||
*/
|
||||
private Model model(boolean supportImage) {
|
||||
Model model = new Model();
|
||||
model.setId(BigInteger.TEN);
|
||||
model.setModelName("test-model");
|
||||
model.setEndpoint("http://model.example.com");
|
||||
model.setRequestPath("/v1/chat/completions");
|
||||
model.setApiKey("test-key");
|
||||
model.setSupportImage(supportImage);
|
||||
ModelProvider provider = new ModelProvider();
|
||||
provider.setProviderType("gpustack");
|
||||
model.setModelProvider(provider);
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建使用测试工厂的验证器。
|
||||
*
|
||||
* @param factory 记录型模型工厂
|
||||
* @return 验证器
|
||||
*/
|
||||
private AgentScopeChatModelConnectivityVerifier verifier(RecordingModelFactory factory) {
|
||||
return new AgentScopeChatModelConnectivityVerifier(
|
||||
factory,
|
||||
new AgentScopeMessageAdapter(),
|
||||
Duration.ofSeconds(2));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单个文本响应片段。
|
||||
*
|
||||
* @param text 文本内容
|
||||
* @return AgentScope 响应
|
||||
*/
|
||||
private ChatResponse response(String text) {
|
||||
return ChatResponse.builder()
|
||||
.content(List.of(TextBlock.builder().text(text).build()))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按阶段返回预设响应并记录调用参数的模型工厂。
|
||||
*/
|
||||
private static final class RecordingModelFactory
|
||||
implements AgentModelFactory<io.agentscope.core.model.Model> {
|
||||
|
||||
/** 每个阶段的预设响应。 */
|
||||
private final List<Flux<ChatResponse>> phaseResponses;
|
||||
/** 模型工厂收到的流式参数。 */
|
||||
private final List<Boolean> factoryStreams = new ArrayList<>();
|
||||
/** 模型请求收到的流式参数。 */
|
||||
private final List<Boolean> requestStreams = new ArrayList<>();
|
||||
/** 模型请求收到的消息。 */
|
||||
private final List<Msg> messages = new ArrayList<>();
|
||||
/** OpenAI-compatible 请求中的思考开关。 */
|
||||
private final List<Object> enableThinkingValues = new ArrayList<>();
|
||||
/** GPUStack 模板参数中的思考开关。 */
|
||||
private final List<Object> chatTemplateThinkingValues = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 创建记录型模型工厂。
|
||||
*
|
||||
* @param phaseResponses 每个阶段的预设响应
|
||||
*/
|
||||
@SafeVarargs
|
||||
private RecordingModelFactory(Flux<ChatResponse>... phaseResponses) {
|
||||
this.phaseResponses = List.of(phaseResponses);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建当前验证阶段的测试模型。
|
||||
*
|
||||
* @param modelSpec 模型声明
|
||||
* @param generationOptions 生成参数
|
||||
* @return 测试模型
|
||||
*/
|
||||
@Override
|
||||
public io.agentscope.core.model.Model create(
|
||||
AgentModelSpec modelSpec,
|
||||
AgentGenerationOptions generationOptions) {
|
||||
int phaseIndex = factoryStreams.size();
|
||||
factoryStreams.add(Boolean.TRUE.equals(generationOptions.getStream()));
|
||||
enableThinkingValues.add(
|
||||
generationOptions.getAdditionalBodyParams().get("enable_thinking"));
|
||||
Object templateOptions = generationOptions.getAdditionalBodyParams()
|
||||
.get("chat_template_kwargs");
|
||||
chatTemplateThinkingValues.add(templateOptions instanceof java.util.Map<?, ?> map
|
||||
? map.get("enable_thinking")
|
||||
: null);
|
||||
Flux<ChatResponse> responses = phaseResponses.get(phaseIndex);
|
||||
return new io.agentscope.core.model.Model() {
|
||||
/**
|
||||
* 返回预设响应并记录真实请求参数。
|
||||
*
|
||||
* @param inputMessages 模型消息
|
||||
* @param tools 工具声明
|
||||
* @param options 生成参数
|
||||
* @return 预设响应
|
||||
*/
|
||||
@Override
|
||||
public Flux<ChatResponse> stream(
|
||||
List<Msg> inputMessages,
|
||||
List<ToolSchema> tools,
|
||||
GenerateOptions options) {
|
||||
requestStreams.add(Boolean.TRUE.equals(options.getStream()));
|
||||
messages.add(inputMessages.get(0));
|
||||
return responses;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回测试模型名称。
|
||||
*
|
||||
* @return 测试模型名称
|
||||
*/
|
||||
@Override
|
||||
public String getModelName() {
|
||||
return modelSpec.getModelName();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工厂流式参数记录。
|
||||
*
|
||||
* @return 流式参数列表
|
||||
*/
|
||||
private List<Boolean> getFactoryStreams() {
|
||||
return factoryStreams;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求流式参数记录。
|
||||
*
|
||||
* @return 流式参数列表
|
||||
*/
|
||||
private List<Boolean> getRequestStreams() {
|
||||
return requestStreams;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求消息记录。
|
||||
*
|
||||
* @return 消息列表
|
||||
*/
|
||||
private List<Msg> getMessages() {
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OpenAI-compatible 请求中的思考开关。
|
||||
*
|
||||
* @return 各阶段思考开关
|
||||
*/
|
||||
private List<Object> getEnableThinkingValues() {
|
||||
return enableThinkingValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 GPUStack 模板参数中的思考开关。
|
||||
*
|
||||
* @return 各阶段模板思考开关
|
||||
*/
|
||||
private List<Object> getChatTemplateThinkingValues() {
|
||||
return chatTemplateThinkingValues;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user