Jak zacząć z LangChain w TypeScript: praktyczny przewodnik dla programistów AI

0
75
3.5/5 - (2 votes)

Z tej publikacji dowiesz się:

Od „wrzuć prompta do API” do zaplanowanego łańcucha

Krótka scenka startowa – kiedy pojedynczy prompt przestaje wystarczać

Wyobraź sobie backend w Node.js, gdzie kilka miesięcy temu powstał „prosty” endpoint: przyjmuje tekst od użytkownika, robi `fetch` do OpenAI i zwraca odpowiedź. Działało to świetnie przez pierwszy tydzień. Później ktoś poprosił o pamięć konwersacji, ktoś inny o integrację z bazą wiedzy, kolejny stakeholder o różne style odpowiedzi i przełączanie modeli. Kod rósł, prompty puchły, a debugowanie przypominało zgadywankę.

Po kilku iteracjach w projekcie lądują gigantyczne stringi promptów, kopiowane między plikami. Parametry modeli ustawiane są ad hoc, nikt nie pamięta, czemu w jednym miejscu temperatura to 0.2, a w innym 0.8. Historia rozmowy bywa dołączana jako zwykły tekst, a gdy pojawiają się halucynacje lub błędy, trudno wskazać, na którym etapie coś poszło nie tak.

W tym momencie surowe „wrzucanie prompta do API” zaczyna być balastem. Aplikacja AI przestaje być eksperymentem, a staje się produktem, który wymaga kontrolowanej orkiestracji: jawnych kroków, powtarzalnych łańcuchów, testowalnych promptów i spójnej obsługi stanu.

Kiedy potrzebna jest warstwa orkiestracji

Im więcej funkcji dodajesz, tym bardziej widać, że brakuje pośredniej warstwy pomiędzy „gołym API LLM” a logiką biznesową. W szczególności, gdy pojawiają się wymagania typu:

  • czat z pamięcią konwersacji, który nie gubi kontekstu po dwóch wiadomościach,
  • łączenie modelu z narzędziami: bazą danych, API produktu, wyszukiwarką,
  • RAG – Retrieval Augmented Generation, czyli odpowiedzi oparte na własnych dokumentach,
  • wielokrokowe procesy: najpierw analiza, potem plan, dopiero na końcu wynik końcowy,
  • łatwe przełączanie modeli (OpenAI, Ollama, Azure, inne API) bez przepisywania kodu.

To nie są „fajerwerki”, tylko typowe wymagania w każdym projekcie, który wychodzi poza demo. Każde z nich da się oczywiście napisać „ręcznie”, ale łączny koszt utrzymania szybko rośnie. Dochodzą do tego kwestie testowania, logowania, wersjonowania promptów oraz obsługi błędów.

Rola LangChain jako „kleju” w aplikacjach AI

LangChain w TypeScript (LangChain.js) pełni rolę warstwy orkiestracji. Nie zastępuje on modeli ani Twojej logiki biznesowej, ale łączy je w spójny proces: od wejścia użytkownika, przez przygotowanie promptu, do wywołania LLM i przetworzenia odpowiedzi. Może też obsłużyć pamięć, narzędzia i retrieval.

Trzonem podejścia LangChain jest myślenie w kategoriach komponowalnych bloków:

  • modele (LLM/ChatModel) – abstrakcja nad konkretnym API,
  • prompty – jawne, testowalne szablony,
  • łańcuchy (Chains / Runnables) – przepływy danych,
  • pamięć (Memory) – historia konwersacji i stan,
  • narzędzia (Tools) i agenci (Agents) – łączenie LLM z logiką aplikacji.

LangChain porządkuje to, co i tak w pewnym momencie musisz zbudować, jeśli Twoja aplikacja AI ma być powtarzalna, zrozumiała i rozwijalna w zespole. Zamiast własnego, ad hoc „mini-frameworka” – dostajesz spójny model myślenia i gotowe komponenty.

Ekran komputera z kodem TypeScript i menu akcji AI
Źródło: Pexels | Autor: Daniil Komov

Co daje LangChain w TypeScript i kiedy go użyć

Kluczowe elementy architektury LangChain.js

W wersji TypeScript najczęściej spotkasz się z kilkoma podstawowymi elementami. Zrozumienie ich na początku oszczędza później sporo czasu:

  • LLM / ChatModel – abstrakcja nad modelami językowymi. W praktyce często używa się klas typu `ChatOpenAI` (OpenAI) czy modeli od innych providerów.
  • PromptTemplate / ChatPromptTemplate – szablony promptów z placeholderami, które możesz parametryzować danymi użytkownika oraz instrukcjami systemowymi.
  • Chain / Runnable – łączenie kroków: wejście → prompt → model → parser → wynik.
  • Tool – funkcja lub usługa, którą model może wywołać (np. zapytanie do bazy, HTTP, kalkulator).
  • Agent – komponent, który decyduje, jak i kiedy użyć narzędzi, bazując na odpowiedzi modelu.
  • Memory – zarządzanie historią czatu, buforowaniem kontekstu, podsumowaniami.

LangChain.js i LangChain dla Pythona są do siebie zbliżone koncepcyjnie, ale nie są identyczne 1:1. W TypeScript mocno wykorzystasz typowanie, integrację z ekosystemem Node/Frontend i nowy system Runnables, który ułatwia budowanie potoków danych zza pomocą operatora `pipe` lub `RunnableSequence`.

