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

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