SELECT AI no Oracle 23ai e 26ai: controlando o consumo de tokens por schema

Compartilhar
SELECT AI no Oracle 23ai e 26ai: controlando o consumo de tokens por schema

🇬🇧 Read this post in English: SELECT AI on Oracle 23ai and 26ai: Tracking Token Usage per Schema

"Quem foi que gastou tudo isso?"

Se você já operou um ambiente com múltiplos times usando o mesmo banco de dados, sabe como essa pergunta aparece. Começa com uma linha de tablespace não contabilizada, depois é um job que "ninguém sabe de quem é", e agora — na era do SELECT AI — vai ser uma fatura de OCI Generative AI maior do que o esperado, com zero visibilidade de qual schema foi o responsável.

O SELECT AI é uma baita ferramenta.

No post anterior mostrei como configurar o profile, comentar o schema e fazer ociclo showsql → runsql → narrate funcionar. Mas aquele exemplo tinha um único usuário, um único profile, e ninguém dividindo conta.

A realidade de quem vai colocar SELECT AI em produção de verdade é diferente: vários schemas, várias aplicações, cada uma com seu contexto de negócio e seu profile — e, no fim do mês, a necessidade de saber quem consumiu o quê. Para chargeback, para capacity planning, para identificar o schema que resolveu fazer narrate em cima de uma tabela inteira às 3h da manhã (é raro, mas acontece muito! 😄).

Este post mostra como construir essa camada de rastreamento do zero. O lab foi feito no ADB 23ai na OCI e as diferenças para o Oracle 26ai on-premises estão destacadas em notas ao longo do texto.


O que o banco oferece nativamente

No ADB 23ai, o DBMS_CLOUD_AI expõe um conjunto de views para rastreamento de conversas e prompts. Vale conhecer cada uma antes de decidir o que usar:

View

Escopo

USER_CLOUD_AI_CONVERSATIONS

Conversas do schema conectado

USER_CLOUD_AI_CONVERSATION_PROMPTS

Prompts do schema conectado

USER_CLOUD_AI_CONVERSATION_PROMPTS_EXT

Versão estendida dos prompts do schema conectado

SESSION_CLOUD_AI_CONVERSATION_PROMPTS

Prompts da sessão atual (sem filtro de schema)

DBA_CLOUD_AI_CONVERSATIONS

Conversas de todos os schemas (requer privilégio DBA)

DBA_CLOUD_AI_CONVERSATION_PROMPTS

Prompts de todos os schemas (requer privilégio DBA)

ALL_CLOUD_AI_PROFILES

Profiles visíveis ao usuário conectado

DBA_CLOUD_AI_PROFILES

Todos os profiles do banco

A DBA_CLOUD_AI_CONVERSATION_PROMPTS é a mais interessante para o nosso cenário: ela consolida os prompts de todos os schemas em uma única view, acessível ao ADMIN. A estrutura é a seguinte:

Coluna

Nulo?

Tipo

O que registra

CONVERSATION_PROMPT_ID

VARCHAR2(36)

ID único do prompt

CONVERSATION_ID

NOT NULL

VARCHAR2(36)

ID da conversa

CONVERSATION_TITLE

NOT NULL

VARCHAR2(128)

Título da conversa

OWNER

NOT NULL

VARCHAR2(128)

Schema dono da conversa

PROFILE_NAME

VARCHAR2(128)

Profile AI usado

PROMPT_ACTION

VARCHAR2(11)

narrate, runsql, chat, showsql

PROMPT

CLOB

O texto do prompt enviado

PROMPT_RESPONSE

CLOB

A resposta retornada

CREATED

TIMESTAMP(6) WITH TIME ZONE

Quando o prompt foi criado

MODIFIED

TIMESTAMP(6) WITH TIME ZONE

Última modificação do registro

CLIENT_IDENTIFIER

VARCHAR2(128)

Identificador do cliente da sessão

CLIENT_IP

VARCHAR2(128)

IP do cliente da sessão

SID

NUMBER

Identificador da sessão

SERIAL#

NUMBER

Serial da sessão

A coluna OWNER resolve o problema de identificar qual schema originou cada chamada — sem precisar de instrumentação adicional para isso. Parece que o problema está resolvido, certo?

