Ir para o conteúdo
Backend

CLI com IA: Construindo Ferramentas de Linha de Comando em Python e JavaScript que Usam LLMs de Verdade

Marcos Soares
Atualizado em 
13 minutos de leitura
Ilustracao 3D de terminal CLI em vidro fosco com condutos bioluminescentes verdes representando integracao com LLMs
Ouça este artigo
0:00CLI com IA: Construindo Ferramentas de Linha de Comando em Python e JavaScript que Usam LLMs de Verdade--:--

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.

Neste artigo

O problema real: CLIs que chamam IA e quebram no primeiro timeout

A maioria dos tutoriais de "CLI com IA" para no openai.chat.completions.create(). O resultado é uma ferramenta que funciona na demo, trava no primeiro rate limit e não oferece nenhuma experiência decente quando a API demora 8 segundos para responder.

Construir uma CLI que integra LLMs para uso real exige resolver três problemas que ninguém cobre: streaming de resposta para o terminal não parecer congelado, fallback entre provedores quando um cai, e cache local para não torrar créditos repetindo a mesma pergunta. Este post resolve os três, em Python e em JavaScript, com código que roda.

Anatomia de uma CLI com IA que funciona

Antes de código, a estrutura. Uma CLI inteligente tem quatro camadas:

  1. Parser de argumentos: recebe input do usuário (prompt, arquivo, flags).
  2. Camada de cache: verifica se já respondeu aquela pergunta antes.
  3. Client HTTP com retry e fallback: chama o provedor primário, cai para o secundário se necessário.
  4. Renderer de output: streaming para o terminal com formatação.

A tabela abaixo compara as escolhas de biblioteca em cada camada:

CamadaPythonJavaScript (Node.js)
Parser de argumentosargparse (stdlib) ou clickcommander ou yargs
Cache localdiskcache ou SQLite via sqlite3keyv com adapter SQLite
Client HTTPhttpx (async, streaming nativo)fetch nativo (Node 18+) com ReadableStream
Streaming no terminalsys.stdout.write + flushprocess.stdout.write
Formatação de Markdownrichmarked-terminal

Se você já trabalha com ferramentas de automação em JavaScript, a camada de parser e output vai parecer familiar. A diferença é que agora o "meio" da CLI faz uma chamada de rede lenta e imprevisível.

Python: CLI completa com streaming e cache

Começando pelo parser. O click é mais ergonômico que argparse para CLIs que crescem:

Python
# cli.py
import click
import hashlib
import json
import sqlite3
import os
 
DB_PATH = os.path.expanduser("~/.cache/aicli/cache.db")
 
def get_cache_db() -> sqlite3.Connection:
    os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
    conn = sqlite3.connect(DB_PATH)
    conn.execute(
        "CREATE TABLE IF NOT EXISTS cache "
        "(key TEXT PRIMARY KEY, response TEXT, created_at REAL DEFAULT (unixepoch()))"
    )
    return conn
 
def cache_key(model: str, prompt: str) -> str:
    # SHA256 garante chave fixa independente do tamanho do prompt
    raw = f"{model}::{prompt}"
    return hashlib.sha256(raw.encode()).hexdigest()
 
@click.command()
@click.argument("prompt")
@click.option("--model", default="gpt-4o-mini", help="Modelo a usar")
@click.option("--no-cache", is_flag=True, help="Ignora cache local")
def ask(prompt: str, model: str, no_cache: bool):
    """Envia um prompt para o LLM e exibe a resposta com streaming."""
    db = get_cache_db()
 
    if not no_cache:
        key = cache_key(model, prompt)
        row = db.execute("SELECT response FROM cache WHERE key = ?", (key,)).fetchone()
        if row:
            click.echo(row[0])
            return
 
    response_text = stream_completion(model, prompt)
 
    if response_text and not no_cache:
        db.execute(
            "INSERT OR REPLACE INTO cache (key, response) VALUES (?, ?)",
            (cache_key(model, prompt), response_text),
        )
        db.commit()

