feat: 按模型上下文自动配置智能体压缩阈值
- 从 llm.json 解析上下文与输出上限,并增强短模型 ID 匹配 - 切换模型、保存和试运行时按预算公式重算,目录缺失时回退 30K - 修复模型列表接口未返回能力元数据
This commit is contained in:
@@ -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