Quase. Há uma limitação que muda tudo: essas views registram apenas chamadas feitas no conversation mode — ou seja, via DBMS_CLOUD_AI.CREATE_CONVERSATION e DBMS_CLOUD_AI.CHAT. Chamadas diretas ao DBMS_CLOUD_AI.GENERATE com action => 'runsql''narrate' ou 'showsql' não aparecem nessas views. Se a sua aplicação usa SELECT AI ou chama GENERATE diretamente — que é o caso mais comum — a DBA_CLOUD_AI_CONVERSATION_PROMPTS ficará vazia.

E mesmo para o conversation mode, persiste o problema mais crítico: não há coluna de tokens. As views registram o prompt e a resposta, mas não quantos tokens foram consumidos. Para custo real e chargeback, você precisa capturar isso na camada de chamada.

> 📝 Oracle 26ai on-premises: nenhuma dessas views existe. A instrumentação é 100% manual desde o início.

A solução que vou mostrar resolve esses problemas com uma abordagem consistente para os dois ambientes: um schema de observabilidade dedicado e um wrapper PL/SQL que os schemas de aplicação chamam em vez do DBMS_CLOUD_AI.GENERATE diretamente.


A arquitetura: um schema para gerenciar todos

A ideia é simples. Criamos um schema, AI_OPS, que é o único com EXECUTE no DBMS_CLOUD_AI. Os schemas de aplicação (APP_VENDAS, APP_RH, qualquer um) não têm acesso direto ao pacote — eles chamam um procedure público do AI_OPS, que executa a chamada ao LLM, captura os metadados e grava o log antes de devolver o resultado.

Esse modelo tem vantagens que vão além do rastreamento: você também ganha um ponto único para trocar o profile ou o modelo sem precisar alterar nada nos schemas de aplicação, e uma barreira natural contra uso não autorizado do LLM.


Passo 1: Criando o schema AI_OPS

Execute como ADMIN no seu ADB:

-- Criação do schema de observabilidade
CREATE USER ai_ops IDENTIFIED BY "&senha_forte"
  DEFAULT TABLESPACE data
  QUOTA UNLIMITED ON data;
  
-- Privilégios mínimos necessários
GRANT CREATE SESSION    TO ai_ops;
GRANT CREATE TABLE      TO ai_ops;
GRANT CREATE PROCEDURE  TO ai_ops;
GRANT CREATE SEQUENCE   TO ai_ops;
GRANT SELECT ON dba_cloud_ai_profile_attributes TO ai_ops;

-- modelo do profile no log
GRANT SELECT ON v$session TO ai_ops; -- captura de SID/SERIAL#
GRANT EXECUTE ON dbms_cloud_ai TO ai_ops; – chamadas ao LLM

-- Habilitar Resource Principal para AI_OPS
-- Dispensa credencial explícita para acessar o OCI GenAI
EXEC DBMS_CLOUD_ADMIN.ENABLE_RESOURCE_PRINCIPAL(username => 'AI_OPS');

Criação do schema de observabilidade

> 📝 Oracle 26ai on-premises: DBMS_CLOUD_ADMIN.ENABLE_RESOURCE_PRINCIPAL não existe. Em vez disso, crie uma credencial de API Key explícita com DBMS_CLOUD.CREATE_CREDENTIAL conectado como AI_OPS, e referencie-a pelo nome nos profiles. Todos os demais grants são idênticos.


Passo 2: A tabela de log

Conectado como AI_OPS:

-- Tabela principal de rastreamento de uso
CREATE TABLE ai_ops.ai_token_log (
  log_id            NUMBER         GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  log_ts            TIMESTAMP      DEFAULT SYSTIMESTAMP NOT NULL,
  caller_schema     VARCHAR2(128)  NOT NULL,
  profile_name      VARCHAR2(128)  NOT NULL,
  action            VARCHAR2(20)   NOT NULL,
  prompt_text       CLOB,
  response_text     CLOB,
  tokens_input      NUMBER,
  tokens_output     NUMBER,
  tokens_total      NUMBER         GENERATED ALWAYS AS (tokens_input + tokens_output) VIRTUAL,
  model_name        VARCHAR2(256),
  duration_ms       NUMBER,
  session_sid       NUMBER,
  session_serial    NUMBER,
  client_identifier VARCHAR2(128),
  client_ip         VARCHAR2(128),
  error_code        NUMBER,
  error_message     VARCHAR2(4000),
  status            VARCHAR2(10)   DEFAULT 'OK' NOT NULL
    CONSTRAINT chk_status CHECK (status IN ('OK','ERROR'))
) TABLESPACE data;
 
