Recientemente construí un agente de IA para fitness en mi app usando LangChain.js. Este post cubre la implementación real, no un ejemplo de juguete: decisiones arquitectónicas concretas, el diseño de herramientas que hace útil al agente y lo que aprendí en el proceso.

El Desafío: Construir un Agente de Fitness Contextual

El dominio del fitness plantea dificultades reales para los agentes de IA. Los usuarios describen actividades de formas muy distintas ("comí pollo" vs "comí 200g de pechuga de pollo a la plancha"). El agente necesita recordar el historial del usuario. Los datos nutricionales y de ejercicio requieren precisión para el seguimiento de la salud. Y las respuestas deben sentirse inmediatas y personales.

Estos no son problemas que se resuelvan con un chatbot. Necesitan un sistema que entienda la intención, gestione el contexto y ejecute tareas específicas. Ahí es donde encaja LangChain.js.

¿Por Qué LangChain.js?

LangChain.js es un framework open-source para construir agentes de IA capaces de razonar, tomar decisiones y llamar herramientas externas a partir de entrada en lenguaje natural. Ofrece una forma modular de orquestar LLMs como GPT con flujos de trabajo estructurados, memoria, herramientas y prompts.

Es la elección correcta cuando tu aplicación necesita interactuar con APIs o bases de datos externas según las consultas del usuario, mantener el historial de conversación para respuestas personalizadas, seleccionar dinámicamente qué función o herramienta usar, analizar y devolver salidas estructuradas como JSON, y escalar de forma segura con abstracción de herramientas y manejo de errores.

Visión General de la Arquitectura

La implementación usa una estructura limpia y modular construida en torno a los conceptos centrales de LangChain.js:

// Core service structure
export default class AiCoreService {
  protected llm = new ChatOpenAI({
    model: 'gpt-4.1-mini',
    temperature: 0
  });

  protected prompt = ChatPromptTemplate.fromMessages([
    ['system', fs.readFileSync(path.join(__dirname, 'prompt.md'), 'utf8')],
    new MessagesPlaceholder('chat_history'),
    ['human', '{input}'],
    new MessagesPlaceholder('agent_scratchpad')
  ]);

  async run(rawInput: string): Promise<AiCoreServiceResponse> {
    const agent = await createOpenAIToolsAgent({
      llm: this.llm,
      prompt: this.prompt,
      tools
    });

    const agentExecutor = new AgentExecutor({
      agent,
      verbose: false,
      tools
    });

    const result = await agentExecutor.invoke(
      { input: rawInput, chat_history: this.chatHistory },
      { metadata: { userId: this.userId } }
    );

    return this.parseStructuredOutput(result.output);
  }
}

Diseño Inteligente de Herramientas

El verdadero poder proviene de herramientas bien diseñadas que se encargan de tareas específicas relacionadas con el fitness.

1. Herramienta de Búsqueda en el Contexto

Esta herramienta permite al agente buscar en el historial del usuario mediante búsqueda semántica:

export default new DynamicStructuredTool({
  name: 'search_on_context',
  description: 'Search user\'s personal context and history to answer questions about past activities, progress, or feelings.',
  schema: z.object({
    questionOrMessage: z.string().describe('User question or message'),
    questionOrMessageLanguage: z.number().describe('Original language code'),
    questionOrMessageInEnglish: z.string().describe('Question translated to English')
  }),
  func: async ({ questionOrMessageInEnglish }, runManager?, config?) => {
    const userId = config?.metadata?.userId;
    
    const graphiti = new GraphitiService('user', userId);
    const searchResults = await graphiti.search({
      query: questionOrMessageInEnglish
    });
    
    return searchResults.results
      .filter(result => result.invalid_at === null)
      .map(result => result.fact)
      .join('\n');
  }
});

Lee más sobre Graphiti en: Long-Term Memory for AI: How Graphiti Works

2. Herramienta de Registro de Alimentación

La herramienta de alimentación muestra cuánta lógica corresponde a una herramienta bien diseñada:

export default new DynamicStructuredTool({
  name: 'register_food_eating',
  description: 'Register food consumption when user reports eating something.',
  schema: z.object({
    date: z.string().optional().describe('Date in ISO format'),
    foods: z.array(z.object({
      name: z.string().describe('Food name in Portuguese'),
      englishName: z.string().describe('Food name in English'),
      amount: z.number().describe('Amount consumed'),
      unit: z.string().describe('Measurement unit (g, ml, etc.)'),
      amountInGrams: z.number().describe('Amount in grams'),
      calories: z.number().describe('Calories in portion'),
      proteinsInGrams: z.number().describe('Protein content'),
      carbsInGrams: z.number().describe('Carbohydrate content'),
      fatsInGrams: z.number().describe('Fat content'),
      confidence: z.number().min(0).max(100).describe('Accuracy confidence 0-100'),
      source: z.string().optional().describe('Data source (TACO, USDA, etc.)')
    }))
  }),
  func: async ({ foods, date }, runManager?, config?) => {
    // Process and validate food data
    const processedFoods = await Promise.all(
      foods.map(food => validateAndEnrichFoodData(food))
    );

    // Save to user's daily log
    const dailyMealLog = await DailyMealLogService.findOrCreate(userId, date);
    const result = await dailyMealLog.addFoods(processedFoods);

    return formatFoodRegistrationResponse(result);
  }
});

