P
imvdmolen.nl
Blog

Ollama als gespreksgeheugen koppelen aan een lokale AI agent met Node.js

Soms werk ik aan projecten waarbij ik een lokale AI agent wil bouwen die meerdere berichten in dezelfde sessie begrijpt, zonder dat ik elke keer de volledige context handmatig meestuur. Standaard onthoudt Ollama helemaal niets: je stuurt een prompt in, je krijgt een antwoord terug, en daarmee is de cyclus klaar. Dat werkt prima voor losse vragen, maar zodra je een agent wil bouwen die redenering opbouwt over meerdere stappen, loop je al snel tegen die statelessheid aan. Wat ik dan doe is een gespreksgeheugen bouwen in Node.js dat de berichtenhistorie bijhoudt en die bij elk verzoek meestuurt naar de Ollama API.

Hoe Ollama omgaat met context

De /api/chat-endpoint van Ollama verwacht een array van berichten met rollen: system, user en assistant. Elke aanroep is volledig zelfstandig, wat betekent dat jij als ontwikkelaar zelf verantwoordelijk bent voor het aanleveren van de volledige gesprekshistorie bij elk verzoek. Dat klinkt omslachtig, maar het geeft tegelijkertijd volledige controle over wat het model wel en niet "weet". Je kunt het venster inkorten, irrelevante berichten eruit filteren, of de historiek per agent-taak gescheiden bewaren.

Wat ik als eerste doe is een eenvoudige array initialiseren die de berichten opslaat, aangevuld met een systeem-prompt die de agent een rol geeft:

const history = [
  {
    role: "system",
    content: "Je bent een assistent die helpt bij het analyseren van PHP-code. Geef concrete, beknopte antwoorden."
  }
];

Elke keer dat de gebruiker iets stuurt, voeg ik dat bericht toe aan history vóórdat ik de API aanroep. Nadat Ollama antwoordt, voeg ik ook dat antwoord toe. Op die manier groeit de array mee met het gesprek en stuurt elke volgende aanroep de complete context mee.

De gespreksloop opzetten in Node.js

Voor de HTTP-aanroep naar Ollama werk ik met de ingebouwde fetch-API in Node 18+, zodat ik geen extra dependencies nodig heb. De sleutel zit in de stream: false optie: die zorgt ervoor dat Ollama de complete response in één keer teruggeeft in plaats van als een stream van tokens. Voor een interactieve CLI-agent is streaming fijn, maar voor een agent die acties onderneemt op basis van het antwoord is een volledig antwoord makkelijker te verwerken.

async function chat(userMessage) {
  history.push({ role: "user", content: userMessage });

  const response = await fetch("http://localhost:11434/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      model: "llama3",
      messages: history,
      stream: false
    })
  });

  const data = await response.json();
  const assistantMessage = data.message.content;

  history.push({ role: "assistant", content: assistantMessage });

  return assistantMessage;
}

Met deze opzet blijft de gesprekshistorie in geheugen zolang het Node.js-proces actief is. Dat is voor lokale experimenten prima. Wil je de agent ook na een herstart laten verdergaan, dan schrijf je history weg naar een JSON-bestand of een lichtgewicht SQLite-database na elk antwoord.

import { writeFileSync, readFileSync, existsSync } from "fs";

const HISTORY_FILE = "./agent_history.json";

function loadHistory() {
  if (existsSync(HISTORY_FILE)) {
    return JSON.parse(readFileSync(HISTORY_FILE, "utf-8"));
  }
  return [
    {
      role: "system",
      content: "Je bent een assistent die helpt bij het analyseren van PHP-code."
    }
  ];
}

function saveHistory(history) {
  writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2));
}

Na elke aanroep van chat() roep ik saveHistory(history) aan. De volgende keer dat het script opstart, laadt loadHistory() de vorige sessie terug en gaat het gesprek naadloos verder. Dit patroon kost vrijwel niets aan overhead en werkt goed zolang de histories niet extreem lang worden.

Contextvensters beheren om tokens te sparen

Elk model heeft een maximale contextlengte. LLaMA 3 met 8B parameters heeft standaard een venster van 8192 tokens, maar bij langere gesprekken kun je daar overheen gaan. De eenvoudigste aanpak is een rollend venster: je bewaart altijd de systeem-prompt en de laatste N berichten, en alles daarvoor gooi je weg.

function trimHistory(history, maxMessages = 20) {
  const systemPrompt = history[0];
  const conversation = history.slice(1);

  if (conversation.length <= maxMessages) {
    return history;
  }

  const trimmed = conversation.slice(conversation.length - maxMessages);
  return [systemPrompt, ...trimmed];
}