Agora a parte que importa: o client HTTP com streaming. Usar httpx em modo async permite ler chunks conforme chegam, em vez de esperar a resposta inteira:

Python
# client.py
import httpx
import sys
import os
import json
 
OPENAI_URL = "https://api.openai.com/v1/chat/completions"
ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"
 
def stream_completion(model: str, prompt: str) -> str:
    """Tenta OpenAI primeiro. Se falhar com status >= 500 ou timeout, cai para Anthropic."""
    providers = [
        ("openai", _stream_openai),
        ("anthropic", _stream_anthropic),
    ]
 
    for name, fn in providers:
        try:
            return fn(model, prompt)
        except (httpx.TimeoutException, httpx.HTTPStatusError) as exc:
            # Só faz fallback em erro de servidor ou timeout, não em 401/403
            if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 500:
                raise
            click.echo(f"\n[fallback] {name} falhou, tentando próximo...", err=True)
 
    raise click.ClickException("Todos os provedores falharam.")
 
def _stream_openai(model: str, prompt: str) -> str:
    api_key = os.environ.get("OPENAI_API_KEY")
    if not api_key:
        raise click.ClickException("OPENAI_API_KEY não definida.")
 
    chunks = []
    # timeout de 30s para connect, 60s para read: LLMs demoram
    with httpx.Client(timeout=httpx.Timeout(connect=30.0, read=60.0)) as client:
        with client.stream(
            "POST",
            OPENAI_URL,
            headers={"Authorization": f"Bearer {api_key}"},
            json={
                "model": model,
                "messages": [{"role": "user", "content": prompt}],
                "stream": True,
            },
        ) as resp:
            resp.raise_for_status()
            for line in resp.iter_lines():
                if not line.startswith("data: "):
                    continue
                payload = line.removeprefix("data: ").strip()
                if payload == "[DONE]":
                    break
                data = json.loads(payload)
                token = data["choices"][0]["delta"].get("content", "")
                sys.stdout.write(token)
                sys.stdout.flush()
                chunks.append(token)
 
    sys.stdout.write("\n")
    return "".join(chunks)

Esse padrão de fallback sequencial é o mesmo que se aplica a clients HTTP resilientes com retry e timeout: tente o primário, caia para o secundário, falhe ruidosamente se ambos caírem.

JavaScript: a mesma CLI com Node.js

A versão JavaScript usa commander para parsing e fetch nativo do Node.js 18+ para streaming via SSE:

JAVASCRIPT
// cli.mjs
import { Command } from "commander";
import { createHash } from "node:crypto";
import Database from "better-sqlite3";
import { homedir } from "node:os";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { streamCompletion } from "./client.mjs";
 
const DB_DIR = join(homedir(), ".cache", "aicli");
mkdirSync(DB_DIR, { recursive: true });
 
const db = new Database(join(DB_DIR, "cache.db"));
db.exec(`
  CREATE TABLE IF NOT EXISTS cache (
    key TEXT PRIMARY KEY,
    response TEXT,
    created_at INTEGER DEFAULT (unixepoch())
  )
`);
 
function cacheKey(model, prompt) {
  return createHash("sha256").update(`${model}::${prompt}`).digest("hex");
}
 
const program = new Command();
program
  .argument("<prompt>")
  .option("--model <model>", "Modelo a usar", "gpt-4o-mini")
  .option("--no-cache", "Ignora cache local")
  .action(async (prompt, opts) => {
    const key = cacheKey(opts.model, prompt);
 
    if (opts.cache !== false) {
      const row = db.prepare("SELECT response FROM cache WHERE key = ?").get(key);
      if (row) {
        process.stdout.write(row.response + "\n");
        return;
      }
    }
 
    const text = await streamCompletion(opts.model, prompt);
 
    if (text && opts.cache !== false) {
      db.prepare("INSERT OR REPLACE INTO cache (key, response) VALUES (?, ?)").run(key, text);
    }
  });
 
