Al març vaig escriure sobre per què vam posar un agent d'IA a WhatsApp per a Lueira: l'aposta de producte que una escola d'esports de muntanya no hauria de perdre una reserva només perquè són les onze de la nit d'un dimarts i no hi ha ningú al taulell. Aquell article tractava sobre el perquè: la recerca de clients, la tria de canal, la qüestió de la confiança. Aquest tracta sobre el com, i és la part que de veritat em va treure la son: aconseguir que un model de llenguatge comprovi disponibilitat real, creï una reserva real i enviï un enllaç de pagament real, sense deixar-lo acostar-se a la lògica atòmica d'instructor-més-material-més-franja horària que vam construir per al motor de reserves allà pel juny de 2023. L'agent havia de convertir-se en un client nou d'aquell motor, no en una nova font de veritat.
1. Donar al model un conjunt petit d'eines, no via lliure
La primera decisió, i la que condiciona tot el que ve després, és negar-se a deixar que el model generi accions en text lliure i després parsejar-les. És temptador: el model retorna alguna cosa com «reserva el dimarts a les 10 amb Marc per a dues persones» i escrius una expressió regular per convertir això en una escriptura a base de dades. He vist aquest patró en prou demos per saber exactament com falla en producció: dates ambigües, noms d'instructors inventats, ids que no existeixen, formats que es desvien cada cop que toques el prompt.
El que fem en el seu lloc és definir un conjunt petit i explícit de funcions que el model pot invocar —check_availability, create_booking, send_payment_link— cadascuna amb un esquema JSON estricte. L'única feina del model és entendre què vol el client en llenguatge natural i traduir-ho en una crida ben tipada a una d'aquestes funcions. No escriu SQL, no inventa un id de franja, i no decideix pel seu compte que una reserva està confirmada. Una versió simplificada de l'esquema de create_booking té aquest aspecte:
{
"name": "create_booking",
"description": "Crea una reserva per a una franja, instructor i material concrets. Retorna un id de reserva i un enllaç de pagament, o un error de conflicte si la franja ja no està disponible.",
"parameters": {
"type": "object",
"properties": {
"school_id": { "type": "string" },
"activity_id": { "type": "string" },
"slot_id": {
"type": "string",
"description": "Identificador opac retornat per check_availability. El model no pot inventar aquest valor."
},
"customer": {
"type": "object",
"properties": {
"name": { "type": "string" },
"phone": { "type": "string" },
"email": { "type": "string" }
},
"required": ["name", "phone"]
},
"party_size": { "type": "integer", "minimum": 1 }
},
"required": ["school_id", "activity_id", "slot_id", "customer", "party_size"]
}
}
Fixa't en el camp slot_id. És un identificador opac que només existeix perquè check_availability el va retornar moments abans: el model no en pot construir un del no-res, i no pot convertir en silenci «les 10 del dimarts» en una franja sense que hi hagi pel mig una consulta de disponibilitat real. Aquesta única decisió de disseny elimina tota una categoria de reserves al·lucinades abans fins i tot d'arribar a la validació.
2. L'agent no es pot saltar la cua
Tenir una crida a eina tipada no vol dir que hi confiem. El model pot estar extremadament segur i tot i així equivocar-se: sobre quin instructor està realment lliure, sobre si el material encara és en estoc, sobre si algú altre ha reservat la mateixa franja fa quatre segons en un altre fil de WhatsApp diferent. Així que cada crida a eina que el model proposa passa exactament per la mateixa lògica de validació i transacció que passa una reserva normal feta des de la web: la mateixa comprovació atòmica d'instructor, material i franja junts que vam descriure en dissenyar el motor de reserves. L'LLM proposa; el motor disposa.
En codi, aquesta frontera es veu més o menys així:
func handleCreateBooking(ctx context.Context, call ToolCall) (ToolResult, error) {
var args CreateBookingArgs
if err := json.Unmarshal(call.Arguments, &args); err != nil {
return ToolResult{}, fmt.Errorf("invalid arguments from model: %w", err)
}
// La feina del model acaba aquí. A partir d'aquest punt, la mateixa
// transacció atòmica de comprovar-i-reservar que fa servir el flux
// de reserva web és l'única cosa que pot tocar l'estat de les reserves.
booking, err := bookingEngine.CreateBooking(ctx, CreateBookingInput{
SlotID: args.SlotID,
Customer: args.Customer,
PartySize: args.PartySize,
})
if errors.Is(err, ErrSlotNoLongerAvailable) {
return ToolResult{
Content: "Aquesta franja ja no està disponible.",
Retryable: true,
}, nil
}
if err != nil {
return ToolResult{}, err
}
return ToolResult{
BookingID: booking.ID,
PaymentLink: booking.PaymentLink,
}, nil
}
El comentari d'aquest handler resumeix tota la filosofia del projecte: a partir del moment en què el model lliura una crida estructurada, queda fora del cicle. Res del que diu és prou fiable com per tocar l'estat de les reserves directament. Si això sembla molta cerimònia per al que sembla un chatbot, ho és, perquè el chatbot no és el que importa. La reserva sí.
3. Quan la franja desapareix a mig conversa
Com que revalidem tot, també hem de gestionar el cas que intentàvem evitar: la franja que el model va suggerir fa cinc segons ha desaparegut quan create_booking s'executa de veritat. Algú l'ha agafat des de l'app, o una altra conversa de WhatsApp s'ha avançat a aquesta. En una integració ingènua això torna com un error, i un error en una interfície de xat és un carreró sense sortida: el client només veu «alguna cosa ha anat malament» i ha de començar de nou, normalment frustrant-se i trucant directament a l'escola, cosa que trenca tot el sentit d'haver construït això.
Així que el resultat de l'eina create_booking no és només èxit o fracàs: porta prou estructura perquè l'agent es recuperi amb elegància. Quan el motor de reserves retorna un conflicte, la resposta de l'eina diu al model que la franja ja no està disponible i li retorna una nova crida a check_availability amb els mateixos paràmetres. El següent missatge del model al client es genera llavors a partir de dades reals i actuals —«aquella franja de les 10 amb Marc s'acaba d'ocupar, però té lliures les 11:30 i les 15:00 avui»— en lloc d'una disculpa genèrica. El client mai no veu el reintent; només veu un agent que sembla saber què està passant en temps real. Aquest comportament no és el model sent llest. És el resultat directe de donar-li una fallada estructurada sobre la qual pot actuar, en lloc d'una traça d'error que no pot.
4. Converses que sobreviuen a un forat de tres hores
L'altra cosa que trenca els agents de qualitat demo en producció és assumir que una conversa és un únic cicle de petició-resposta, com una sessió web. WhatsApp no funciona així. Un client pot preguntar per disponibilitat a les 9 del matí, distreure's, i respondre al migdia des d'un altre telèfon després de canviar de dispositiu. Des del punt de vista del model, això ha de semblar una conversa contínua, encara que res en el transport garanteixi aquesta continuïtat.
A la pràctica això vol dir que l'estat que l'agent necessita —què ha preguntat ja el client, quina franja es va discutir per última vegada, si un enllaç de pagament encara és pendent— no pot viure en memòria lligada a un únic procés que gestiona un missatge. S'ha de reconstruir a partir d'un historial de conversa persistent, indexat per la identitat de WhatsApp del client, no per cap sessió concreta. Cada vegada que arriba un missatge, reconstruïm prou context perquè el model reprengui exactament on es van deixar les coses, incloent-hi quin és de veritat l'estat del motor de reserves ara mateix, no el que tenia fa tres hores. Aquest últim detall importa més del que sembla: si donéssim al model una fotografia de disponibilitat de fa tres hores, tornaríem exactament a fiar-nos d'informació obsoleta, justament el que vam decidir no fer. La conversa és recuperable; la disponibilitat que hi ha dins no és una cosa que emmagatzemem en memòria cau i reutilitzem: es torna a comprovar, cada vegada, contra el motor en viu.
Res d'això fa més llest l'agent. Si de cas, tot el disseny és un exercici per assegurar-se que no ho necessiti ser. L'única contribució del model és convertir «puc reservar una classe amb Marc el proper dimarts al matí» en una pregunta estructurada que el motor de reserves ja sap respondre de manera segura. Tot el que de veritat mou diners o reté una plaça continua passant per les mateixes garanties que vam construir per al flux web. Aquest era l'objectiu des del principi: l'agent és una nova manera de parlar amb Lueira, no una nova manera d'esquivar-lo.
