From f74409dd8918de66bba5778dbca1bcf416206988 Mon Sep 17 00:00:00 2001 From: Meleki Date: Sat, 28 Feb 2026 16:21:08 +0100 Subject: [PATCH] Token-Optimierung: Prompt Caching + Tool-Result-Truncation in History --- Endpoints/ChatEndpoint.cs | 35 +++------------------- Endpoints/SessionEndpoint.cs | 13 +------- Models/ChainRecord.cs | 2 ++ Services/AnthropicService.cs | 31 +++++++++++++++---- Services/DatabaseService.cs | 4 +++ Services/SystemPromptBuilder.cs | 35 ++++++++++++++++++++++ Services/ToolService.cs | 40 ++++++++++++++++++------- wwwroot/css/chat.css | 53 +++++++++++++++++++++++++++++++++ wwwroot/index.html | 1 + wwwroot/js/api.js | 6 ++++ wwwroot/js/pages/chat-page.js | 7 +++++ wwwroot/js/ui.js | 52 ++++++++++++++++++++++++++++++++ 12 files changed, 220 insertions(+), 59 deletions(-) create mode 100644 Services/SystemPromptBuilder.cs diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index be30a99..0f934b9 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -77,36 +77,7 @@ public static class ChatEndpoint res.Headers["Cache-Control"] = "no-cache"; res.Headers["Connection"] = "keep-alive"; - var promptParts = new List(); - promptParts.Add($"Du bist {agent.Name}."); - - if (!string.IsNullOrWhiteSpace(agent.Persona)) - { - promptParts.Add($"## Persona\n{agent.Persona}"); - } - - if (!string.IsNullOrWhiteSpace(agent.Instructions)) - { - promptParts.Add($"## Instructions\n{agent.Instructions}"); - } - - if (!string.IsNullOrWhiteSpace(session.Workspace)) - { - promptParts.Add(@$"## Tools -Du hast Tools zur Verfügung (read_file, write_file, edit_file, list_files, delete_file, execute). Benutze sie um Dateien zu lesen, schreiben und bearbeiten. - -Der Workspace-Pfad ist: `{session.Workspace}` -Alle Dateipfade in Tool-Aufrufen müssen relativ zu diesem Workspace sein oder absolute Pfade verwenden, die mit diesem Pfad beginnen. - -Wenn der User auf eine bestimmte Zeile in einer Datei verweist (z.B. `[Datei: foo.txt:11]`), zähle Leerzeilen immer mit. Verwende `cat -n` oder ein ähnliches Tool um Zeilennummern exakt zu bestimmen - NIEMALS Leerzeilen beim Zählen überspringen."); - } - - if (!string.IsNullOrWhiteSpace(session.Context)) - { - promptParts.Add($"## Kontext\n{session.Context}"); - } - - var systemPromptText = string.Join("\n\n", promptParts); + var systemPromptText = SystemPromptBuilder.Build(agent, session); var chainId = Guid.NewGuid().ToString("N"); @@ -171,9 +142,11 @@ Wenn der User auf eine bestimmte Zeile in einer Datei verweist (z.B. `[Datei: fo }, attachmentRecords); } + // cache_control markiert den System-Prompt als Cache-Breakpoint. + // Anthropic cached alles bis einschließlich dieses Blocks für 5 Minuten → 90% Rabatt auf diese Tokens. var systemPrompt = new List { - new { type = "text", text = systemPromptText } + new { type = "text", text = systemPromptText, cache_control = new { type = "ephemeral" } } }; var workspace = session.Workspace; diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs index d9755dd..5d064ba 100644 --- a/Endpoints/SessionEndpoint.cs +++ b/Endpoints/SessionEndpoint.cs @@ -121,18 +121,7 @@ public static class SessionEndpoint return; } - var promptParts = new List(); - promptParts.Add($"Du bist {agent.Name}."); - if (!string.IsNullOrWhiteSpace(agent.Persona)) - promptParts.Add($"## Persona\n{agent.Persona}"); - if (!string.IsNullOrWhiteSpace(agent.Instructions)) - promptParts.Add($"## Instructions\n{agent.Instructions}"); - if (!string.IsNullOrWhiteSpace(session.Workspace)) - promptParts.Add("## Tools\nDu hast Tools zur Verfügung (read_file, write_file, edit_file, list_files, delete_file, execute). Benutze sie um Dateien zu lesen, schreiben und bearbeiten. Beschreibe NICHT was du tun würdest - tu es einfach über die Tools."); - if (!string.IsNullOrWhiteSpace(session.Context)) - promptParts.Add($"## Kontext\n{session.Context}"); - - var systemPrompt = string.Join("\n\n", promptParts); + var systemPrompt = SystemPromptBuilder.Build(agent, session); ctx.Response.ContentType = "text/plain; charset=utf-8"; await ctx.Response.WriteAsync(systemPrompt); diff --git a/Models/ChainRecord.cs b/Models/ChainRecord.cs index 2a85ba2..a2ab7c5 100644 --- a/Models/ChainRecord.cs +++ b/Models/ChainRecord.cs @@ -10,6 +10,8 @@ public class ChainRecord public string SystemPrompt { get; set; } public long InputTokens { get; set; } public long OutputTokens { get; set; } + public long CacheCreationTokens { get; set; } + public long CacheReadTokens { get; set; } public int Iterations { get; set; } public string StopReason { get; set; } public long DurationMs { get; set; } diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index c3f508a..2203001 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -66,7 +66,7 @@ public class AnthropicService }; if (!string.IsNullOrEmpty(workspace)) - payload["tools"] = _toolService.GetToolDefinitions(); + payload["tools"] = _toolService.GetToolDefinitions(markLastAsCached: true); var client = CreateClient(); var json = JsonSerializer.Serialize(payload); @@ -150,6 +150,8 @@ public class AnthropicService model = modelId, inputTokens = usage.InputTokens, outputTokens = usage.OutputTokens, + cacheCreationTokens = usage.CacheCreationTokens, + cacheReadTokens = usage.CacheReadTokens, iterations, chainId, durationMs = sw.ElapsedMilliseconds, @@ -161,11 +163,20 @@ public class AnthropicService var toolResults = new List(); foreach (var tr in executedTools) { + // Tool-Results in der History auf 20.000 Zeichen kürzen. + // Das vollständige Ergebnis wurde bereits in der DB gespeichert. + // 20k Zeichen ≈ ~5k Tokens – verhindert Token-Explosion bei großen + // read_file / execute-Outputs die sich über alle weiteren Iterationen schleppen. + const int MaxToolResultChars = 20_000; + var content = tr.Result.Length > MaxToolResultChars + ? tr.Result[..MaxToolResultChars] + $"\n\n[... gekürzt, {tr.Result.Length - MaxToolResultChars} weitere Zeichen ...]" + : tr.Result; + toolResults.Add(new { type = "tool_result", tool_use_id = tr.Id, - content = tr.Result, + content, is_error = tr.IsError }); } @@ -202,6 +213,8 @@ public class AnthropicService { chain.InputTokens = usage.InputTokens; chain.OutputTokens = usage.OutputTokens; + chain.CacheCreationTokens = usage.CacheCreationTokens; + chain.CacheReadTokens = usage.CacheReadTokens; chain.Iterations = iterations; chain.StopReason = stopReason; chain.DurationMs = sw.ElapsedMilliseconds; @@ -246,9 +259,15 @@ public class AnthropicService case "message_start": { if (evt.TryGetProperty("message", out var msg) && - msg.TryGetProperty("usage", out var u) && - u.TryGetProperty("input_tokens", out var it)) - usage.InputTokens += it.GetInt64(); + msg.TryGetProperty("usage", out var u)) + { + if (u.TryGetProperty("input_tokens", out var it)) + usage.InputTokens += it.GetInt64(); + if (u.TryGetProperty("cache_creation_input_tokens", out var cc)) + usage.CacheCreationTokens += cc.GetInt64(); + if (u.TryGetProperty("cache_read_input_tokens", out var cr)) + usage.CacheReadTokens += cr.GetInt64(); + } break; } @@ -405,4 +424,6 @@ public class TokenUsage { public long InputTokens { get; set; } public long OutputTokens { get; set; } + public long CacheCreationTokens { get; set; } + public long CacheReadTokens { get; set; } } diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index b84812f..3da774a 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -122,6 +122,8 @@ public class DatabaseService TryAddColumn(conn, "sessions", "is_deleted", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "sessions", "custom_commands", "TEXT"); TryAddColumn(conn, "chains", "agent_id", "TEXT"); + TryAddColumn(conn, "chains", "cache_creation_tokens", "INTEGER NOT NULL DEFAULT 0"); + TryAddColumn(conn, "chains", "cache_read_tokens", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "messages", "attachment_count", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "messages", "attachment_names", "TEXT"); } @@ -376,6 +378,8 @@ public class DatabaseService UPDATE chains SET input_tokens = @InputTokens, output_tokens = @OutputTokens, + cache_creation_tokens = @CacheCreationTokens, + cache_read_tokens = @CacheReadTokens, iterations = @Iterations, stop_reason = @StopReason, duration_ms = @DurationMs diff --git a/Services/SystemPromptBuilder.cs b/Services/SystemPromptBuilder.cs new file mode 100644 index 0000000..3afbf9d --- /dev/null +++ b/Services/SystemPromptBuilder.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using Gott.Models; + +namespace Gott.Services; + +public static class SystemPromptBuilder +{ + public static string Build(AgentRecord agent, SessionRecord session) + { + var parts = new List(); + parts.Add($"Du bist {agent.Name}."); + + if (!string.IsNullOrWhiteSpace(agent.Persona)) + parts.Add($"## Persona\n{agent.Persona}"); + + if (!string.IsNullOrWhiteSpace(agent.Instructions)) + parts.Add($"## Instructions\n{agent.Instructions}"); + + if (!string.IsNullOrWhiteSpace(session.Workspace)) + { + parts.Add(@$"## Tools +Du hast Tools zur Verfügung (read_file, write_file, edit_file, list_files, delete_file, execute). Benutze sie um Dateien zu lesen, schreiben und bearbeiten. + +Der Workspace-Pfad ist: `{session.Workspace}` +Alle Dateipfade in Tool-Aufrufen müssen relativ zu diesem Workspace sein oder absolute Pfade verwenden, die mit diesem Pfad beginnen. + +Wenn der User auf eine bestimmte Zeile in einer Datei verweist (z.B. `[Datei: foo.txt:11]`), zähle Leerzeilen immer mit. Verwende `cat -n` oder ein ähnliches Tool um Zeilennummern exakt zu bestimmen - NIEMALS Leerzeilen beim Zählen überspringen."); + } + + if (!string.IsNullOrWhiteSpace(session.Context)) + parts.Add($"## Kontext\n{session.Context}"); + + return string.Join("\n\n", parts); + } +} diff --git a/Services/ToolService.cs b/Services/ToolService.cs index d0324a3..f29f5a0 100644 --- a/Services/ToolService.cs +++ b/Services/ToolService.cs @@ -11,7 +11,7 @@ namespace Gott.Services; public class ToolService { - public object[] GetToolDefinitions() + public object[] GetToolDefinitions(bool markLastAsCached = false) { return new object[] { @@ -88,20 +88,38 @@ public class ToolService required = new[] { "path" } } }, - new - { - name = "execute", - description = "Führt einen Shell-Befehl im Workspace-Verzeichnis aus. Gibt stdout und stderr zurück. Timeout: 30 Sekunden.", - input_schema = new + // Letztes Tool bekommt cache_control – Anthropic cached alles bis einschließlich + // dieses Breakpoints (System-Prompt + alle Tool-Definitionen) für 5 Minuten. + markLastAsCached + ? (object)new { - type = "object", - properties = new Dictionary + name = "execute", + description = "Führt einen Shell-Befehl im Workspace-Verzeichnis aus. Gibt stdout und stderr zurück. Timeout: 30 Sekunden.", + input_schema = new { - ["command"] = new { type = "string", description = "Shell-Befehl zum Ausführen" } + type = "object", + properties = new Dictionary + { + ["command"] = new { type = "string", description = "Shell-Befehl zum Ausführen" } + }, + required = new[] { "command" } }, - required = new[] { "command" } + cache_control = new { type = "ephemeral" } + } + : new + { + name = "execute", + description = "Führt einen Shell-Befehl im Workspace-Verzeichnis aus. Gibt stdout und stderr zurück. Timeout: 30 Sekunden.", + input_schema = new + { + type = "object", + properties = new Dictionary + { + ["command"] = new { type = "string", description = "Shell-Befehl zum Ausführen" } + }, + required = new[] { "command" } + } } - } }; } diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index 1c53160..5c21400 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -383,3 +383,56 @@ text-decoration: none; color: var(--text); } + +/* System-Prompt Modal */ + +.sp-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.sp-modal { + background: var(--f2-bg); + border: var(--border-width) solid var(--f2-border); + border-radius: var(--border-radius); + overflow: hidden; + width: 90vw; + max-width: 700px; + max-height: 80vh; + display: flex; + flex-direction: column; +} + +.sp-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--box-padding); + border-bottom: var(--border-width) solid var(--f1-border); + background: var(--f1-bg); + flex-shrink: 0; +} + +.sp-close { + width: 2em; + aspect-ratio: 1; + padding: 0; + line-height: 1; +} + +.sp-body { + padding: var(--page-padding); + overflow-y: auto; + flex: 1; +} + +.sp-loading { + text-align: center; + opacity: var(--semi-transparent); + padding: 2em; +} diff --git a/wwwroot/index.html b/wwwroot/index.html index 6bb7048..b442dd3 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -144,6 +144,7 @@
diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index f69cbe5..d11b10a 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -112,6 +112,12 @@ async function apiExecCommand(sessionId, command) { }); } +async function apiGetSystemPrompt(sessionId) { + const resp = await fetch(`/api/sessions/${sessionId}/system-prompt`); + if (!resp.ok) throw new Error('System-Prompt konnte nicht geladen werden'); + return resp.text(); +} + async function apiIsStreaming(sessionId) { const resp = await fetch(`/api/sessions/${sessionId}/streaming`); return resp.json(); diff --git a/wwwroot/js/pages/chat-page.js b/wwwroot/js/pages/chat-page.js index 4de5855..c25a878 100644 --- a/wwwroot/js/pages/chat-page.js +++ b/wwwroot/js/pages/chat-page.js @@ -183,6 +183,13 @@ registerPage('chat', { }); } + // System-Prompt anzeigen + const systemPromptBtn = document.getElementById('systemPromptBtn'); + if (systemPromptBtn) systemPromptBtn.addEventListener('click', () => { + if (chatMenuDropdown) chatMenuDropdown.hidden = true; + showSystemPromptModal(sessionId); + }); + // Archivieren const archiveBtn = document.getElementById('archiveBtn'); if (archiveBtn) archiveBtn.addEventListener('click', () => { diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index 30412e5..ba29dc1 100644 --- a/wwwroot/js/ui.js +++ b/wwwroot/js/ui.js @@ -409,6 +409,58 @@ async function loadAndShowAttachments(messageId, indicator) { } } +// ── System-Prompt Modal ── + +async function showSystemPromptModal(sessionId) { + // Overlay + const overlay = document.createElement('div'); + overlay.className = 'sp-overlay'; + + // Modal + const modal = document.createElement('div'); + modal.className = 'sp-modal'; + + // Header + const header = document.createElement('div'); + header.className = 'sp-header'; + const title = document.createElement('span'); + title.textContent = 'System-Prompt'; + const closeBtn = document.createElement('button'); + closeBtn.textContent = '✕'; + closeBtn.className = 'sp-close'; + header.appendChild(title); + header.appendChild(closeBtn); + + // Body + const body = document.createElement('div'); + body.className = 'sp-body markdown'; + body.innerHTML = '
Laden...
'; + + modal.appendChild(header); + modal.appendChild(body); + overlay.appendChild(modal); + document.body.appendChild(overlay); + + // Schließen + const close = () => overlay.remove(); + closeBtn.addEventListener('click', close); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + const onKey = (e) => { + if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } + }; + document.addEventListener('keydown', onKey); + + // Laden + try { + const prompt = await apiGetSystemPrompt(sessionId); + body.innerHTML = renderMarkdown(prompt); + } catch (e) { + body.innerHTML = '
Fehler beim Laden des System-Prompts
'; + } +} + // ── Meta Bar ── function setMeta(text) {