program.parse();

O client com streaming via fetch no Node.js exige lidar com ReadableStream e decodificar SSE manualmente. Esse é o ponto onde a maioria das implementações quebra:

JAVASCRIPT
// client.mjs
const OPENAI_URL = "https://api.openai.com/v1/chat/completions";
 
export async function streamCompletion(model, prompt) {
  const apiKey = process.env.OPENAI_API_KEY;
  if (!apiKey) throw new Error("OPENAI_API_KEY não definida.");
 
  // AbortController com timeout: sem isso, a CLI trava indefinidamente
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 60_000);
 
  const resp = await fetch(OPENAI_URL, {
    method: "POST",
    signal: controller.signal,
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model,
      messages: [{ role: "user", content: prompt }],
      stream: true,
    }),
  });
 
  clearTimeout(timeoutId);
 
  if (!resp.ok) {
    const body = await resp.text();
    throw new Error(`OpenAI retornou ${resp.status}: ${body}`);
  }
 
  const chunks = [];
  const decoder = new TextDecoder();
 
  // resp.body é um ReadableStream no Node 18+
  for await (const raw of resp.body) {
    const text = decoder.decode(raw, { stream: true });
    // Um chunk pode conter múltiplas linhas SSE
    for (const line of text.split("\n")) {
      if (!line.startsWith("data: ")) continue;
      const payload = line.slice(6).trim();
      if (payload === "[DONE]") break;
 
      const data = JSON.parse(payload);
      const token = data.choices?.[0]?.delta?.content ?? "";
      process.stdout.write(token);
      chunks.push(token);
    }
  }
 
  process.stdout.write("\n");
  return chunks.join("");
}

Se você quer entender por que o fetch nativo do Node.js se comporta diferente do fetch do browser, a explicação está na diferença entre runtime e linguagem: o fetch do Node.js usa undici por baixo, que implementa ReadableStream como async iterable. Esse detalhe importa quando você faz for await direto no resp.body. No browser, esse padrão exige getReader(). Essa distinção entre mecânicas da linguagem e do runtime é o tipo de coisa que separa código que funciona de código que funciona por acidente.

O que NÃO fazer

Anti-pattern 1: esperar a resposta inteira antes de exibir

Python
# ERRADO: o usuário fica olhando para um cursor parado por 5-15 segundos
import openai
 
def ask_wrong(prompt: str) -> str:
    client = openai.OpenAI()
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        # sem stream=True
    )
    return response.choices[0].message.content

Sem streaming, a CLI parece travada. O tempo percebido pelo usuário é o tempo total da requisição. Com streaming, o primeiro token aparece em 200-500ms e o usuário já sabe que algo está acontecendo. A correção é o _stream_openai mostrado acima.

Anti-pattern 2: cache sem considerar o modelo

JAVASCRIPT
// ERRADO: mesma chave para prompts iguais em modelos diferentes
function badCacheKey(prompt) {
  return createHash("sha256").update(prompt).digest("hex");
}
// "Explique closures" com gpt-4o e com claude-3-haiku geram respostas diferentes
// mas essa função retorna a mesma chave para ambos

A versão correta inclui o modelo na composição da chave, como fizemos no cacheKey acima: ${model}::${prompt}.

Anti-pattern 3: fallback em qualquer erro HTTP

Python
# ERRADO: fazer fallback quando o erro é 401 (chave inválida)
# Se a chave da OpenAI está errada, a da Anthropic provavelmente também está.
# Fallback só faz sentido para 5xx e timeout.
try:
    return _stream_openai(model, prompt)
except httpx.HTTPStatusError:
    # Cai aqui mesmo com 401, 403, 429
    return _stream_anthropic(model, prompt)

A versão correta filtra por status code, como no stream_completion que mostrei: erros 4xx do cliente (exceto 429 em alguns casos) indicam problema de configuração, não indisponibilidade do provedor.

Expiração de cache e limpeza

