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
O 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.
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
O 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
A 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 throttling, quotas 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)