Chat agents
Waarom-deed-de-agent-dat: een why-knop in één middag
Je support-lead stuurt een screenshot in Slack: de chat-agent weigert een refund die hij had moeten goedkeuren. Geen idee waarom. Hier is de fix die je in een middag bouwt.

Het is dinsdagmiddag. Je support-lead plakt een screenshot in Slack: de chat-agent heeft een klant verteld dat zijn order niet retourneerbaar was. Hij was wel retourneerbaar. De klant is geïrriteerd, je support-lead is geïrriteerd, en jij, degene die de agent live heeft gezet, hebt geen idee waarom hij zei wat hij zei. Je opent de database. Je scrolt door de messages-tabel. Daar staat de assistant turn, gewoon in platte tekst, zonder tool calls, zonder snapshot van de system prompt, zonder retrieved context, niets. Alleen het foute antwoord.
Dit is het vervelendste aan een agent in productie. Niet de hallucinaties zelf, maar het feit dat je er niet eentje in de ogen kunt kijken en kunt vragen waarom. Hier is de kleine, saaie fix die we steeds bij klant-agents installeren. Eén log-tabel, drie regels UI, en een paneel dat uit elk assistant-bericht klapt en precies vertelt wat er is gebeurd. Het kost een middag. Het is verreweg de meest waardevolle ingreep die je kunt doen aan een chat-agent die al werkt.
De vorm van het probleem
Een moderne chat-agent is niet één call. Het is een kleine pipeline: het user message komt binnen, je haalt wat context op (RAG, CRM-lookup, order-historie), je bouwt een system prompt, je roept het model aan met een set tool-definities, het model besluit een tool aan te roepen of te antwoorden, je voert de tool uit, je geeft het resultaat terug, en uiteindelijk krijg je een laatste assistant-bericht. Vijf tot tien stappen, soms meer.
Standaard sla je het user message en het assistant message op. Dat is wat je chat-UI rendert. Alles ertussenin, het deel dat eigenlijk bepaalt wat de assistant zegt, verdampt op het moment dat de request terugkomt. Als er iets misgaat, heb je de vraag en het antwoord en niets wat ze verbindt.
De fix is geen observability-platform. Die zijn nuttig, maar ze zitten ergens anders, in een aparte tab, achter een login. De fix is om de trace in het product zelf te zetten, gekoppeld aan het bericht dat eruit kwam, achter een klein knopje dat alleen je eigen team ziet. Dan is het eerstvolgende antwoord, als je support-lead weer een fout antwoord in Slack plakt: klik op de why-knop.
Eén tabel, acht kolommen
Voeg één tabel toe naast je bestaande messages-tabel. Noem 'm agent_traces. Hij bevat één rij per assistant turn, gekoppeld aan het bericht dat hij heeft geproduceerd.
create table agent_traces (
id uuid primary key default gen_random_uuid(),
message_id uuid not null references messages(id) on delete cascade,
conversation_id uuid not null,
created_at timestamptz not null default now(),
model text not null,
system_prompt text not null,
retrieved jsonb not null default '[]'::jsonb,
tool_calls jsonb not null default '[]'::jsonb,
raw_response jsonb not null,
latency_ms integer not null,
input_tokens integer,
output_tokens integer
);
create index on agent_traces(message_id);
create index on agent_traces(conversation_id, created_at desc);
Dat is het hele schema. Acht inhoudelijke kolommen. retrieved is de lijst chunks die je RAG-laag heeft binnengehaald, met source-id en similarity score. tool_calls is de geordende lijst function calls die het model heeft gedaan, elk met arguments en result. raw_response is de volledige response van de provider, ongewijzigd, zodat je 'm later kunt replayen. system_prompt is een snapshot van de prompt zoals die werkelijk naar het model is gegaan, inclusief alle per-tenant variabelen, want de versie in je codebase van vandaag is niet de versie die afgelopen dinsdag draaide.
Snapshot de system prompt op het moment van de request, niet via een template-id. Templates veranderen. Het hele punt van deze tabel is het moment vastleggen, niet een verwijzing naar iets wat constant beweegt.
Waar de writes naartoe gaan
Je hebt al een functie die het model aanroept. Wrap 'm. Vóór de call begin je met een trace-object. Na de call schrijf je de rij weg in dezelfde transactie waarin je het assistant-bericht wegschrijft. Als de model call mislukt, schrijf je de rij alsnog weg met de fout in raw_response; een mislukte turn is precies het soort ding dat je verklaarbaar wilt hebben.
async function runAgentTurn(conv: Conversation, userMsg: Message) {
const t0 = Date.now();
const retrieved = await rag.search(userMsg.content, { conv });
const systemPrompt = buildSystemPrompt(conv, retrieved);
const response = await model.chat({
system: systemPrompt,
messages: conv.history,
tools: TOOLS,
});
const toolCalls = await runTools(response.tool_calls, conv);
const assistantText = response.text;
await db.transaction(async (tx) => {
const msg = await tx.messages.insert({
conversation_id: conv.id,
role: 'assistant',
content: assistantText,
});
await tx.agent_traces.insert({
message_id: msg.id,
conversation_id: conv.id,
model: response.model,
system_prompt: systemPrompt,
retrieved,
tool_calls: toolCalls,
raw_response: response,
latency_ms: Date.now() - t0,
input_tokens: response.usage?.input_tokens,
output_tokens: response.usage?.output_tokens,
});
});
}
Roep je de Anthropic SDK of OpenAI direct aan, dan is het raw_response-object al iets dat je naar JSON kunt serialiseren. Stringify 'm en sla 'm op. Opslag is goedkoop. Een doorsnee agent turn schrijft 4 tot 20 kilobytes aan trace weg. Bij duizend turns per dag is dat twintig megabyte per maand. Daar ga je niets van merken. Mocht je dat wel doen: partition de tabel per maand en gooi na negentig dagen weg.
Die drie regels UI
De UI is echt klein. Naast elke assistant-bubble render je een minuscuul knopje. Toon 'm alleen aan ingelogde teamleden, niet aan klanten. Bij een klik haal je de trace op en render je 'm in een side panel.
{isStaff && (
<button onClick={() => openTrace(msg.id)} className="why-btn">why</button>
)}
Dat zijn de drie regels. Het paneel zelf is ook klein, want de tabel is klein. Vier secties: de system prompt zoals hij heeft gedraaid, de retrieved chunks met hun scores, de tool calls op volgorde met inputs en outputs, en de raw response van het model, standaard ingeklapt. Geen grafieken. Geen tijdlijn. Gewoon monospace. Het doel is niet om mooi te zijn, het doel is om binnen dertig seconden antwoord te geven op 'waarom zei hij dat'.
De why-knop is geen debug-tool, het is een product-surface. Zet 'm naast het bericht, niet in een apart dashboard. Je team gaat 'm dan ook daadwerkelijk gebruiken.
Wat je gaat aantreffen
In de eerste week nadat je dit live hebt gezet, ontdek je drie dingen, in deze volgorde. Eerst: je retrieval is slechter dan je dacht. De chunk die de agent nodig had voor het juiste antwoord stond op plek zeven, onder zes bijna-duplicaten. Twee: je system prompt bevat een zinnetje van vier maanden geleden dat nergens meer op slaat, en de agent volgt het al die tijd braaf op. Drie: een van je tools geeft een foutmelding terug die het model interpreteert als een nette afwijzing. Geen van drieën zijn exotische failure modes. Het is het normale meubilair van een agent in productie, en zonder de trace zie je er niets van.
Er zit een breder punt onder. Het engineering-team van Anthropic schreef een stuk over building effective agents, en een van de terugkerende lijnen is dat werkbare agent-systemen afhangen van het leesbaar houden van het gedrag van het model voor de mensen die het bedienen. Een why-knop is daar de goedkoopst denkbare vorm van. Je gaat geen model alignen. Je geeft jezelf de mogelijkheid om te merken wanneer het is afgedreven.
Wat je niet in de trace moet zetten
Twee dingen die er niet in horen. PII die je niet negentig dagen in een JSON-blob wilt hebben staan; bevatten je retrieved chunks klantgegevens, hash of redacteer dan de voor de hand liggende velden voordat ze de tabel raken. En secrets die binnenkomen via tool results, met name API-tokens die in foutmeldingen worden meegestuurd. Beide zijn eenvoudig te scrubben op het moment van schrijven, en heel vervelend om achteraf weg te poetsen. Zit je in de EU: behandel de trace-tabel als elke andere opslag van persoonsgegevens en zet 'm in je Artikel 30 verwerkingsregister.
Als de middag voorbij is
Dan heb je een tabel, een wrapper om je model call heen, en een klein knopje. Je support-lead stopt met screenshots sturen en begint trace-links te sturen. Je prompt-aanpassingen worden onderbouwd met bewijs in plaats van met gevoel. En de volgende keer dat iemand je vraagt waarom de agent zei wat hij zei, klik je op een knop en lees je het antwoord.
Toen we de inbox-triage-agent bouwden voor een logistieke klant in Rotterdam, zat het probleem in één ambigu zinnetje in de system prompt waardoor de agent bij elke vraag over zendingstatus terugviel op een mens. We hadden het alleen door omdat de trace liet zien dat steeds dezelfde retrieved chunk binnenkwam en steeds werd genegeerd. Eén zin schrappen drukte de human-handoff rate met een derde. Daar zijn verklaarbare AI-agents voor: kleine ingrepen, onderbouwd met bewijs, in minuten gemaakt.
Staat je agent live en kun je voor zijn laatste tien antwoorden niet uitleggen waarom, dan is het kleinste nuttige dat je vandaag kunt doen: de agent_traces-tabel aanmaken en beginnen met wegschrijven. De UI kan tot morgen wachten. De data niet.
Kern
Eén log-tabel plus een staff-only knopje bij elk assistant-bericht maakt van prompt-aanpassingen geen gokwerk meer maar bewijs, in één middag.
FAQ
Wordt de trace-tabel niet enorm?
Een doorsnee agent turn schrijft 4 tot 20 KB weg. Bij 1.000 turns per dag is dat zo'n 20 MB per maand. Partition per maand en gooi na 90 dagen weg als je het ooit voelt, en de meeste teams gaan dat niet.
Mogen klanten de why-knop zien?
Nee. Gate 'm achter staff-auth. De trace bevat system prompts, retrieved context en de raw output van het model. Niets daarvan hoort voor een klant te staan, en het meeste is gevoelig.
Heb ik dan nog een echt observability-platform nodig?
Uiteindelijk wel, voor geaggregeerde views zoals kosten per gesprek of retrieval-kwaliteit over de tijd. Maar de in-product why-knop lost de meest voorkomende vraag op: wat is er op precies deze turn gebeurd.
Wat als mijn agent streamt?
Buffer de stream server-side, schrijf de trace weg als de stream is afgerond, en koppel 'm aan het uiteindelijke assistant-bericht. De gebruiker ziet de tokens in real time binnenkomen; de trace landt één transactie later.
Hoe ga ik om met PII in retrieved chunks?
Redacteer bij het schrijven, niet bij het lezen. Hash klant-id's, gooi e-mail- en telefoonvelden eruit voordat ze in de JSON-kolom belanden, en behandel de trace-tabel als opslag van persoonsgegevens onder Artikel 30 AVG.