O cache sem TTL cresce para sempre. Uma query SQLite resolve:

SQL
-- Limpa entradas com mais de 7 dias
DELETE FROM cache WHERE created_at < unixepoch() - (7 * 86400);

Você pode rodar isso no startup da CLI ou como subcomando:

Python
@click.command()
@click.option("--days", default=7, help="Remove entradas mais antigas que N dias")
def clean(days: int):
    db = get_cache_db()
    result = db.execute(
        "DELETE FROM cache WHERE created_at < unixepoch() - (? * 86400)", (days,)
    )
    db.commit()
    click.echo(f"Removidas {result.rowcount} entradas.")

Quando usar Python vs. JavaScript para CLIs com IA

A escolha depende de dois fatores concretos: o ecossistema de bibliotecas de IA que você já usa e a distribuição do binário.

CritérioPythonJavaScript (Node.js)
Bibliotecas de ML/IAEcossistema dominante (langchain, transformers, llama-cpp-python)Limitado, mas suficiente para API calls
Distribuição como bináriopyinstaller ou shiv (pesado, ~50MB+)pkg ou sea do Node 21+ (~30-50MB)
Startup time~100-300ms (import overhead)~50-100ms
Streaming SSEhttpx resolve com elegânciafetch + ReadableStream funciona, mas parsing manual de SSE
Tipagemmypy ou pyright (opt-in)TypeScript (opt-in, mas mais maduro para projetos grandes)
Asyncasyncio (explícito, verboso)Event loop nativo, menos cerimônia

Se a CLI é wrapper de API (chama OpenAI/Anthropic/Ollama e formata output), JavaScript e Python são equivalentes. Se a CLI precisa rodar modelos localmente (quantizados com GGUF, por exemplo), Python é a escolha porque llama-cpp-python e transformers não têm equivalente maduro em JavaScript.

Para quem já tem infraestrutura de backend em Node.js, manter a CLI no mesmo runtime reduz o custo cognitivo de manutenção.

Integrando com modelos locais via Ollama

Ollama expõe uma API compatível com o formato OpenAI. Isso significa que o mesmo client funciona, trocando a URL:

Python
# Para usar com Ollama local, basta trocar a URL e remover a autenticação
OLLAMA_URL = "http://localhost:11434/v1/chat/completions"
 
def _stream_ollama(model: str, prompt: str) -> str:
    chunks = []
    with httpx.Client(timeout=httpx.Timeout(connect=5.0, read=120.0)) as client:
        # Timeout de read maior: modelos locais em CPU podem demorar 2min+
        with client.stream(
            "POST",
            OLLAMA_URL,
            json={
                "model": model,
                "messages": [{"role": "user", "content": prompt}],
                "stream": True,
            },
        ) as resp:
            resp.raise_for_status()
            for line in resp.iter_lines():
                if not line.startswith("data: "):
                    continue
                payload = line.removeprefix("data: ").strip()
                if payload == "[DONE]":
                    break
                data = json.loads(payload)
                token = data["choices"][0]["delta"].get("content", "")
                sys.stdout.write(token)
                sys.stdout.flush()
                chunks.append(token)
    sys.stdout.write("\n")
    return "".join(chunks)

A cadeia de fallback fica: Ollama local (grátis, rápido se GPU disponível) -> OpenAI (rápido, pago) -> Anthropic (fallback final). Essa hierarquia faz sentido para desenvolvimento: durante o dia você usa o modelo local sem custo, e a CLI cai para a nuvem automaticamente se o Ollama não estiver rodando.

Esse tipo de resiliência em camadas segue a mesma lógica de API layers entre fetch e produção: o código que consome não precisa saber qual provedor respondeu.

Estrutura de diretório para publicar no PyPI ou npm

Para quem quer distribuir a CLI, a estrutura mínima:

Bash
# Python (com pyproject.toml)
aicli/
├── pyproject.toml
├── src/
│   └── aicli/
│       ├── __init__.py
│       ├── cli.py       # entry point com @click.command
│       └── client.py    # stream_completion + fallback
└── README.md
 