-- Índices para os relatórios de consumo
CREATE INDEX ai_token_log_caller_ts_ix
  ON ai_ops.ai_token_log (caller_schema, TRUNC(log_ts));
 
CREATE INDEX ai_token_log_ts_ix
  ON ai_ops.ai_token_log (TRUNC(log_ts));
 
CREATE INDEX ai_token_log_action_ix
  ON ai_ops.ai_token_log (action, caller_schema);
 
-- Comentários (bom exemplo de documentar o próprio schema de infra)
COMMENT ON TABLE ai_ops.ai_token_log IS
  'Log centralizado de todas as chamadas ao SELECT AI via ai_ops.pkg_ai_gateway. 
  Cada linha representa uma chamada ao DBMS_CLOUD_AI.GENERATE.';
 
COMMENT ON COLUMN ai_ops.ai_token_log.caller_schema IS
  'Schema que originou a chamada. Passado pelo chamador e validado contra USER na sessão.';
 
COMMENT ON COLUMN ai_ops.ai_token_log.tokens_input IS
  'Tokens de entrada (prompt + metadados do schema) reportados pela API do OCI GenAI.';
 
COMMENT ON COLUMN ai_ops.ai_token_log.tokens_output IS
  'Tokens de saída (resposta do LLM) reportados pela API do OCI GenAI.';
 
COMMENT ON COLUMN ai_ops.ai_token_log.tokens_total IS
  'Coluna virtual: soma de tokens_input + tokens_output.';

Schema AI_OPS


Passo 3: O wrapper — PKG_AI_GATEWAY

Este é o coração da solução. O wrapper chama DBMS_CLOUD_AI.GENERATE, interpreta o JSON de resposta para extrair os tokens, grava o log e devolve o resultado ao chamador.

Uma nota importante sobre tokens no OCI GenAI: a resposta do DBMS_CLOUD_AI.GENERATE com action => 'runsql' ou 'narrate' retorna o resultado da query ou o texto narrado — não o payload bruto da API. Para capturar os tokens, é necessário fazer uma chamada adicional ao endpoint do OCI GenAI via DBMS_CLOUD.SEND_REQUEST, ou usar a ação 'chat' que retorna o JSON completo da API incluindo o campo usage. Para as demais actions, a estratégia mais confiável é estimar os tokens pelo tamanho do prompt e da resposta (aproximação de 1 token ≈ 4 caracteres para inglês/português), ou manter uma chamada separada de telemetria. No lab abaixo mostro apenas a abordagem de estimativa de tokens. Mais para a frente você entenderá o motivo. 😉

CREATE OR REPLACE PACKAGE ai_ops.pkg_ai_gateway AS
 
  -- Procedimento principal: chamada ao LLM com log automático
  FUNCTION generate (
    p_prompt       IN CLOB,
    p_profile_name IN VARCHAR2,
    p_action       IN VARCHAR2 DEFAULT 'runsql'
  ) RETURN CLOB;
 
  -- Procedimento auxiliar: registra erro sem resultado
  PROCEDURE log_error (
    p_caller_schema IN VARCHAR2,
    p_profile_name  IN VARCHAR2,
    p_action        IN VARCHAR2,
    p_prompt        IN CLOB,
    p_error_code    IN NUMBER,
    p_error_message IN VARCHAR2
  );
 
END pkg_ai_gateway;
/
 