LangChain.js a Python – podobieństwa i różnice

Ekosystem LangChain historycznie wyrósł z Pythona, ale LangChain.js rozwija się bardzo dynamicznie, szczególnie pod kątem aplikacji webowych i TypeScriptowych backendów. Kilka różnic praktycznych, które warto znać:

AspektLangChain PythonLangChain.js (TypeScript)
Główny ekosystemData science, backend, analitykaNode.js, backend TS, frontend, edge
TypowanieBrak statycznych typów w runtimeTypeScript – lepsze kontrakty na wejścia/wyjścia
Środowisko uruchomienioweGłównie serwer/batchSerwer, przeglądarka, serverless, edge
IntegracjeBardzo szeroka baza integracji data/MLIntegracje pod typowe API SaaS, web, JS
Use case’yRAG na dużych zbiorach, analitykaCzaty produktowe, asystenci, fronty AI

Jeśli Twoje środowisko to głównie Node, Next.js, Vite, Astro lub inne narzędzia JS/TS, LangChain.js będzie naturalnym wyborem. Zyskasz możliwość użycia tego samego kodu (lub jego fragmentów) zarówno po stronie serwera, jak i w przeglądarce, np. dla lokalnych modeli w połączeniu z Ollama.

Typowe scenariusze użycia w projektach TypeScript

LangChain w TypeScript najczęściej pojawia się w konkretnych klasach projektów:

  • Chatbot produktowy – asystent na stronie SaaS, który rozumie dokumentację produktu, politykę cenową, regulaminy i potrafi prowadzić dłuższe rozmowy.
  • Podsumowania i transformacje tekstu – generowanie streszczeń, przepisów, tłumaczeń, klasyfikacji, np. dla treści blogowych lub rozmów supportu.
  • Asystent programisty – integracje z GitHubem, CI/CD, logami błędów; model może analizować pliki źródłowe, proponować poprawki czy opisy PR-ów.
  • Systemy RAG – wyszukiwanie informacji w bazie dokumentów (regulaminy, dokumentacje, bazy wiedzy), z odpowiedziami generowanymi przez model na podstawie dopasowanych fragmentów.

Wiele z tych przypadków łączy jedno: potrzeba kombinacji kilku kroków i integracji, a nie tylko jednorazowego wywołania modelu. Tu LangChain zaczyna oddawać największe korzyści.

Kiedy LangChain ma sens, a kiedy wystarczy gołe API

Nie każda aplikacja potrzebuje LangChain. Czasem prosty wrapper wokół API LLM jest najlepszym rozwiązaniem. Kilka praktycznych kryteriów:

  • Jeśli tworzysz pojedynczy endpoint, np. generowanie krótkiego opisu produktu z jednego promptu – zwykły `fetch` i własna klasa klienta często są wystarczające.
  • Jeśli wymagania są wielokrokowe (przygotowanie danych → analiza → decyzja → akcja), a logika promptów rośnie – LangChain pomaga uporządkować przepływ.
  • Jeśli projekt wymaga pamięci, RAG, wielu providerów modeli, agentów – ręczna implementacja szybko zamienia się w powtarzanie tego, co LangChain już rozwiązał.
  • Jeśli aplikacja będzie rozwijana przez zespół, typowanie i wspólna abstrakcja łańcuchów z LangChain.js ułatwia komunikację między devami.

Dobrym testem jest proste pytanie: czy logika obsługi LLM zaczyna „przeciekać” po całym projekcie? Jeśli tak – wydzielenie jej do struktury LangChain zwykle porządkuje kod i ogranicza dług techniczny.

Kolorowy kod HTML na ekranie monitora, fragment pracy programisty
Źródło: Pexels | Autor: Pixabay

Przygotowanie środowiska TypeScript pod LangChain

Podstawowy setup Node.js i TypeScript

Pierwszy krok to sensowna, powtarzalna konfiguracja projektu TS. Dla nowego repozytorium w Node.js możesz zacząć od:

mkdir langchain-ts-demo
cd langchain-ts-demo
npm init -y         # albo pnpm init
npm install typescript ts-node --save-dev
npx tsc --init

W `tsconfig.json` zadbaj o kompatybilność z ES Modules (LangChain tego wymaga) i nowszym Node:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src"]
}

Minimum, które warto mieć od początku, to oddzielny katalog `src` i gotowość do uruchamiania plików TypeScript poprzez `ts-node` lub skrypt budujący.

Instalacja LangChain i providerów modeli

LangChain jest pakietem wielomodułowym. Dla OpenAI typowa instalacja wygląda następująco:

npm install langchain @langchain/openai
npm install dotenv --save
npm install @types/node --save-dev

Struktura katalogów może wyglądać tak:

  • `src/llm` – konfiguracja modeli (ChatModel, LLM, providerzy),
  • `src/prompts` – definicje promptów, szablony, pliki tekstowe,
  • `src/chains` – łańcuchy i orkiestracja,
  • `src/rag` – logika indexowania dokumentów i retrievery (jeśli używasz RAG),
  • `.env` – klucze i sekrety, np. `OPENAI_API_KEY`.

Dzięki temu od początku utrzymujesz przejrzysty podział: konfiguracja modeli nie miesza się z logiką chainów, a prompty da się wersjonować niezależnie od reszty.

