eduardweb.
OpenAI & ClaudeAvansat#typescript#openai#postgres#sql#ai-agents

Function calling cu OpenAI direct pe SQL: cum faci un agent fără să-ți omori baza

De Mihai Popescu, 14 aug. 2026 · 5 vizualizări · 2 like-uri

Postat acum 3 zile
typescript
import OpenAI from "openai";
import { Pool } from "pg";

const openai = new OpenAI();
const readOnlyPool = new Pool({ connectionString: process.env.DATABASE_READONLY_URL });

const tools: OpenAI.ChatCompletionTool[] = [{
  type: "function",
  function: {
    name: "execute_readonly_sql",
    description: "Rulează interogări SELECT pe baza de date de comenzi. Returnează maxim 50 de rânduri.",
    parameters: {
      type: "object",
      properties: {
        query: { type: "string", description: "Query-ul SQL Postgres de tip SELECT" }
      },
      required: ["query"]
    }
  }
}];

export async function askAgent(userPrompt: string) {
  const messages: OpenAI.ChatCompletionMessageParam[] = [
    { role: "system", content: "Ești un asistent de date. Generează doar SELECT-uri optimizate." },
    { role: "user", content: userPrompt }
  ];

  const response = await openai.chat.completions.create({
    model: "gpt-4o",
    messages,
    tools
  });

  const toolCall = response.choices[0].message.tool_calls?.[0];
  if (toolCall && toolCall.function.name === "execute_readonly_sql") {
    const { query } = JSON.parse(toolCall.function.arguments);
    
    // Guard de siguranță la nivel de aplicație
    if (!query.trim().toLowerCase().startsWith("select")) {
      throw new Error("Doar operațiuni SELECT sunt permise.");
    }

    const client = await readOnlyPool.connect();
    try {
      await client.query("SET statement_timeout = '3000ms'");
      const { rows } = await client.query(query);
      
      messages.push(response.choices[0].message);
      messages.push({
        role: "tool",
        tool_call_id: toolCall.id,
        content: JSON.stringify(rows.slice(0, 50))
      });

      const finalResponse = await openai.chat.completions.create({
        model: "gpt-4o",
        messages
      });
      return finalResponse.choices[0].message.content;
    } finally {
      client.release();
    }
  }
  return response.choices[0].message.content;
}

Am implementat acum două luni un agent intern pentru echipa de operațiuni pe un Postgres cu vreo 180k de comenzi și câteva tabele destul de stufoase. Scopul a fost simplu: să nu mai scrie devii query-uri ad-hoc de fiecare dată când cineva din management voia să știe „câte retururi am avut pe categoria X în weekend”.

Mulți încearcă abordarea naivă de text-to-SQL direct în prompt. Îi dai schema în system message și îi ceri să scuipe un SELECT. Am încercat și eu la început; e o rețetă sigură pentru dezastru.

De ce Function Calling și nu prompt injection direct

Când lași modelul să scrie direct SQL într-un string liber, nu ai niciun control pe tipurile de date, parser-ul crapă la ghilimele și ești la un pas de un prompt injection prin care un user deștept extrage date din users sau auth_tokens.

Prin Function Calling (sau Tools în API-ul recent OpenAI), modelul nu generează text brut destinat execuției directe, ci construiește un payload JSON structurat. Tu decizi ce funcții expui: fie funcții granulare (get_orders_by_date, calculate_revenue), fie un runner de SQL strict restricționat.

Eu am mers pe varianta unui runner de query-uri dinamice, dar cu trei bariere stricte de siguranță:

  1. User de DB cu rol READ ONLY dedicat. Fără drept de INSERT, UPDATE, DROP sau acces la tabele de configurare/parole.
  2. Statement timeout agresiv. Maxim 3000ms per interogare. Dacă modelul halucinează un CROSS JOIN fără WHERE, baza îi taie macaroana imediat.
  3. Sanitizare prin EXPLAIN. Înainte să rulez query-ul propus de OpenAI, trec comanda printr-un EXPLAIN ca să verific dacă nu face scan complet pe tabele de milioane de rânduri.

Pattern-ul buclei de conversație

Agentul are nevoie de o buclă (while loop) până când decide că are toate datele pentru a răspunde userului.

Fluxul e următorul: trimiți întrebarea userului împreună cu definiția uneltei (run_sql_query). Dacă modelul cere apelarea funcției (tool_calls), iei argumentul JSON, rulezi query-ul pe Postgres prin conexiunea read-only, iei rezultatul (limitat la max 50 rânduri ca să nu depășești contextul) și îl trimiți înapoi ca mesaj cu rol tool.

Modelul reia execuția, analizează JSON-ul primit din baza ta și formulează răspunsul în limbaj natural.

Unde crapă treaba: trade-off-uri reale

Să fim sinceri, soluția nu e un glonț de argint.

La tabele cu peste 30 de coloane și denumiri criptice (c_stat_01, flg_usr), modelul ghicește greșit relațiile în 20-30% din cazuri dacă nu îi trimiți un context semantic clar. Iar dacă adaugi descrieri detaliate pentru 15 tabele în schema uneltei, adaugi 2.000 de tokeni la fiecare request. La un volum de 500 de întrebări pe zi, costul se simte.

Un alt minus e latența: ai două hop-uri către OpenAI (unul pentru generat tool call, altul pentru interpretat răspunsul) plus execuția SQL-ului. Răspunsul vine în 2.5 - 4 secunde, ceea ce e perfect pentru un Slack bot intern, dar inacceptabil pentru o interfață directă de client-facing dashboard.

Pentru rapoarte interne și analytics rapid, pattern-ul ăsta ne-a scutit de cel puțin 4-5 ore pe săptămână de tichete stupide de date.

Voi ați lăsat LLM-uri să execute SQL direct pe baze de producție sau preferați să expuneți doar endpoint-uri REST predefinite ca unelte?

Răspunsuri 0

Se încarcă răspunsurile…

Loghează-te pentru a răspunde

Doar membrii comunității pot lăsa comentarii.