CREATE OR REPLACE PACKAGE BODY ai_ops.pkg_ai_gateway AS
 
  -- Estimativa de tokens por contagem de caracteres
  -- Aproximação: 1 token ≈ 4 chars (inglês/português)
  -- Para contagem exata, substitua por chamada à API de tokenização do provider
  FUNCTION estimate_tokens (p_text IN CLOB) RETURN NUMBER IS
  BEGIN
    RETURN CEIL(DBMS_LOB.GETLENGTH(NVL(p_text, EMPTY_CLOB())) / 4);
  END estimate_tokens;
 
  FUNCTION generate (
    p_prompt       IN CLOB,
    p_profile_name IN VARCHAR2,
    p_action       IN VARCHAR2 DEFAULT 'runsql'
  ) RETURN CLOB IS
 
    l_result         CLOB;
    l_start_ts       TIMESTAMP := SYSTIMESTAMP;
    l_start_time     NUMBER    := DBMS_UTILITY.GET_TIME;
    l_duration_ms    NUMBER;
    l_tokens_in      NUMBER;
    l_tokens_out     NUMBER;
    l_caller_schema  VARCHAR2(128) := SYS_CONTEXT('USERENV', 'SESSION_USER');
    l_sid            NUMBER        := SYS_CONTEXT('USERENV', 'SID');
    l_serial         NUMBER;
    l_client_id      VARCHAR2(128) := SYS_CONTEXT('USERENV', 'CLIENT_IDENTIFIER');
    l_client_ip      VARCHAR2(128) := SYS_CONTEXT('USERENV', 'IP_ADDRESS');
    l_model_name     VARCHAR2(256);
    l_usage_json     JSON_OBJECT_T;
    l_json_response  CLOB;
 
  BEGIN
    -- Recuperar SERIAL# da sessão atual
    SELECT serial#
      INTO l_serial
      FROM v$session
     WHERE sid = l_sid
       AND rownum = 1;
 
    -- Para 'chat', o retorno é o JSON completo da API (inclui usage)
    -- Para as demais ações, o retorno é o resultado final (SQL, texto, etc.)
    IF p_action = 'chat' THEN
      l_json_response := DBMS_CLOUD_AI.GENERATE(
        prompt       => p_prompt,
        profile_name => p_profile_name,
        action       => p_action
      );
 
      -- Tentar extrair tokens do campo usage do JSON
      BEGIN
        l_usage_json  := JSON_OBJECT_T.PARSE(l_json_response);
        l_tokens_in   := l_usage_json.get_Object('usage').get_Number('prompt_tokens');
        l_tokens_out  := l_usage_json.get_Object('usage').get_Number('completion_tokens');
        l_result      := l_usage_json.get_String('content');
        IF l_result IS NULL THEN
          l_result := l_json_response; -- fallback: devolve o JSON completo
        END IF;
      EXCEPTION
        WHEN OTHERS THEN
          -- JSON não no formato esperado; devolve tudo e estima tokens
          l_result     := l_json_response;
          l_tokens_in  := estimate_tokens(p_prompt);
          l_tokens_out := estimate_tokens(l_result);
      END;
 
    ELSE
      -- runsql, narrate, showsql: retornam resultado direto
      l_result     := DBMS_CLOUD_AI.GENERATE(
        prompt       => p_prompt,
        profile_name => p_profile_name,
        action       => p_action
      );
      -- Estimativa de tokens (substituir por API de tokenização se necessário)
      l_tokens_in  := estimate_tokens(p_prompt);
      l_tokens_out := estimate_tokens(l_result);
    END IF;
 
    -- Recuperar nome do modelo configurado no profile
    BEGIN
      SELECT attribute_value
        INTO l_model_name
        FROM dba_cloud_ai_profile_attributes
       WHERE profile_name   = p_profile_name
         AND attribute_name = 'model'
         AND rownum = 1;
    EXCEPTION
      WHEN NO_DATA_FOUND THEN
        l_model_name := NULL;
    END;
 
    -- Calcular duração em milissegundos
    -- DBMS_UTILITY.GET_TIME retorna centésimos de segundo
    l_duration_ms := (DBMS_UTILITY.GET_TIME - l_start_time) * 10;
 
    -- Gravar log
    INSERT INTO ai_ops.ai_token_log (
      log_ts, caller_schema, profile_name, action,
      prompt_text, response_text,
      tokens_input, tokens_output,
      model_name, duration_ms,
      session_sid, session_serial,
      client_identifier, client_ip,
      status
    ) VALUES (
      l_start_ts, l_caller_schema, p_profile_name, p_action,
      p_prompt, l_result,
      l_tokens_in, l_tokens_out,
      l_model_name,
      l_duration_ms,
      l_sid, l_serial,
      l_client_id, l_client_ip,
      'OK'
    );
 
    COMMIT;
    RETURN l_result;
 
  EXCEPTION
    WHEN OTHERS THEN
      l_duration_ms := EXTRACT(SECOND FROM (SYSTIMESTAMP - l_start_ts)) * 1000;
      log_error(
        p_caller_schema => l_caller_schema,
        p_profile_name  => p_profile_name,
        p_action        => p_action,
        p_prompt        => p_prompt,
        p_error_code    => SQLCODE,
        p_error_message => SQLERRM
      );
      RAISE;
  END generate;
 
  PROCEDURE log_error (
    p_caller_schema IN VARCHAR2,
    p_profile_name  IN VARCHAR2,
    p_action        IN VARCHAR2,
    p_prompt        IN CLOB,
    p_error_code    IN NUMBER,
    p_error_message IN VARCHAR2
  ) IS
    PRAGMA AUTONOMOUS_TRANSACTION;
  BEGIN
    INSERT INTO ai_ops.ai_token_log (
      log_ts, caller_schema, profile_name, action,
      prompt_text, tokens_input, tokens_output,
      error_code, error_message, status
    ) VALUES (
      SYSTIMESTAMP, p_caller_schema, p_profile_name, p_action,
      p_prompt, 0, 0,
      p_error_code, p_error_message, 'ERROR'
    );
    COMMIT;
  END log_error;
 