W miarę jak projekt rośnie, przyda się integracja z CI/CD i automatyczne uruchamianie testów, lintów czy builda – podobnie jak w klasycznych projektach TS/Node. Jeśli równolegle budujesz inne rozwiązania z obszaru informatyka, komputery, nowe technologie, dodatkową inspirację do organizacji repozytorium i procesów znajdziesz na więcej o informatyka.

Zarządzanie kluczami API i konfiguracja narzędzi deweloperskich

Konfiguracja środowiska bezpiecznego dla kluczy API to podstawa. Ustal na starcie konwencję, np. korzystanie z `dotenv` i zmiennych środowiskowych:

// src/config/env.ts
import "dotenv/config";

if (!process.env.OPENAI_API_KEY) {
  throw new Error("Missing OPENAI_API_KEY");
}

export const OPENAI_API_KEY = process.env.OPENAI_API_KEY;

Dla wygody lokalnego developmentu warto dodać kilka narzędzi:

  • ts-node/nodemon – szybkie uruchamianie plików TS bez ręcznego kompilowania,
  • ESLint + Prettier – spójny styl kodu i wykrywanie błędów,
  • Vitest lub Jest – minimalny setup testów, np. do sprawdzania outputu chainów.
Zbliżenie ekranu z kodem w Pythonie używanym do tworzenia aplikacji AI
Źródło: Pexels | Autor: Pixabay

Pierwszy model w LangChain – od surowego API do ChatModel

Różnica między `fetch` a `ChatModel` w praktyce

Surowe wywołanie API OpenAI wygląda zwykle tak:

const response = await fetch("https://api.openai.com/v1/chat/completions", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
  },
  body: JSON.stringify({
    model: "gpt-4o-mini",
    messages: [{ role: "user", content: "Hello!" }],
  }),
});
const data = await response.json();

W LangChain tworzysz natomiast model jako obiekt, który można wielokrotnie wykorzystywać:

// src/llm/openai.ts
import { ChatOpenAI } from "@langchain/openai";
import { OPENAI_API_KEY } from "../config/env";

export const chatModel = new ChatOpenAI({
  apiKey: OPENAI_API_KEY,
  modelName: "gpt-4o-mini",
  temperature: 0.2,
});

Wywołanie sprowadza się do `chatModel.invoke()`, a zmiana modelu lub parametrów nie wymaga grzebania w wielu miejscach kodu. Dodatkowo od razu zyskujesz typy dla wejścia i wyjścia, co ułatwia integrację z resztą aplikacji.

Od pojedynczego wywołania do prostego „łańcucha”

Wyobraź sobie, że product owner prosi o „prosty endpoint do streszczania artykułów”. Dzień później dochodzi wymóg: streszczenie ma mieć konkretną strukturę, a za chwilę – wersję po angielsku i analizę tonu. Kod zaczyna się rozłazić po kontrolerach, helperach i middleware.

LangChain zachęca, żeby takie przepływy zamknąć w jasno zdefiniowanych krokach. Nawet prosta transformacja tekstu zyskuje na czytelności, gdy zamiast jednego dużego „prompta-potwora” budujesz sekwencję kroków.

// src/chains/summarize.ts
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { OPENAI_API_KEY } from "../config/env";

const model = new ChatOpenAI({
  apiKey: OPENAI_API_KEY,
  modelName: "gpt-4o-mini",
  temperature: 0.1,
});

// 1. Szablon promptu z parametrami
const prompt = ChatPromptTemplate.fromTemplate(`
Streść poniższy tekst w maksymalnie 5 zdaniach.
Skup się na najważniejszych informacjach biznesowych.

Tekst:
{input}
`);

// 2. Parser – sprowadza odpowiedź do stringa
const parser = new StringOutputParser();

// 3. Złożenie w chain
export const summarizeChain = prompt.pipe(model).pipe(parser);

// 4. Użycie w dowolnym miejscu aplikacji
export async function summarize(input: string): Promise<string> {
  return summarizeChain.invoke({ input });
}

Taki łańcuch można bezboleśnie rozszerzać: dodać drugi prompt, walidację odpowiedzi czy logowanie. Całość wciąż żyje w jednym, jasno nazwanym module, a reszta aplikacji widzi tylko funkcję `summarize()`.

Praca z szablonami promptów: ChatPromptTemplate w akcji

Najczęstszy ból po kilku sprintach z LLM to „magiczne” stringi w kontrolerach. Prompt rośnie, zmienia się, ktoś robi copy-paste i kończy się na trzech podobnych wersjach tego samego tekstu w różnych plikach.

Szablony promptów dają prostą zasadę: prompty to osobny „artefakt” – można je wersjonować, testować, a nawet trzymać w osobnych plikach Markdown.

// src/prompts/support.ts
import { ChatPromptTemplate } from "@langchain/core/prompts";

export const supportPrompt = ChatPromptTemplate.fromMessages([
  [
    "system",
    `
Jesteś asystentem supportu SaaS.
Odpowiadasz krótko, konkretnie, po polsku.
Jeśli nie znasz odpowiedzi z kontekstu, przyznaj to wprost i zaproponuj kontakt z supportem.
`,
  ],
  [
    "human",
    `
Kontekst (przygotowany przez system):

{context}

Pytanie użytkownika:
{question}
`,
  ],
]);
// src/chains/supportAssistant.ts
import { ChatOpenAI } from "@langchain/openai";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { supportPrompt } from "../prompts/support";
import { OPENAI_API_KEY } from "../config/env";

