feat: 按模型上下文自动配置智能体压缩阈值

- 从 llm.json 解析上下文与输出上限,并增强短模型 ID 匹配

- 切换模型、保存和试运行时按预算公式重算,目录缺失时回退 30K

- 修复模型列表接口未返回能力元数据
This commit is contained in:
2026-07-30 14:20:10 +08:00
parent 5f11219226
commit c78074a969
14 changed files with 410 additions and 23 deletions

View File

@@ -9,7 +9,6 @@ import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import tech.easyflow.ai.dto.ModelInvokeConfigDtos;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.ai.entity.table.ModelTableDef;
import tech.easyflow.ai.mapper.ModelMapper;
import tech.easyflow.ai.service.ModelService;
@@ -27,7 +26,6 @@ import java.math.BigInteger;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
@@ -53,16 +51,7 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
@GetMapping("list")
@SaCheckPermission("/api/v1/model/query")
public Result<List<Model>> list(Model entity, Boolean asTree, String sortKey, String sortType) {
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
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);
return Result.ok(service.listSelectableModels(entity, asTree, sortKey, sortType));
}
@GetMapping("getList")

View File

@@ -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");
}
}

View File

@@ -20,6 +20,7 @@ import com.easyagents.rerank.DefaultRerankModel;
import com.easyagents.rerank.DefaultRerankModelConfig;
import com.easyagents.rerank.gitee.GiteeRerankModel;
import com.easyagents.rerank.gitee.GiteeRerankModelConfig;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.RelationManyToOne;
import com.mybatisflex.annotation.Table;
import tech.easyflow.ai.entity.base.ModelBase;
@@ -39,6 +40,18 @@ public class Model extends ModelBase {
@RelationManyToOne(selfField = "providerId", targetField = "id")
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;
}
/**
* 获取模型最大上下文窗口。
*
* @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() {
String providerType = modelProvider.getProviderType();
if (StringUtil.noText(providerType)) {

View File

@@ -27,7 +27,7 @@ public class ModelCapabilityCatalog {
/** 按规范化完整模型 ID 建立的目录索引。 */
private final Map<String, ModelCatalogMetadata> metadataById;
/** 仅在模型短 ID 唯一时建立的目录别名索引。 */
/** 仅在规范化模型短 ID 唯一时建立的目录别名索引。 */
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));
}
@@ -114,7 +114,7 @@ public class ModelCapabilityCatalog {
}
ModelCatalogMetadata metadata = toMetadata(normalizedId, field.getValue());
fullIdIndex.put(normalizedId, metadata);
registerAlias(shortId(normalizedId), metadata, aliasCandidates, ambiguousAliases);
registerAlias(canonicalAlias(normalizedId), metadata, aliasCandidates, ambiguousAliases);
}
} catch (IOException exception) {
throw new IllegalStateException("无法加载模型能力库 " + CATALOG_RESOURCE, exception);
@@ -147,20 +147,26 @@ public class ModelCapabilityCatalog {
*/
private ModelCapabilityResolution toCapability(String normalizedId, JsonNode node) {
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)) {
return new ModelCapabilityResolution(
modelType,
Boolean.FALSE,
Boolean.FALSE,
Boolean.FALSE,
ModelCapabilitySource.CATALOG);
ModelCapabilitySource.CATALOG,
contextWindowTokens,
maxOutputTokens);
}
return new ModelCapabilityResolution(
modelType,
hasInputModality(node, "image"),
booleanValue(node, "reasoning"),
booleanValue(node, "tool_call"),
ModelCapabilitySource.CATALOG);
ModelCapabilitySource.CATALOG,
contextWindowTokens,
maxOutputTokens);
}
/**
@@ -191,6 +197,22 @@ public class ModelCapabilityCatalog {
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('/');
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();
}
}

View File

@@ -15,6 +15,10 @@ public final class ModelCapabilityResolution {
private final Boolean supportTool;
/** 能力识别来源。 */
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 supportTool,
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.supportImage = supportImage;
this.supportThinking = supportThinking;
this.supportTool = supportTool;
this.source = source;
this.contextWindowTokens = contextWindowTokens;
this.maxOutputTokens = maxOutputTokens;
}
/**
@@ -82,6 +109,24 @@ public final class ModelCapabilityResolution {
return source;
}
/**
* 获取模型最大上下文窗口。
*
* @return 最大上下文窗口 Token 数,目录未提供时返回 null
*/
public Long getContextWindowTokens() {
return contextWindowTokens;
}
/**
* 获取模型最大输出 Token 数。
*
* @return 最大输出 Token 数,目录未提供时返回 null
*/
public Long getMaxOutputTokens() {
return maxOutputTokens;
}
/**
* 判断是否获得了模型库或命名规则证据。
*

View File

@@ -420,10 +420,16 @@ public class ModelServiceImpl extends ServiceImpl<ModelMapper, Model> implements
if (model == null) {
return;
}
String providerName = Optional.ofNullable(model.getModelProvider())
ModelProvider provider = model.getModelProvider();
String providerName = Optional.ofNullable(provider)
.map(ModelProvider::getProviderName)
.orElse("-");
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) {

View File

@@ -34,6 +34,28 @@ public class ModelCapabilityResolverTest {
Assert.assertEquals(Boolean.TRUE, result.getSupportImage());
Assert.assertEquals(Boolean.TRUE, result.getSupportThinking());
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.getSupportThinking());
Assert.assertNull(result.getSupportTool());
Assert.assertNull(result.getContextWindowTokens());
Assert.assertNull(result.getMaxOutputTokens());
}
}

View File

@@ -43,6 +43,8 @@ export interface ModelCapabilityResolution {
detected: boolean;
modelType: 'chatModel' | 'embeddingModel' | 'rerankModel';
source: ModelCapabilitySource;
contextWindowTokens?: null | number;
maxOutputTokens?: null | number;
supportImage?: boolean | null;
supportThinking?: boolean | null;
supportTool?: boolean | null;
@@ -141,6 +143,8 @@ export interface llmType {
providerId?: string;
title: string;
modelName?: string;
contextWindowTokens?: null | number;
maxOutputTokens?: null | number;
modelProvider: {
icon: string;
providerName: string;

View File

@@ -38,6 +38,7 @@ import AgentStudioCanvas from './components/agent-studio/AgentStudioCanvas.vue';
import AgentCommandBar from './components/AgentCommandBar.vue';
import AgentInspectorPanel from './components/AgentInspectorPanel.vue';
import { useAgentDesignerState } from './composables/useAgentDesignerState';
import { resolveAgentCompressionTokenThreshold } from './compression-threshold';
import { createMcpToolLoader } from './mcpToolLoader';
const route = useRoute();
@@ -385,8 +386,25 @@ function runValidation() {
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) {
if (!runValidation()) return false;
syncAgentCompressionThreshold();
saveLoading.value = true;
try {
const agentPayload = buildPayloadAgent();
@@ -497,6 +515,7 @@ async function handleOffline() {
function handleTryout() {
if (!runValidation()) return;
syncAgentCompressionThreshold();
openTryout();
}

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
/* 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 {
@@ -15,13 +15,33 @@ import {
ElTooltip,
} from 'element-plus';
defineProps<{
import { resolveAgentCompressionTokenThreshold } from '../compression-threshold';
const props = defineProps<{
agent: AgentInfo;
categories: AgentOption[];
models: AgentOption[];
toolBindings: AgentToolBinding[];
}>();
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>
<template>
@@ -67,7 +87,7 @@ const emit = defineEmits<{ change: [] }>();
</ElSelect>
</ElFormItem>
<ElFormItem label="模型" required>
<ElSelect v-model="agent.modelId" filterable @change="emit('change')">
<ElSelect v-model="agent.modelId" filterable @change="handleModelChange">
<ElOption
v-for="item in models"
:key="item.value"
@@ -172,7 +192,7 @@ const emit = defineEmits<{ change: [] }>();
<span class="agent-form__label">
最小压缩 Token 阈值
<ElTooltip
content="工作记忆的 Token 数达到该值时,会触发上下文压缩。"
content="选择模型时按上下文 85% 与扣除输出、系统提示词、工具和安全余量后的可用值取较小值;模型目录未提供上下文时使用 30K。"
effect="light"
placement="top"
>

View File

@@ -201,6 +201,7 @@ onBeforeUnmount(() => {
:agent="state.agent"
:categories="categories"
:models="models"
:tool-bindings="state.toolBindings"
@change="emit('change')"
/>
</ElTabPane>

View File

@@ -9,6 +9,7 @@ import type {
import { computed, reactive } from 'vue';
import { DEFAULT_AGENT_COMPRESSION_TOKEN_THRESHOLD } from '../compression-threshold';
import {
buildInteractionConfigPayload,
createEmptyInteractionConfig,
@@ -103,7 +104,7 @@ export function createEmptyAgent(): AgentInfo {
compressionParameter: {
enabled: true,
lastKeep: 8,
minCompressionTokenThreshold: 6000,
minCompressionTokenThreshold: DEFAULT_AGENT_COMPRESSION_TOKEN_THRESHOLD,
},
},
interactionConfigJson: createEmptyInteractionConfig(),

View File

@@ -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);
}
});
});

View File

@@ -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 '';
}
}