END pkg_ai_gateway;
/

PKG_AI_GATEWAY

log_error usa PRAGMA AUTONOMOUS_TRANSACTION por um motivo prático: se a chamada ao LLM falhar e o chamador fizer rollback da sua transação, o registro de erro ainda precisa ser gravado. Sem o pragma, o log de erro sumiria junto com o rollback.


Passo 4: criando os profiles no AI_OPS

Cada schema de aplicação terá seu próprio profile, criado e gerenciado pelo AI_OPS. É o AI_OPS quem executa o DBMS_CLOUD_AI — portanto é ele quem precisa ser dono dos profiles, não os schemas de aplicação.

object_list define quais tabelas o LLM pode enxergar para gerar SQL. Aqui vamos ser explícitos: cada profile lista apenas as tabelas relevantes para aquele contexto de negócio. Isso limita a superfície de acesso do LLM e evita que ele tente fazer JOIN em tabelas que não têm nada a ver com a pergunta.

No nosso lab, as tabelas do schema RREZENDE (base do artigo anterior) são distribuídas assim:

App

Tabelas

Contexto

APP_VENDAS

EMPRESA, CONTATO, AMBIENTE

Clientes, contratos e ambientes atendidos

APP_SUPORTE

SUPORTE, TAREFA, MANUTENCAO

Tickets, tarefas e manutenções

APP_RH

CONTATO, TAREFA

Pessoas e alocação de atividades

Execute conectado como AI_OPS:

-- Profile para APP_VENDAS
-- Contexto: clientes, contratos e ambientes
BEGIN
  DBMS_CLOUD_AI.CREATE_PROFILE(
    profile_name => 'AI_PROFILE_VENDAS',
    attributes   => '{
      "provider"          : "oci",
      "credential_name"   : "OCI$RESOURCE_PRINCIPAL",
      "model"             : "cohere.command-r-plus-08-2024",
      "oci_compartment_id": "ocid1.compartment.oc1..xxxxxxxx",
      "temperature"       : 0,
      "comments"          : true,
      "object_list"       : [
        {"owner": "RREZENDE", "name": "EMPRESA"},
        {"owner": "RREZENDE", "name": "CONTATO"},
        {"owner": "RREZENDE", "name": "AMBIENTE"}
      ]
    }'
  );
END;
/
 
-- Profile para APP_SUPORTE
-- Contexto: tickets de suporte, tarefas e manutenções
BEGIN
  DBMS_CLOUD_AI.CREATE_PROFILE(
    profile_name => 'AI_PROFILE_SUPORTE',
    attributes   => '{
      "provider"          : "oci",
      "credential_name"   : "OCI$RESOURCE_PRINCIPAL",
      "model"             : "cohere.command-r-plus-08-2024",
      "oci_compartment_id": "ocid1.compartment.oc1..xxxxxxxx",
      "temperature"       : 0,
      "comments"          : true,
      "object_list"       : [
        {"owner": "RREZENDE", "name": "SUPORTE"},
        {"owner": "RREZENDE", "name": "TAREFA"},
        {"owner": "RREZENDE", "name": "MANUTENCAO"}
      ]
    }'
  );
END;
/
 
-- Profile para APP_RH
-- Contexto: pessoas e alocação de atividades
BEGIN
  DBMS_CLOUD_AI.CREATE_PROFILE(
    profile_name => 'AI_PROFILE_RH',
    attributes   => '{
      "provider"          : "oci",
      "credential_name"   : "OCI$RESOURCE_PRINCIPAL",
      "model"             : "cohere.command-r-plus-08-2024",
      "oci_compartment_id": "ocid1.compartment.oc1..xxxxxxxx",
      "temperature"       : 0,
      "comments"          : true,
      "object_list"       : [
        {"owner": "RREZENDE", "name": "CONTATO"},
        {"owner": "RREZENDE", "name": "TAREFA"}
      ]
    }'
  );
END;
/
 