const model = new ChatOpenAI({
  apiKey: OPENAI_API_KEY,
  modelName: "gpt-4o-mini",
  temperature: 0.2,
});

const parser = new StringOutputParser();

export const supportChain = supportPrompt.pipe(model).pipe(parser);

export async function answerSupportQuestion(params: {
  context: string;
  question: string;
}): Promise<string> {
  return supportChain.invoke(params);
}

Z czasem taki prompt można rozbić na jeszcze bardziej granularne części albo pobierać jego treść z pliku `.md`, co pozwala np. osobie nietechnicznej edytować ton odpowiedzi bez dotykania kodu.

Typowanie wejść i wyjść chainów w TypeScript

W małym PoC-ie nikomu nie przeszkadza `any` na wejściu i wyjściu. W większej aplikacji, gdy chainy wywoływane są z różnych modułów i endpointów, brak typów szybko kończy się serią runtime errorów.

LangChain.js dobrze współpracuje z TypeScriptem, a prosty wzorzec to otoczenie chainu cienką, silnie typowaną funkcją.

// src/chains/productDescription.ts
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { OPENAI_API_KEY } from "../config/env";

export interface ProductDescriptionInput {
  name: string;
  features: string[];
  tone: "formal" | "casual";
}

export interface ProductDescriptionOutput {
  title: string;
  shortDescription: string;
  longDescription: string;
}

const model = new ChatOpenAI({
  apiKey: OPENAI_API_KEY,
  modelName: "gpt-4o-mini",
  temperature: 0.7,
});

const prompt = ChatPromptTemplate.fromTemplate(`
Stwórz opis produktu w tonie: {tone}.

Nazwa: {name}
Funkcje:
{features}

Zwróć wynik w formacie JSON z kluczami:
- title
- shortDescription
- longDescription
`);

const parser = new StringOutputParser();

const baseChain = prompt.pipe(model).pipe(parser);

export async function generateProductDescription(
  input: ProductDescriptionInput
): Promise<ProductDescriptionOutput> {
  const raw = await baseChain.invoke({
    ...input,
    features: input.features.join("n- "),
  });

  // Tu możesz dodać walidację / JSON.parse z bezpieczną obsługą błędów
  const parsed = JSON.parse(raw);
  return parsed as ProductDescriptionOutput;
}

Dzięki zdefiniowanym interfejsom IDE od razu podpowiada, czego brakuje, a kontrakt między chainem a resztą aplikacji jest jasny. Frontend czy inny mikroserwis dostaje stabilny format danych zamiast luźno opisanej „odpowiedzi modelu”.

Dobrym uzupełnieniem będzie też materiał: Zod vs Joi: walidacja danych w TypeScript bez frustracji — warto go przejrzeć w kontekście powyższych wskazówek.

Obsługa błędów i time-outów: defensywne podejście do LLM

W projektach produkcyjnych prędzej czy później pojawia się ta sama sytuacja: model nie odpowiada, zwraca błąd limitu, albo – co gorsza – odpowiedź nie ma w ogóle oczekiwanego formatu. Bez twardych zabezpieczeń takie przypadki rozlewają się po logach i support ma pełne ręce roboty.

Warstwa „defensywna” wokół chainu często jest równie ważna jak sam prompt.

// src/llm/withTimeout.ts
export async function withTimeout<T>(
  promise: Promise<T>,
  ms: number
): Promise<T> {
  let timeoutId: NodeJS.Timeout;

  const timeoutPromise = new Promise<never>((_, reject) => {
    timeoutId = setTimeout(() => {
      reject(new Error(`LLM timeout after ${ms}ms`));
    }, ms);
  });

  try {
    return await Promise.race([promise, timeoutPromise]);
  } finally {
    clearTimeout(timeoutId);
  }
}
// src/chains/safeSummary.ts
import { summarizeChain } from "./summarize";
import { withTimeout } from "../llm/withTimeout";

export async function safeSummarize(input: string): Promise<string> {
  try {
    const result = await withTimeout(
      summarizeChain.invoke({ input }),
      8000 // 8 sekund
    );

    if (!result || result.length < 10) {
      throw new Error("Summary too short or empty");
    }

    return result;
  } catch (error) {
    // Tu możesz wysłać log do Sentry / Datadog itd.
    // i zwrócić neutralny komunikat lub fallback
    return "Nie udało się wygenerować streszczenia. Spróbuj ponownie za chwilę.";
  }
}

Z takim podejściem integracja z kontrolerem HTTP staje się przewidywalna: endpoint zawsze coś zwraca (nawet jeśli fallback), a sekcja „LLM error” w logach ma sensowny kontekst.

Streaming odpowiedzi w Node i w przeglądarce

W aplikacjach chatowych użytkownik nie chce czekać kilku sekund na pełną odpowiedź. Dużo przyjemniejsze wrażenie daje tekst pojawiający się „na żywo”. LangChain.js wspiera strumieniowanie tokenów zarówno na backendzie, jak i w przeglądarce.

Na backendzie (np. w Nest.js, Expressie czy handlerze Next.js) można wypuścić strumień do klienta i aktualizować interfejs w czasie rzeczywistym.

// src/chains/streamingChat.ts
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { OPENAI_API_KEY } from "../config/env";

