LLM Function Calling in Rails: De Model Leren Je App te Gebruiken
Een klant came vorig jaar bij me met een vertrouwde briefing: “We willen AI toevoegen aan ons CRM.” Ik verwachtte dat ze een chatbot wilden. Wat ze eigenlijk nodig hadden was een LLM dat hun klantgegevens kon opvragen, vervolgafspraken kon aanmaken, e-mails kon versturen vanuit sjablonen en accountgeschiedenis kon samenvatten—allemaal via bestaande Rails-modellen.
De technologie die dit mogelijk maakt heet function calling (de term van OpenAI) of tool use (de term van Anthropic). Het idee is eenvoudig: je beschrijft de mogelijkheden van je applicatie aan het model in gestructureerde JSON, en wanneer het model iets wil doen, vertelt het je welke functie het wil aanroepen en met welke argumenten—in plaats van een antwoord te verzinnen of ongestructureerde tekst te produceren die je zelf moet ontleden.
Na het bouwen van meerdere van deze integraties in productie Rails-apps heb ik sterke meningen over wat werkt en wat niet.
Wat Function Calling Eigenlijk Is
Wanneer je de OpenAI- of Anthropic-API normaal aanroept, genereert het model tekst. Met tool use kan het gesprek halverwege pauzeren: het model geeft een gestructureerde tool-aanroep terug, jouw code voert iets echts uit, jij geeft het resultaat terug, en het model gaat verder.
Hier is het eenvoudigste voorbeeld met de ruby-openai gem:
client = OpenAI::Client.new(access_token: ENV["OPENAI_API_KEY"])
tools = [
{
type: "function",
function: {
name: "get_customer",
description: "Look up a customer record by email address",
parameters: {
type: "object",
properties: {
email: {
type: "string",
description: "The customer's email address"
}
},
required: ["email"]
}
}
}
]
response = client.chat(
parameters: {
model: "gpt-4o",
messages: [
{ role: "user", content: "What's the status of roger@example.com's account?" }
],
tools: tools,
tool_choice: "auto"
}
)
Als het model besluit dat het klantgegevens nodig heeft, bevat response.dig("choices", 0, "message", "tool_calls") een tool-aanroep in plaats van (of naast) tekst. Jouw taak is om dat te detecteren, door te sturen naar je eigen code, en de loop voort te zetten.
De Dispatch-loop
Het interessante deel is de loop. Eén gebruikersbericht kan meerdere tool-aanroepen vereisen—het model raadpleegt de klant, zoekt daarna recente bestellingen op, en besluit vervolgens een vervolgafspraak aan te maken. Je moet dat allemaal afhandelen.
class AiCrmService
TOOLS = [
{
type: "function",
function: {
name: "get_customer",
description: "Look up a customer by email address. Returns account status, plan, and MRR.",
parameters: {
type: "object",
properties: { email: { type: "string" } },
required: ["email"]
}
}
},
{
type: "function",
function: {
name: "create_task",
description: "Create a follow-up task for a customer. Use when the user asks to schedule or remember something.",
parameters: {
type: "object",
properties: {
customer_id: { type: "integer" },
description: { type: "string" },
due_date: { type: "string", description: "ISO 8601 date, e.g. 2026-04-15" }
},
required: ["customer_id", "description", "due_date"]
}
}
}
].freeze
def initialize(user)
@user = user
@client = OpenAI::Client.new(access_token: ENV["OPENAI_API_KEY"])
@messages = []
end
def run(user_message)
@messages << { role: "user", content: user_message }
loop do
response = @client.chat(
parameters: {
model: "gpt-4o",
messages: @messages,
tools: TOOLS,
tool_choice: "auto"
}
)
assistant_message = response.dig("choices", 0, "message")
@messages << assistant_message
tool_calls = assistant_message["tool_calls"]
break unless tool_calls
tool_calls.each do |tool_call|
result = dispatch(tool_call)
@messages << {
role: "tool",
tool_call_id: tool_call["id"],
content: result.to_json
}
end
end
@messages.last["content"]
end
private
def dispatch(tool_call)
name = tool_call.dig("function", "name")
args = JSON.parse(tool_call.dig("function", "arguments"))
case name
when "get_customer"
customer = @user.accessible_customers.find_by(email: args["email"]&.downcase&.strip)
return { error: "No customer found with that email" } unless customer
customer_as_tool_result(customer)
when "create_task"
customer = @user.accessible_customers.find(args["customer_id"])
task = Task.create!(
customer: customer,
description: args["description"],
due_date: Date.parse(args["due_date"]),
created_by: @user
)
{ task_id: task.id, created: true }
else
{ error: "Unknown tool: #{name}" }
end
end
def customer_as_tool_result(customer)
{
id: customer.id,
name: customer.full_name,
email: customer.email,
plan: customer.plan_name,
mrr: customer.monthly_revenue_cents / 100.0,
status: customer.status,
created_at: customer.created_at.to_date.iso8601,
open_tasks_count: customer.tasks.open.count
}
end
end
Dit is het kernpatroon. Al het andere is verfijning.
Houd Je Tool-beschrijvingen Scherp
Het model beslist welke tools het aanroept op basis van jouw beschrijvingen. Vage beschrijvingen leiden tot verkeerde keuzes, of het model beantwoordt vragen vanuit trainingsdata in plaats van je functie aan te roepen.
Slecht:
{
"name": "get_data",
"description": "Gets data from the system"
}
Goed:
{
"name": "get_customer_orders",
"description": "Retrieve recent orders for a customer. Returns order ID, date, total, and status. Use this when asked about purchase history, recent transactions, or order status.",
"parameters": {
"properties": {
"customer_id": {
"type": "integer",
"description": "The internal customer ID (not email address)"
},
"limit": {
"type": "integer",
"description": "Maximum orders to return. Default 10, maximum 50."
}
},
"required": ["customer_id"]
}
}
Specificiteit in de beschrijving bespaart je het compenseren in de systeemprompt. Schrijf beschrijvingen alsof je een publieke API documenteert—want vanuit het perspectief van het model is dat precies wat je doet.
Beperk Wat Je Teruggeeft
Een veelgemaakte fout: hele ActiveRecord-objecten of grote resultaatsets teruggeven als tool-output. Het model heeft geen 47 kolommen en 500 rijen nodig. Elke byte in het tool-resultaat gaat terug het contextvenster in en kost geld.
Wees bewust over wat je blootstelt. De customer_as_tool_result-methode hierboven is doelbewust—hij geeft precies terug wat het model nodig heeft om vragen over een klant te beantwoorden, en niets wat het niet zou moeten zien.
Dit voorkomt ook dat je per ongeluk gevoelige velden lekt. customer.attributes teruggeven werkt totdat het dat niet meer doet.
Foutafhandeling en het Leugenachtige Model
Het model geeft soms argumenten door die niet overeenkomen met je data. Een klant-ID dat niet bestaat. Een datum in het verkeerde formaat. Een e-mailadres met een typfout. Je dispatch-methode moet dit netjes afhandelen en nuttige foutinformatie teruggeven:
when "get_customer"
return { error: "Email parameter is required" } if args["email"].blank?
email = args["email"].downcase.strip
customer = @user.accessible_customers.find_by(email: email)
unless customer
domain = email.split("@").last
suggestions = @user.accessible_customers
.where("email ILIKE ?", "%#{domain}%")
.limit(3)
.pluck(:email, :full_name)
return {
error: "No customer found with email #{email}",
suggestions: suggestions.map { |e, n| { email: e, name: n } }
}
end
customer_as_tool_result(customer)
Wanneer je een fout teruggeeft in het tool-resultaat, probeert het model doorgaans te herstellen—het gebruikt de suggesties, probeert een andere aanpak, of vraagt de gebruiker om verduidelijking. Dit werkt verrassend goed in de praktijk.
Autorisatie Is Jouw Verantwoordelijkheid, Niet Die van het Model
Je tool-dispatch draait met de autorisatiecontext die jij meegeeft. Het model heeft geen begrip van je permissiemodel. Het vraagt vrolijk data op voor klanten die je gebruiker niet mag zien, als je het toestaat.
Beperk elke query tot de records die toegankelijk zijn voor de huidige gebruiker—zoals de dispatch-methode hierboven doet met @user.accessible_customers. Voeg geen autorisatiecontrole achteraf toe. Bouw het in de query.
Dit is belangrijker dan het klinkt. Als je het model gebruikersgegenereerde content voert—klantnotities, supporttickets, inkomende e-mails—kan een kwaadwillende gebruiker tekst schrijven die het model instrueert acties te ondernemen die het niet zou moeten ondernemen. Dit is prompt injection, en het is een echte aanvalsvector in productiesystemen.
Log elke tool-aanroep met het gebruikers-ID, de toolnaam en de argumenten. Als er iets misgaat, wil je precies weten wat het model gevraagd werd te doen en wat het deed.
Achtergrondverwerking voor Langzame Tool-ketens
Een reeks van vijf tool-aanroepen kan 10-15 seconden duren. Houd een HTTP-verzoek daar niet voor open.
Verplaats het werk naar een achtergrondtaak:
class AiCrmJob < ApplicationJob
queue_as :default
def perform(user_id, message, conversation_id)
user = User.find(user_id)
conversation = Conversation.find(conversation_id)
service = AiCrmService.new(user)
result = service.run(message)
conversation.update!(response: result, status: "complete")
ActionCable.server.broadcast("conversation_#{conversation_id}", {
type: "response",
content: result
})
end
end
De controller plaatst de taak in de wachtrij en geeft direct een conversation_id terug. De client abonneert zich op het ActionCable-kanaal en ontvangt het resultaat wanneer de taak klaar is. Het model kan zo lang doen als nodig zonder een HTTP-verbinding te laten verlopen.
Laad Niet Alle Tools bij Elk Verzoek
Definieer 40 tools en voeg ze allemaal toe aan elk verzoek, en je betaalt tokens voor tools die het model in die context nooit zal gebruiken. Je vergroot ook de kans op het selecteren van de verkeerde tool.
Laad alleen de tools die relevant zijn voor de huidige context:
def tools_for_context(context)
base = [LOOKUP_CUSTOMER_TOOL]
case context
when "crm"
base + [CREATE_TASK_TOOL, SEND_EMAIL_TOOL, UPDATE_STATUS_TOOL]
when "reporting"
base + [GENERATE_REPORT_TOOL, EXPORT_CSV_TOOL]
else
base
end
end
Het model hoeft niets te weten over export_csv wanneer de gebruiker naar een klantprofiel kijkt.
Wat Anthropic Claude Anders Doet
Als je Claude gebruikt—wat ik verkies voor langere gesprekken met complexe tool-ketens; Claudes instructietrouw bij tool use is bijzonder betrouwbaar—is de API-structuur iets anders:
require "anthropic"
client = Anthropic::Client.new(api_key: ENV["ANTHROPIC_API_KEY"])
response = client.messages(
model: "claude-opus-4-6",
max_tokens: 4096,
tools: [
{
name: "get_customer",
description: "Look up a customer by email address",
input_schema: {
type: "object",
properties: { email: { type: "string" } },
required: ["email"]
}
}
],
messages: @messages
)
# Tool use komt terug als een content-blok met type "tool_use"
tool_use_blocks = response.content.select { |block| block.type == "tool_use" }
tool_use_blocks.each do |block|
result = dispatch_by_name(block.name, block.input)
@messages << {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: block.id,
content: result.to_json
}
]
}
end
De dispatch-logica is identiek. De API-structuur verschilt. De fundamentele loop—berichten versturen, controleren op tool-aanroepen, doorsturen, resultaten teruggeven, doorgaan—is hetzelfde bij alle aanbieders.
De Eerlijke Grenzen
Function calling is krachtig. Het is geen magie.
Het model begrijpt je domein niet. Het begrijpt je tool-beschrijvingen. Als je beschrijving zegt dat create_task een taak aanmaakt en je code ook een e-mail verstuurt, heeft het model geen idee. Houd tools single-responsibility.
Het model zal soms onnodig tools aanroepen. Het zal soms nalaten tools aan te roepen wanneer het dat zou moeten. Test met echte, rommelige gebruikersinput—niet met geïdealiseerde prompts die jij zelf hebt geschreven.
Latentie stapelt op. Elke tool-aanroep voegt een volledige LLM-rondtrip toe. Ontwerp je tools zodat het model parallel kan vergaren wat het nodig heeft. Zowel OpenAI (parallel_tool_calls) als Claude (meerdere tool use-blokken in één antwoord) ondersteunen parallelle tool-uitvoering. Gebruik het.
De klant die ik aan het begin noemde, lanceerde na vier weken. De eerste twee weken waren de tool-integraties. De laatste twee weken waren het harden: randgevallen, autorisatie, logging, en zorgen dat salesmensen niet per ongeluk records konden verwijderen door een vraag verkeerd te formuleren.
Die verhouding klopt.
Veelgestelde Vragen
Moet ik OpenAI function calling of Anthropic tool use gebruiken?
Beide werken goed. Claude is doorgaans preciezer bij complexe meerstapsredenering en minder geneigd tool-argumenten te verzinnen. GPT-4o is sneller en goedkoper op schaal voor eenvoudigere flows. Voor verkennende of lange tool-ketens neig ik naar Claude. Voor hoogvolume, goed gedefinieerde integraties is OpenAI prima. Gebruik wat je al productie-ervaring mee hebt.
Hoe voorkom ik prompt injection via tool-resultaten?
Behandel tool-resultaten net zoals gebruikersinput: potentieel vijandig. Als je door de klant geschreven content (notities, e-mails, tickets) opneemt in tool-resultaten of systeemprompts, instrueer het model expliciet dat deze content instructies kan bevatten die genegeerd moeten worden. Echo geen onveilige gebruikersinput terug in systeemprompts.
Hoeveel tool-aanroepen kunnen er in één gesprekstour plaatsvinden?
Er is geen door de API opgelegd limiet, maar contextvensters zijn eindig. In de praktijk: als een enkel gebruikersverzoek meer dan 8-10 tool-aanroepen vereist, zijn je tools te fijnmazig. Groepeer gerelateerde opzoekopdrachten in één tool die rijkere data teruggeeft, in plaats van het model vijf smalle lookups te laten ketenen.
Bouw je een AI-gestuurde feature in Rails en loop je tegen de harde grenzen aan? TTB Software heeft function-calling integraties opgeleverd voor CRM-, logistiek- en analyticsdomeinen. Negentien jaar Rails-ervaring, plus wat het AI-ecosysteem ons deze week ook gooit.
About the Author
Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation