AiTokenAnalyzer: Token-Analyse (Zeichen/3, Zeichen/3,5, cl100k, prompt_eval_count)

- OllamaApiClient: Überladung mit prompt_eval_count/eval_count
- Tool auf net48 umgestellt, Referenzen auf AICore/ServerUtils/Shared
- UI: Text-Eingabe, Zeichenanzahlen (50k, 100k, ...), Ergebnistabelle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 11:58:48 +02:00
parent 88044ee60d
commit 7574dbfd08
5 changed files with 485 additions and 26 deletions

View File

@@ -72,10 +72,17 @@ namespace AICore.Facade.LLM
}
public override ApiResponse<string> SendAiMessageRequest(dynamic payload, out string message, out string model, out int? duration, FileLogger logger = null)
{
return SendAiMessageRequest((object)payload, out message, out model, out duration, out long? _, out long? _, logger);
}
public ApiResponse<string> SendAiMessageRequest(dynamic payload, out string message, out string model, out int? duration, out long? prompt_eval_count, out long? eval_count, FileLogger logger = null)
{
message = null;
duration = null;
model = null;
prompt_eval_count = null;
eval_count = null;
_PostClientFacade.JsonObject = payload;
@@ -123,6 +130,9 @@ namespace AICore.Facade.LLM
var json = response.GetResponseData();
prompt_eval_count = json.prompt_eval_count;
eval_count = json.eval_count;
if (json.message is object && !string.IsNullOrEmpty(json.message.content))
{
var total_ns = json.total_duration;

View File

@@ -1,11 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TargetFramework>net48</TargetFramework>
<UseWPF>true</UseWPF>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SharpToken" Version="2.0.3" />
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.1.2" />
<PackageReference Include="System.Buffers" Version="4.6.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Server\Components\AICore\AICore.csproj" />
<ProjectReference Include="..\..\..\Server\Components\ServerUtils\ServerUtils.csproj" />
<ProjectReference Include="..\..\..\Shared\Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,12 +1,116 @@
<Window x:Class="AiTokenAnalyzer.MainWindow"
<Window x:Class="AiTokenAnalyzer.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:AiTokenAnalyzer"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<Grid>
xml:lang="de-DE"
Title="Ai Token Analyzer" Height="850" Width="1200" WindowStartupLocation="CenterScreen">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="2*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="3*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<GroupBox Grid.Row="0" Header="Verbindung" Padding="5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="3*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Label Grid.Row="0" Grid.Column="0" Content="URL:" VerticalAlignment="Center"/>
<TextBox Grid.Row="0" Grid.Column="1" x:Name="TxtUrl" Margin="0,2" VerticalContentAlignment="Center" Text="https://ai2.ownsoft.de"/>
<Label Grid.Row="0" Grid.Column="2" Content="API-Key:" VerticalAlignment="Center" Margin="10,0,0,0"/>
<TextBox Grid.Row="0" Grid.Column="3" Grid.ColumnSpan="2" x:Name="TxtApiKey" Margin="0,2" VerticalContentAlignment="Center" Text="mu9ZPuUXJ4aVGLU1WL2RfPCeSMRmJREwyjVJBP7bHfSz8quZRqN7e7UwnwCDi8GZ"/>
<Label Grid.Row="1" Grid.Column="0" Content="Modell:" VerticalAlignment="Center"/>
<ComboBox Grid.Row="1" Grid.Column="1" x:Name="CmbModel" Margin="0,2" IsEditable="True" VerticalContentAlignment="Center" Text="gemma3_12b_max_context:latest"/>
<Button Grid.Row="1" Grid.Column="4" x:Name="BtnLoadModels" Content="Modelle laden" Width="120" Margin="10,2,0,2" Click="BtnLoadModels_Click"/>
</Grid>
</GroupBox>
<GroupBox Grid.Row="1" Header="Analyse-Einstellungen" Padding="5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Label Grid.Row="0" Grid.Column="0" Content="Zeichenanzahlen:" VerticalAlignment="Center"/>
<TextBox Grid.Row="0" Grid.Column="1" x:Name="TxtSizes" Margin="0,2" VerticalContentAlignment="Center" Text="50k, 100k, 150k"
ToolTip="Kommagetrennte Liste, z.B. '50k, 100k, 150k' oder '50000, 100000'"/>
<Label Grid.Row="0" Grid.Column="2" Content="num_ctx:" VerticalAlignment="Center" Margin="10,0,0,0"/>
<TextBox Grid.Row="0" Grid.Column="3" x:Name="TxtNumCtx" Margin="0,2" VerticalContentAlignment="Center"
ToolTip="Optional: wird als options.num_ctx mitgesendet. Ohne ausreichend großes Kontextfenster schneidet Ollama den Prompt ab und prompt_eval_count ist gedeckelt."/>
<CheckBox Grid.Row="0" Grid.Column="4" x:Name="ChkRepeat" Content="Text wiederholen bis Zielgröße" IsChecked="True" VerticalAlignment="Center" Margin="10,0,0,0"
ToolTip="Ist der Eingabetext kürzer als die Zielgröße, wird er so oft aneinandergehängt, bis die Zielgröße erreicht ist."/>
<Label Grid.Row="1" Grid.Column="0" Content="System-Prompt:" VerticalAlignment="Center"/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="TxtSystemPrompt" Margin="0,2" VerticalContentAlignment="Center" Text="Antworte ausschließlich mit 'OK'."
ToolTip="Optional: hält die Antwort kurz. Zählt mit wenigen Tokens in prompt_eval_count mit."/>
<Label Grid.Row="1" Grid.Column="2" Content="CustomerId:" VerticalAlignment="Center" Margin="10,0,0,0"/>
<TextBox Grid.Row="1" Grid.Column="3" x:Name="TxtCustomerId" Margin="0,2" VerticalContentAlignment="Center"
ToolTip="Optional: wird als 'customerid' mitgesendet."/>
</Grid>
</GroupBox>
<GroupBox Grid.Row="2" Header="Eingabetext" Padding="5">
<DockPanel>
<TextBlock DockPanel.Dock="Bottom" x:Name="TxtInputInfo" Margin="0,3,0,0" Text="Zeichen: 0"/>
<TextBox x:Name="TxtInput" AcceptsReturn="True" TextWrapping="Wrap"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
TextChanged="TxtInput_TextChanged"/>
</DockPanel>
</GroupBox>
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,5">
<Button x:Name="BtnAnalyze" Content="Analysieren" Width="120" Height="26" Click="BtnAnalyze_Click"/>
<Button x:Name="BtnCancel" Content="Abbrechen" Width="120" Height="26" Margin="10,0,0,0" IsEnabled="False" Click="BtnCancel_Click"/>
</StackPanel>
<GroupBox Grid.Row="4" Header="Ergebnis" Padding="5">
<DataGrid x:Name="GridResults" AutoGenerateColumns="False" IsReadOnly="True" CanUserAddRows="False"
HeadersVisibility="Column" GridLinesVisibility="All">
<DataGrid.Columns>
<DataGridTextColumn Header="Zielgröße" Binding="{Binding TargetChars, StringFormat=N0}" Width="Auto"/>
<DataGridTextColumn Header="Zeichen (gesendet)" Binding="{Binding ActualChars, StringFormat=N0}" Width="Auto"/>
<DataGridTextColumn Header="Zeichen / 3" Binding="{Binding EstDiv3, StringFormat=N0}" Width="Auto"/>
<DataGridTextColumn Header="Zeichen / 3,5" Binding="{Binding EstDiv3_5, StringFormat=N0}" Width="Auto"/>
<DataGridTextColumn Header="cl100k" Binding="{Binding Cl100k, StringFormat=N0}" Width="Auto"/>
<DataGridTextColumn Header="prompt_eval_count" Binding="{Binding PromptEvalCount, StringFormat=N0}" Width="Auto"/>
<DataGridTextColumn Header="Dauer (ms)" Binding="{Binding Duration, StringFormat=N0}" Width="Auto"/>
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="*"/>
</DataGrid.Columns>
</DataGrid>
</GroupBox>
<StatusBar Grid.Row="5" Margin="0,5,0,0">
<TextBlock x:Name="TxtStatus" Text="Bereit."/>
</StatusBar>
</Grid>
</Window>

View File

@@ -1,24 +1,307 @@
using System.Text;
using AICore.Facade.LLM;
using SharpToken;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
namespace AiTokenAnalyzer
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public ObservableCollection<TokenAnalyzeRow> Rows { get; } = new ObservableCollection<TokenAnalyzeRow>();
private CancellationTokenSource _Cts;
public MainWindow()
{
InitializeComponent();
GridResults.ItemsSource = Rows;
}
private OllamaApiClient createClient()
=> new OllamaApiClient(TxtUrl.Text.Trim(), TxtApiKey.Text.Trim());
private void TxtInput_TextChanged(object sender, TextChangedEventArgs e)
{
if (TxtInputInfo is object)
TxtInputInfo.Text = $"Zeichen: {TxtInput.Text.Length:N0}";
}
private async void BtnLoadModels_Click(object sender, RoutedEventArgs e)
{
BtnLoadModels.IsEnabled = false;
TxtStatus.Text = "Lade Modelle...";
try
{
var client = createClient();
var response = await Task.Run(() => client.GetAiModelle());
if (response?.Success != true)
{
TxtStatus.Text = "Fehler beim Laden der Modelle: " + response?.ToErrorString();
return;
}
var model_names = response.Data.Select(m => m.ModelName).OrderBy(x => x).ToList();
var current = CmbModel.Text;
CmbModel.ItemsSource = model_names;
CmbModel.Text = current;
TxtStatus.Text = $"{model_names.Count} Modelle geladen.";
}
catch (Exception ex)
{
TxtStatus.Text = "Fehler beim Laden der Modelle: " + ex.Message;
}
finally
{
BtnLoadModels.IsEnabled = true;
}
}
private async void BtnAnalyze_Click(object sender, RoutedEventArgs e)
{
var text = TxtInput.Text;
if (string.IsNullOrEmpty(text))
{
MessageBox.Show("Bitte einen Eingabetext angeben.", Title, MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var model = CmbModel.Text?.Trim();
if (string.IsNullOrEmpty(model))
{
MessageBox.Show("Bitte ein Modell angeben.", Title, MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
List<int> sizes;
try
{
sizes = parseSizes(TxtSizes.Text);
}
catch (FormatException ex)
{
MessageBox.Show(ex.Message, Title, MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
if (sizes.Count == 0)
{
MessageBox.Show("Bitte mindestens eine Zeichenanzahl angeben (z.B. '50k, 100k, 150k').", Title, MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
int? num_ctx = null;
var num_ctx_text = TxtNumCtx.Text?.Trim();
if (!string.IsNullOrEmpty(num_ctx_text))
{
if (!int.TryParse(num_ctx_text, out var tmp) || tmp <= 0)
{
MessageBox.Show("Ungültiger Wert für num_ctx.", Title, MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
num_ctx = tmp;
}
var repeat = ChkRepeat.IsChecked == true;
var system_prompt = TxtSystemPrompt.Text?.Trim();
var customer_id = TxtCustomerId.Text?.Trim();
BtnAnalyze.IsEnabled = false;
BtnCancel.IsEnabled = true;
_Cts = new CancellationTokenSource();
try
{
await runAnalysisAsync(text, sizes, model, repeat, system_prompt, customer_id, num_ctx, _Cts.Token);
}
finally
{
_Cts.Dispose();
_Cts = null;
BtnAnalyze.IsEnabled = true;
BtnCancel.IsEnabled = false;
}
}
private void BtnCancel_Click(object sender, RoutedEventArgs e)
{
_Cts?.Cancel();
TxtStatus.Text = "Abbruch angefordert (laufender Request wird noch abgewartet)...";
}
private async Task runAnalysisAsync(string text, List<int> sizes, string model, bool repeat, string system_prompt, string customer_id, int? num_ctx, CancellationToken token)
{
Rows.Clear();
// Zeilen + Text-Chunks vorbereiten
var chunks = new List<KeyValuePair<TokenAnalyzeRow, string>>();
foreach (var size in sizes.Distinct().OrderBy(x => x))
{
var chunk = buildChunk(text, size, repeat);
var row = new TokenAnalyzeRow
{
TargetChars = size,
ActualChars = chunk.Length,
Status = "Wartet"
};
Rows.Add(row);
chunks.Add(new KeyValuePair<TokenAnalyzeRow, string>(row, chunk));
}
// Lokale cl100k-Abschätzung
TxtStatus.Text = "Berechne cl100k-Abschätzung...";
var encoding = await Task.Run(() => GptEncoding.GetEncoding("cl100k_base"));
foreach (var pair in chunks)
pair.Key.Cl100k = await Task.Run(() => encoding.Encode(pair.Value).Count);
// LLM-Requests (sequenziell)
var client = createClient();
var index = 0;
foreach (var pair in chunks)
{
var row = pair.Key;
if (token.IsCancellationRequested)
{
row.Status = "Abgebrochen";
continue;
}
index++;
TxtStatus.Text = $"Sende Request {index}/{chunks.Count} ({row.ActualChars:N0} Zeichen) an '{model}'...";
row.Status = "Läuft...";
var payload = buildPayload(model, system_prompt, pair.Value, customer_id, num_ctx);
try
{
var result = await Task.Run(() =>
{
var response = client.SendAiMessageRequest(payload, out string message, out string response_model, out int? duration, out long? prompt_eval_count, out long? eval_count);
return new { response, duration, prompt_eval_count };
});
row.PromptEvalCount = result.prompt_eval_count;
row.Duration = result.duration;
if (result.prompt_eval_count is long)
row.Status = "OK";
else if (result.response is object && !result.response.Success)
row.Status = "Fehler: " + result.response.ToErrorString();
else
row.Status = "Fehler: keine prompt_eval_count in der Antwort";
}
catch (Exception ex)
{
row.Status = "Fehler: " + ex.Message;
}
}
TxtStatus.Text = token.IsCancellationRequested ? "Analyse abgebrochen." : "Analyse abgeschlossen.";
}
/// <summary>
/// Kürzt den Text auf die Zielgröße. Ist er zu kurz, wird er optional
/// so oft wiederholt, bis die Zielgröße erreicht ist.
/// </summary>
private static string buildChunk(string text, int target_chars, bool repeat)
{
if (text.Length >= target_chars)
return text.Substring(0, target_chars);
if (!repeat)
return text;
var sb = new StringBuilder(target_chars + text.Length);
while (sb.Length < target_chars)
sb.Append(text);
return sb.ToString(0, target_chars);
}
/// <summary>
/// Parst z.B. "50k, 100k, 150k" oder "50000; 100000" zu Zeichenanzahlen.
/// </summary>
private static List<int> parseSizes(string input)
{
var result = new List<int>();
foreach (var part in (input ?? "").Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
{
var s = part.Trim().ToLowerInvariant().Replace(".", "").Replace(" ", "");
var factor = 1;
if (s.EndsWith("k"))
{
factor = 1000;
s = s.Substring(0, s.Length - 1);
}
else if (s.EndsWith("m"))
{
factor = 1000000;
s = s.Substring(0, s.Length - 1);
}
if (!int.TryParse(s, out var value) || value <= 0)
throw new FormatException($"Ungültige Zeichenanzahl: '{part.Trim()}'");
result.Add(value * factor);
}
return result;
}
private static object buildPayload(string model, string system_prompt, string user_message, string customer_id, int? num_ctx)
{
var messages = new List<object>();
if (!string.IsNullOrEmpty(system_prompt))
messages.Add(new { role = "system", content = system_prompt });
messages.Add(new { role = "user", content = user_message });
var payload = new Dictionary<string, object>
{
["model"] = model,
["messages"] = messages,
["requestid"] = Guid.NewGuid().ToString("N")
};
if (!string.IsNullOrEmpty(customer_id))
payload["customerid"] = customer_id;
if (num_ctx is int ctx)
payload["options"] = new { num_ctx = ctx };
return payload;
}
}
}

View File

@@ -0,0 +1,51 @@
using System;
using System.ComponentModel;
namespace AiTokenAnalyzer
{
/// <summary>
/// Eine Zeile der Ergebnistabelle (eine Textgröße).
/// </summary>
public class TokenAnalyzeRow : INotifyPropertyChanged
{
private int? _Cl100k;
private long? _PromptEvalCount;
private int? _Duration;
private string _Status;
public int TargetChars { get; set; }
public int ActualChars { get; set; }
public int EstDiv3 => (int)Math.Round(ActualChars / 3.0);
public int EstDiv3_5 => (int)Math.Round(ActualChars / 3.5);
public int? Cl100k
{
get => _Cl100k;
set { _Cl100k = value; onPropertyChanged(nameof(Cl100k)); }
}
public long? PromptEvalCount
{
get => _PromptEvalCount;
set { _PromptEvalCount = value; onPropertyChanged(nameof(PromptEvalCount)); }
}
public int? Duration
{
get => _Duration;
set { _Duration = value; onPropertyChanged(nameof(Duration)); }
}
public string Status
{
get => _Status;
set { _Status = value; onPropertyChanged(nameof(Status)); }
}
public event PropertyChangedEventHandler PropertyChanged;
private void onPropertyChanged(string name)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}