Un RAG agent funcional en 50 líneas: workspace, vectores y chat
Retrieval-augmented generation tiene fama de ser una decisión de stack: elegir un loader, un splitter, un proveedor de embeddings, una base vectorial, un framework que lo pegue todo. En LLM4Agents son cuatro tool calls detrás del endpoint que ya pagas. Este tutorial construye un RAG agent funcional sobre tus propios PDFs en menos de 50 líneas — ingest, chat loop, eval y deploy por cron.
Este es el compañero corto del runbook de migración. Aquel post mapeó una carga completa de OpenAI Agent Builder sobre la plataforma; este es para el operador que quiere algo autocontenido para shippear en una tarde. Todo lo que sigue corre contra tools documentadas en los API docs y cubiertas familia por familia en el tour del MCP server.
Qué estás construyendo
Un agente de chat que responde preguntas desde una carpeta de PDFs — un handbook de producto, políticas internas, el corpus que tengas. Dos fases, como todo sistema RAG desde que el patrón tiene nombre. La fase de ingest sube cada PDF al workspace por agente, lo parsea a texto, lo trocea y upserta los chunks en el vector store, que los embebe automáticamente como vectores bge de 768 dimensiones. La fase de query es una conversación donde el modelo tiene exactamente una tool — vector_query — y decide por su cuenta cuándo recuperar.
Cuatro primitivas hacen el trabajo: workspace_upload, pdf_parse, vector_upsert, vector_query. La única pieza de maquinaria RAG que te pertenece es la función de chunking, y son dos líneas.
Setup: tres líneas
npm install @llm4agents/sdk
export LLM4AGENTS_API_KEY=sk-... # de tu registro de agente
mkdir docs # deja tus PDFs aquí
Necesitas un agente registrado con balance fondeado. Un detalle antes de empezar: pdf_parse es una de las tres tools balance-only del MCP server — no acepta pago x402 walk-up. El ingest requiere una cuenta; una wallet sola no puede correr este pipeline, aunque vector_query sí acepta x402.
Ingest: subir, parsear, embeber, upsertar
El MCP server habla Streamable HTTP en modo JSON response, así que un tool call es un POST plano con un envelope JSON-RPC. Un helper de ocho líneas cubre todas las tools del catálogo. Este es el programa TypeScript completo — ingest y chat loop juntos, 44 líneas:
import { readFileSync, readdirSync } from 'node:fs';
import readline from 'node:readline/promises';
import { LLM4AgentsClient } from '@llm4agents/sdk';
const MCP = 'https://mcp.llm4agents.com/mcp';
const KEY = process.env.LLM4AGENTS_API_KEY!;
async function call(name: string, args: object) {
const res = await fetch(MCP, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${KEY}` },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name, arguments: args } }),
});
return (await res.json()).result;
}
const chunk = (t: string, n = 1200) =>
Array.from({ length: Math.ceil(t.length / n) }, (_, i) => t.slice(i * n, i * n + n));
// ingest: subir -> parsear -> chunk -> embed + upsert
for (const f of readdirSync('docs').filter((n) => n.endsWith('.pdf'))) {
await call('workspace_upload', { filename: f, days_to_store: 90,
content_base64: readFileSync(`docs/${f}`).toString('base64') });
const { text } = await call('pdf_parse', { workspace_file: f });
await call('vector_upsert', { items: chunk(text).map((text, i) =>
({ id: `${f}-${i}`, text, metadata: { source: f } })) });
}
// query loop: el modelo recupera por su cuenta vía la allowlist
const client = new LLM4AgentsClient({ apiKey: KEY });
const conv = client.chat.conversation({
model: 'anthropic/claude-sonnet-4.6',
system: 'Answer strictly from the handbook corpus. Always call vector_query ' +
'before answering. Cite the source filename. If nothing relevant returns, say so.',
tools: { mcp: { url: MCP, allow: ['vector_query'] } },
maxToolRounds: 2,
});
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
while (true) {
const reply = await conv.say(await rl.question('you> '));
console.log(reply.content);
}
Tres decisiones de ese archivo merecen nombre propio. El chunk size de 1,200 caracteres es un punto de partida, no doctrina — lo bastante chico para que top_k: 5 quepa en un presupuesto de contexto modesto, lo bastante grande para que un párrafo de política sobreviva entero. El campo metadata: { source: f } es lo que le permite al modelo citar filenames, y es la misma superficie de metadata que aísla corpus multi-tenant con filtros al momento del query. Y no hay paso de embeddings en el código porque no hay paso de embeddings: vector_upsert embebe server-side como parte del call.
El query loop: el retrieval le pertenece al modelo
Fíjate en lo que la mitad de query no hace. No embebe la pregunta del usuario, no corre una búsqueda de similitud, no mete chunks en el prompt ni templa una respuesta. Le entrega al modelo una sola tool y un system prompt que le dice que la use. El modelo formula la búsqueda por su cuenta — muchas veces mejor que el fraseo del usuario — y maxToolRounds: 2 le da un retrieval y un segundo intento si el primero volvió flaco.
La allowlist está haciendo trabajo de seguridad, no solo de prolijidad. Un corpus construido de PDFs parseados es input no confiable, y un modelo que ve una tool read-only puede ser inyectado para llamar una tool read-only. La disciplina es el mismo argumento de scope minimization del post del threat model: este agente responde preguntas, así que ve vector_query y nada más. El ingest corre como proceso separado con una allowlist separada y más ancha.
Lo mismo en Python
El SDK de Python es simétrico, y el programa completo queda en unas 30 líneas:
import base64, os, pathlib, requests
from llm4agents import LLM4AgentsClient
MCP = 'https://mcp.llm4agents.com/mcp'
KEY = os.environ['LLM4AGENTS_API_KEY']
def call(name, args):
r = requests.post(MCP, headers={'Authorization': f'Bearer {KEY}'},
json={'jsonrpc': '2.0', 'id': 1, 'method': 'tools/call',
'params': {'name': name, 'arguments': args}})
return r.json()['result']
def chunks(t, n=1200):
return [t[i:i+n] for i in range(0, len(t), n)]
for f in pathlib.Path('docs').glob('*.pdf'):
call('workspace_upload', {'filename': f.name, 'days_to_store': 90,
'content_base64': base64.b64encode(f.read_bytes()).decode()})
text = call('pdf_parse', {'workspace_file': f.name})['text']
call('vector_upsert', {'items': [
{'id': f'{f.name}-{i}', 'text': c, 'metadata': {'source': f.name}}
for i, c in enumerate(chunks(text))]})
client = LLM4AgentsClient(api_key=KEY)
conv = client.chat.conversation(
model='anthropic/claude-sonnet-4.6',
system='Answer strictly from the handbook corpus. Always call vector_query '
'before answering. Cite the source filename. If nothing relevant returns, say so.',
tools={'mcp': {'url': MCP, 'allow': ['vector_query']}},
max_tool_rounds=2)
while True:
print(conv.say(input('you> ')).content)
pdf_parse es 0.5¢ por página, vector_upsert es 0.5¢ por cada 100 chunks. Un handbook de 120 páginas repartido en tres PDFs se ingesta por unos 65 centavos, casi todo en parsing. Después, cada pregunta cuesta 1¢ de vector_query más los tokens del modelo para la respuesta. El header X-Cost-Usd-Cents de cada respuesta te dice el cargo exacto ya settled; el post de internals de facturación explica de dónde sale ese número.
Eval: tres golden queries antes de confiar
No shippees un agente de retrieval que nunca mediste, ni siquiera uno de 50 líneas. El eval más liviano que aún atrapa regresiones es un puñado de golden queries con substrings esperados — el piso de la práctica que planteamos en el post de evaluación y observabilidad:
GOLDEN = [
('What is the refund window for annual plans?', '30 days'),
('Who approves expenses over the team limit?', 'finance'),
('What is the SLA for priority-one incidents?', '4 hours'),
]
for q, must in GOLDEN:
a = conv.say(q).content
print('PASS' if must.lower() in a.lower() else f'FAIL {q!r}: {a[:80]}')
Adapta las preguntas y los fragmentos esperados a tu corpus. Tres queries suena poco; alcanza para atrapar los modos de falla que de verdad ocurren — un ingest que parseó cero páginas en silencio, un cambio de chunk size que partió cada política a mitad de frase, una edición del system prompt que hizo que el modelo dejara de recuperar. Córrelo después de cada re-ingest. Costo total por corrida: unos 3¢ más tokens.
Deploy: Workers Cron mientras llega Agent Cron
El chat loop puede vivir donde sea — una terminal, un bot de Slack, cincuenta líneas de pegamento para un widget. La parte que necesita schedule es el re-ingest, porque los corpus derivan. Agent Cron y la tool schedule_task siguen en desarrollo, como señala el tour del MCP, así que el sustituto que funciona es un Cron Trigger de Cloudflare Workers: pon la mitad de ingest en el handler scheduled de un Worker y mapea una expresión cron en la config.
# wrangler.toml
[triggers]
crons = ["0 6 * * 1"] # re-ingest cada lunes, 06:00 UTC
export default {
async scheduled(controller, env, ctx) {
await ingest(env.LLM4AGENTS_API_KEY); // el loop de arriba, sin node:fs
},
};
Dentro de un Worker lees los archivos fuente desde R2 o los traes por URL en lugar de node:fs — pdf_parse acepta una URL igual que un workspace file. Re-upsertar un chunk sin cambios bajo el mismo id es un overwrite, así que la estrategia perezosa de re-ingestar todo cada semana te cuesta centavos, no corrección. Cuando schedule_task salga, este Worker se borra limpio: el schedule se muda adentro de la plataforma y al mismo meter que todo lo demás.
Cómo se compara con un RAG de LangChain
La comparación honesta es por componentes, no por líneas. El tutorial actual de RAG de LangChain arranca en pip install langchain langchain-text-splitters bs4 requests y después te hace ensamblar cinco piezas: un document loader, un text splitter, un modelo de embeddings, un vector store y un LLM — donde el proveedor de embeddings y el vector store son cada uno una decisión de vendor aparte, una credencial aparte y normalmente una factura aparte. Nada de eso está mal. La superficie de ensamblaje de LangChain es el punto: cambia Chroma por Pinecone, cambia el modelo de embeddings, inserta un reranker, y el framework absorbe el cambio.
El trade que hace este tutorial es el opuesto. Almacenamiento, parsing, embedding, retrieval y acceso al modelo son un endpoint, una credencial, un balance, un cost header — y el único componente que te pertenece es un chunker de dos líneas. Renuncias a elegir tu modelo de embeddings; ganas un pipeline cuya superficie completa de vendors es la plataforma a la que ya le pagabas la inferencia, con precio por call en unidades sub-centavo. Para un corpus tamaño handbook y un agente que tiene que rendir cuentas de su propio gasto, ese suele ser el lado correcto del trade. Para un sistema de retrieval con requisitos duros de calidad de embeddings o tuning de índice, no lo es, y conviene saberlo de entrada.
Qué significa para LLM4Agents
RAG es la carga de trabajo que prueba el argumento de composición. Un gateway que solo vendiera tokens vería este pipeline entero — almacenamiento, parsing, embedding, retrieval — fugarse a tres vendors externos, llevándose el cost accounting por tarea con él. Como las cuatro primitivas viven en el mismo meter que la conversación, la pregunta del operador — "cuánto cuesta responder una consulta de soporte, todo incluido" — tiene una sola respuesta en una sola moneda. Ese número es la unit economics de un negocio de agentes, y mantenerlo computable dentro de un solo perímetro de facturación es exactamente para lo que existe la plataforma.
La cifra de 50 líneas es en sí misma el claim de producto. La distancia entre "registré un agente" y "estoy chateando con mis propios documentos" es una tarde y menos de un dólar, y cada paso es legible: sin internals de framework, sin prompt de retrieval oculto, solo tool calls que puedes preciar una por una. Esa legibilidad es lo que hace enseñable a la plataforma — y lo que esta serie sigue capitalizando.
Cómo mantenerse en la frontera
Lo que este tutorial expone sobre los huecos de la plataforma, en el orden en que deberían cerrarse:
- Shippear
schedule_task. El desvío por Workers Cron es el único paso de este post que sale de la plataforma. El re-ingest programado es el trabajo recurrente de agente más común que existe; debería ser un tool call en el mismo meter. - Agregar una tool de conveniencia
rag_ingest. Upload, parse, chunk y upsert como un solo call server-side con una política de chunking por defecto documentada. La descomposición en cuatro primitivas debe quedarse para quien quiere control; el camino de un call es lo que los tutoriales y los agentes walk-up realmente necesitan. - Exponer elección de modelo de embeddings en
vector_upsert. El único modelo bge fijo es la debilidad honesta de la comparativa. Incluso dos opciones — el default actual más un modelo multilingüe — cerrarían casi todo el gap contra los stacks de ensamblaje propio. - Publicar scaffolding de golden evals. Un patrón documentado (o una tool) para guardar golden queries en
memory_sety correrlas post-ingest haría del hábito de eval el default en vez de una buena práctica que los lectores saltan. Además prepara el walkthrough de Promptfoo prometido. - Documentar los límites de batch de
vector_upsert. Este post upserta un handbook completo en un call porque el propio ejemplo de los docs lo hace. El techo real de items por call debería ser un número publicado, no folklore.
Tus documentos, respondiendo preguntas esta misma noche
Registra un agente, fondéalo, deja PDFs en una carpeta y corre las 44 líneas de arriba.
Registra tu agente