353 lines
16 KiB
Markdown
353 lines
16 KiB
Markdown
# Создаём клон Gamma.app: от промпта до AI-агента на LangGraph
|
||
|
||
*Как фронт-энд разработчик из Сбера создал аналог популярного сервиса для генерации презентаций с помощью LangChain и LangGraph*
|
||
|
||

|
||
*[0:00:02 → 0:00:50]*
|
||
|
||
## Кто создаёт AI-сервисы сегодня?
|
||
|
||
В списке Forbes [топ-50 AI-компаний](https://www.forbes.com/lists/ai50) есть множество знакомых названий: OpenAI, Cursor, Bolt New. Я вот обратил внимание на компанию Speak — они с 2016 года разрабатывают приложение для изучения языков с AI-помощником и попали в топ-50.
|
||
|
||

|
||
*[0:00:50 → 0:02:10]*
|
||
|
||
Но главное открытие: **сейчас создавать AI-приложения можно довольно быстро**. Наши студенты делают продукты не хуже тех, что попадают в топ-рейтинги.
|
||
|
||
## Gamma.app: что внутри магии?
|
||
|
||
[Gamma.app](https://gamma.app/) — это сервис для создания презентаций. Вы вводите тему, он создаёт план презентации с заголовками и буллетами для каждого слайда, а затем генерирует полную презентацию с изображениями и оформлением.
|
||
|
||

|
||
*[0:02:10 → 0:03:12]*
|
||
|
||
Выглядит как волшебство, но давайте разберём, как создаются подобные проекты.
|
||
|
||
## Декомпозиция задачи
|
||
|
||
Чтобы создать аналог Gamma.app, нужно решить две основные задачи:
|
||
|
||

|
||
*[0:03:12 → 0:04:15]*
|
||
|
||
1. **Генерировать план презентации** — структуру с заголовками и основными пунктами
|
||
2. **Создавать презентацию** — контент, изображения и оформление на основе плана
|
||
|
||
## Часть 1: Генерация плана с LangChain
|
||
|
||
### Промпт-инжиниринг в реальности
|
||
|
||
Простой промпт в GigaChat может создать план презентации, но для продакшена нужен более сложный подход:
|
||
|
||

|
||
*[0:05:40 → 0:06:23]*
|
||
|
||
Мой промпт включает:
|
||
- Инструкции по созданию плана
|
||
- Описание доступных типов слайдов (титульный, контентный и т.д.)
|
||
- Требования к формату JSON
|
||
- Текущую дату (важно для актуальных тем)
|
||
|
||
### Шаблоны в LangChain
|
||
|
||
LangChain предоставляет механизм шаблонов для удобной работы с промптами:
|
||
|
||
```typescript
|
||
const template = `
|
||
Создай план презентации на тему {topic}.
|
||
Учти следующие требования: {requirements}
|
||
Текущая дата: {current_date}
|
||
{format_instructions}
|
||
`;
|
||
```
|
||
|
||
Переменные в фигурных скобках заменяются на реальные значения при выполнении.
|
||
|
||
### Структурированный вывод с Zod
|
||
|
||
Для надёжной работы с JSON-ответами использую Zod для описания схемы:
|
||
|
||

|
||
*[0:06:23 → 0:08:00]*
|
||
|
||
```typescript
|
||
const presentationSchema = z.object({
|
||
title: z.string().describe("Заголовок презентации"),
|
||
description: z.string().describe("Описание презентации"),
|
||
imageStyle: z.string().describe("Общий стиль изображений"),
|
||
slides: z.array(z.object({
|
||
title: z.string().describe("Заголовок слайда"),
|
||
bullets: z.array(z.string()).describe("Основные пункты"),
|
||
imagePrompt: z.string().describe("Промпт для генерации изображения"),
|
||
webSearchQuery: z.string().optional().describe("Запрос для поиска в интернете")
|
||
}))
|
||
});
|
||
```
|
||
|
||
### Создание цепочки LangChain
|
||
|
||

|
||
*[0:08:00 → 0:09:25]*
|
||
|
||
```typescript
|
||
const parser = StructuredOutputParser.fromZodSchema(presentationSchema);
|
||
const chain = template.pipe(model).pipe(parser);
|
||
|
||
const result = await chain.invoke({
|
||
topic: "Фотографирование котят",
|
||
format_instructions: parser.getFormatInstructions(),
|
||
current_date: new Date().toISOString()
|
||
});
|
||
```
|
||
|
||

|
||
*[0:09:25 → 0:09:55]*
|
||
|
||
## Часть 2: Создание презентации с LangGraph
|
||
|
||
Для генерации самой презентации простого LangChain недостаточно. Нужны:
|
||
- Поиск в интернете для актуальной информации
|
||
- Генерация изображений для каждого слайда
|
||
- Создание контента с учётом контекста
|
||
- Условная логика и роутинг
|
||
|
||

|
||
*[0:09:55 → 0:10:57]*
|
||
|
||
Здесь на помощь приходит **LangGraph**.
|
||
|
||
## Архитектура LangGraph: от нод к агентам
|
||
|
||

|
||
*[0:10:57 → 0:26:20]*
|
||
|
||
### Основные концепции
|
||
|
||
LangGraph работает с **нодами** (nodes) — функциями, которые выполняют конкретные задачи:
|
||
- Каждая нода получает состояние и обновляет его
|
||
- Между нодами есть рёбра (edges) для управления потоком
|
||
- Возможен условный роутинг и циклы
|
||
- Единое состояние передаётся между всеми нодами
|
||
|
||
### Архитектура моего клона
|
||
|
||
Я создал компактный и эффективный граф из нескольких нод:
|
||
|
||
1. **Prepare** — подготовка данных и присвоение ID слайдам
|
||
2. **Router** — решение, нужен ли веб-поиск
|
||
3. **WebSearch** — поиск актуальной информации
|
||
4. **Generate Images** — генерация изображений
|
||
5. **Generate Content** — создание контента слайдов
|
||
6. **Final** — финализация результата
|
||
|
||
### Нода подготовки данных
|
||
|
||
```typescript
|
||
async function prepareNode(state: GraphState): Promise<Partial<GraphState>> {
|
||
const presentation = state.presentation;
|
||
|
||
// Присваиваем ID каждому слайду
|
||
presentation.slides.forEach((slide, index) => {
|
||
slide.id = `slide-${index}`;
|
||
});
|
||
|
||
return { presentation };
|
||
}
|
||
```
|
||
|
||
### Условный роутинг
|
||
|
||
Для принятия решений использую **Conditional Edge**:
|
||
|
||
```typescript
|
||
function routeToWebSearch(state: GraphState): string[] {
|
||
const sends = [];
|
||
|
||
state.presentation.slides.forEach(slide => {
|
||
if (slide.webSearchQuery) {
|
||
sends.push(
|
||
new Send("webresearch", { slide })
|
||
);
|
||
}
|
||
});
|
||
|
||
return sends.length > 0 ? sends : ["generate"];
|
||
}
|
||
```
|
||
|
||
### Веб-поиск с Tavily
|
||
|
||
Для поиска актуальной информации использую Tavily API:
|
||
|
||
```typescript
|
||
async function webSearchNode(data: { slide: Slide }): Promise<Partial<GraphState>> {
|
||
const tavily = new TavilySearchResults({
|
||
maxResults: 5,
|
||
searchDepth: "advanced"
|
||
});
|
||
|
||
const results = await tavily.search(data.slide.webSearchQuery);
|
||
|
||
// Экранируем фигурные скобки в коде
|
||
const cleanResults = results.replace(/\{/g, '{{').replace(/\}/g, '}}');
|
||
|
||
return {
|
||
webSearchResults: {
|
||
[data.slide.id]: cleanResults
|
||
}
|
||
};
|
||
}
|
||
```
|
||
|
||
**Важный момент**: LangChain воспринимает фигурные скобки как переменные шаблона, поэтому код нужно экранировать двойными скобками.
|
||
|
||
### Генерация изображений с Kandinsky
|
||
|
||
Для генерации изображений использую российскую нейросеть Kandinsky через API:
|
||
|
||
```typescript
|
||
async function generateImageNode(data: {
|
||
slide: Slide,
|
||
imageStyle: string
|
||
}): Promise<Partial<GraphState>> {
|
||
const kandinsky = new KandinskyAPI();
|
||
|
||
const imagePrompt = `${data.slide.imagePrompt}, ${data.imageStyle}`;
|
||
const image = await kandinsky.generateImage({
|
||
prompt: imagePrompt,
|
||
negativePrompt: data.slide.negativeImagePrompt,
|
||
width: data.slide.type === 'title' ? 512 : 768,
|
||
height: data.slide.type === 'title' ? 512 : 432
|
||
});
|
||
|
||
return {
|
||
generatedImages: {
|
||
[data.slide.id]: image.url
|
||
}
|
||
};
|
||
}
|
||
```
|
||
|
||
### Генерация контента с сохранением контекста
|
||
|
||
Самая интересная часть — создание контента с учётом предыдущих слайдов:
|
||
|
||
```typescript
|
||
async function generateContentNode(state: GraphState): Promise<Partial<GraphState>> {
|
||
const messages = [
|
||
{ role: 'system', content: 'Ты создаёшь контент для слайдов презентации...' }
|
||
];
|
||
|
||
for (const slide of state.presentation.slides) {
|
||
// Добавляем запрос на генерацию контента
|
||
messages.push({
|
||
role: 'user',
|
||
content: `Создай контент для слайда "${slide.title}".
|
||
Используй найденную информацию: ${state.webSearchResults[slide.id] || ''}
|
||
Основные пункты: ${slide.bullets.join(', ')}`
|
||
});
|
||
|
||
// Получаем ответ от LLM
|
||
const response = await model.invoke(messages);
|
||
messages.push({ role: 'assistant', content: response });
|
||
|
||
// Запрашиваем комментарий для спикера
|
||
messages.push({
|
||
role: 'user',
|
||
content: 'Добавь комментарий для спикера: что рассказывать по этому слайду?'
|
||
});
|
||
|
||
const speakerNotes = await model.invoke(messages);
|
||
messages.push({ role: 'assistant', content: speakerNotes });
|
||
|
||
// Сохраняем результат
|
||
slide.content = response;
|
||
slide.speakerNotes = speakerNotes;
|
||
}
|
||
|
||
return { presentation: state.presentation };
|
||
}
|
||
```
|
||
|
||
Такой подход **сохраняет контекст** между слайдами, позволяя создавать логически связанные презентации.
|
||
|
||
### Сборка графа воедино
|
||
|
||
```typescript
|
||
const graph = new StateGraph(GraphState)
|
||
.addNode("prepare", prepareNode)
|
||
.addNode("webresearch", webSearchNode)
|
||
.addNode("generateImages", generateImageNode)
|
||
.addNode("generateContent", generateContentNode)
|
||
.addNode("final", finalNode)
|
||
.setEntryPoint("prepare")
|
||
.addConditionalEdges("prepare", routeToWebSearch)
|
||
.addEdge("webresearch", "generateImages")
|
||
.addEdge("generateImages", "generateContent")
|
||
.addEdge("generateContent", "final")
|
||
.setFinishPoint("final");
|
||
|
||
const app = graph.compile();
|
||
|
||
// Запуск генерации
|
||
const result = await app.invoke({
|
||
presentation: planFromLangChain
|
||
});
|
||
```
|
||
|
||
## Технологический стек
|
||
|
||
- **LangChain** — для простых цепочек обработки
|
||
- **LangGraph** — для сложных сценариев с условными переходами
|
||
- **Zod** — для структурированного вывода
|
||
- **Tavily** — для веб-поиска
|
||
- **Kandinsky API** — для генерации изображений
|
||
- **TypeScript** — для типизации и надёжности
|
||
|
||
## Оптимизация и экономика
|
||
|
||
### Параллельное выполнение
|
||
|
||
LangGraph позволяет запускать ноды параллельно. Например, генерация изображений для разных слайдов происходит одновременно, что значительно ускоряет процесс.
|
||
|
||
### Стоимость генерации
|
||
|
||
Примерная стоимость создания презентации из 8 слайдов:
|
||
- Планирование: ~$0.02
|
||
- Веб-поиск: ~$0.01
|
||
- Генерация изображений: ~$0.06
|
||
- Создание контента: ~$0.02
|
||
|
||
**Итого: ~$0.11** себестоимость при продажной цене $2-5.
|
||
|
||
## Практические советы
|
||
|
||
1. **Экранируйте фигурные скобки** в коде, иначе LangChain будет пытаться их интерпретировать
|
||
2. **Используйте Zod** для гарантированной структуры ответов
|
||
3. **Сохраняйте контекст** между запросами для связности контента
|
||
4. **Тестируйте промпты** на разных темах и языках
|
||
5. **Добавляйте текущую дату** для актуальных тем
|
||
|
||
## Заключение
|
||
|
||
Создание AI-агентов — это **не rocket science**. Фронт-энд разработчик может создать полноценный аналог коммерческого сервиса, используя современные инструменты.
|
||
|
||
Главное — **пробовать и экспериментировать**. Это направление развивается очень быстро, и скоро каждый разработчик будет работать с AI-агентами.
|
||
|
||

|
||
|
||
*QR-код для доступа к демо-версии клона Gamma.app*
|
||
|
||
---
|
||
|
||
*Статья основана на докладе Александра Примакова, фронт-энд разработчика Сбера с 10-летним опытом в IT. Александр также преподаёт в университетах и успешно запустил курс по созданию AI-агентов для магистрантов КФУ.*
|
||
|
||
### Полезные ссылки
|
||
|
||
- [LangChain Documentation](https://langchain.com/docs)
|
||
- [LangGraph Tutorial](https://langchain.com/langgraph)
|
||
- [Zod Schema Validation](https://zod.dev/)
|
||
- [Tavily Search API](https://tavily.com/)
|
||
- [Kandinsky API](https://fusionbrain.ai/)
|
||
|
||
**Хэштеги:** #AI #LangChain #LangGraph #TypeScript #Презентации #Kandinsky #MachineLearning |