feat: 按模型上下文自动配置智能体压缩阈值
- 从 llm.json 解析上下文与输出上限,并增强短模型 ID 匹配 - 切换模型、保存和试运行时按预算公式重算,目录缺失时回退 30K - 修复模型列表接口未返回能力元数据
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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.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)) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否获得了模型库或命名规则证据。
|
||||
*
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -201,6 +201,7 @@ onBeforeUnmount(() => {
|
||||
:agent="state.agent"
|
||||
:categories="categories"
|
||||
:models="models"
|
||||
:tool-bindings="state.toolBindings"
|
||||
@change="emit('change')"
|
||||
/>
|
||||
</ElTabPane>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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