Estrategia Avanzada de Prompting

El prompt de sistema es fundamental para el comportamiento del agente. Esta es la estructura real utilizada:

You are the AI agent for the app www.peso-certo.com, an app focused on health, fitness, weight loss, and overall well-being.

Your mission is to help users achieve their physical goals by providing **clear**, **useful**, and **personalized** answers, without offering medical diagnoses or treatments.

### Available Tools and Functions:

• When the user asks about their history, use `search_on_context`.  
• When the user reports having eaten something with quantity, use `register_food_eating`.  
• When the user reports exercising, use `register_exercise`.  
• When the user expresses emotions or reflections, use `register_diary_entry`.

### Important Rules:

- For exercises, always recommend **demonstration videos and photos**.  
- For food substitutions, match them **by calorie content**.  
- Respond in the user's language.  
- Use headings and emojis moderately.  
- **Never provide medical diagnoses.**

## Response Format:

If you're not calling a function, respond in JSON:

{
  "answer": "Markdown-formatted answer",
  "sources": [{ "title": "Title", "url": "https://example.com" }]
}

Gestión de Contexto y Memoria

El servicio de chat gestiona el flujo de conversación y el contexto:

export default class ChatService {
  constructor(readonly chat: DocumentType<GPTConversation>) {
    const userId = this.chat.userId.toString();
    this.aiCoreService = new AiCoreService(userId);
    this.messageProcessingService = new MessageProcessingService(this);
  }

  async addUserMessage(params: {
    message?: string | null
    imageData?: string | null
    audioData?: string | null
  }) {
    const content = await this.messageProcessingService.processMessage(params);
    this.chat.messages.push({ role: 'user', content });
    this.aiCoreService.setChatHistory(this.chat.messages);

    return this;
  }

  async invokeAiCore() {
    const response = await this.aiCoreService.run(
      await this.getLastUserMessage()
    );
    this.addAssistantMessage(response);
    return this;
  }
}

Análisis de la Salida Estructurada

Una característica clave es manejar tanto las llamadas a herramientas como las respuestas estructuradas:

parseStructuredOutput(output: string): AiCoreServiceResponse {
  let content = output;
  let sources: Array<{ title: string, url: string }> | undefined;

  try {
    // Try to parse JSON response
    const match = output.match(/```json\s*([\s\S]*?)```/i);
    
    if (match) {
      const json = JSON.parse(match[1]);
      content = json.answer;
      sources = json.sources;
    } else {
      const json = JSON.parse(output);
      content = json.answer;
      sources = json.sources;
    }
  } catch (e) {
    // Fallback to raw output if parsing fails
  }

  return { content, sources: sources || this.DEFAULT_SOURCES };
}

Aprendizajes Clave de Implementación

1. Temperatura Cero para Consistencia

temperature: 0 produce llamadas a herramientas consistentes y reduce las alucinaciones en salidas estructuradas.

2. Procesamiento de Alimentos Basado en Confianza

La herramienta de alimentación valida datos en APIs externas cuando la confianza es baja:

const process = async (food: Food): Promise<Food> => {
  if (food.confidence >= 85) {
    return food;
  }

  // Search external food databases
  const searchResults = await FoodSearchService.searchFoods(
    food.name, 
    food.englishName, 
    food.amount, 
    food.unit
  );

  const bestMatch = await FoodSearchService.selectBestFoodMatch(
    food.name,
    searchResults,
    { macros: extractMacros(food) }
  );

  return enrichFoodWithExternalData(food, bestMatch);
};

3. Soporte Multilingüe

El agente procesa la entrada en portugués mientras usa el inglés para las llamadas a la API y el procesamiento de datos.

4. Inyección Contextual del ID de Usuario

Las herramientas reciben el contexto del usuario a través del parámetro metadata, lo que permite respuestas personalizadas sin exponer datos sensibles al modelo.

Lecciones Aprendidas

  1. El diseño de las herramientas es la parte más difícil. Herramientas atómicas, bien definidas y con descripciones claras marcan la diferencia entre un agente que adivina y uno que actúa correctamente.
  2. El prompt de sistema necesita reglas explícitas sobre cuándo usar cada herramienta, no solo qué hace cada una.
  3. Combinar llamadas a herramientas con respuestas JSON estructuradas da flexibilidad sin perder consistencia.
  4. La búsqueda semántica sobre el historial del usuario es lo que hace que las respuestas se sientan personales en lugar de genéricas.
  5. Los fallbacks elegantes y el manejo de errores son innegociables en producción.

Conclusión

Un agente de IA listo para producción requiere atención cuidadosa al diseño de las herramientas, la estrategia de prompting y la arquitectura del sistema. El dominio del fitness es una buena prueba de estrés porque mezcla datos estructurados (macros, volúmenes de entrenamiento) con entrada no estructurada (cómo los usuarios realmente hablan sobre su día).

El insight que cambió mi forma de pensar sobre esto: el agente no es un chatbot. Es un coordinador. Entiende la intención, recupera los datos relevantes y ejecuta la acción correcta. Diseña bien las herramientas, sé específico en el prompt, y el resto se da solo.