Roep trimHistory() aan vóórdat je de API-call doet, niet erna. Zo stuur je altijd een gecontroleerde hoeveelheid context mee. Twintig berichten zijn in de praktijk genoeg voor de meeste gesprekscontexten, maar voor een agent die langdurig over een codebase redeneert, verhoog ik dat soms naar dertig of veertig. Het model zelf bepaalt uiteindelijk hoeveel het er daadwerkelijk uit haalt.

Wat ik ook doe is berichten samenvatten als ze een bepaalde lengte overschrijden. Dat is een stap verder: je stuurt een apart verzoek naar Ollama met de opdracht om het gesprek tot nu toe samen te vatten in twee zinnen, en die samenvatting gebruik je als nieuw startpunt. Dat kost een extra API-aanroep maar houdt het contextvenster klein bij lange sessies.

Tools en beslissingen toevoegen aan de agent

Een agent die alleen praat is handig, maar een agent die ook acties kan ondernemen is interessanter. De aanpak die ik volg is gebaseerd op het ReAct-patroon: Reason, Act, Observe. Je vraagt het model niet alleen om een antwoord, maar ook om aan te geven welke actie het wil uitvoeren. Dat doe je door de systeem-prompt uit te breiden met een beschrijving van de beschikbare tools.

const systemPrompt = `
Je bent een PHP-code assistent. Als je een taak niet direct kunt beantwoorden zonder extra informatie,
gebruik dan één van de volgende tools door je antwoord te beginnen met TOOL:

TOOL: read_file <pad>
TOOL: run_php <code>

Geef anders direct een antwoord zonder TOOL-prefix.
`;

Na elke response van het model parseer ik de output om te kijken of er een TOOL-aanroep in zit:

function parseToolCall(response) {
  const match = response.match(/^TOOL:\s+(\w+)\s+(.+)/m);
  if (!match) return null;
  return { tool: match[1], argument: match[2].trim() };
}

Als er een tool-aanroep wordt gedetecteerd, voer ik die actie uit, stop ik het resultaat terug in de history als een user-bericht met de observatie, en roep ik de API opnieuw aan. Dat gaat door totdat het model een antwoord geeft zonder TOOL-prefix. Dit is de kern van een agentlus: niet één aanroep, maar een cyclus van denken, handelen en waarnemen.

Het mooie van deze aanpak lokaal draaien met Ollama is dat er geen tokens worden gefactureerd, geen data naar externe servers gaan, en je volledig offline kunt werken. Voor projecten waarbij ik klantcode analyseer is dat geen luxe maar een vereiste. Ik heb dit patroon inmiddels bij meerdere interne tools toegepast, van een agent die pull request-beschrijvingen schrijft op basis van een git diff, tot een agent die Laravel-logbestanden doorzoekt en mogelijke oorzaken van fouten aanwijst. De basis blijft elke keer hetzelfde: een simpele history-array, een aanroep naar Ollama, en logica die de output interpreteert en besluit wat er vervolgens moet gebeuren.

Veelgestelde vragen

Hoe onthoud je gespreksgeschiedenis bij Ollama als het standaard stateless is?
Ollama slaat zelf geen gesprekshistorie op, dus je moet als ontwikkelaar de berichtenhistorie zelf bijhouden en bij elk nieuw verzoek de volledige array van eerdere berichten meesturen naar de /api/chat-endpoint. In Node.js kun je dit eenvoudig doen met een array die je na elk bericht aanvult met de rol (user of assistant) en de bijbehorende inhoud.
Wat is het verschil tussen stream true en stream false in de Ollama API?
Met stream: true stuurt Ollama het antwoord terug als een reeks losse tokens zodra ze gegenereerd worden, wat handig is voor live weergave in een chat-interface. Met stream: false wacht de API totdat het volledige antwoord klaar is en stuurt dit in één keer terug, wat eenvoudiger te verwerken is in een geautomatiseerde agent.
Hoe ga je om met de maximale contextlengte van een lokaal taalmodel zoals LLaMA 3?
Elk model heeft een tokenvenster waarboven het geen oudere informatie meer kan verwerken; LLaMA 3 8B heeft standaard een limiet van 8192 tokens. Een praktische oplossing is een rollend venster waarbij je altijd de systeem-prompt bewaart maar alleen de laatste N berichten meestuurt en oudere berichten weggooit.
Wat is het ReAct-patroon en waarom is het nuttig voor een lokale AI agent?
ReAct staat voor Reason, Act, Observe en is een methode waarbij je het taalmodel niet alleen een antwoord laat genereren, maar het ook laat aangeven welke actie het wil uitvoeren op basis van zijn redenering. Dit maakt de agent veel krachtiger omdat hij stap voor stap kan redeneren, externe acties kan triggeren en vervolgens het resultaat daarvan kan verwerken in zijn volgende stap.