Zelf-hosten legt je slordige prompts bloot: contextbudget voor .NET-agents
Een trending Hacker News-verhaal over het verhuizen van 35KB aan preprompts naar een lokaal model laat zien wat frontier-API's al die tijd voor je verborgen. Zo bouw je een agent in .NET die wel binnen zijn contextbudget past.
Jean-Pierre Broeders
Freelance .NET Developer
Deze week haalde een verslag de voorpagina van Hacker News dat ik herkende zonder het te hebben meegemaakt. Patrick McCanna beschrijft hoe hij 35KB aan zorgvuldig opgebouwde preprompts verhuisde van Anthropic en OpenAI naar een zelf-gehost model op Ollama. Op papier een lift-and-shift. In de praktijk viel de agent binnen minuten uit elkaar. Hij las bestanden opnieuw die hij net had gelezen, deed dezelfde tool-call twee keer achter elkaar, en herhaalde zijn eigen opdracht alsof hij hardop zocht naar de draad die hij kwijt was.
De HN-discussie ging alle kanten op, maar de kern is simpel. Die 35KB vrat meteen 14 procent van zijn lokale contextvenster van 65K tokens. Bij een frontier-model met een venster van 200K en een berg verborgen chain-of-thought valt zo'n prompt niet op. Zet hem op een kleiner model en je ziet ineens wat je al die tijd betaalde. Niet in euro's, maar in ruimte om na te denken.
Ik werk veel met .NET en ik bouw steeds vaker agent-achtige features in C#. Dus las ik dit verhaal niet als "Ollama is lastig", maar als een spiegel. Je prompt was altijd al te dik. Je frontier-model verborg het alleen.
Waarom een groter venster het probleem verplaatst, niet oplost
Er is een hardnekkig idee dat een groter contextvenster betekent dat je je minder zorgen hoeft te maken over wat je erin stopt. Het tegenovergestelde klopt. Een model let niet even goed op elk token in zijn venster. Hoe voller je het maakt met achtergrond, regels en voorbeelden die dit specifieke verzoek niet raken, hoe meer je de aandacht verdunt voor de paar honderd tokens die er echt toe doen.
McCanna's agent liet de scherpe versie van dat probleem zien. Zodra het model zijn eigen output erbij optelde, en de tool-resultaten, en de bestanden die het opende, zat het venster vol voordat het werk af was. En een vol venster gedraagt zich niet als een net iets tragere agent. Het gedraagt zich als iemand die halverwege een zin vergeet waar hij aan begon.
Dat maakt zijn conclusie zo bruikbaar. Hij loste het niet op met een groter model. Hij loste het op door minder te vragen per stap.
Meet je prompt voordat je hem verstuurt
De eerste stap in .NET is banaal en toch slaan de meeste mensen hem over: tel je tokens. Niet je karakters, je tokens. Een systeemprompt van 35KB voelt klein op schijf en is dat niet in een venster.
Met Microsoft.ML.Tokenizers doe je dat zonder naar een externe service te bellen:
using Microsoft.ML.Tokenizers;
// Werkt met de tokenizer van je doelmodel. Voor Llama-achtigen
// laad je het model-specifieke vocab; hier als voorbeeld cl100k.
var tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
string systemPrompt = await File.ReadAllTextAsync("prompts/agent-system.md");
int tokens = tokenizer.CountTokens(systemPrompt);
Console.WriteLine($"Systeemprompt: {tokens} tokens");
const int windowSize = 65_536;
double share = (double)tokens / windowSize;
if (share > 0.10)
Console.WriteLine($"Waarschuwing: prompt pakt {share:P0} van het venster nog voor de eerste turn.");
Zet dit in een test die faalt zodra je prompt over een drempel gaat. Ik hang mijn drempel op tien procent. Gaat een pull request eroverheen, dan wil ik een reden zien, geen stilzwijgende groei van iemand die er "voor de zekerheid" nog een alinea bij plakte. Prompts groeien precies zoals logbestanden groeien: één regel per keer, tot niemand meer weet waarom ze er staan.
Zet je contextvenster expliciet in Ollama
Een addertje dat McCanna noemt en dat veel .NET-devs zal bijten: Ollama gebruikt standaard een klein contextvenster, vaak 2048 of 4096 tokens, ongeacht wat het model aankan. Stel je het niet in, dan kapt de runtime stilletjes je oudste context af. Je agent lijkt dan te "vergeten", terwijl je in werkelijkheid nooit de ruimte hebt aangezet.
Via Microsoft.Extensions.AI praat je met Ollama achter dezelfde IChatClient-abstractie als met OpenAI. Het num_ctx-veld zet je in de aanvullende opties:
using Microsoft.Extensions.AI;
IChatClient client = new OllamaChatClient(
new Uri("http://localhost:11434"),
modelId: "llama3.1:8b");
var options = new ChatOptions
{
Temperature = 0.2f,
AdditionalProperties = new()
{
// Zonder deze regel kapt Ollama af op zijn default van 4096.
["num_ctx"] = 32_768,
},
};
var response = await client.GetResponseAsync(
"Vat de openstaande migraties in deze repo samen.", options);
Console.WriteLine(response.Text);
Een groter num_ctx kost geheugen op de machine die het model draait, dus dit is een echte afweging en geen gratis knop. Maar hem op de default laten staan terwijl je een prompt van 20K tokens verstuurt is geen zuinigheid, dat is een bug. Meet eerst je prompt, kies dan je venster, laat daarna genoeg over voor het antwoord en de tool-resultaten.
Splits de monoliet in agents met één doel
Het meest overdraagbare deel van McCanna's verhaal is wat hij "single objective prompting" noemt. In plaats van één systeemprompt die alles wist, ontleedt hij het werk in losse eenheden met elk hun eigen kleine, scherpe instructie. Dat is geen truc voor lokale modellen. Het is gewoon beter ontwerp, en een klein model dwingt je het te doen omdat het niet meer verbergt hoe duur de monoliet is.
In .NET valt dit netjes op zijn plek. Definieer per taak een agent met precies de context die die taak nodig heeft:
public sealed record AgentDefinition(
string Name,
string Objective,
string SystemPrompt,
IReadOnlyList<string> AllowedTools);
// Elke definitie is klein. Samen zijn ze groot; los is elk verzoek slank.
var migrationAuditor = new AgentDefinition(
Name: "migration-auditor",
Objective: "Vind EF Core-migraties die nog niet op productie draaien.",
SystemPrompt: """
Je inspecteert een .NET-repo. Geef alleen migratienamen terug
die in de code staan maar niet in de meegeleverde __EFMigrationsHistory.
Antwoord als JSON-array van strings. Geen uitleg.
""",
AllowedTools: ["read_file", "list_migrations"]);
Merk op wat de systeemprompt niet doet. Hij legt geen algemene coding-standaarden uit, geen bedrijfsgeschiedenis, geen twaalf randgevallen die deze taak niet raken. Al die kennis leeft in andere definities, die pas laden als hun taak aan de beurt is. Dat is het verschil tussen 35KB per turn en misschien 2KB per turn.
Positieve instructies kosten minder en falen minder
Een detail dat makkelijk wegvalt maar de moeite van het overnemen waard is: McCanna verving verboden door richtingen. Niet "gebruik geen reflectie en raak de database niet aan en verander de config niet", maar "beperk je tot deze twee bestanden en geef een diff terug".
Daar zitten twee winsten in. De eerste is tokens. Een lange lijst dingen-die-niet-mogen is meestal langer dan de korte beschrijving van wat wel mag. De tweede is betrouwbaarheid. Kleinere modellen missen vaker een ontkenning. "Doe X niet" glijdt soms weg en dan doet het model precies X. Zeg je alleen wat wel mag, dan is er geen ontkenning om te missen.
Ik pas dezelfde regel toe op mijn tool-definities. Geef een agent drie tools die alleen kunnen wat mag, en je hoeft nergens te schrijven dat hij van de rest af moet blijven.
Vang thrashing af in code, niet in de prompt
De duurste failure mode in het verhaal was thrashing: dezelfde tool-call twee keer achter elkaar, hetzelfde bestand steeds opnieuw lezen. Je kunt een model vragen dat niet te doen, maar op een klein venster is vragen zwak. Een goedkope guard in je eigen code is sterker.
Omdat je in .NET de tool-calls zelf orchestreert, kun je een korte call-geschiedenis bijhouden en een identieke herhaling kortsluiten voor hij naar het model teruggaat:
public sealed class ToolCallGuard
{
private readonly Queue<string> _recent = new();
private const int Window = 4;
public bool IsRepeat(string tool, string argsJson)
{
var key = $"{tool}:{argsJson}";
if (_recent.Contains(key))
return true;
_recent.Enqueue(key);
if (_recent.Count > Window)
_recent.Dequeue();
return false;
}
}
Als een call terugkomt die in de laatste paar stappen al langskwam, geef je het model niet nog eens hetzelfde resultaat. Je geeft een kort bericht terug dat het dit al heeft, en je duwt het naar de volgende stap. Dat breekt de lus die anders je hele sessie opeet, zoals McCanna het verwoordt, "als een gesprongen waterleiding".
Bewaar de sessiestand op schijf
Het laatste stukje is operationeel. Op een klein venster ontkom je niet aan overdrachten: op enig moment moet je context comprimeren of doorgeven aan een nieuwe turn. McCanna schrijft de sessiestand naar schijf zodat een agent kan stoppen en oppakken zonder alles opnieuw op te bouwen.
In .NET is dat een paar regels, en het geeft je meteen een auditspoor van wat de agent op elke stap wist:
public sealed record SessionState(
string AgentName,
string Objective,
List<ChatMessage> History,
Dictionary<string, string> Scratchpad);
// Bewaar na elke betekenisvolle stap; hervat door terug te lezen.
var json = JsonSerializer.Serialize(state, _jsonOptions);
await File.WriteAllTextAsync($"sessions/{sessionId}.json", json);
Dit is niet spannend en juist daarom sla je het over tot het een keer misgaat. Een agent die crasht op stap negen en helemaal opnieuw moet beginnen is niet alleen traag. Hij verbrandt je hele contextbudget een tweede keer aan werk dat al af was.
Wat ik hiervan meeneem naar .NET-werk
Ik ga morgen niet mijn hele stack naar Ollama verhuizen, en dat is ook niet het punt. Het punt is dat een klein, zelf-gehost model een eerlijke meetlat is. Het laat je niet wegkomen met een opgeblazen prompt, met vage verboden, of met een agent die in cirkels draait, want er is geen ruime marge meer om die kosten in te verstoppen.
Dus tel je tokens en zet er een test op. Kies je contextvenster bewust in plaats van op de default te vertrouwen. Splits je monoliet in agents met één doel. Schrijf wat wel mag in plaats van wat niet mag. En vang thrashing en overdrachten af in je eigen C#, waar je er controle over hebt, in plaats van erover te smeken in een systeemprompt. Doe dat, en je frontier-model wordt er ook goedkoper en scherper van. Je hoeft alleen niet meer te betalen voor de troep die het tot nu toe voor je wegmoffelde.
Veelgestelde vragen over prompts en zelf-gehoste LLM's
Waarom vergeet mijn agent op Ollama dingen die op OpenAI wel bleven staan? Waarschijnlijk omdat Ollama standaard een klein contextvenster gebruikt, vaak 2048 of 4096 tokens, en je oudste context stilletjes afkapt zodra je daaroverheen gaat. Zet num_ctx expliciet op een waarde die past bij je prompt plus het verwachte antwoord, en houd er rekening mee dat een groter venster meer geheugen kost op de machine die het model draait.
Hoe tel ik het aantal tokens in mijn systeemprompt in .NET? Gebruik het pakket Microsoft.ML.Tokenizers en roep CountTokens aan op de tekst met de tokenizer die bij je doelmodel hoort. Doe dit in een unit-test die faalt zodra de prompt over een drempel gaat, bijvoorbeeld tien procent van je venster, zodat prompt-groei zichtbaar wordt in code review in plaats van pas in productie.
Wat is single objective prompting en waarom helpt het? Het betekent dat je het werk opdeelt in losse taken met elk een eigen kleine systeemprompt en alleen de tools die die taak nodig heeft, in plaats van één monoliet die alles moet weten. Het houdt elk afzonderlijk verzoek slank, wat de aandacht van het model bij de paar honderd tokens houdt die er echt toe doen en de kans op afdwalen verkleint.
Kan ik dezelfde .NET-code tegen zowel Ollama als OpenAI gebruiken? Ja, Microsoft.Extensions.AI biedt een IChatClient-abstractie waar zowel de Ollama- als de OpenAI-client achter zitten, dus je applicatiecode blijft gelijk en je wisselt de client en de opties. Modelspecifieke instellingen zoals num_ctx geef je mee via AdditionalProperties, zodat de rest van je pijplijn er niets van merkt.
Is thrashing beter op te lossen in de prompt of in code? In code. Op een klein venster is een instructie om niet te herhalen zwak, terwijl een korte guard die identieke tool-calls in de laatste paar stappen herkent en kortsluit betrouwbaar werkt. Omdat je in .NET de tool-orchestratie zelf in handen hebt, kun je de lus breken voordat hij je contextbudget opeet.
Verder lezen
- Context rot: waarom een groter context window je LLM-feature niet redt
- Het Kubernetes-moment van open-weight AI: wat het betekent voor .NET-teams
- Markdown serveren aan AI-agents via Accept-headers: de .NET-versie
Conclusie: Behandel een klein, zelf-gehost model als een meetlat, niet als een handicap. Het laat je precies zien welke tokens je prompt verspilt, en die les maakt ook je frontier-agent goedkoper en scherper.
Bronnen: Notes on migrating large preprompts to self-hosted LLMs · Hacker News-discussie.