-- Confirmar os profiles e tabelas registradas
COL PROFILE_NAME FOR A20
COL ATTRIBUTE_NAME FOR A20
COL ATTRIBUTE_VALUE FOR A100
SELECT profile_name, attribute_name, attribute_value
  FROM user_cloud_ai_profile_attributes
  WHERE profile_name IN ('AI_PROFILE_VENDAS','AI_PROFILE_SUPORTE','AI_PROFILE_RH')
  ORDER BY profile_name, attribute_name;

Criação dos Profiles

O AI_OPS também precisa de SELECT nas tabelas do schema RREZENDE, pois é ele quem executa o SQL gerado pelo LLM.

Conceda como ADMIN:

GRANT SELECT ON rrezende.empresa TO ai_ops;
GRANT SELECT ON rrezende.contato TO ai_ops;
GRANT SELECT ON rrezende.ambiente TO ai_ops;
GRANT SELECT ON rrezende.suporte TO ai_ops;
GRANT SELECT ON rrezende.tarefa TO ai_ops;
GRANT SELECT ON rrezende.manutencao TO ai_ops;

Privilégios necessários ao schema de monitoramento

A tabela DB_GROWTH foi intencionalmente omitida dos profiles — ela contém métricas internas de crescimento de banco e não é relevante para nenhum dos três contextos de negócio deste lab.

📝 Oracle 26ai on-premises: inclua "credential_name": "<nome_da_credencial>" nos profiles, referenciando a credencial criada no Passo 1. O restante é idêntico.


Passo 5: Concedendo acesso aos schemas de aplicação

Conectado como ADMIN

-- Criar schemas de aplicação (se ainda não existirem)
CREATE USER app_vendas  IDENTIFIED BY "&senha"
  DEFAULT TABLESPACE data QUOTA UNLIMITED ON data;
CREATE USER app_rh      IDENTIFIED BY "&senha"
  DEFAULT TABLESPACE data QUOTA UNLIMITED ON data;
CREATE USER app_suporte IDENTIFIED BY "&senha"
  DEFAULT TABLESPACE data QUOTA UNLIMITED ON data;
 
GRANT CREATE SESSION TO app_vendas, app_rh, app_suporte;
 
-- Conceder EXECUTE no gateway para cada schema de aplicação
-- NÃO conceder EXECUTE em DBMS_CLOUD_AI diretamente
GRANT EXECUTE ON ai_ops.pkg_ai_gateway TO app_vendas;
GRANT EXECUTE ON ai_ops.pkg_ai_gateway TO app_rh;
GRANT EXECUTE ON ai_ops.pkg_ai_gateway TO app_suporte;
 
-- Criar sinônimo público para facilitar (opcional)
CREATE PUBLIC SYNONYM pkg_ai_gateway FOR ai_ops.pkg_ai_gateway;

Privilégios necessários aos schemas de aplicação

Com o sinônimo público, os schemas de aplicação chamam simplesmente pkg_ai_gateway.generate(...) sem qualificador.


Passo 6: Como os schemas de aplicação chamam o gateway

Ao invés de DBMS_CLOUD_AI.GENERATE direto, a aplicação chama o wrapper:

-- Conectado como APP_VENDAS
DECLARE
  l_resultado CLOB;
BEGIN
  l_resultado := pkg_ai_gateway.generate(
    p_prompt       => 'Quais empresas têm contrato ativo?',
    p_profile_name => 'AI_PROFILE_VENDAS',
    p_action       => 'runsql'
  );
  DBMS_OUTPUT.PUT_LINE(l_resultado);
END;
/
 
-- Ou com narrate
DECLARE
  l_resumo CLOB;
BEGIN
  l_resumo := pkg_ai_gateway.generate(
    p_prompt       => 'Resuma as empresas por segmento de mercado',
    p_profile_name => 'AI_PROFILE_VENDAS',
    p_action       => 'narrate'
  );
DBMS_OUTPUT.PUT_LINE(l_resumo);
END;
/
 
-- Conectado como APP_SUPORTE
DECLARE
  l_resultado CLOB;
BEGIN
  l_resultado := pkg_ai_gateway.generate(
    p_prompt       => 'Quais chamados de suporte estão abertos há mais de 5 dias?',
    p_profile_name => 'AI_PROFILE_SUPORTE',
    p_action       => 'runsql'
  );
  DBMS_OUTPUT.PUT_LINE(l_resultado);
END;
/
 
DECLARE
  l_resumo CLOB;
