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.

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ć:
| Aspekt | LangChain Python | LangChain.js (TypeScript) |
|---|---|---|
| Główny ekosystem | Data science, backend, analityka | Node.js, backend TS, frontend, edge |
| Typowanie | Brak statycznych typów w runtime | TypeScript – lepsze kontrakty na wejścia/wyjścia |
| Środowisko uruchomieniowe | Głównie serwer/batch | Serwer, przeglądarka, serverless, edge |
| Integracje | Bardzo szeroka baza integracji data/ML | Integracje pod typowe API SaaS, web, JS |
| Use case’y | RAG na dużych zbiorach, analityka | Czaty 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.

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.

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.