const model = new ChatOpenAI({
  apiKey: OPENAI_API_KEY,
  modelName: "gpt-4o-mini",
  temperature: 0.3,
  streaming: true,
});

const prompt = ChatPromptTemplate.fromTemplate(`
Jesteś pomocnym asystentem programisty TypeScript.
Odpowiadaj zwięźle, z przykładami, gdy to potrzebne.

Pytanie:
{question}
`);

export function streamAnswer(question: string) {
  const chain = prompt.pipe(model);
  return chain.stream({ question });
}

Przykładowy handler HTTP w Node może wyglądać tak (w uproszczeniu, bez całej otoczki CORS i auth):

// src/http/chatHandler.ts
import type { Request, Response } from "express";
import { streamAnswer } from "../chains/streamingChat";

export async function chatHandler(req: Request, res: Response) {
  const { question } = req.body;

  res.setHeader("Content-Type", "text/plain; charset=utf-8");

  const stream = await streamAnswer(question);

  for await (const chunk of stream) {
    res.write(chunk.content);
  }

  res.end();
}

Po stronie frontendu (np. Next.js, Vite) klient może odczytywać ten strumień i aktualizować komponent czatu w miarę napływu danych. W praktyce wystarczy `ReadableStream` i prosty hook, który dokleja kolejne fragmenty odpowiedzi.

Prosty RAG w TypeScript: od dokumentu do odpowiedzi

W pewnym momencie prosty chatbot „ogólnej wiedzy” przestaje wystarczać. Trzeba, żeby rozumiał FAQ produktu, regulaminy, dokumentację API. Zamiast kopiować te treści w prompty, sensowniej jest zbudować prosty system RAG.

Najpierw potrzebna jest wektoryzacja dokumentów. W małych projektach często wystarcza prosta baza w pamięci połączona z embedderem OpenAI.

// src/rag/vectorStore.ts
import { MemoryVectorStore } from "langchain/vectorstores/memory";
import { OpenAIEmbeddings } from "@langchain/openai";
import { OPENAI_API_KEY } from "../config/env";

const embeddings = new OpenAIEmbeddings({
  apiKey: OPENAI_API_KEY,
  modelName: "text-embedding-3-small",
});

export const memoryStore = new MemoryVectorStore(embeddings);
// src/rag/ingest.ts
import { memoryStore } from "./vectorStore";

export async function ingestDocuments(
  docs: { id: string; content: string; metadata?: Record<string, any> }[]
) {
  await memoryStore.addDocuments(
    docs.map((d) => ({
      pageContent: d.content,
      metadata: { id: d.id, ...d.metadata },
    }))
  );
}

Gdy dokumenty są już zaindeksowane, można zbudować prosty przepływ: pytanie użytkownika → wyszukiwanie podobnych fragmentów → odpowiedź modelu oparta na tych fragmentach.

// src/rag/qaChain.ts
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { memoryStore } from "./vectorStore";
import { OPENAI_API_KEY } from "../config/env";

const model = new ChatOpenAI({
  apiKey: OPENAI_API_KEY,
  modelName: "gpt-4o-mini",
  temperature: 0.1,
});

const qaPrompt = ChatPromptTemplate.fromTemplate(`
Korzystając wyłącznie z dostarczonego kontekstu odpowiedz na pytanie użytkownika.
Jeśli odpowiedź nie jest dostępna w kontekście, napisz wprost, że nie wiesz.

Kontekst:
{context}

Pytanie:
{question}
`);

const parser = new StringOutputParser();
const qaBaseChain = qaPrompt.pipe(model).pipe(parser);

export async function answerWithDocs(question: string): Promise<string> {
  const retriever = memoryStore.asRetriever(4);
  const relevantDocs = await retriever.getRelevantDocuments(question);

  const context = relevantDocs
    .map((doc) => `Źródło: ${doc.metadata.id}n${doc.pageContent}`)
    .join("nn---nn");

  return qaBaseChain.invoke({ question, context });
}

Taki RAG nadaje się na start dla wewnętrznego asystenta w firmie, bota dokumentacyjnego czy prosty help center. Gdy baza rośnie, memory store można wymienić na Pinecone, Qdrant czy inne wektorowe backendy, zachowując ten sam kontrakt `asRetriever()`.

Podział na warstwy w projekcie TypeScript z LangChain

W projektach, gdzie kilka osób grzebie równocześnie w kodzie, brak jasnego podziału na warstwy dość szybko kończy się „big ball of mud”. Jeden kontroler HTTP ma w sobie prompt, konfigurację modelu, logikę parsowania JSON-a i jeszcze retry w razie błędu.

Prosty, praktyczny podział sprawdza się w większości projektów:

  • Warstwa LLM/konfiguracji – pliki w `src/llm` z konkretnymi providerami (OpenAI, Anthropic, lokalne modele), osobne instancje modeli, helpery typu `withTimeout` czy logowanie kosztów.
  • Warstwa promptów – `src/prompts` z jasno nazwanymi szablonami dla konkretnych ról (support, marketing, analityka), najlepiej z testowalnym API.
  • Warstwa chainów – `src/chains` jako miejsce, gdzie łączysz modele, prompty, parsery i narzędzia, a na zewnątrz wystawiasz typowane funkcje domenowe.
  • Warstwa dostępu do danych/RAG – `src/rag` dla retrieverów, indexerów i logiki wektorowej, niezależna od HTTP czy UI.
  • Warstwa transportu – `src/http`, `src/routes`, `src/controllers` lub analogiczna struktura w Nest/Next, gdzie tylko mapujesz request/response na wywołania chainów.

