Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bootstrap/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ ai:

vlm:
default-model: qwen-vl-max
timeout-ms: 120000
candidates:
- id: qwen-vl-max
provider: bailian
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/pages/admin/settings/SystemSettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,9 +270,17 @@ function ModelCandidatesCard({
<div className="settings-card">
<div className="settings-card-title">
{title}
{group.defaultModel ? (
{group.defaultModel || group.timeoutMs != null ? (
<span className="settings-card-title-hint">
默认 <code className="font-mono text-slate-500">{group.defaultModel}</code>
{group.defaultModel ? (
<>
默认 <code className="font-mono text-slate-500">{group.defaultModel}</code>
</>
) : null}
{group.defaultModel && group.timeoutMs != null ? (
<span className="mx-1.5 text-slate-200">|</span>
) : null}
{group.timeoutMs != null ? `超时 ${formatDurationMs(group.timeoutMs)}` : null}
</span>
) : null}
</div>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/services/settingsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export interface RetrievalChannel {

export interface ModelGroup {
defaultModel?: string | null;
timeoutMs?: number | null;
candidates: ModelCandidate[];
// chat 组档位机制字段,embedding/rerank/vlm 为空
defaultTier?: string | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ public static class ModelGroup {
*/
private String defaultModel;

/**
* 同步调用超时预算(毫秒,当前用于 VLM)
*/
private Long timeoutMs;

/**
* 候选模型列表
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,16 @@ public List<ModelTarget> selectChatCandidates(boolean thinking, Tier override, S
}

public List<ModelTarget> selectEmbeddingCandidates() {
return selectCandidates(properties.getEmbedding());
return selectCandidates(properties.getEmbedding(), null);
}

public List<ModelTarget> selectRerankCandidates() {
return selectCandidates(properties.getRerank());
return selectCandidates(properties.getRerank(), null);
}

public List<ModelTarget> selectVlmCandidates() {
return selectCandidates(properties.getVlm());
AIModelProperties.ModelGroup group = properties.getVlm();
return selectCandidates(group, group == null ? null : group.getTimeoutMs());
}

// ==================== chat:档位机制 ====================
Expand Down Expand Up @@ -185,13 +186,13 @@ private Map<String, AIModelProperties.ModelCandidate> buildRegistry(List<AIModel

// ==================== embedding/rerank/vlm:defaultModel + priority ====================

private List<ModelTarget> selectCandidates(AIModelProperties.ModelGroup group) {
private List<ModelTarget> selectCandidates(AIModelProperties.ModelGroup group, Long timeoutMs) {
if (group == null || group.getCandidates() == null) {
return List.of();
}
List<AIModelProperties.ModelCandidate> orderedCandidates =
filterAndSortCandidates(group.getCandidates(), group.getDefaultModel());
return buildAvailableTargets(orderedCandidates);
return buildAvailableTargets(orderedCandidates, timeoutMs);
}

/**
Expand All @@ -211,12 +212,11 @@ private List<AIModelProperties.ModelCandidate> filterAndSortCandidates(List<AIMo
.collect(Collectors.toList());
}

private List<ModelTarget> buildAvailableTargets(List<AIModelProperties.ModelCandidate> candidates) {
private List<ModelTarget> buildAvailableTargets(List<AIModelProperties.ModelCandidate> candidates, Long timeoutMs) {
Map<String, AIModelProperties.ProviderConfig> providers = properties.getProviders();

// embedding/rerank/vlm 无档位预算,超时走 HTTP 客户端默认
return candidates.stream()
.map(candidate -> buildModelTarget(candidate, providers, null))
.map(candidate -> buildModelTarget(candidate, providers, timeoutMs))
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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.nageoffer.ai.ragent.infra.model;

import com.nageoffer.ai.ragent.infra.config.AIModelProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;

/**
* VLM 配置启动期校验器
*/
@Component
@RequiredArgsConstructor
public class VlmConfigValidator implements InitializingBean {

private final AIModelProperties properties;

@Override
public void afterPropertiesSet() {
AIModelProperties.ModelGroup group = properties.getVlm();
if (group == null || group.getCandidates() == null || group.getCandidates().isEmpty()) {
return;
}

Long timeoutMs = group.getTimeoutMs();
if (timeoutMs != null && timeoutMs <= 0) {
throw new IllegalStateException("VLM 配置校验失败: ai.vlm.timeout-ms 必须为正数");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
import java.io.IOException;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

/**
* 路由式 VLM 服务实现类
Expand All @@ -58,6 +61,7 @@ public class RoutingVlmService implements VlmService {

private final ModelSelector selector;
private final OkHttpClient syncHttpClient;
private final Map<Long, OkHttpClient> syncClientByTimeout = new ConcurrentHashMap<>();

public RoutingVlmService(ModelSelector selector,
@Qualifier("syncHttpClient") OkHttpClient syncHttpClient) {
Expand All @@ -82,7 +86,7 @@ public String describeImage(byte[] imageBytes, String mime, String prompt, Integ
.build();

JsonObject respJson;
try (Response response = syncHttpClient.newCall(request).execute()) {
try (Response response = resolveSyncClient(target.timeoutMs()).newCall(request).execute()) {
if (!response.isSuccessful()) {
String body = HttpResponseHelper.readBody(response.body());
log.warn("VLM 请求失败: status={}, body={}", response.code(), body);
Expand All @@ -101,6 +105,19 @@ public String describeImage(byte[] imageBytes, String mime, String prompt, Integ
return extractContent(respJson);
}

/**
* VLM 推理通常显著慢于普通同步请求,按模型组预算覆盖 read/call 超时。
*/
private OkHttpClient resolveSyncClient(Long timeoutMs) {
if (timeoutMs == null) {
return syncHttpClient;
}
return syncClientByTimeout.computeIfAbsent(timeoutMs, ms -> syncHttpClient.newBuilder()
.readTimeout(ms, TimeUnit.MILLISECONDS)
.callTimeout(ms, TimeUnit.MILLISECONDS)
.build());
}

private ModelTarget resolveTarget() {
List<ModelTarget> targets = selector.selectVlmCandidates();
if (targets.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ private AISettings.ModelGroup toModelGroup(AIModelProperties.ModelGroup group) {
}
return AISettings.ModelGroup.builder()
.defaultModel(group.getDefaultModel())
.timeoutMs(group.getTimeoutMs())
.candidates(group.getCandidates() == null
? null
: group.getCandidates().stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ public static class ModelGroup {
* defaultModel 供 embedding/rerank/vlm 使用;chat 组走档位(tiers)字段
*/
private String defaultModel;
private Long timeoutMs;
private List<ModelCandidate> candidates;
private String defaultTier;
private String deepThinkingTier;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,18 @@ private static AIModelProperties buildProperties() {
List<ModelTarget> targets = selector.selectChatCandidates(false);
assertEquals(List.of("qwen3-local", "gpt-5.4"), ids(targets));
}

@Test
void vlm_候选携带模型组超时预算() {
AIModelProperties.ModelGroup vlm = new AIModelProperties.ModelGroup();
vlm.setDefaultModel("qwen-vl-max");
vlm.setTimeoutMs(120000L);
vlm.setCandidates(List.of(cand("qwen-vl-max", "bailian", "qwen-vl-max", false)));
properties.setVlm(vlm);

List<ModelTarget> targets = selector.selectVlmCandidates();

assertEquals(1, targets.size());
assertEquals(120000L, targets.get(0).timeoutMs());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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.nageoffer.ai.ragent.infra.model;

import com.nageoffer.ai.ragent.infra.config.AIModelProperties;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;

class VlmConfigValidatorTest {

@Test
void 未配置候选时跳过校验() {
assertDoesNotThrow(() -> validate(new AIModelProperties()));
}

@Test
void 正数超时校验通过() {
AIModelProperties properties = configuredProperties();
properties.getVlm().setTimeoutMs(120000L);

assertDoesNotThrow(() -> validate(properties));
}

@Test
void 未配置超时时兼容全局客户端() {
assertDoesNotThrow(() -> validate(configuredProperties()));
}

@ParameterizedTest
@ValueSource(longs = {0, -1})
void 非正数超时时校验失败(long timeoutMs) {
AIModelProperties properties = configuredProperties();
properties.getVlm().setTimeoutMs(timeoutMs);

assertThrows(IllegalStateException.class, () -> validate(properties));
}

private static AIModelProperties configuredProperties() {
AIModelProperties properties = new AIModelProperties();
AIModelProperties.ModelCandidate candidate = new AIModelProperties.ModelCandidate();
candidate.setId("qwen-vl-max");
properties.getVlm().setCandidates(List.of(candidate));
return properties;
}

private static void validate(AIModelProperties properties) {
new VlmConfigValidator(properties).afterPropertiesSet();
}
}
Loading