BEGIN
  l_resumo := pkg_ai_gateway.generate(
    p_prompt       => 'Resuma as manutenções concluídas no último mês',
    p_profile_name => 'AI_PROFILE_SUPORTE',
    p_action       => 'narrate'
  );
  DBMS_OUTPUT.PUT_LINE(l_resumo);
END;
/
 
-- Conectado como APP_RH
DECLARE
  l_resultado CLOB;
BEGIN
  l_resultado := pkg_ai_gateway.generate(
    p_prompt       => 'Quais contatos têm tarefas em andamento?',
    p_profile_name => 'AI_PROFILE_RH',
    p_action       => 'runsql'
  );
  DBMS_OUTPUT.PUT_LINE(l_resultado);
END;
/
 
DECLARE
  l_resumo CLOB;
BEGIN
  l_resumo := pkg_ai_gateway.generate(
    p_prompt       => 'Resuma a distribuição de tarefas por contato no último trimestre',
    p_profile_name => 'AI_PROFILE_RH',
    p_action       => 'narrate'
  );
  DBMS_OUTPUT.PUT_LINE(l_resumo);
END;
/

Schamas de aplicação chamando o gateway

caller_schema é capturado automaticamente via SYS_CONTEXT('USERENV','SESSION_USER') — o schema que chama o wrapper é gravado no log sem depender de nenhum parâmetro passado pelo chamador. Isso evita que um schemase passe pelo outro.


Passo 7: Os relatórios de consumo

Agora a parte que todo gestor vai querer ver. Execute as consultas a seguir conectado como ADMIN ou como AI_OPS.

Consumo diário por schema (últimos 30 dias)

SELECT TRUNC(log_ts) AS dia,
       caller_schema,
       action,
       COUNT(*) AS chamadas,
       SUM(tokens_input) AS tokens_entrada,
       SUM(tokens_output) AS tokens_saida,
       SUM(tokens_total) AS tokens_total,
       ROUND(AVG(duration_ms)) AS media_ms,
       SUM(CASE WHEN status = 'ERROR' THEN 1 ELSE 0 END) AS erros
  FROM ai_ops.ai_token_log
  WHERE log_ts >= SYSDATE - 30
  GROUP BY TRUNC(log_ts), caller_schema, action
  ORDER BY dia DESC, tokens_total DESC;

Consumo diário por schema (últimos 30 dias)

Consumo mensal por schema (ano corrente)

SELECT TO_CHAR(log_ts, 'YYYY-MM') AS mes,
       caller_schema,
       COUNT(*)                    AS total_chamadas,
       SUM(tokens_input)           AS tokens_entrada,
       SUM(tokens_output)          AS tokens_saida,
       SUM(tokens_total)           AS tokens_total,
       ROUND(RATIO_TO_REPORT(SUM(tokens_total))
         OVER (PARTITION BY TO_CHAR(log_ts, 'YYYY-MM')) * 100, 2) AS pct_do_mes
  FROM ai_ops.ai_token_log
  WHERE log_ts >= TRUNC(SYSDATE, 'YEAR')
    AND status = 'OK'
  GROUP BY TO_CHAR(log_ts, 'YYYY-MM'), caller_schema
  ORDER BY mes DESC, tokens_total DESC;

Consumo mensal por schema (ano corrente)

Top 10 prompts mais caros (por tokens totais)

SELECT *
  FROM (SELECT log_id,
               log_ts,
               caller_schema,
               profile_name,
               action,
               tokens_total,
               duration_ms,
               SUBSTR(prompt_text, 1, 200) AS prompt_resumido
          FROM ai_ops.ai_token_log
          WHERE status = 'OK'
          ORDER BY tokens_total DESC)
  WHERE rownum <= 10;

Top 10 prompts mais caros (por tokens totais)

Custo mensal por schema (em R$)

O modelo cohere.command-r-plus-08-2024 é cobrado pelo OCI como Large Cohere (SKU B108077). Segundo a documentação da Oracle, a métrica de cobrança no modo on-demand funciona assim:

  • 1 transação = 1 caractere (prompt + resposta)
  • 10.000 transações = 10.000 caracteres
  • Fórmula: custo = (total_chars / 10.000) × preço_unitário

Para fins de exemplificação utilizarei o valor hipotético de R$0,09 por 10.000 transações. Consulte o valor do SKU do seu contrato com a Oracle para obter o valor real.

Como a tabela AI_TOKEN_LOG armazena tokens estimados pela aproximação de 1 token ≈ 4 caracteres, convertemos de volta para caracteres multiplicando por 4:

