Rails LLM Gespreksgeschiedenis: Context Opslaan, Samenvatten en het Context Window Beheren
LLM gespreksgeschiedenis in Rails: sla beurtenwissels op, beheer context windows, implementeer samenvatting en bouw multi-turn chat die echt schaalt.
De demo zag er geweldig uit. Streaming responses, een nette chatinterface, Claude die vragen over het product beantwoordde in vloeiende, behulpzame tekst. We hebben het live gezet. Drie dagen later arriveerde de eerste echte klacht: “Jullie chatbot herinnert zich niet wat ik twee berichten geleden heb gezegd.”
Ik keek naar de code. De ontwikkelaar had de streaming UI correct gebouwd — Server-Sent Events, Turbo Streams, het hele pakket. Maar de controller action stelde de berichtenreeks bij elk verzoek opnieuw samen door alleen het meest recente gebruikersbericht uit de database te laden. De context array die naar de API werd gestuurd had altijd twee regels: de systeemprompt en de huidige invoer. Geen geschiedenis. Elk bericht was een frisse start. Cosmetisch een chat. Functioneel een vraag-en-antwoord met één beurt en een mooie laadspinner.
Dit is de fout die ik het vaakst zie bij Rails LLM-applicaties. Ontwikkelaars krijgen de streaming interface goed voor elkaar, implementeren tool use, bouwen soms zelfs RAG met pgvector — en definiëren dan een context window dat alleen het laatste uitwisseling bevat. Na negentien jaar Rails en de laatste drie jaar serieuze AI-geïntegreerde applicaties bouwen, hier het volledige plaatje: hoe je LLM gespreksgeschiedenis correct opslaat, hoe je het context window beheert zonder je tokenbudget op te blazen, en hoe je samenvatting implementeert zodat gesprekken onbeperkt kunnen doorgaan.
Het Databaseschema: De Fundamenten Goed Leggen
Voordat je een LLM API aanraakt, heb je een schema nodig dat gesprekken daadwerkelijk kan representeren. Het minimaal levensvatbare ontwerp:
# db/migrate/20260929100000_create_conversations_and_messages.rb
class CreateConversationsAndMessages < ActiveRecord::Migration[8.0]
def change
create_table :conversations do |t|
t.references :user, null: false, foreign_key: true
t.string :title
t.text :summary # gecomprimeerde geschiedenis zodra context wordt ingekort
t.integer :summary_token_count, default: 0
t.integer :total_token_count, default: 0
t.timestamps
end
create_table :messages do |t|
t.references :conversation, null: false, foreign_key: true
t.string :role, null: false # "user" | "assistant" | "tool"
t.text :content, null: false
t.string :model # welk model deze beurt heeft verwerkt
t.integer :input_tokens
t.integer :output_tokens
t.integer :position, null: false # expliciete volgorde binnen gesprek
t.boolean :included_in_summary, default: false
t.jsonb :tool_calls # ruwe tool-use metadata indien relevant
t.timestamps
end
add_index :messages, [:conversation_id, :position]
add_index :messages, [:conversation_id, :included_in_summary]
end
end
Een paar ontwerpbeslissingen die toelichting verdienen:
position — een expliciete volgnummer binnen een gesprek. Vertrouw niet op created_at voor volgorde. Clockskew, bulk inserts en transactieisolatie kunnen timestampvolgorde onbetrouwbaar maken. Een expliciete teller is geen voortijdige optimalisatie; het is correct.
included_in_summary — markeert welke berichten zijn samengevat in de summary kolom. Zodra je oude berichten samenperst, heb je een betrouwbare manier nodig om ze uit te sluiten van de context array zonder ze te verwijderen. Verwijdering verliest je auditspoor; deze vlag bewaart de rijen terwijl de query ze negeert.
summary op de conversation — hier woont de gecomprimeerde context. Het begint null en wordt gevuld door de summarizer naarmate het gesprek groeit.
tool_calls JSONB — als je tool use of function calling gebruikt, sla de ruwe metadata hier op. Je wilt de exacte tool-call/resultaat-uitwisseling kunnen reproduceren voor de API als je ooit een gesprek moet reconstrueren.
De Context Array Bouwen
De LLM API verwacht een berichtenreeks: [{role: "user", content: "..."}, {role: "assistant", content: "..."}, ...]. Deze correct samenstellen vanuit de database is de kernoperatie van je LLM gespreksgeschiedenislaag.
# app/models/conversation.rb
class Conversation < ApplicationRecord
belongs_to :user
has_many :messages, -> { order(:position) }, dependent: :destroy
MAX_ACTIVE_TOKENS = 80_000 # ruimte voor systeemprompt + respons
def context_messages
active = messages.where(included_in_summary: false)
if summary.present?
# Voeg een synthetische uitwisseling toe die de gecomprimeerde context vestigt
[
{ role: "user",
content: "Samenvatting van ons eerdere gesprek:\n\n#{summary}" },
{ role: "assistant",
content: "Begrepen. Ik heb de context van ons eerdere gesprek en bouw daarop voort." }
] + active.map(&:to_api_format)
else
active.map(&:to_api_format)
end
end
def next_position
(messages.maximum(:position) || 0) + 1
end
end
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :conversation
def to_api_format
{ role: role, content: content }
end
end
De controller blijft overzichtelijk omdat de complexiteit in het model zit:
# app/controllers/chat_controller.rb
class ChatController < ApplicationController
def create
conversation = current_user.conversations.find(params[:conversation_id])
user_input = params[:message].to_s.strip
return head :unprocessable_entity if user_input.blank?
conversation.messages.create!(
role: "user",
content: user_input,
position: conversation.next_position
)
response = ClaudeClient.chat(
system: system_prompt_for(current_user),
messages: conversation.context_messages
)
assistant_text = response.content.first.text
conversation.messages.create!(
role: "assistant",
content: assistant_text,
model: response.model,
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
position: conversation.next_position
)
conversation.increment!(:total_token_count,
response.usage.input_tokens + response.usage.output_tokens)
ConversationSummarizerJob.perform_later(conversation.id) if summarization_needed?(conversation)
render json: { content: assistant_text }
end
private
def summarization_needed?(conversation)
active_tokens = conversation.messages
.where(included_in_summary: false)
.pick(Arel.sql("COALESCE(SUM(input_tokens), 0) + COALESCE(SUM(output_tokens), 0)"))
.to_i
active_tokens > Conversation::MAX_ACTIVE_TOKENS * 0.8
end
def system_prompt_for(user)
"Je bent een behulpzame assistent voor #{user.company_name}."
end
end
Het Context Window Probleem
Hier falen de meeste implementaties. Claude Sonnet heeft een context window van 200K tokens. GPT-4o van 128K. Dit klinkt enorm. Het is niet onbeperkt, en de kosten stapelen zich op op manieren die ontwikkelaars niet meteen zien.
Bij huidige tarieven kost 80K tokens invoer bij elk verzoek in een druk gesprek serieus geld. Vermenigvuldig dat met gelijktijdige gebruikers en de getallen worden snel oncomfortabel. Maar kosten zijn niet het enige probleem.
Latentie. 80K tokens per verzoek sturen betekent wachten tot het model ze allemaal heeft verwerkt voordat het de eerste output-token produceert. Lange contexten zijn trager dan korte contexten.
Verslechtering van modelkwaliteit. Paradoxaal genoeg kan een zeer lange context de responsekwaliteit schaden. Relevante informatie diep begraven in een gesprek van 150K tokens concurreert met meer recente tokens om de aandacht van het model. Het “verloren in het midden”-fenomeen — waarbij modellen slechter presteren op informatie die noch aan het begin noch aan het einde van de context staat — is goed gedocumenteerd.
De juiste aanpak is een schuifvenster met samenvatting: bewaar de meest recente N berichten als ruwe context, comprimeer oudere berichten tot een samenvatting, en reconstrueer de volledige context uit samenvatting plus recente berichten bij elk verzoek.
Samenvatting: Het LLM Comprimeert Zichzelf
Wanneer het actieve berichtenvenster de limiet nadert, rol je de oudste batch samen tot een samenvatting. Toekomstige verzoeken beginnen met de samenvatting in plaats van de ruwe berichten.
# app/jobs/conversation_summarizer_job.rb
class ConversationSummarizerJob < ApplicationJob
queue_as :default
BATCH_SIZE = 20 # comprimeer in batches van dit aantal berichten
def perform(conversation_id)
conversation = Conversation.find(conversation_id)
to_compress = conversation.messages
.where(included_in_summary: false)
.order(:position)
.limit(BATCH_SIZE)
return if to_compress.count < BATCH_SIZE
transcript = to_compress.map do |m|
"#{m.role.upcase}: #{m.content}"
end.join("\n\n")
existing_summary = conversation.summary.presence
prompt = if existing_summary
<<~PROMPT
Je comprimeert een gesprek voor langetermijnopslag.
BESTAANDE SAMENVATTING:
#{existing_summary}
NIEUWE BERICHTEN OM TE VERWERKEN:
#{transcript}
Schrijf een nieuwe uitgebreide samenvatting die beide combineert. Bewaar: sleutelbeslissingen,
gebruikersvoorkeuren, vastgestelde feiten, openstaande vragen en technische details.
Wees specifiek. Gebruik verleden tijd. Maximaal 400 woorden.
PROMPT
else
<<~PROMPT
Vat dit gespreksverslag samen voor latere referentie.
Bewaar: sleutelbeslissingen, gebruikersvoorkeuren, vastgestelde feiten,
openstaande vragen en technische details. Wees specifiek. Gebruik verleden tijd.
Maximaal 300 woorden.
VERSLAG:
#{transcript}
PROMPT
end
response = ClaudeClient.chat(
system: "Je bent een beknopte gesprekssamenvattor. Geef alleen de samenvattingstekst terug.",
messages: [{ role: "user", content: prompt }],
max_tokens: 600
)
new_summary = response.content.first.text
Conversation.transaction do
conversation.update!(
summary: new_summary,
summary_token_count: response.usage.input_tokens + response.usage.output_tokens
)
to_compress.update_all(included_in_summary: true)
end
end
end
De transactie rondom de write is niet optioneel. Als update! mislukt, moeten de berichten actief blijven zodat de job veilig opnieuw kan proberen. Berichten als samengevat markeren voordat de samenvatting is opgeslagen is het soort race condition dat stilletjes context verliest.
Het progressieve samenvattingspatroon hierboven — de bestaande samenvatting verwerken in plaats van opnieuw samenvatten vanaf nul — houdt de kosten lineair. Elke samenvattingsronde kost ruwweg 1-2K tokens om een samenvatting van 300-400 woorden te produceren. Vergelijk dat met de 15-25K tokens die je bespaart op elk volgend verzoek door die ruwe berichten niet mee te sturen.
Per-gebruiker Geheugen dat Over Gesprekken Heen Bewaard Blijft
Contextbeheer op gespreksniveau lost één probleem op. Een apart probleem: gebruikers verwachten dat een AI-assistent dingen onthoudt over sessies heen. Hun favoriete programmeertaal. De stack die ze draaien. Dat ze vorige week al hun factureringssituatie hadden uitgelegd.
Dit is geheugen op gebruikersniveau, niet gespreksgeschiedenis, en dat verdient een apart model:
create_table :user_memories do |t|
t.references :user, null: false
t.string :key, null: false # "preferred_language", "tech_stack", etc.
t.text :value, null: false
t.datetime :last_accessed_at
t.timestamps
end
add_index :user_memories, [:user_id, :key], unique: true
Extraheer feiten uit gesprekken als achtergrondtaak:
# app/services/memory_extractor.rb
class MemoryExtractor
EXTRACTION_PROMPT = <<~PROMPT
Extraheer sleutel-waardeparen die het waard zijn te onthouden over deze gebruiker voor toekomstige gesprekken.
Focus op voorkeuren, feiten en technische context — niet op conversatiedetails.
Geef alleen een JSON-reeks terug: [{"key": "snake_case_naam", "value": "één zin feit"}]
Geef [] terug als er niets de moeite waard is om te extraheren.
PROMPT
def self.extract(conversation)
recent = conversation.messages
.where(included_in_summary: false)
.order(position: :desc)
.limit(10)
.map(&:to_api_format)
response = ClaudeClient.chat(
system: "Je extraheert gebruikersfeiten voor geheugenopslag. Geef alleen JSON terug.",
messages: recent + [{ role: "user", content: EXTRACTION_PROMPT }],
max_tokens: 400
)
JSON.parse(response.content.first.text)
rescue JSON::ParserError
[]
end
end
Upsert de geëxtraheerde feiten:
MemoryExtractor.extract(conversation).each do |fact|
next unless fact["key"].present? && fact["value"].present?
conversation.user.user_memories.upsert(
{ key: fact["key"].to_s.strip,
value: fact["value"].to_s.strip,
last_accessed_at: Time.current },
unique_by: [:user_id, :key]
)
end
En verwerk de meest relevante herinneringen in de systeemprompt:
def system_prompt_for(user)
memories = user.user_memories
.order(last_accessed_at: :desc)
.limit(15)
.map { |m| "- #{m.key}: #{m.value}" }
.join("\n")
base = "Je bent een behulpzame assistent."
return base if memories.blank?
<<~PROMPT
#{base}
Wat je weet over deze gebruiker:
#{memories}
Verwijs naar deze context wanneer relevant. Herhaal het niet uit jezelf.
PROMPT
end
Multi-Tenant Toegangsbeheer
Als je een SaaS-applicatie bouwt, is gespreksgeschiedenis persoonsgegevens, en de toegangsbeheerfout is eenvoudig te maken:
# Fout — gebroken toegangsbeheer
def show
@conversation = Conversation.find(params[:id])
end
# Correct — altijd afbakenen tot current_user
def show
@conversation = current_user.conversations.find(params[:id])
end
De afgebakende query genereert WHERE user_id = ? AND id = ?. Een niet-geautoriseerde gebruikers-ID geeft 404 terug. Zonder de afbakening kan elke geauthenticeerde gebruiker elk gesprek lezen door ID’s te raden.
Als je applicatie ook PostgreSQL row-level security implementeert, zijn gesprekken een natuurlijke kandidaat voor een RLS-beleid — de database hangt de tenantgrens af in plaats van te vertrouwen op toepassingslaagafbakeningen.
Geschiedenis Efficiënt Laden
Twee afzonderlijke queries om apart te optimaliseren:
Voor de API context array — je wilt alleen niet-samengevatte berichten, geladen op volgorde. De samengestelde index op (conversation_id, included_in_summary) maakt dit een smalle indexscan in plaats van een tabelscan. Bevestig dat deze wordt gebruikt met EXPLAIN ANALYZE op een gesprek met honderden berichten.
Voor UI-weergave — je wilt gepagineerde geschiedenis inclusief samengevatte berichten, voor de gespreksweergave. Pagineer met een cursor op position in plaats van OFFSET, zeker zodra gesprekken in de honderden rijen lopen:
# cursorgebaseerde paginering op position
def messages_before(position:, limit: 30)
messages.where("position < ?", position).order(position: :desc).limit(limit)
end
Voor de tokenschatting, vermijd het laden van alle berichtrijen alleen om tokens te tellen:
def active_token_estimate
messages
.where(included_in_summary: false)
.pick(Arel.sql(
"COALESCE(SUM(input_tokens), 0) + COALESCE(SUM(output_tokens), 0)"
))
.to_i + summary_token_count.to_i
end
Verbinding met Kostentracking en RAG
Als je al LLM-kosten per tenant bijhoudt, voeden de input_tokens en output_tokens kolommen op messages rechtstreeks in dat systeem. Een eenvoudige GROUP BY geeft je kosten per gesprek; optelling per gebruiker of organisatie is nog één aggregatie verder.
Als je pgvector hebt opgezet voor RAG-retrieval, overweeg dan om een vectorinbedding van elk bericht op te slaan. In plaats van altijd de N meest recente berichten te laden, kun je de K meest semantisch relevante vorige uitwisselingen ophalen voor de huidige query en daarna de laatste 3-4 beurten toevoegen voor recentheid. Deze hybride aanpak — semantische retrieval plus recentievenster — is bijzonder effectief voor ondersteuningsapplicaties.
De Persistentielaag Testen
De foutmodi die de moeite waard zijn om te dekken in je testpakket:
# test/models/conversation_test.rb
class ConversationTest < ActiveSupport::TestCase
test "context_messages voegt samenvattingspreamble toe wanneer samenvatting aanwezig is" do
conversation = conversations(:with_summary)
active_count = conversation.messages.where(included_in_summary: false).count
context = conversation.context_messages
assert_equal active_count + 2, context.length
assert_equal "user", context.first[:role]
assert_includes context.first[:content], conversation.summary
end
test "context_messages zonder samenvatting geeft alle berichten in positievolgorde terug" do
conversation = conversations(:fresh)
context = conversation.context_messages
assert_equal conversation.messages.count, context.length
assert_equal conversation.messages.first.content, context.first[:content]
end
test "next_position is altijd groter dan huidig maximum" do
conversation = conversations(:fresh)
max_before = conversation.messages.maximum(:position)
assert_operator conversation.next_position, :>, max_before
end
end
# test/jobs/conversation_summarizer_job_test.rb
class ConversationSummarizerJobTest < ActiveJob::TestCase
test "markeert gecomprimeerde berichten als included_in_summary" do
conversation = conversations(:long)
stub_claude_response("Een duidelijke samenvatting van het gesprek.")
ConversationSummarizerJob.perform_now(conversation.id)
conversation.reload
assert conversation.summary.present?
assert_operator conversation.messages.where(included_in_summary: true).count, :>, 0
end
test "draait terug als de samenvattingsupdate mislukt" do
conversation = conversations(:long)
Conversation.any_instance.stubs(:update!).raises(ActiveRecord::RecordInvalid)
assert_no_changes -> { conversation.messages.where(included_in_summary: true).count } do
assert_raises(ActiveRecord::RecordInvalid) do
ConversationSummarizerJob.perform_now(conversation.id)
end
end
end
end
Veelgestelde Vragen
Hoeveel berichten moet ik in het actieve venster bewaren voor ik ga samenvatten?
Een praktische basislijn: activeer samenvatting wanneer actieve berichten 20K tokens overschrijden en comprimeer de oudste 20 berichten per keer. Dit houdt het actieve venster beheersbaar terwijl je in brokken samenvat die groot genoeg zijn om een coherente samenvatting te produceren. Als jouw berichten typisch kort zijn, kun je een groter berichtenaantal gebruiken. Als berichten lange codeblokken of documentplakken bevatten, stel een lagere drempel in.
Moet ik het LLM gebruiken om samen te vatten, of kan ik extractieve technieken gebruiken?
Gebruik het LLM. Extractieve samenvatting — de meest “belangrijke” zinnen kiezen — behoudt de oppervlaktbewoordingen maar verliest redeneerketens, genomen beslissingen en de causale draad van het gesprek. Een door het model gegenereerde abstractieve samenvatting legt vast wat er is besloten en waarom, niet alleen wat er is gezegd. De kosten zijn klein: een samenvatting van 300 woorden genereren kost doorgaans minder dan 1K uitvoertokens, en je bespaart 15-25K invoertokens op elk volgend verzoek.
Hoe ga ik om met gesprekken die bestandsbijlagen of afbeeldingen bevatten?
Sla bijlagen afzonderlijk op met Active Storage en verwijs er vanuit het bericht naar in plaats van de ruwe bytes in te sluiten. Voor afbeeldingen in het actieve venster, geef het vision API-formaat door. Voor afbeeldingen in samengevatte berichten, instrueer de summarizer om de afbeelding in proza te beschrijven — “Gebruiker deelde een screenshot van een Postgres EXPLAIN ANALYZE-uitvoer die een sequentiële scan op de orders-tabel toont” — zodat de beschrijving de relevante informatie meeneemt zonder het bestand opnieuw bij te voegen.
Werkt dit schema met streaming responses?
Ja. Streaming en persistentie zijn onafhankelijke zorgen. Verzamel de volledige gestreamde respons in het geheugen en schrijf vervolgens één messages-record zodra de stream voltooid is. De streamingmechanismen — SSE, Turbo Streams — veranderen niet. Zie het bericht over streaming LLM-responses met Action Controller Live voor de renderkant.
Een LLM-geïntegreerde Rails-applicatie bouwen die daadwerkelijk een gesprek kan voeren? TTB Software bouwt en schaalt productie-AI-functionaliteiten — geen demo’s. We doen dit al negentien jaar, en de laatste drie jaar zijn er steeds meer AI-gedreven.
Related Articles
Rails API Serialisatie: Blueprinter, Alba en JSONAPI-Serializer Vergeleken voor Productie-API's
Rails API serialisatie goed aanpakken: vergelijk Blueprinter, Alba en jsonapi-serializer met echte code, N+1-valkuile...
Rails Data Migrations: Veilige Backfills met data-migrate, Maintenance Tasks en Batched Updates
Rails data migrations correct uitvoeren: gebruik data-migrate of maintenance_tasks voor veilige, hervatbare backfills...
Rails Timeouts: Statement, Rack, HTTP Client en Job Timeouts die Productiecascades Voorkomen
Rails timeouts goed geregeld: configureer statement_timeout, rack-timeout, HTTP client en background job limieten om ...