Efekt jest taki, że zmiana modelu, promptu albo całej strategii RAG-u rzadko wymaga naruszania warstwy HTTP. Zespół może równolegle rozwijać UI, routery API i samą logikę AI bez nieustannego wchodzenia sobie w drogę.

Testowanie chainów i promptów w TypeScript

W pewnej firmie SaaS prosty „asystent PR-ów” wciągnięto do oficjalnego procesu review. Po tygodniu zaczęły się zgłoszenia, że generuje nierównej jakości opisy. Okazało się, że ktoś „na czuja” poprawił prompt i nikt tego nie przetestował na reprezentatywnych przykładach.

LangChain nie zwalnia z testów – wręcz przeciwnie, przenosi część złożoności do promptów i chainów, więc sensowne jest potraktowanie ich jak zwykłego kodu.

Strategie testowania: od snapshotów po testy regresyjne na danych

W jednym projekcie wewnętrzny „asystent sprzedaży” nagle zaczął generować zbyt agresywne maile. Jedyna zmiana w repo? „Kosmetyczna” korekta promptu i podbicie temperatury. Brakowało jakiegokolwiek safety netu w postaci testów.

Testy dla chainów w TypeScript można traktować jak mieszankę klasycznych unit testów z testami danych. W praktyce sprawdzają trzy rzeczy: stabilność interfejsu (input/output), jakość treści na przykładowych danych oraz odporność na edge case’y.

// test/summarizeChain.test.ts
import { describe, it, expect } from "vitest";
import { summarizeChain } from "../src/chains/summarize";

describe("summarizeChain", () => {
  it("zwraca krótszy tekst niż wejście", async () => {
    const input =
      "TypeScript to nadzbiór JavaScriptu, który dodaje typowanie statyczne. " +
      "Pozwala łapać błędy w czasie kompilacji i poprawia DX.";

    const result = await summarizeChain.invoke({ input });

    expect(result.length).toBeLessThan(input.length);
    expect(result.toLowerCase()).toContain("typescript");
  });

  it("radzi sobie z bardzo krótkim wejściem", async () => {
    const input = "Hello";
    const result = await summarizeChain.invoke({ input });
    expect(result.length).toBeGreaterThan(0);
  });
});

Takie testy nie gwarantują „idealnej” jakości, ale przynajmniej wychwytują przypadki, gdy ktoś przypadkiem zmieni prompt z polskiego na angielski albo wytnie kluczową instrukcję.

Dla bardziej krytycznych funkcji (np. generowanie odpowiedzi prawno-regulaminowych) przydają się testy regresyjne na przygotowanych przykładach. To zwykły JSON z kilkoma scenariuszami, który można utrzymywać w repo tak jak fixture’y.

// test/data/qa-regressions.json
[
  {
    "name": "Odpowiedź gdy brak informacji w kontekście",
    "question": "Czy w regulaminie jest zapis o karach umownych?",
    "expectedSubstring": "nie posiadam informacji"
  },
  {
    "name": "Odpowiedź na pytanie o czas wypowiedzenia",
    "question": "Jaki jest okres wypowiedzenia umowy?",
    "expectedSubstring": "okres wypowiedzenia"
  }
]
// test/qaChain.regression.test.ts
import { describe, it, expect } from "vitest";
import { answerWithDocs } from "../src/rag/qaChain";
import cases from "./data/qa-regressions.json";

describe("answerWithDocs – regresje", () => {
  for (const testCase of cases) {
    it(testCase.name, async () => {
      const answer = await answerWithDocs(testCase.question);
      expect(answer.toLowerCase()).toContain(
        testCase.expectedSubstring.toLowerCase()
      );
    });
  }
});

Taki plik z testami można rozszerzać w miarę pojawiania się nowych błędów. Każdy zgłoszony problem staje się kolejnym scenariuszem, który chroni przed powrotem tej samej wpadki.

Mockowanie providerów LLM w testach

Jedna z najgorszych niespodzianek to pipeline CI, który losowo się wywala, bo quota w OpenAI się skończyła albo sieć na chwilę padła. Jeśli testy „hapią” bezpośrednio do modeli w chmurze, prędzej czy później to się zdarzy.

Na koniec warto zerknąć również na: Konfiguracja CI/CD dla projektu open source na GitHub Actions — to dobre domknięcie tematu.

Rozsądne podejście: rozdzielenie testów na dwie kategorie – szybkie, deterministyczne (z mockami) i wolne, integracyjne (rzadziej odpalane, np. raz dziennie), które realnie wołają LLM.

// src/llm/openaiChat.ts
import { ChatOpenAI } from "@langchain/openai";
import { OPENAI_API_KEY } from "../config/env";

export function createDefaultChatModel() {
  return new ChatOpenAI({
    apiKey: OPENAI_API_KEY,
    modelName: "gpt-4o-mini",
    temperature: 0.2,
  });
}

W testach można podmienić fabrykę modelu na prosty mock, który nie dzwoni do zewnętrznego API.

// test/mocks/mockChatModel.ts
import type {
  BaseMessageLike,
  ChatResult,
} from "@langchain/core/dist/messages";
import { BaseChatModel } from "@langchain/core/language_models/chat_models";