COL mes           FOR A10
COL caller_schema FOR A15
COL model_name    FOR A30
COL chamadas      FOR 999,999
COL total_chars   FOR 999,999,999
COL custo_brl     FOR 999,990.999999
 
-- Custo mensal por schema
-- SKU B108077: Large Cohere = R$ 0,09 por 10.000 transações (Valor hipotético)
-- 1 transação = 1 caractere (prompt + resposta)
-- tokens * 4 converte tokens estimados de volta para caracteres
-- Atualize o valor unitário conforme o contrato vigente
SELECT TO_CHAR(log_ts, 'YYYY-MM') AS mes,
       caller_schema,
       model_name,
       COUNT(*) AS chamadas,
       SUM(tokens_total * 4) AS total_chars,
       ROUND(SUM(tokens_total * 4) / 10000 * 0.09, 6) AS custo_brl
  FROM ai_ops.ai_token_log
  WHERE status  = 'OK'
    AND log_ts >= TRUNC(SYSDATE, 'YEAR')
  GROUP BY TO_CHAR(log_ts, 'YYYY-MM'), caller_schema, model_name
  ORDER BY mes DESC, custo_brl DESC;

Custo mensal por schema (em R$)

O rastreamento de tokens é ainda mais relevante nesse modelo de cobrança: quanto maior o prompt e a resposta, mais caracteres são consumidos e maior o custo. Identificar os prompts mais longos — via a query de top 10 acima — é o primeiro passo para otimizar o gasto.

Erros por schema no último mês

SELECT caller_schema,
       error_code,
       error_message,
       COUNT(*) AS ocorrencias,
       MIN(log_ts) AS primeira_vez,
       MAX(log_ts) AS ultima_vez
  FROM ai_ops.ai_token_log
  WHERE status  = 'ERROR'
    AND log_ts >= ADD_MONTHS(SYSDATE, -1)
  GROUP BY caller_schema, error_code, error_message
  ORDER BY ocorrencias DESC;

Erros por schema no último mês


Por que não usar o endpoint /actions/tokenize?

O OCI GenAI expõe um endpoint de "tokenização" que retornaria a contagem exata de tokens para cada chamada. A escolha pela estimativa de chars/4 foi intencional: cada chamada ao /actions/tokenize é uma transação faturável — o que significa 2 chamadas extras por execução do gateway (uma para o prompt, outra para a resposta), triplicando o número de transações cobradas.

Além disso, como a própria cobrança da OCI é baseada em caracteres e não em tokens, a estimativa já está naturalmente alinhada com o que aparece na fatura. O /actions/tokenize faz sentido em cenários onde o modelo é cobrado por token — como os modelos da família OpenAI disponíveis no OCI — e fica como evolução futura para quem precisar dessa precisão.


O que levar pra casa

DBA_CLOUD_AI_CONVERSATION_PROMPTS existe no ADB 23ai e consolida prompts de todos os schemas com a coluna OWNER — mas só registra chamadas feitas no conversation mode. Para o padrão mais comum de uso — GENERATE direto ou SELECT AI — ela ficará vazia. E em nenhum cenário ela expõe contagem de tokens.

O padrão AI_OPS + PKG_AI_GATEWAY resolve os três problemas de uma vez: rastreamento de todas as actions, visibilidade de tokens, e controle de acesso centralizado. O custo de implementação é baixo — algumas horas de lab — e o retorno aparece na primeira vez que o gestor perguntar "De onde veio esse gasto?"

Uma nota final sobre tokens: a estimativa de 1 token ≈ 4 caracteres é uma aproximação aceitável para português e inglês na maioria dos modelos. Para custo exato, o OCI GenAI expõe um endpoint /actions/tokenize — e isso fica como assunto para um post futuro.


E você?

Já tem múltiplos times usando SELECT AI no mesmo banco?
Como está controlando o consumo hoje?
Se está na fase de avaliar se compensa instrumentar, o meu palpite é: sim, compensa — especialmente porque o PKG_AI_GATEWAY vira naturalmente o lugar onde você vai querer adicionar throttlingquotas por schema, e talvez até um cache de promptsrepetidos.

Nos comentários, no LinkedIn ou lá no GUOB Tech Day — esse assunto vai render conversa boa.

Até lá.

Ahhhhh! Já estava me esquecendo!
Será que você encontrou os Easter Eggs na imagem do início do artigo??? 😜

— Ricardo Rezende (@ricarezende)

Veja mais