# JavaScript (com package.json + bin field)
aicli/
├── package.json        # "bin": { "aicli": "./cli.mjs" }
├── cli.mjs
├── client.mjs
└── README.md

No pyproject.toml, o entry point fica em [project.scripts]:

TOML
[project.scripts]
aicli = "aicli.cli:ask"

No package.json:

JSON
{
  "name": "aicli",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "aicli": "./cli.mjs"
  },
  "dependencies": {
    "commander": "^12.0.0",
    "better-sqlite3": "^11.0.0"
  }
}

Quem trabalha com automação de tarefas em JavaScript já conhece o padrão bin no package.json. A diferença aqui é que o shebang #!/usr/bin/env node no topo do cli.mjs é obrigatório para funcionar como comando global após npm install -g.

FAQ

Streaming de SSE funciona com todos os provedores de LLM? OpenAI, Anthropic, Google (Gemini), Mistral e Ollama suportam streaming via SSE no formato data: {json}\n\n. O formato exato do JSON varia entre provedores (OpenAI usa choices[0].delta.content, Anthropic usa content_block.text), mas o protocolo de transporte é o mesmo. Se você padronizar o parsing em uma função por provedor, o resto do código não precisa mudar.

Qual o custo real de cache local com SQLite para uma CLI? SQLite adiciona ~1-2ms por lookup em tabelas com até 100k entradas. O arquivo de cache fica em torno de 10-50MB para milhares de respostas armazenadas. O custo é desprezível comparado aos 1-15 segundos de uma chamada de API. O ganho financeiro depende do modelo: com gpt-4o, cada chamada custa ~$0.01-0.03 para prompts médios. Se você repete 50 queries por dia, são $1.50/dia economizados.

Dá para usar TypeScript em vez de JavaScript puro para a CLI? Sim, mas adiciona um passo de build. Use tsx para executar TypeScript diretamente durante desenvolvimento e compile com tsc para distribuição. A tipagem ajuda especialmente no parsing das respostas SSE, onde o formato do JSON varia entre provedores. Se o projeto passa de 500 linhas, TypeScript compensa. Abaixo disso, JavaScript puro com JSDoc resolve.

Como testar uma CLI que depende de API externa? Grave as respostas SSE em arquivos .txt e use um servidor HTTP local que reproduz esses arquivos. Em Python, pytest com httpx.MockTransport permite injetar respostas fake sem subir servidor. Em JavaScript, msw (Mock Service Worker) intercepta fetch no nível de rede. Nunca dependa de chamadas reais em CI: além de lento, consome créditos e falha por rate limit.

Python ou JavaScript para CLIs com IA em 2025? Se a CLI só chama APIs remotas: tanto faz, escolha o runtime que seu time já domina. Se a CLI precisa rodar inferência local ou pré/pós-processar embeddings: Python, porque numpy, transformers e llama-cpp-python não têm equivalente em JavaScript. Se a CLI é parte de um monorepo JavaScript e só faz API calls: Node.js, para não adicionar outro runtime no pipeline de CI/CD.

A posição que defendo

CLIs com IA não são um nicho: são a interface mais eficiente para integrar LLMs em workflows de desenvolvimento. Pipes, redirecionamento, composição com jq, grep, xargs: tudo isso funciona de graça quando o output é texto no stdout. GUIs de chat são demonstrações. CLIs são ferramentas.

O investimento que compensa é padronizar o client HTTP com streaming e fallback uma vez, cachear localmente com SQLite, e tratar cada provedor de LLM como um backend substituível. O modelo muda a cada 3 meses. A interface de linha de comando, não.

Marcos Soares

Escrito por

Marcos Soares

Fullstack Developer · CEO da Agência Poti

Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.

Comentários

Participe da discussão

Seja o primeiro a comentar!

Continue Aprofundando

Guias de integração relacionados

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.