export class MockChatModel extends BaseChatModel {
  _llmType(): string {
    return "mock-chat";
  }

  async _generate(
    messages: BaseMessageLike[]
  ): Promise<ChatResult> {
    const lastUserMessage = messages[messages.length - 1];
    const content =
      typeof lastUserMessage === "string"
        ? lastUserMessage
        : "Mock response";

    return {
      generations: [
        [
          {
            text: `MOCKED: ${content}`,
          } as any,
        ],
      ],
    };
  }
}
// test/chainWithMock.test.ts
import { describe, it, expect, vi } from "vitest";
import * as openaiChat from "../src/llm/openaiChat";
import { MockChatModel } from "./mocks/mockChatModel";
import { someChainFactory } from "../src/chains/someChainFactory";

describe("someChain z mockowanym modelem", () => {
  it("buduje poprawny prompt", async () => {
    vi.spyOn(openaiChat, "createDefaultChatModel").mockReturnValue(
      new MockChatModel({}) as any
    );

    const chain = someChainFactory();
    const result = await chain.invoke({ input: "test" });

    expect(result).toContain("MOCKED:");
  });
});

Mockowanie modelu pozwala skupić się na logice promptów, parsowania i łączenia narzędzi. Osobna ścieżka integracyjna może raz na jakiś czas sprawdzać rzeczywiste zachowanie modeli w chmurze.

Typowanie odpowiedzi LLM: JSON, schematy i walidacja

W jednym z projektów system generował plany kampanii marketingowych jako JSON. Działało świetnie, dopóki ktoś nie dodał nowego pola i nie uaktualnił frontendu. Model raz je zwracał, raz nie, a UI zaczął rzucać losowymi błędami.

LangChain w TypeScript świetnie współgra z podejściem „LLM-as-JSON-API”, zwłaszcza jeśli połączysz go z walidacją runtime (np. Zod, Valibot). Najpierw warto zdefiniować kształt odpowiedzi.

// src/schemas/planSchema.ts
import { z } from "zod";

export const CampaignPlanSchema = z.object({
  goal: z.string(),
  targetAudience: z.string(),
  channels: z.array(
    z.object({
      name: z.string(),
      budgetPercent: z.number().min(0).max(100),
      description: z.string(),
    })
  ),
  summary: z.string(),
});

export type CampaignPlan = z.infer<typeof CampaignPlanSchema>;

Następnie można zbudować chain, który wymusza JSON, a wynik przepuszcza przez walidator.

// src/chains/campaignPlan.ts
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { CampaignPlanSchema, type CampaignPlan } from "../schemas/planSchema";
import { zodToJsonSchema } from "zod-to-json-schema";
import { OPENAI_API_KEY } from "../config/env";

const model = new ChatOpenAI({
  apiKey: OPENAI_API_KEY,
  modelName: "gpt-4o-mini",
  temperature: 0,
});

const prompt = ChatPromptTemplate.fromTemplate(`
Jesteś strategiem marketingowym B2B.

Wygeneruj plan kampanii w formacie **czystego JSON** bez komentarzy i tekstu poza JSON.
Struktura JSON ma dokładnie odpowiadać poniższemu schematowi:

{jsonSchema}

Dane wejściowe:
Cel: {goal}
Produkt: {product}
Rynek: {market}
`);

const jsonSchema = zodToJsonSchema(CampaignPlanSchema, "CampaignPlan");

export async function generateCampaignPlan(input: {
  goal: string;
  product: string;
  market: string;
}): Promise<CampaignPlan> {
  const chain = prompt.pipe(model);
  const res = await chain.invoke({
    ...input,
    jsonSchema: JSON.stringify(jsonSchema, null, 2),
  });

  const raw = typeof res.content === "string" ? res.content : String(res.content);

  // Prostym podejściem jest usunięcie wszelkiego tekstu poza JSON
  const jsonStart = raw.indexOf("{");
  const jsonEnd = raw.lastIndexOf("}");
  if (jsonStart === -1 || jsonEnd === -1) {
    throw new Error("Model nie zwrócił poprawnego JSON-a");
  }

  const jsonString = raw.slice(jsonStart, jsonEnd + 1);
  const parsed = JSON.parse(jsonString);

  const validated = CampaignPlanSchema.parse(parsed);
  return validated;
}

W razie problemu masz czytelny błąd walidacji z Zoda zamiast tajemniczego „Cannot read property 'x’ of undefined” w komponencie frontendu. Dodatkowo możesz logować odrzucone odpowiedzi i na ich podstawie doprecyzowywać prompt.

Middleware kosztów i logowania zapytań

Przy małym side-projekcie nikt nie patrzy na koszty tokenów. Przy pierwszym poważniejszym wdrożeniu ktoś włącza dashboard OpenAI i nagle okazuje się, że jedna funkcja QA ładuje prompty po kilka kilobajtów każdy.

Prosty wrapper wokół modeli LangChain pozwala zbierać statystyki i szybko namierzać najdroższe ścieżki. Nie trzeba od razu wchodzić w pełne rozwiązania observability – na start wystarczy własne middleware.

// src/llm/withLogging.ts
import type {
  BaseLanguageModelInput,
  BaseLanguageModelCallOptions,
} from "@langchain/core/language_models/base";
import type { BaseChatModel } from "@langchain/core/language_models/chat_models";

