发布 v1.10 #5
@@ -9,7 +9,6 @@ import org.springframework.util.StringUtils;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import tech.easyflow.ai.dto.ModelInvokeConfigDtos;
|
import tech.easyflow.ai.dto.ModelInvokeConfigDtos;
|
||||||
import tech.easyflow.ai.entity.Model;
|
import tech.easyflow.ai.entity.Model;
|
||||||
import tech.easyflow.ai.entity.ModelProvider;
|
|
||||||
import tech.easyflow.ai.entity.table.ModelTableDef;
|
import tech.easyflow.ai.entity.table.ModelTableDef;
|
||||||
import tech.easyflow.ai.mapper.ModelMapper;
|
import tech.easyflow.ai.mapper.ModelMapper;
|
||||||
import tech.easyflow.ai.service.ModelService;
|
import tech.easyflow.ai.service.ModelService;
|
||||||
@@ -27,7 +26,6 @@ import java.math.BigInteger;
|
|||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,16 +51,7 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
|
|||||||
@GetMapping("list")
|
@GetMapping("list")
|
||||||
@SaCheckPermission("/api/v1/model/query")
|
@SaCheckPermission("/api/v1/model/query")
|
||||||
public Result<List<Model>> list(Model entity, Boolean asTree, String sortKey, String sortType) {
|
public Result<List<Model>> list(Model entity, Boolean asTree, String sortKey, String sortType) {
|
||||||
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
|
return Result.ok(service.listSelectableModels(entity, asTree, sortKey, sortType));
|
||||||
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
|
|
||||||
List<Model> list = Tree.tryToTree(modelMapper.selectListWithRelationsByQuery(queryWrapper), asTree);
|
|
||||||
list.forEach(item -> {
|
|
||||||
String providerName = Optional.ofNullable(item.getModelProvider())
|
|
||||||
.map(ModelProvider::getProviderName)
|
|
||||||
.orElse("-");
|
|
||||||
item.setTitle(providerName + "/" + item.getTitle());
|
|
||||||
});
|
|
||||||
return Result.ok(list);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("getList")
|
@GetMapping("getList")
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
|
import org.testng.Assert;
|
||||||
|
import org.testng.annotations.Test;
|
||||||
|
import tech.easyflow.ai.entity.Model;
|
||||||
|
import tech.easyflow.ai.service.ModelService;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型列表控制器测试。
|
||||||
|
*/
|
||||||
|
public class ModelControllerTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证模型列表使用包含目录能力元数据的服务查询。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void listShouldReturnCapabilityDecoratedModels() {
|
||||||
|
ModelService service = mock(ModelService.class);
|
||||||
|
Model query = new Model();
|
||||||
|
Model decoratedModel = new Model();
|
||||||
|
decoratedModel.setContextWindowTokens(1_000_000L);
|
||||||
|
decoratedModel.setMaxOutputTokens(64_000L);
|
||||||
|
when(service.listSelectableModels(query, false, "id", "desc"))
|
||||||
|
.thenReturn(List.of(decoratedModel));
|
||||||
|
ModelController controller = new ModelController(service);
|
||||||
|
|
||||||
|
Result<List<Model>> result = controller.list(query, false, "id", "desc");
|
||||||
|
|
||||||
|
Assert.assertSame(decoratedModel, result.getData().get(0));
|
||||||
|
Assert.assertEquals(Long.valueOf(1_000_000L),
|
||||||
|
result.getData().get(0).getContextWindowTokens());
|
||||||
|
verify(service).listSelectableModels(query, false, "id", "desc");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import com.easyagents.rerank.DefaultRerankModel;
|
|||||||
import com.easyagents.rerank.DefaultRerankModelConfig;
|
import com.easyagents.rerank.DefaultRerankModelConfig;
|
||||||
import com.easyagents.rerank.gitee.GiteeRerankModel;
|
import com.easyagents.rerank.gitee.GiteeRerankModel;
|
||||||
import com.easyagents.rerank.gitee.GiteeRerankModelConfig;
|
import com.easyagents.rerank.gitee.GiteeRerankModelConfig;
|
||||||
|
import com.mybatisflex.annotation.Column;
|
||||||
import com.mybatisflex.annotation.RelationManyToOne;
|
import com.mybatisflex.annotation.RelationManyToOne;
|
||||||
import com.mybatisflex.annotation.Table;
|
import com.mybatisflex.annotation.Table;
|
||||||
import tech.easyflow.ai.entity.base.ModelBase;
|
import tech.easyflow.ai.entity.base.ModelBase;
|
||||||
@@ -39,6 +40,18 @@ public class Model extends ModelBase {
|
|||||||
@RelationManyToOne(selfField = "providerId", targetField = "id")
|
@RelationManyToOne(selfField = "providerId", targetField = "id")
|
||||||
private ModelProvider modelProvider;
|
private ModelProvider modelProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型最大上下文窗口 Token 数。
|
||||||
|
*/
|
||||||
|
@Column(ignore = true)
|
||||||
|
private Long contextWindowTokens;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型最大输出 Token 数。
|
||||||
|
*/
|
||||||
|
@Column(ignore = true)
|
||||||
|
private Long maxOutputTokens;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 模型类型
|
* 模型类型
|
||||||
*/
|
*/
|
||||||
@@ -53,6 +66,42 @@ public class Model extends ModelBase {
|
|||||||
this.modelProvider = modelProvider;
|
this.modelProvider = modelProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模型最大上下文窗口。
|
||||||
|
*
|
||||||
|
* @return 最大上下文窗口 Token 数,目录未提供时返回 null
|
||||||
|
*/
|
||||||
|
public Long getContextWindowTokens() {
|
||||||
|
return contextWindowTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置模型最大上下文窗口。
|
||||||
|
*
|
||||||
|
* @param contextWindowTokens 最大上下文窗口 Token 数
|
||||||
|
*/
|
||||||
|
public void setContextWindowTokens(Long contextWindowTokens) {
|
||||||
|
this.contextWindowTokens = contextWindowTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模型最大输出 Token 数。
|
||||||
|
*
|
||||||
|
* @return 最大输出 Token 数,目录未提供时返回 null
|
||||||
|
*/
|
||||||
|
public Long getMaxOutputTokens() {
|
||||||
|
return maxOutputTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置模型最大输出 Token 数。
|
||||||
|
*
|
||||||
|
* @param maxOutputTokens 最大输出 Token 数
|
||||||
|
*/
|
||||||
|
public void setMaxOutputTokens(Long maxOutputTokens) {
|
||||||
|
this.maxOutputTokens = maxOutputTokens;
|
||||||
|
}
|
||||||
|
|
||||||
public ChatModel toChatModel() {
|
public ChatModel toChatModel() {
|
||||||
String providerType = modelProvider.getProviderType();
|
String providerType = modelProvider.getProviderType();
|
||||||
if (StringUtil.noText(providerType)) {
|
if (StringUtil.noText(providerType)) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public class ModelCapabilityCatalog {
|
|||||||
|
|
||||||
/** 按规范化完整模型 ID 建立的目录索引。 */
|
/** 按规范化完整模型 ID 建立的目录索引。 */
|
||||||
private final Map<String, ModelCatalogMetadata> metadataById;
|
private final Map<String, ModelCatalogMetadata> metadataById;
|
||||||
/** 仅在模型短 ID 唯一时建立的目录别名索引。 */
|
/** 仅在规范化模型短 ID 唯一时建立的目录别名索引。 */
|
||||||
private final Map<String, ModelCatalogMetadata> metadataByAlias;
|
private final Map<String, ModelCatalogMetadata> metadataByAlias;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,7 +83,7 @@ public class ModelCapabilityCatalog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String alias = shortId(normalizedId);
|
String alias = canonicalAlias(normalizedId);
|
||||||
return Optional.ofNullable(metadataByAlias.get(alias));
|
return Optional.ofNullable(metadataByAlias.get(alias));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ public class ModelCapabilityCatalog {
|
|||||||
}
|
}
|
||||||
ModelCatalogMetadata metadata = toMetadata(normalizedId, field.getValue());
|
ModelCatalogMetadata metadata = toMetadata(normalizedId, field.getValue());
|
||||||
fullIdIndex.put(normalizedId, metadata);
|
fullIdIndex.put(normalizedId, metadata);
|
||||||
registerAlias(shortId(normalizedId), metadata, aliasCandidates, ambiguousAliases);
|
registerAlias(canonicalAlias(normalizedId), metadata, aliasCandidates, ambiguousAliases);
|
||||||
}
|
}
|
||||||
} catch (IOException exception) {
|
} catch (IOException exception) {
|
||||||
throw new IllegalStateException("无法加载模型能力库 " + CATALOG_RESOURCE, exception);
|
throw new IllegalStateException("无法加载模型能力库 " + CATALOG_RESOURCE, exception);
|
||||||
@@ -147,20 +147,26 @@ public class ModelCapabilityCatalog {
|
|||||||
*/
|
*/
|
||||||
private ModelCapabilityResolution toCapability(String normalizedId, JsonNode node) {
|
private ModelCapabilityResolution toCapability(String normalizedId, JsonNode node) {
|
||||||
String modelType = resolveModelType(normalizedId);
|
String modelType = resolveModelType(normalizedId);
|
||||||
|
Long contextWindowTokens = positiveLongValue(node.path("limit"), "context");
|
||||||
|
Long maxOutputTokens = positiveLongValue(node.path("limit"), "output");
|
||||||
if (!Model.MODEL_TYPES[0].equals(modelType)) {
|
if (!Model.MODEL_TYPES[0].equals(modelType)) {
|
||||||
return new ModelCapabilityResolution(
|
return new ModelCapabilityResolution(
|
||||||
modelType,
|
modelType,
|
||||||
Boolean.FALSE,
|
Boolean.FALSE,
|
||||||
Boolean.FALSE,
|
Boolean.FALSE,
|
||||||
Boolean.FALSE,
|
Boolean.FALSE,
|
||||||
ModelCapabilitySource.CATALOG);
|
ModelCapabilitySource.CATALOG,
|
||||||
|
contextWindowTokens,
|
||||||
|
maxOutputTokens);
|
||||||
}
|
}
|
||||||
return new ModelCapabilityResolution(
|
return new ModelCapabilityResolution(
|
||||||
modelType,
|
modelType,
|
||||||
hasInputModality(node, "image"),
|
hasInputModality(node, "image"),
|
||||||
booleanValue(node, "reasoning"),
|
booleanValue(node, "reasoning"),
|
||||||
booleanValue(node, "tool_call"),
|
booleanValue(node, "tool_call"),
|
||||||
ModelCapabilitySource.CATALOG);
|
ModelCapabilitySource.CATALOG,
|
||||||
|
contextWindowTokens,
|
||||||
|
maxOutputTokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,6 +197,22 @@ public class ModelCapabilityCatalog {
|
|||||||
return value != null && value.asBoolean(false);
|
return value != null && value.asBoolean(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取正整数长整型字段。
|
||||||
|
*
|
||||||
|
* @param node 字段所属节点
|
||||||
|
* @param fieldName 字段名
|
||||||
|
* @return 正整数值,字段缺失或非法时返回 null
|
||||||
|
*/
|
||||||
|
private Long positiveLongValue(JsonNode node, String fieldName) {
|
||||||
|
JsonNode value = node.get(fieldName);
|
||||||
|
if (value == null || !value.isIntegralNumber() || !value.canConvertToLong()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
long number = value.longValue();
|
||||||
|
return number > 0L ? number : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断模型输入模态是否包含指定类型。
|
* 判断模型输入模态是否包含指定类型。
|
||||||
*
|
*
|
||||||
@@ -309,4 +331,24 @@ public class ModelCapabilityCatalog {
|
|||||||
int separator = normalizedId.lastIndexOf('/');
|
int separator = normalizedId.lastIndexOf('/');
|
||||||
return separator < 0 ? normalizedId : normalizedId.substring(separator + 1);
|
return separator < 0 ? normalizedId : normalizedId.substring(separator + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成忽略厂商前缀、大小写和常见连接符的模型短 ID 索引键。
|
||||||
|
*
|
||||||
|
* @param normalizedId 已完成大小写与首尾空白规范化的模型 ID
|
||||||
|
* @return 保留版本点号、移除横线、下划线和空白字符的索引键
|
||||||
|
*/
|
||||||
|
private String canonicalAlias(String normalizedId) {
|
||||||
|
String modelId = shortId(normalizedId);
|
||||||
|
StringBuilder alias = new StringBuilder(modelId.length());
|
||||||
|
for (int index = 0; index < modelId.length(); index++) {
|
||||||
|
char character = modelId.charAt(index);
|
||||||
|
if (character == '-' || character == '_'
|
||||||
|
|| Character.isWhitespace(character) || Character.isSpaceChar(character)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
alias.append(character);
|
||||||
|
}
|
||||||
|
return alias.toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public final class ModelCapabilityResolution {
|
|||||||
private final Boolean supportTool;
|
private final Boolean supportTool;
|
||||||
/** 能力识别来源。 */
|
/** 能力识别来源。 */
|
||||||
private final ModelCapabilitySource source;
|
private final ModelCapabilitySource source;
|
||||||
|
/** 模型最大上下文窗口 Token 数,空值表示目录未提供。 */
|
||||||
|
private final Long contextWindowTokens;
|
||||||
|
/** 模型最大输出 Token 数,空值表示目录未提供。 */
|
||||||
|
private final Long maxOutputTokens;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建模型能力识别结果。
|
* 创建模型能力识别结果。
|
||||||
@@ -30,11 +34,34 @@ public final class ModelCapabilityResolution {
|
|||||||
Boolean supportThinking,
|
Boolean supportThinking,
|
||||||
Boolean supportTool,
|
Boolean supportTool,
|
||||||
ModelCapabilitySource source) {
|
ModelCapabilitySource source) {
|
||||||
|
this(modelType, supportImage, supportThinking, supportTool, source, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建包含上下文窗口限制的模型能力识别结果。
|
||||||
|
*
|
||||||
|
* @param modelType 模型类型
|
||||||
|
* @param supportImage 是否支持视觉输入
|
||||||
|
* @param supportThinking 是否支持推理
|
||||||
|
* @param supportTool 是否支持工具调用
|
||||||
|
* @param source 能力识别来源
|
||||||
|
* @param contextWindowTokens 模型最大上下文窗口 Token 数
|
||||||
|
* @param maxOutputTokens 模型最大输出 Token 数
|
||||||
|
*/
|
||||||
|
public ModelCapabilityResolution(String modelType,
|
||||||
|
Boolean supportImage,
|
||||||
|
Boolean supportThinking,
|
||||||
|
Boolean supportTool,
|
||||||
|
ModelCapabilitySource source,
|
||||||
|
Long contextWindowTokens,
|
||||||
|
Long maxOutputTokens) {
|
||||||
this.modelType = modelType;
|
this.modelType = modelType;
|
||||||
this.supportImage = supportImage;
|
this.supportImage = supportImage;
|
||||||
this.supportThinking = supportThinking;
|
this.supportThinking = supportThinking;
|
||||||
this.supportTool = supportTool;
|
this.supportTool = supportTool;
|
||||||
this.source = source;
|
this.source = source;
|
||||||
|
this.contextWindowTokens = contextWindowTokens;
|
||||||
|
this.maxOutputTokens = maxOutputTokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,6 +109,24 @@ public final class ModelCapabilityResolution {
|
|||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模型最大上下文窗口。
|
||||||
|
*
|
||||||
|
* @return 最大上下文窗口 Token 数,目录未提供时返回 null
|
||||||
|
*/
|
||||||
|
public Long getContextWindowTokens() {
|
||||||
|
return contextWindowTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模型最大输出 Token 数。
|
||||||
|
*
|
||||||
|
* @return 最大输出 Token 数,目录未提供时返回 null
|
||||||
|
*/
|
||||||
|
public Long getMaxOutputTokens() {
|
||||||
|
return maxOutputTokens;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断是否获得了模型库或命名规则证据。
|
* 判断是否获得了模型库或命名规则证据。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -420,10 +420,16 @@ public class ModelServiceImpl extends ServiceImpl<ModelMapper, Model> implements
|
|||||||
if (model == null) {
|
if (model == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String providerName = Optional.ofNullable(model.getModelProvider())
|
ModelProvider provider = model.getModelProvider();
|
||||||
|
String providerName = Optional.ofNullable(provider)
|
||||||
.map(ModelProvider::getProviderName)
|
.map(ModelProvider::getProviderName)
|
||||||
.orElse("-");
|
.orElse("-");
|
||||||
model.setTitle(providerName + "/" + model.getTitle());
|
model.setTitle(providerName + "/" + model.getTitle());
|
||||||
|
ModelCapabilityResolution resolution = modelCapabilityResolver.resolve(
|
||||||
|
resolveProviderType(model.getProviderId(), provider),
|
||||||
|
model.getModelName());
|
||||||
|
model.setContextWindowTokens(resolution.getContextWindowTokens());
|
||||||
|
model.setMaxOutputTokens(resolution.getMaxOutputTokens());
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildOrderBy(String sortKey, String sortType) {
|
private String buildOrderBy(String sortKey, String sortType) {
|
||||||
|
|||||||
@@ -34,6 +34,28 @@ public class ModelCapabilityResolverTest {
|
|||||||
Assert.assertEquals(Boolean.TRUE, result.getSupportImage());
|
Assert.assertEquals(Boolean.TRUE, result.getSupportImage());
|
||||||
Assert.assertEquals(Boolean.TRUE, result.getSupportThinking());
|
Assert.assertEquals(Boolean.TRUE, result.getSupportThinking());
|
||||||
Assert.assertEquals(Boolean.TRUE, result.getSupportTool());
|
Assert.assertEquals(Boolean.TRUE, result.getSupportTool());
|
||||||
|
Assert.assertEquals(Long.valueOf(1_000_000L), result.getContextWindowTokens());
|
||||||
|
Assert.assertEquals(Long.valueOf(64_000L), result.getMaxOutputTokens());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证目录匹配忽略厂商前缀、大小写和常见连接符。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldResolveCatalogCapabilitiesByCanonicalShortId() {
|
||||||
|
String[] modelIds = {
|
||||||
|
"阿里百炼/QWEN3.7_PLUS",
|
||||||
|
"custom/qwen3.7 plus",
|
||||||
|
"qwen3.7plus"
|
||||||
|
};
|
||||||
|
|
||||||
|
for (String modelId : modelIds) {
|
||||||
|
ModelCapabilityResolution result = resolver.resolve(null, modelId);
|
||||||
|
|
||||||
|
Assert.assertEquals(ModelCapabilitySource.CATALOG, result.getSource());
|
||||||
|
Assert.assertEquals(Long.valueOf(1_000_000L), result.getContextWindowTokens());
|
||||||
|
Assert.assertEquals(Long.valueOf(64_000L), result.getMaxOutputTokens());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,5 +98,7 @@ public class ModelCapabilityResolverTest {
|
|||||||
Assert.assertNull(result.getSupportImage());
|
Assert.assertNull(result.getSupportImage());
|
||||||
Assert.assertNull(result.getSupportThinking());
|
Assert.assertNull(result.getSupportThinking());
|
||||||
Assert.assertNull(result.getSupportTool());
|
Assert.assertNull(result.getSupportTool());
|
||||||
|
Assert.assertNull(result.getContextWindowTokens());
|
||||||
|
Assert.assertNull(result.getMaxOutputTokens());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export interface ModelCapabilityResolution {
|
|||||||
detected: boolean;
|
detected: boolean;
|
||||||
modelType: 'chatModel' | 'embeddingModel' | 'rerankModel';
|
modelType: 'chatModel' | 'embeddingModel' | 'rerankModel';
|
||||||
source: ModelCapabilitySource;
|
source: ModelCapabilitySource;
|
||||||
|
contextWindowTokens?: null | number;
|
||||||
|
maxOutputTokens?: null | number;
|
||||||
supportImage?: boolean | null;
|
supportImage?: boolean | null;
|
||||||
supportThinking?: boolean | null;
|
supportThinking?: boolean | null;
|
||||||
supportTool?: boolean | null;
|
supportTool?: boolean | null;
|
||||||
@@ -141,6 +143,8 @@ export interface llmType {
|
|||||||
providerId?: string;
|
providerId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
modelName?: string;
|
modelName?: string;
|
||||||
|
contextWindowTokens?: null | number;
|
||||||
|
maxOutputTokens?: null | number;
|
||||||
modelProvider: {
|
modelProvider: {
|
||||||
icon: string;
|
icon: string;
|
||||||
providerName: string;
|
providerName: string;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import AgentStudioCanvas from './components/agent-studio/AgentStudioCanvas.vue';
|
|||||||
import AgentCommandBar from './components/AgentCommandBar.vue';
|
import AgentCommandBar from './components/AgentCommandBar.vue';
|
||||||
import AgentInspectorPanel from './components/AgentInspectorPanel.vue';
|
import AgentInspectorPanel from './components/AgentInspectorPanel.vue';
|
||||||
import { useAgentDesignerState } from './composables/useAgentDesignerState';
|
import { useAgentDesignerState } from './composables/useAgentDesignerState';
|
||||||
|
import { resolveAgentCompressionTokenThreshold } from './compression-threshold';
|
||||||
import { createMcpToolLoader } from './mcpToolLoader';
|
import { createMcpToolLoader } from './mcpToolLoader';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -385,8 +386,25 @@ function runValidation() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncAgentCompressionThreshold() {
|
||||||
|
const selectedModel = models.value.find(
|
||||||
|
(model) => model.value === String(state.agent.modelId ?? ''),
|
||||||
|
);
|
||||||
|
const memoryConfig = (state.agent.memoryConfigJson ||= {});
|
||||||
|
const compressionParameter = (memoryConfig.compressionParameter ||= {});
|
||||||
|
compressionParameter.minCompressionTokenThreshold =
|
||||||
|
resolveAgentCompressionTokenThreshold({
|
||||||
|
contextWindowTokens: selectedModel?.raw?.contextWindowTokens,
|
||||||
|
generationConfig: state.agent.generationConfigJson,
|
||||||
|
maxOutputTokens: selectedModel?.raw?.maxOutputTokens,
|
||||||
|
systemPrompt: state.agent.promptConfigJson?.systemPrompt,
|
||||||
|
toolBindings: state.toolBindings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave(showMessage = true) {
|
async function handleSave(showMessage = true) {
|
||||||
if (!runValidation()) return false;
|
if (!runValidation()) return false;
|
||||||
|
syncAgentCompressionThreshold();
|
||||||
saveLoading.value = true;
|
saveLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const agentPayload = buildPayloadAgent();
|
const agentPayload = buildPayloadAgent();
|
||||||
@@ -497,6 +515,7 @@ async function handleOffline() {
|
|||||||
|
|
||||||
function handleTryout() {
|
function handleTryout() {
|
||||||
if (!runValidation()) return;
|
if (!runValidation()) return;
|
||||||
|
syncAgentCompressionThreshold();
|
||||||
openTryout();
|
openTryout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/* eslint-disable vue/no-mutating-props */
|
/* eslint-disable vue/no-mutating-props */
|
||||||
import type { AgentInfo, AgentOption } from '../types';
|
import type { AgentInfo, AgentOption, AgentToolBinding } from '../types';
|
||||||
|
|
||||||
import { InfoFilled } from '@element-plus/icons-vue';
|
import { InfoFilled } from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
@@ -15,13 +15,33 @@ import {
|
|||||||
ElTooltip,
|
ElTooltip,
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
defineProps<{
|
import { resolveAgentCompressionTokenThreshold } from '../compression-threshold';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
agent: AgentInfo;
|
agent: AgentInfo;
|
||||||
categories: AgentOption[];
|
categories: AgentOption[];
|
||||||
models: AgentOption[];
|
models: AgentOption[];
|
||||||
|
toolBindings: AgentToolBinding[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{ change: [] }>();
|
const emit = defineEmits<{ change: [] }>();
|
||||||
|
|
||||||
|
function handleModelChange(modelId: AgentInfo['modelId']) {
|
||||||
|
const selectedModel = props.models.find(
|
||||||
|
(model) => model.value === String(modelId ?? ''),
|
||||||
|
);
|
||||||
|
const memoryConfig = (props.agent.memoryConfigJson ||= {});
|
||||||
|
const compressionParameter = (memoryConfig.compressionParameter ||= {});
|
||||||
|
compressionParameter.minCompressionTokenThreshold =
|
||||||
|
resolveAgentCompressionTokenThreshold({
|
||||||
|
contextWindowTokens: selectedModel?.raw?.contextWindowTokens,
|
||||||
|
generationConfig: props.agent.generationConfigJson,
|
||||||
|
maxOutputTokens: selectedModel?.raw?.maxOutputTokens,
|
||||||
|
systemPrompt: props.agent.promptConfigJson?.systemPrompt,
|
||||||
|
toolBindings: props.toolBindings,
|
||||||
|
});
|
||||||
|
emit('change');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -67,7 +87,7 @@ const emit = defineEmits<{ change: [] }>();
|
|||||||
</ElSelect>
|
</ElSelect>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem label="模型" required>
|
<ElFormItem label="模型" required>
|
||||||
<ElSelect v-model="agent.modelId" filterable @change="emit('change')">
|
<ElSelect v-model="agent.modelId" filterable @change="handleModelChange">
|
||||||
<ElOption
|
<ElOption
|
||||||
v-for="item in models"
|
v-for="item in models"
|
||||||
:key="item.value"
|
:key="item.value"
|
||||||
@@ -172,7 +192,7 @@ const emit = defineEmits<{ change: [] }>();
|
|||||||
<span class="agent-form__label">
|
<span class="agent-form__label">
|
||||||
最小压缩 Token 阈值
|
最小压缩 Token 阈值
|
||||||
<ElTooltip
|
<ElTooltip
|
||||||
content="工作记忆的 Token 数达到该值时,会触发上下文压缩。"
|
content="选择模型时按上下文 85% 与扣除输出、系统提示词、工具和安全余量后的可用值取较小值;模型目录未提供上下文时使用 30K。"
|
||||||
effect="light"
|
effect="light"
|
||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ onBeforeUnmount(() => {
|
|||||||
:agent="state.agent"
|
:agent="state.agent"
|
||||||
:categories="categories"
|
:categories="categories"
|
||||||
:models="models"
|
:models="models"
|
||||||
|
:tool-bindings="state.toolBindings"
|
||||||
@change="emit('change')"
|
@change="emit('change')"
|
||||||
/>
|
/>
|
||||||
</ElTabPane>
|
</ElTabPane>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
|
|
||||||
import { computed, reactive } from 'vue';
|
import { computed, reactive } from 'vue';
|
||||||
|
|
||||||
|
import { DEFAULT_AGENT_COMPRESSION_TOKEN_THRESHOLD } from '../compression-threshold';
|
||||||
import {
|
import {
|
||||||
buildInteractionConfigPayload,
|
buildInteractionConfigPayload,
|
||||||
createEmptyInteractionConfig,
|
createEmptyInteractionConfig,
|
||||||
@@ -103,7 +104,7 @@ export function createEmptyAgent(): AgentInfo {
|
|||||||
compressionParameter: {
|
compressionParameter: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
lastKeep: 8,
|
lastKeep: 8,
|
||||||
minCompressionTokenThreshold: 6000,
|
minCompressionTokenThreshold: DEFAULT_AGENT_COMPRESSION_TOKEN_THRESHOLD,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
interactionConfigJson: createEmptyInteractionConfig(),
|
interactionConfigJson: createEmptyInteractionConfig(),
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_AGENT_COMPRESSION_TOKEN_THRESHOLD,
|
||||||
|
resolveAgentCompressionTokenThreshold,
|
||||||
|
} from './compression-threshold';
|
||||||
|
|
||||||
|
describe('resolveAgentCompressionTokenThreshold', () => {
|
||||||
|
it('uses the lower value between 85 percent and reserved context', () => {
|
||||||
|
expect(
|
||||||
|
resolveAgentCompressionTokenThreshold({
|
||||||
|
contextWindowTokens: 128_000,
|
||||||
|
maxOutputTokens: 64_000,
|
||||||
|
}),
|
||||||
|
).toBe(108_800);
|
||||||
|
expect(
|
||||||
|
resolveAgentCompressionTokenThreshold({
|
||||||
|
contextWindowTokens: 128_000,
|
||||||
|
generationConfig: { maxCompletionTokens: 32_000 },
|
||||||
|
systemPrompt: 'a'.repeat(10_000),
|
||||||
|
}),
|
||||||
|
).toBe(85_595);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to 30K when the context window is unavailable', () => {
|
||||||
|
for (const contextWindowTokens of [undefined, 0, 'invalid']) {
|
||||||
|
expect(
|
||||||
|
resolveAgentCompressionTokenThreshold({ contextWindowTokens }),
|
||||||
|
).toBe(DEFAULT_AGENT_COMPRESSION_TOKEN_THRESHOLD);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/** 模型目录未提供上下文窗口时使用的保守压缩阈值。 */
|
||||||
|
export const DEFAULT_AGENT_COMPRESSION_TOKEN_THRESHOLD = 30_000;
|
||||||
|
|
||||||
|
/** 智能体工作记忆占模型上下文窗口的最大比例。 */
|
||||||
|
export const AGENT_COMPRESSION_CONTEXT_RATIO = 0.85;
|
||||||
|
|
||||||
|
/** 未配置生成上限时预留的默认输出 Token 数。 */
|
||||||
|
const DEFAULT_OUTPUT_RESERVE_TOKENS = 8192;
|
||||||
|
|
||||||
|
/** AgentScope 1.x 混合文本 Token 估算使用的平均字符数。 */
|
||||||
|
const CHARS_PER_TOKEN = 2.5;
|
||||||
|
|
||||||
|
/** 系统消息角色与协议结构的估算开销。 */
|
||||||
|
const SYSTEM_MESSAGE_OVERHEAD_TOKENS = 5;
|
||||||
|
|
||||||
|
/** 单个工具 Schema 的估算结构开销。 */
|
||||||
|
const TOOL_SCHEMA_OVERHEAD_TOKENS = 15;
|
||||||
|
|
||||||
|
export interface AgentCompressionThresholdInput {
|
||||||
|
contextWindowTokens?: unknown;
|
||||||
|
generationConfig?: Record<string, any>;
|
||||||
|
maxOutputTokens?: unknown;
|
||||||
|
systemPrompt?: unknown;
|
||||||
|
toolBindings?: Array<Record<string, any>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据模型上下文窗口、输出预算和系统/工具占用计算智能体压缩阈值。
|
||||||
|
*
|
||||||
|
* @param input 压缩阈值计算输入
|
||||||
|
* @returns 压缩触发 Token 数
|
||||||
|
*/
|
||||||
|
export function resolveAgentCompressionTokenThreshold(
|
||||||
|
input: AgentCompressionThresholdInput,
|
||||||
|
) {
|
||||||
|
const normalizedContextWindow = positiveInteger(input.contextWindowTokens);
|
||||||
|
if (normalizedContextWindow === undefined) {
|
||||||
|
return DEFAULT_AGENT_COMPRESSION_TOKEN_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratioThreshold = Math.floor(
|
||||||
|
normalizedContextWindow * AGENT_COMPRESSION_CONTEXT_RATIO,
|
||||||
|
);
|
||||||
|
const safetyMargin = Math.max(
|
||||||
|
2048,
|
||||||
|
Math.floor(normalizedContextWindow * 0.05),
|
||||||
|
);
|
||||||
|
const availableContextThreshold =
|
||||||
|
normalizedContextWindow -
|
||||||
|
resolveOutputReserveTokens(input) -
|
||||||
|
estimateSystemAndToolTokens(input) -
|
||||||
|
safetyMargin;
|
||||||
|
|
||||||
|
return Math.max(1, Math.min(ratioThreshold, availableContextThreshold));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOutputReserveTokens(input: AgentCompressionThresholdInput) {
|
||||||
|
const generationConfig = input.generationConfig || {};
|
||||||
|
const configuredOutputReserve = Math.max(
|
||||||
|
positiveInteger(generationConfig.maxCompletionTokens) || 0,
|
||||||
|
positiveInteger(generationConfig.maxTokens) || 0,
|
||||||
|
);
|
||||||
|
const catalogMaxOutput = positiveInteger(input.maxOutputTokens);
|
||||||
|
if (configuredOutputReserve > 0) {
|
||||||
|
return catalogMaxOutput
|
||||||
|
? Math.min(configuredOutputReserve, catalogMaxOutput)
|
||||||
|
: configuredOutputReserve;
|
||||||
|
}
|
||||||
|
return Math.min(
|
||||||
|
DEFAULT_OUTPUT_RESERVE_TOKENS,
|
||||||
|
catalogMaxOutput || DEFAULT_OUTPUT_RESERVE_TOKENS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateSystemAndToolTokens(input: AgentCompressionThresholdInput) {
|
||||||
|
const systemPrompt = String(input.systemPrompt || '');
|
||||||
|
let tokens =
|
||||||
|
estimateTextTokens(systemPrompt) +
|
||||||
|
(systemPrompt ? SYSTEM_MESSAGE_OVERHEAD_TOKENS : 0);
|
||||||
|
|
||||||
|
for (const binding of input.toolBindings || []) {
|
||||||
|
if (binding?.enabled === false) continue;
|
||||||
|
const schemaPayload = {
|
||||||
|
hitlConfigJson: binding?.hitlConfigJson,
|
||||||
|
optionsJson: binding?.optionsJson,
|
||||||
|
resourceSnapshot: binding?.resourceSnapshot,
|
||||||
|
resourceSummary: binding?.resourceSummary,
|
||||||
|
toolName: binding?.toolName,
|
||||||
|
toolType: binding?.toolType,
|
||||||
|
};
|
||||||
|
tokens +=
|
||||||
|
estimateTextTokens(safeJsonStringify(schemaPayload)) +
|
||||||
|
TOOL_SCHEMA_OVERHEAD_TOKENS;
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateTextTokens(text: string) {
|
||||||
|
return text ? Math.ceil(text.length / CHARS_PER_TOKEN) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInteger(value: unknown) {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number) || number <= 0) return;
|
||||||
|
return Math.floor(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJsonStringify(value: unknown) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value) || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user