export interface LlmLogEntry {
  model: string;
  inputPreview: string;
  durationMs: number;
  success: boolean;
  errorMessage?: string;
}

export type LlmLogger = (entry: LlmLogEntry) => void;

export function withLogging(
  model: BaseChatModel,
  logger: LlmLogger
): BaseChatModel {
  const originalInvoke = model.invoke.bind(model);

  (model as any).invoke = async (
    input: BaseLanguageModelInput,
    options?: BaseLanguageModelCallOptions
  ) => {
    const start = Date.now();
    try {
      const result = await originalInvoke(input, options);
      const durationMs = Date.now() - start;

      logger({
        model: (model as any).modelName ?? "unknown",
        inputPreview: JSON.stringify(input).slice(0, 500),
        durationMs,
        success: true,
      });

      return result;
    } catch (error: any) {
      const durationMs = Date.now() - start;

      logger({
        model: (model as any).modelName ?? "unknown",
        inputPreview: JSON.stringify(input).slice(0, 500),
        durationMs,
        success: false,
        errorMessage: error?.message,
      });

      throw error;
    }
  };

  return model;
}
// src/llm/index.ts
import { ChatOpenAI } from "@langchain/openai";
import { withLogging } from "./withLogging";
import { OPENAI_API_KEY } from "../config/env";

function consoleLogger(entry: LlmLogEntry) {
  // W realnym projekcie: wysyłka do Datadog / Kibana / własnego serwisu
  console.info("[LLM]", JSON.stringify(entry));
}

export const defaultChatModel = withLogging(
  new ChatOpenAI({
    apiKey: OPENAI_API_KEY,
    modelName: "gpt-4o-mini",
    temperature: 0.2,
  }),
  consoleLogger
);

Przy kilku tygodniach danych z takiego loggera łatwo zobaczyć, które prompty są największym „spalaczem” tokenów i które endpointy notorycznie wyrzucają błędy.

LangChain w przeglądarce: bezpieczna integracja z UI

W pewnym startupie produktowcy poprosili o „kliknij i przetestuj” bez lokalnego środowiska. Programiści podpięli LangChain.js bezpośrednio w przeglądarce, razem z API key-em w kodzie frontu. Kilka godzin później klucz krążył już po Discordzie.

LangChain.js działa zarówno w Node, jak i w przeglądarce, ale sposób integracji z UI ma duże znaczenie dla bezpieczeństwa. Typowy, bezpieczny wzorzec to cienka warstwa HTTP (lub serverless) pośrednicząca między frontendem a LLM.

Można jednak wykorzystać LangChain także po stronie klienta – głównie do łączenia strumieni, lokalnej pamięci konwersacji czy transformacji tekstu. Wszystko, co wymaga sekretnych kluczy, powinno zostać na backendzie.

// src/frontend/hooks/useStreamingAnswer.ts
import { useEffect, useState, useRef } from "react";

interface UseStreamingAnswerOptions {
  endpoint: string;
}

export function useStreamingAnswer(
  options: UseStreamingAnswerOptions
) {
  const [data, setData] = useState("");
  const [loading, setLoading] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  const start = async (question: string) => {
    setData("");
    setLoading(true);

    const controller = new AbortController();
    abortRef.current = controller;

    const res = await fetch(options.endpoint, {
      method: "POST",
      body: JSON.stringify({ question }),
      headers: { "Content-Type": "application/json" },
      signal: controller.signal,
    });

    if (!res.body) {
      setLoading(false);
      return;
    }

    const reader = res.body.getReader();
    const decoder = new TextDecoder("utf-8");

    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      const chunk = decoder.decode(value, { stream: true });
      setData((prev) => prev + chunk);
    }

    setLoading(false);
  };

  const stop = () => {
    abortRef.current?.abort();
    setLoading(false);
  };

  return { data, loading, start, stop };
}

W takim układzie LangChain uruchamia się na serwerze, a frontend w ogóle nie musi znać szczegółów używanego modelu. W razie zmiany providera z OpenAI na innego dostawcę, UI nawet się o tym nie dowiaduje.

Orkiestracja wielu chainów: sekwencje i paralelizacja

W jednym narzędziu do analizy konkurencji pipeline wyglądał mniej więcej tak: najpierw streszczenie strony, potem ekstrakcja kluczowych cech produktu, na końcu porównanie z własną ofertą. Początkowo wszystko leciało w jednym, ogromnym promptcie – runtime był słaby, a odpowiedzi chaotyczne.

LangChain zachęca do rozbijania takiej logiki na kilka mniejszych chainów i łączenia ich w sekwencje lub zadania równoległe. Mniejszy kontekst, wyraźniejsza rola modelu i bardziej przewidywalne zachowanie.

// src/chains/analyzeCompetitor.ts
import { RunnableSequence, RunnableMap } from "@langchain/core/runnables";
import { summarizeChain } from "./summarize";
import { extractFeaturesChain } from "./extractFeatures";
import { compareProductsChain } from "./compareProducts";

export const analyzeCompetitorChain = RunnableSequence.from([
  // 1. Wejście: url, html
  async (input: { url: string; html: string }) => input,

  // 2. Równolegle: streszczenie i ekstrakcja cech
  RunnableMap.from({
    summary: (input) => summarizeChain.invoke({ input: input.html }),
    features: (input) =>
      extractFeaturesChain.invoke({ url: input.url, html: input.