Главная / Статьи / Встраивание агента LangChain в FastAPI: инструменты, поиск в руководстве, потоковая передача данных.

Встраивание агента LangChain в FastAPI: инструменты, поиск в руководстве, потоковая передача данных.

Создание встроенного ассистента в приложении с использованием FastAPI и LangChain: PDF-руководство в ChromaDB, доступное как инструмент, контекст для каждого пользователя, история с сохранением состояния и потоковые ответы.

6913 слов

Как только приложение вырастает за пределы нескольких экранов, его документация начинает разрастаться, и пользователи перестают её читать. 20-страничный руководство, объясняющее функции, настройки и правила домена, имеет ценность, но только в том случае, если люди могут найти ответы без долгих поисков. Встроенный в продукт ассистент может закрыть этот разрыв: он отвечает на вопросы типа «Как это работает?», основываясь на руководстве, и «Что есть в моем проекте?», используя собственные данные приложения.

В этом руководстве показана компактная рабочая версия такого ассистента. Вы подключите агента LangChain к сервису FastAPI, предоставите ему инструмент для чтения данных приложения и второй инструмент для поиска информации в PDF-руководстве, хранящемся в ChromaDB, передадите авторизованного пользователя агенту по запросу, сохраните историю разговора с помощью checkpointer от LangGraph и передадите ответ обратно клиенту. По пути мы указываем на недостатки минимального кода, которые необходимо устранить для его корректной работы, а также на изменения, требуемые перед запуском в производственных условиях.

Сценарий и компоненты

Представьте команду, которая разрабатывает инструмент для проектирования фотовольтаических установок. Изначально продукт был простым, но затем к нему добавились панели, инверторы, оценки производства, схемы расположения на крышах и длинный список правил проектирования. Теперь его руководство насчитывает более 20 страниц. Постоянно возникают два типа вопросов:

  • Вопросы о самом продукте: что делает та или иная функция, где находится соответствующее настроение, какое правило применяется. Ответы содержатся в документации.
  • Вопросы, связанные с работой пользователя: какой инвертор он выбрал, сколько мощности производит его система, какие крыши он оснастил оборудованием. Ответы хранятся в базе данных приложения, и ни одна модель по умолчанию их не знает.

Технология Retrieval-Augmented Generation (RAG) обрабатывает первый тип вопросов: индексируется руководство, при поступлении вопроса находятся соответствующие фрагменты, которые затем передаются модели в качестве контекста. Инструменты обрабатывают второй тип вопросов: небольшие функции, которые модель может вызывать для получения данных из приложения. Сочетая оба подхода, можно создать помощника, способного как объяснять характеристики продукта, так и анализировать конкретный проект.

Настоящая система, стоящая за этим сценарием, включает гораздо больше инструментов и значительно более сложную логику домена. Приведенное ниже описание намеренно сокращено до основных компонентов, чтобы архитектура оставалась понятной:

  • FastAPI обеспечивает доступ к HTTP-API и определяет, кто является вызывающим элементом.
  • Агент LangChain (работающий на LangGraph) отвечает за цикл рассуждений и состояние разговора.
  • LLM интерпретирует каждый запрос и решает, требуется ли внешняя информация.
  • Инструменты предоставляют агенту контролируемый доступ к функциям приложения.
  • RAG позволяет агенту искать информацию в документации.
  • ChromaDB хранит фрагменты руководства и выполняет векторный поиск.
  • Стриминг передает токены клиенту на протяжении генерации ответа.

Преимущество такого подхода заключается в том, что ассистент работает внутри существующего полноценного приложения с реальными пользователями и данными, а не существует отдельно в качестве обычного чат-бота.

Структура проекта

Каждая функция имеет свой пакет: маршрутизация HTTP, аутентификация, логика агента, инструменты и пайплайн RAG. Это позволяет сохранять файлы небольшими и четко определять, куда следует добавить новую функциональность.

project/
│
├── main.py
├── .env
├── .gitignore
│
├── auth/
│   ├── __init__.py
│   └── dependencies.py
│
├── routers/
│   ├── __init__.py
│   └── chat.py
│
├── llm/
│   ├── __init__.py
│   ├── agent.py
│   ├── context.py
│   ├── orchestrator.py
│   ├── prompts.py
│   ├── provider.py
│   │
│   ├── tools/
│   │   ├── __init__.py
│   │   ├── demo_tool.py
│   │   └── manual_tool.py
│   │
│   └── rag/
│       ├── __init__.py
│       ├── config.py
│       ├── context.py
│       │
│       ├── ingestion/
│       │   ├── __init__.py
│       │   ├── loader.py
│       │   ├── chunker.py
│       │   ├── chroma.py
│       │   └── indexer.py
│       │
│       └── retrieval/
│           ├── __init__.py
│           └── retriever.py
│
├── scripts/
│   ├── __init__.py
│   └── index_manual.py
│
├── docs/
│   └── manual.pdf
│
└── chroma_data/ (*generated locally, not commited or deployed)

Назначение каждой части:

  • main.py создает приложение FastAPI.
  • auth/ содержит имитацию компонента аутентификации.
  • routers/ хранит точки входа HTTP.
  • llm/ включает всё, что связано с агентом.
  • llm/tools/ содержит функции, которые может вызывать агент.
  • llm/rag/ содержит пайплайн по извлечению информации, разделенный на ingestion/ (загрузка, разбиение на части и индексация PDF) и retrieval/ (запрос к ChromaDB).
  • scripts/ хранит команды, которые выполняются вручную, например, индексация руководства.
  • docs/ содержит исходный PDF-файл.
  • chroma_data/ генерируется локально и никогда не должен подвергаться коммиту или развертыванию.
  • Вы не будете создавать все эти элементы сразу. Порядок сборки: слой API, затем модель, затем инструменты, затем пайплайн RAG, после чего — контекст, потоковая обработка и история.

    Шаг 1: Шаблон FastAPI с имитированным пользователем

    Начните с установки всего, что понадобится проекту. В список входят веб-сервер, LangChain и LangGraph, чат-клиент совместимый с OpenAI, ChromaDB, загрузчик PDF и инструменты для разбиения текста, а также python-dotenv для настройки.

    pip install fastapi uvicorn langchain langgraph langchain-openai chromadb langchain-community langchain-text-splitters pypdf python-dotenv
    

    Доступ к модели будет осуществляться через OpenRouter, поэтому создайте файл .env в корне проекта, в котором будет храниться ключ API.

    OPENROUTER_API_KEY=your_api_key_here
    

    Немедленно добавьте файл .env в список .gitignore. Ключ, попавший в систему контроля версий, следует считать утекшим.

    Точка входа приложения

    Файл main.py остается минимальным: он создает приложение и регистрирует маршрутизатор чата; здесь не должно быть ничего, связанного с моделью или агентом.

    from fastapi import FastAPI
    from routers.chat import chat_router
    
    app = FastAPI(
        title="AI Agent Demo",
    )
    app.include_router(chat_router)
    

    Первый конечный пункт чата

    В файле routers/chat.py определите маршрутизатор под префиксом /chat с одним маршрутом типа POST. Пока он просто воспроизводит поступившее сообщение, что достаточно для подтверждения корректной работы системы до включения ИИ.

    from fastapi import APIRouter
    
    chat_router = APIRouter(
        prefix="/chat",
        tags=["Chat"],
    )
    
    @chat_router.post("")
    def ask_ai(
        message: str,
    ):
        return {
            "message": message,
        }
    

    Обратите внимание, что при использовании маршрута POST без модели тела параметр message: str заставляет FastAPI читать его из строки запроса. Это удобно для тестирования в Swagger UI, но в реальном клиентском приложении обычно используется JSON-тело, определённое с помощью модели Pydantic, поскольку строки запроса попадают в журналы доступа и имеют ограничения по длине.

    Виртуальная зависимость для аутентификации

    В производственном приложении проверяется куки сессии или JWT, после чего пользователя загружают из базы данных. Здесь всё это заменяется пользовательским заголовком. Создайте файл auth/dependencies.py, в котором будет находиться небольшой класс-данные MockUser и функция get_current_user, читающая заголовок X-Demo-User и отклоняющая запрос с кодом 401 при его отсутствии.

    from dataclasses import dataclass
    from fastapi import Header, HTTPException
    
    @dataclass
    class MockUser:
        id: str
        name: str
    
    def get_current_user(
        x_demo_user: str | None = Header(default=None),
    ) -> MockUser:
        if x_demo_user is None:
            raise HTTPException(
                status_code=401,
                detail="Missing X-Demo-User header",
            )
        return MockUser(
            id=x_demo_user,
            name=x_demo_user,
        )
    

    В FastAPI внедрение зависимостей теперь передает пользователя на конечную точку. Для этого достаточно указать параметр с помощью Depends(get_current_user).

    from fastapi import APIRouter, Depends
    from auth.dependencies import (
        MockUser,
        get_current_user,
    )
    
    chat_router = APIRouter(
        prefix="/chat",
        tags=["Chat"],
    )
    
    @chat_router.post("")
    def ask_ai(
        message: str,
        current_user: MockUser = Depends(
            get_current_user,
        ),
    ):
        return {
            "user": current_user.name,
            "message": message,
        }
    

    Клиент идентифицирует себя, отправляя заголовок вот такого вида:

    X-Demo-User: user-123
    

    При каждом запросе FastAPI сначала вызывает get_current_user(), а затем передаёт полученный объект MockUser в функцию ask_ai. Важным аспектом проектирования является разделение обязанностей: FastAPI отвечает за аутентификацию, а слой ИИ просто получает объект пользователя, которому может доверять. Позже именно этот объект пользователя позволяет инструментам возвращать данные, принадлежащие соответствующему пользователю. Замена мок-объекта на реальную систему аутентификации позже влияет только на эту одну зависимость.

    Шаг 2: Подключение модели через OpenRouter

    При наличии рабочего конечной точки и известного вызывающего элемента сервису требуется модель. OpenRouter предоставляет API, совместимое с OpenAI, поэтому класс ChatOpenAI из LangChain может с ним работать, если указать base_url на OpenRouter и передать свой ключ от OpenRouter.

    Разместите этот код в файле llm/provider.py. Он загружает файл .env, мгновенно выдает четкую ошибку при отсутствии ключа и оборачивает его с помощью класса SecretStr из Pydantic, чтобы он случайно не попал в логи или выводы.

    import os
    from dotenv import load_dotenv
    from pydantic import SecretStr
    from langchain_openai import ChatOpenAI
    
    load_dotenv()
    
    api_key = os.getenv(
        "OPENROUTER_API_KEY",
    )
    if not api_key:
        raise RuntimeError(
            "OPENROUTER_API_KEY environment variable is not set."
        )
    
    model = ChatOpenAI(
        model="YOUR_MODEL",
        api_key=SecretStr(api_key),
        base_url="https://openrouter.ai/api/v1",
    )
    

    Поскольку ключ берется из среды, он никогда не появляется в исходном коде. На этом этапе уже можно отправлять запросы модели и получать ответы, но это будет обычный вызов LLM. Цель — создать агента, который сам может решить, когда ему нужен инструмент.

    Выбор модели

    Аргумент model представляет собой просто идентификатор модели OpenRouter, поэтому вы можете менять модели без изменения остального кода. При сравнении вариантов проверьте:

    • наличие поддержки вызова инструментов, от которой зависит агент;
    • поддержку стриминга;
    • размер окна контекста;
    • ограничения по частоте запросов;
    • наличие бесплатного тарифа.

    OpenRouter предлагает некоторые модели бесплатно, что удобно во время экспериментов. Каталог регулярно обновляется, поэтому просмотрите текущий список и отфильтруйте бесплатные модели, вместо того чтобы полагаться на фиксированные рекомендации. Любой выбранный вариант сразу передается в конструктор:

    model = ChatOpenAI(
        model="YOUR_MODEL_ID",
        api_key=SecretStr(api_key),
        base_url="https://openrouter.ai/api/v1",
    )
    

    Например, если в каталоге указан такой идентификатор, как показано ниже (пример взят на момент написания; он может уже не быть доступен), вы должны передать именно эту строку в качестве model:

    google/gemma-4-26b-a4b-it:free
    

    Имейте в виду, что бесплатные модели используют общую пропускную способность. Их работа может ограничиваться по скорости или становиться недоступной, часто в самый неподходящий момент во время демонстрации. Переключение на другую модель или использование собственного ключа поставщика через OpenRouter обычно решает эту проблему. Для производственного использования выбирайте модели исходя из надежности, функциональных возможностей, задержек и стоимости, а не только по цене.

    Шаг 3: От модели к агенту

    Прямой вызов модели представляет собой один этап: текст пользователя поступает в модель, и получается ответ. Агент вводит цикл принятия решений. Модель анализирует запрос, определяет, может ли она ответить сразу или ей нужны дополнительные данные, при необходимости вызывает соответствующий инструмент, читает результат и только затем формирует окончательный ответ. В общих чертах:

    • простой вызов: пользователь → LLM → ответ;
  • агент: пользователь, затем агент, после чего LLM определяет необходимое, затем выполняется вызов инструмента или поиск при необходимости, и наконец формируется ответ.
  • Модуль агента

    В файле llm/agent.py функция create_agent из LangChain создаёт агента на основе модели и системного промпта. Под спудом она генерирует граф LangGraph, который автоматически запускает цикл работы с моделью и инструментами.

    from langchain.agents import create_agent
    from llm.provider import model
    from llm.prompts import SYSTEM_PROMPT
    
    agent = create_agent(
        model=model,
        system_prompt=SYSTEM_PROMPT,
    )
    

    У этого агента пока нет инструментов, поэтому он ведёт себя примерно как чистая модель. Сначала необходимо дать ему инструкции.

    Системный промпт

    В файле llm/prompts.py хранится краткий промпт, который объясняет модели её назначение, запрещает выдумывать данные и указывает, какой тип вопроса соответствует тому или иному виду поиска.

    SYSTEM_PROMPT = """
    You are an AI assistant for our demo application.
    You help users understand the application and navigate the system.
    Never invent data.
    When information about the demo system
    is required, use the available application tools.
    When answering questions about the application,
    use the documentation search tool.
    Always answer in clear, conversational language.
    """.strip()
    

    Промпт определяет два источника информации:

    • данные приложения (то, что находится в учётной записи пользователя) поступают от инструментов приложения;
    • Знания о приложении (как работает продукт) получаются путем поиска в документации.

    Одно уточнение перед тем, как продолжить. Инструменты и RAG рассматриваются отдельно ниже, потому что это облегчает их понимание, но в готовом агенте сам поиск в документации является инструментом. Другого механизма нет: агент видит список функций, которые можно вызвать, и поиск в руководстве — одна из них.

    Шаг 4: Первый инструмент

    Инструмент — это функция, которую агенту разрешено вызывать. Именно это позволяет архитектуре масштабироваться: вместо того чтобы вкладывать всю информацию о приложении в промпт, вы предоставляете конкретные операции и позволяете модели запрашивать их только тогда, когда это необходимо для ответа на вопрос.

    Для демонстрации файл llm/tools/demo_tool.py определяет инструмент, который возвращает фиксированный блок информации о проекте.

    from langchain.tools import tool
    
    @tool
    def get_my_demo_data() -> str:
        """
        Return information about the demonstration data.
        This is just for demo data. But in production, make a more detailed instruction.
        """
    
        return """
        Project: Aperture Analytics Dashboard
        Owner: Jordan Lee
        Status: In Progress
        Team size: 6
        Budget: $84,000
        Deadline: 2026-11-15
        Description: An internal dashboard for visualizing customer usage
        metrics, built with FastAPI and React, integrating with the
        company's data warehouse.
        """.strip()
    

    Здесь важны два момента. Декоратор @tool превращает обычную функцию Python в инструмент LangChain, беря её имя и схему аргументов из подписи функции. А документация становится описанием инструмента, которое модель читает при принятии решения о его вызове. В реальной системе такое описание требует особого внимания: необходимо точно указать, что возвращает инструмент, когда это уместно, а когда — нет. Неопределённое описание является одной из наиболее распространённых причин того, что агент вызывает неверный инструмент или вообще не вызывает его.

    Чтобы зарегистрировать инструмент, передайте его в функцию create_agent:

    from langchain.agents import create_agent
    from llm.provider import model
    from llm.prompts import SYSTEM_PROMPT
    from llm.tools.demo_tool import get_my_demo_data
    
    agent = create_agent(
        model=model,
        tools=[
            get_my_demo_data,
        ],
        system_prompt=SYSTEM_PROMPT,
    )
    

    Ваш код никогда не определяет момент выполнения функции. Если пользователь спрашивает «Какие у меня есть демо-данные?», модель понимает, что ей нужна информация, специфичная для аккаунта, и вызывает get_my_demo_data(). Если пользователь спрашивает «Что такое демо?», поиск не требуется, и модель отвечает непосредственно. Этот выбор происходит при каждом шаге внутри цикла агента.

    Шаг 5: Создание пайплайна RAG для руководства

    Теперь агент может загружать данные приложения, но он по-прежнему ничего не знает о том, как работает продукт. Вставка 20-страничного руководства в системный промпт приведет к трате токенов при каждом запросе и затруднит его обновление. RAG позволяет избежать обоих этих проблем.

    Важно четко определить, что такое RAG и что в него не входит. На документации ничего не обучается и не настраивается. При поступлении запроса система ищет в руководстве фрагменты, наиболее соответствующие вопросу, и передает эти фрагменты модели в качестве контекста, после чего модель отвечает на основе их содержимого.

    Процесс состоит из двух этапов:

    1. Прием данных, который выполняется отдельно от веб-приложения: загружается PDF, он разбивается на фрагменты, которые затем хранятся вместе с их эмбеддингами в ChromaDB.
    2. Поиск информации, который происходит в рамках одного запроса: берется вопрос, производится поиск в ChromaDB, собираются наилучшие фрагменты, которые затем передаются модели.

    Если вам нужен более полный обзор этих концепций, в обзоре блога метода извлечения свежих знаний по запросу они рассматриваются более подробно; здесь мы сосредоточимся на реализации.

    Загрузка PDF

    llm/rag/ingestion/loader.py использует компонент PyPDFLoader из LangChain, который преобразует каждую страницу PDF в объект Document.

    from pathlib import Path
    from langchain_community.document_loaders import PyPDFLoader
    
    PDF_PATH = Path("docs/manual.pdf")
    
    def load_manual():
        loader = PyPDFLoader(
            str(PDF_PATH),
        )
        documents = loader.load()
        return documents
    

    Объект Document содержит два элемента: извлеченный текст в поле page_content и словарь metadata, описывающий источник этого текста. Именно метаданные позволяют позже указывать на конкретную страницу в ответе. Концептуально каждая загруженная страница выглядит следующим образом:

    Document
    ├── page_content
    │   └── "To create a new demo data..."
    │
    └── metadata
        ├── source: docs/manual.pdf
        └── page: 12
    

    PyPDFLoader обычно сохраняет как индекс page, начинающийся с нуля, так и page_label, удобный для человека. В приведённом ниже коде используется page_label, который соответствует номерам страниц, видимым у читателей в PDF.

    Разделение страниц на блоки

    Поиск по всем страницам, не говоря уже о поиске по всему документу как по одному блоку, даёт крупные результаты. Файл llm/rag/ingestion/chunker.py разделяет документы с помощью RecursiveCharacterTextSplitter.

    from langchain_text_splitters import (
        RecursiveCharacterTextSplitter,
    )
    from langchain_core.documents import Document
    
    def chunk_documents(
        documents: list[Document],
    ) -> list[Document]:
        splitter = RecursiveCharacterTextSplitter(
            chunk_size=1000,
            chunk_overlap=150,
            separators=[
                "\n\n",
                "\n",
                ". ",
                " ",
                "",
            ],
        )
        return splitter.split_documents(
            documents,
        )
    

    Разделитель предназначен для создания фрагментов длиной примерно 1 000 символов с перекрытием в 150 символов. Список разделителей применяется в определённом порядке: сначала предпочтение отдаётся разделению на границах абзацев, затем — на разрывах строк, после этого — на концах предложений, далее — на пробелах, и только в крайнем случае — посередине слова. Перекрытие необходимо для того, чтобы один факт мог распространяться на несколько точек разделения; повторение небольшого участка текста с обеих сторон снижает вероятность того, что соответствующее предложение окажется разрезанным пополам.

    Эти цифры являются отправной точкой, а не строгими правилами. Оптимальный размер зависит от того, как написаны ваши документы и насколько точным должно быть поиск, поэтому рассматривайте их как значения, которые необходимо настроить в соответствии с конкретными задачами. Статья блога о разбиении на фрагменты для сохранения доказательств более подробно рассматривает этот компромисс.

    Постоянная коллекция Chroma

    llm/rag/ingestion/chroma.py открывает объект PersistentClient, который хранит данные на диске и возвращает коллекцию документов, создавая её при первом использовании.

    import chromadb
    from llm.rag.config import (
        CHROMA_PATH,
        MANUAL_COLLECTION_NAME,
    )
    
    def get_chroma_client():
        return chromadb.PersistentClient(
            path=CHROMA_PATH,
        )
    
    def get_manual_collection():
        client = get_chroma_client()
        return client.get_or_create_collection(
            name=MANUAL_COLLECTION_NAME,
        )
    

    Пути и имена берутся из файла llm/rag/config.py, который считывает переменные окружения и при необходимости использует разумные значения по умолчанию:

    import os
    
    CHROMA_PATH = os.getenv(
        "CHROMA_PATH",
        "./chroma_data",
    )
    MANUAL_PATH = os.getenv(
        "MANUAL_PATH",
        "docs/manual.pdf",
    )
    MANUAL_COLLECTION_NAME = os.getenv(
        "MANUAL_COLLECTION_NAME",
        "manual",
    )
    

    Добавьте соответствующие записи в файл .env:

    CHROMA_PATH=./chroma_data
    MANUAL_PATH=docs/manual.pdf
    MANUAL_COLLECTION_NAME=manual
    

    Нигде не настроена модель встраивания, и это сделано намеренно для демонстрации. Когда коллекция создаётся без явно указанной функции встраивания, Chroma использует свою встроенную модель по умолчанию: каждый раз при добавлении документов Chroma сама вычисляет их векторы представления и сохраняет их рядом с текстом и метаданными. Модель по умолчанию работает локально и загружается при первом использовании, поэтому первая процедура индексации может замедлиться во время загрузки.

    В результате формируется локальный, постоянно существующий хранилище векторов в каталоге chroma_data/. Поскольку оно полностью создается на основе PDF, добавьте его в файл .gitignore вместе с файлом .env.

    Задача индексации

    llm/rag/ingestion/indexer.py объединяет все шаги обработки данных.

    from pathlib import Path
    from llm.rag.config import MANUAL_PATH
    from llm.rag.ingestion.loader import load_manual
    from llm.rag.ingestion.chunker import chunk_documents
    from llm.rag.ingestion.chroma import get_manual_collection
    
    def index_manual():
        collection = get_manual_collection()
        if collection.count() > 0:
            print(
                f"Manual already indexed "
                f"({collection.count()} chunks)."
            )
            return
        manual_path = Path(
            MANUAL_PATH,
        )
        if not manual_path.exists():
            raise FileNotFoundError(
                f"Manual not found: {manual_path}"
            )
        documents = load_manual()
        print(
            f"Loaded {len(documents)} pages."
        )
        chunks = chunk_documents(
            documents,
        )
        print(
            f"Created {len(chunks)} chunks."
        )
        collection.add(
            ids=[
                f"manual-chunk-{i}"
                for i in range(len(chunks))
            ],
            documents=[
                chunk.page_content
                for chunk in chunks
            ],
            metadatas=[
                chunk.metadata
                for chunk in chunks
            ],
        )
        print(
            f"Stored {len(chunks)} chunks."
        )
    

    Рассмотрим, что он делает. Он открывает коллекцию и сразу возвращает результат, если в ней уже есть фрагменты данных, что делает повторные запуски бесполезными. Затем он проверяет наличие PDF; в противном случае выдает четкую ошибку. После этого он загружает страницы, разделяет их на фрагменты и добавляет всё в Chroma за один вызов, присваивая стабильные идентификаторы (manual-chunk-0, manual-chunk-1 и т. д.), тексты фрагментов и их метаданные. Сообщения о ходе работы указывают, сколько страниц и фрагментов было обработано.

    Одна из проблем возникает из-за этого преждевременного возврата: если вы измените руководство и снова запустите скрипт, ничего не произойдет, поскольку коллекция не пуста. Чтобы учесть изменения, необходимо удалить коллекцию (или каталог chroma_data/) перед повторным индексированием, либо заменить защитный код на логику, которая намеренно выполняет операции вставки или пересоздания.

    В списке не отображается scripts/index_manual.py; ему достаточно импортировать index_manual и вызвать его. Запустите его один раз в качестве модуля из корня проекта:

    python -m scripts.index_manual
    

    В ходе одной только этой обработки PDF считывается, разбивается на фрагменты, эти фрагменты встраиваются и сохраняются. В выводе в терминале указывается количество обработанных фрагментов. В упрощенной демонстрации руководство представляет собой одностраничный PDF, содержащий единственное правило: «Данные демо-версии могут быть предоставлены только административным пользователям», что достаточно для проверки корректности процесса извлечения информации. После этого запуск FastAPI совсем не взаимодействует с PDF, поскольку векторы уже сохранены.

    Поиск в коллекции

    Одного лишь индексирования недостаточно для агента; ему нужен способ поиска. Файл llm/rag/retrieval/retriever.py оборачивает API запросов Chroma.

    from dataclasses import dataclass
    from typing import Any
    from llm.rag.ingestion.chroma import (
        get_manual_collection,
    )
    
    @dataclass
    class RetrievedChunk:
        content: str
        metadata: dict[str, Any]
        distance: float
    
    def retrieve_manual(
        query: str,
        n_results: int = 5,
    ) -> list[RetrievedChunk]:
        collection = get_manual_collection()
        results = collection.query(
            query_texts=[query],
            n_results=n_results,
            include=[
                "documents",
                "metadatas",
                "distances",
            ],
        )
        documents = results["documents"] or []
        metadatas = results["metadatas"] or []
        distances = results["distances"] or []
        retrieved_chunks = []
        for document, metadata, distance in zip(
            documents[0],
            metadatas[0],
            distances[0],
        ):
            retrieved_chunks.append(
                RetrievedChunk(
                    content=document,
                    metadata=dict(metadata)
                    if metadata else {},
                    distance=distance,
                )
            )
        return retrieved_chunks
    

    Функция отправляет вопрос в виде query_texts, запрашивает до пяти результатов, а также документы, их метаданные и расстояния до них. Chroma возвращает по одному списку на каждый запрос, поэтому в коде считается индекс [0] для каждого поля; условие or [] служит защитой от отсутствия полей. Каждый найденный результат упаковывается в объект типа RetrievedChunk, чтобы остальная часть кода не зависела от формата ответа Chroma.

    Поскольку запрос обрабатывается с использованием той же модели, что и сохранённые фрагменты текста, сопоставление происходит на уровне семантики. Вопрос вроде «Как добавить новые демо-данные?» находит отрывки, говорящие о создании или предоставлении демо-данных, даже если в них никогда не используются слова «добавить новые». Значение расстояния показывает, насколько близки друг к другу найденные результаты; чем меньше значение, тем выше степень сходства. Это значение полезно позже, если нужно отбросить менее подходящие результаты вместо того, чтобы всегда передавать модели пять фрагментов.

    При наличии этого механизма работает часть системы RAG, отвечающая за поиск информации. Остаётся только передать полученные результаты модели.

    Преобразование фрагментов в контекст

    llm/rag/context.py форматирует найденные результаты в одну строку, которую может прочитать модель.

    from llm.rag.retrieval.retriever import (
        RetrievedChunk,
    )
    
    def build_context(
        chunks: list[RetrievedChunk],
    ) -> str:
        context_parts = []
        for chunk in chunks:
            page = chunk.metadata.get(
                "page_label",
            )
            context_parts.append(
                f"Source: User Guide, page {page}\n"
                f"{chunk.content}"
            )
        return "\n\n---\n\n".join(
            context_parts,
        )
    

    Каждый фрагмент сопровождается строкой исходного кода, в которой указаны руководство для пользователя и его номер страницы; фрагменты разделены разделителем. Именно эта строка позволяет модели указать источник ответа, а также дает пользователям возможность его проверить.

    Шаг 6: Предоставление руководства в виде инструмента

    Пайплайн готов: страницы загружаются и делятся на фрагменты, фрагменты хранятся в ChromaDB, можно находить и форматировать необходимые из них. Однако агент не имеет ни малейшего представления о существовании всего этого. Именно здесь пригождается ранее упомянутая архитектурная особенность: поиск документации становится просто ещё одним инструментом.

    llm/tools/manual_tool.py определяет функцию search_user_manual, которая принимает запрос, находит пять фрагментов информации и возвращает их в форматированном виде. Если ничего не находится, функция возвращает явное сообщение о том, что руководство не содержит ответа на вопрос, тем самым предоставляя модели честный ответ вместо пустой строки.

    from langchain.tools import tool
    from llm.rag.context import build_context
    from llm.rag.retrieval.retriever import retrieve_manual
    
    @tool
    def search_user_manual(
        query: str,
    ) -> str:
        """
        Search the application user manual.
        Use this tool when the user asks about application
        behavior, instructions, rules, limitations, or
        how something works.
        """
        chunks = retrieve_manual(
            query=query,
            n_results=5,
        )
        if not chunks:
            return (
                "The manual does not contain enough "
                "information to answer this question."
            )
        return build_context(
            chunks,
        )
    

    Как и раньше, документация служит описанием инструмента для модели. В ней указано, что этот инструмент следует использовать для вопросов о поведении, инструкциях, правилах, ограничениях и способе работы системы, что соответствует системному промпту.

    Теперь зарегистрируйте оба инструмента у агента:

    from langchain.agents import create_agent
    from llm.provider import model
    from llm.prompts import SYSTEM_PROMPT
    from llm.tools.demo_tool import (
        get_my_solar_system,
    )
    from llm.tools.manual_tool import (
        search_user_manual,
    )
    
    agent = create_agent(
        model=model,
        tools=[
            get_my_solar_system,
            search_user_manual,
        ],
        system_prompt=SYSTEM_PROMPT,
    )
    

    Обратите внимание на импорт в этом списке: он ссылается на get_my_solar_system, название из полной версии приложения, тогда как модуль демо-инструмента определяет get_my_demo_data. Используйте get_my_demo_data как в импорте, так и в списке tools, иначе модуль не сможет быть импортирован.

    Почему подход, основанный на инструментах, остается эффективным по мере роста приложения

    Агенту никогда не нужен один огромный запрос, описывающий всё, что знает приложение. Вместо этого у него есть узкие, контролируемые возможности. Для добавления новой функции в ассистента необходимо написать новый инструмент и зарегистрировать его; слой HTTP при этом не меняется. В демо-версии сохраняются ровно один инструмент для работы с данными и один инструмент для документации, чтобы паттерн был понятен, но в полной версии приложения используется гораздо более большой набор инструментов.

    Шаг 7: Передача авторизованного пользователя агенту

    Инструмент-демо по-прежнему возвращает жестко заданные данные. Настоящий инструмент должен знать, кто задает запрос, и приложение уже это знает: FastAPI определил пользователя в зависимости аутентификации. Недостающим элементом является передача этого пользователя в процесс выполнения агента. LangChain называет это контекстом выполнения.

    Определите структуру контекста в файле llm/context.py:

    from dataclasses import dataclass
    from auth.dependencies import MockUser
    
    @dataclass
    class AgentContext:
        user: MockUser
    

    Этот объект передаётся при вызове агента, и это имеет важное значение. Пользователь — это информация, связанная с конкретным запросом. Она принадлежит текущему HTTP-запросу, а не разговору, и её никогда не следует хранить в виде сообщения, которое может прочитать или переписать модель. Изоляция её от истории сообщений также предотвращает возможность того, чтобы промпт заставил агента действовать от имени другого пользователя. В полноценном приложении тот же объект контекста также содержит такие элементы, как сессия базы данных и ID проекта, который находится в редактировании.

    Пример кода останавливается на определении класса, поэтому вам предстоит установить два соединения; стоит также ознакомиться с актуальной документацией LangChain для получения точных сведений об API. Во-первых, при создании агента необходимо указать схему, обычно с использованием аргумента context_schema=AgentContext в функции create_agent. Во-вторых, инструменты должны читать эту схему: в LangChain 1.x инструмент может принимать параметр времени выполнения (например, обозначенный как ToolRuntime[AgentContext]) и читать данные пользователя из его атрибута context, который скрыт от модели среди аргументов инструмента. Именно здесь настоящая функция get_my_demo_data искала бы записи по значению user.id.

    Шаг 8: Слой оркестрации для потоковой обработки

    Вместо того чтобы вызывать агента изнутри роутера, перенесите взаимодействие в файл llm/orchestrator.py. Таким образом роутер будет сосредоточен на обработке HTTP-запросов, а оркестратор будет отвечать за преобразование сообщения в запуск агента.

    from collections.abc import Iterator
    from langchain_core.messages import (
        AIMessage,
        AIMessageChunk,
        BaseMessage,
        ToolMessage,
    )
    from langchain_core.runnables import RunnableConfig
    from llm.agent import agent
    from llm.context import AgentContext
    
    def chat_stream(
        user_message: str,
        user,
    ) -> Iterator[str]:
        config: RunnableConfig = {
            "configurable": {
                "thread_id": f"user:{user.id}",
            }
        }
        context = AgentContext(
            user=user,
        )
        for chunk, metadata in agent.stream(
            {
                "messages": [
                    {
                        "role": "user",
                        "content": user_message,
                    }
                ]
            },
            config=config,
            context=context,
            stream_mode="messages",
        ):
            if not isinstance(
                chunk,
                BaseMessage,
            ):
                continue
            if isinstance(
                chunk,
                ToolMessage,
            ):
                continue
            if not isinstance(
                chunk,
                (
                    AIMessage,
                    AIMessageChunk,
                ),
            ):
                continue
            if isinstance(
                chunk.content,
                str,
            ):
                yield chunk.content
    

    В этой функции слишком много кода, поэтому рассматривайте её по частям.

    ID потока определяет диалог

    Первый блок формирует конфигурацию запуска:

    config = {
        "configurable": {
            "thread_id": f"user:{user.id}",
        }
    }
    

    Контроллер LangGraph хранит состояние разговора по ключу thread_id. Каждая работа с одинаковым идентификатором потока продолжает тот же разговор, и его сообщения можно прочитать позже. Здесь идентификатор потока формируется на основе ID пользователя, что означает, что у каждого пользователя есть ровно один разговор. Это подходит для демо-версии; в реальном приложении должны создаваться корректные идентификаторы разговоров, разрешаться несколько разговоров у одного пользователя, а при каждом запросе проверяться, принадлежит ли вызывающий пользователь потоку, к которому осуществляется доступ.

    В описании предполагается наличие объекта проверки состояния, но ни один из фрагментов агента не проходит его проверку. Без него параметр thread_id не оказывает никакого воздействия, и между запросами ничего не сохраняется. Создайте единственный объект InMemorySaver в файле llm/agent.py и передайте его функции create_agent через аргумент checkpointer, чтобы как агент, так и функции обработки истории могли импортировать один и тот же экземпляр.

    Контекст выполнения передается вместе с процессом

    Далее оркестратор оборачивает данные пользователя объектом контекста:

    context = AgentContext(
        user=user,
    )
    

    Этот объект передается как параметр context= в функцию agent.stream(). Именно по этому пути идентификатор, установленный в FastAPI, доходит до агента, а затем — до соответствующих инструментов.

    Фильтрация потока

    agent.stream() вызывается с параметром stream_mode="messages", что позволяет получать пары из фрагмента сообщения и метаданных по мере генерации токенов моделью. Не всё из этого потока должно доходить до пользователя. Цикл игнорирует всё, что не является сообщением LangChain, игнорирует объекты ToolMessage (сырой результат работы инструмента, такой как полученный ручной текст), сохраняет только сообщения от ИИ и фрагменты таких сообщений, выдавая их содержимое в виде обычной строки. Содержимое, которое некоторые поставщики передают в виде списка частей, безвозвратно удаляется при этой проверке; поэтому если вы смените модель и увидите пустые ответы, стоит обратить внимание именно на это.

    Шаг 9: Возврат потокового ответа

    Решение сделать chat_stream() генератором было осознанным. Ожидание полного ответа перед отправкой байта заставляет пользователя смотреть на индикатор загрузки, а ответы больших языковых моделей могут занять несколько секунд. Потоковая передача позволяет показать первые слова почти мгновенно, что делает ассистента казаться гораздо более отзывчивым.

    StreamingResponse из FastAPI принимает генератор напрямую. Обновите файл routers/chat.py:

    from fastapi import APIRouter, Depends
    from fastapi.responses import StreamingResponse
    from auth.dependencies import (
        MockUser,
        get_current_user,
    )
    from llm.orchestrator import chat_stream
    
    chat_router = APIRouter(
        prefix="/chat",
        tags=["Chat"],
    )
    
    @chat_router.post("")
    def ask_ai(
        message: str,
        current_user: MockUser = Depends(
            get_current_user,
        ),
    ):
        return StreamingResponse(
            chat_stream(
                user_message=message,
                user=current_user,
            ),
            media_type="text/plain",
        )
    

    Ответ отправляется в формате text/plain, причем каждый генерируемый фрагмент записывается в соединение сразу по его созданию. Поскольку chat_stream — это обычный (синхронный) генератор, Starlette обрабатывает его в отдельном рабочем потоке, поэтому это не блокирует цикл событий. Если позже потребуются структурированные события на стороне клиента (например, для отображения сообщения «поиск в руководстве...» во время работы инструмента), Server-Sent Events станут естественным следующим шагом.

    Теперь полный путь запроса выглядит следующим образом: клиент отправляет данные на /chat, FastAPI аутентифицирует звонящего, функция chat_stream() запускает агента, модель решает, нужно ли вызвать инструмент, любой из инструментов выполняется и возвращает свой результат, модель записывает ответ, а токены возвращаются клиенту.

    Ключевой особенностью является то, что FastAPI никогда не запускает саму модель. Маршрутизатор обменивается данными по протоколу HTTP, оркестратор управляет агентом, агент определяет, какую информацию ему нужно, а инструменты выполняют операции по получению данных. Каждый слой может меняться без влияния на остальные.

    Шаг 10: Чтение истории разговора

    Поскольку состояние агента сохраняется после каждого хода, становится возможным показать возвращающемуся пользователю его предыдущий разговор. Для этого используются две вспомогательные функции.

    Чтение сырого файла чекпоинта

    Первая функция загружает самый свежий чекпоинт для потока и возвращает канал messages, либо пустой список, если поток никогда не использовался:

    def get_conversation_messages(
        thread_id: str,
    ) -> list[BaseMessage]:
    config: RunnableConfig = {
            "configurable": {
                "thread_id": thread_id,
            }
        }
        checkpoint = checkpointer.get(
            config,
        )
        if checkpoint is None:
            return []
        return checkpoint[
            "channel_values"
        ].get(
            "messages",
            [],
        )
    

    Если вы скопируете этот код, исправьте отступы при присваивании значений config: они должны находиться внутри тела функции, иначе Python выдаст ошибку. Кроме того, в функцию необходимо импортировать классы BaseMessage, RunnableConfig и общий экземпляр checkpointer.

    В результате возвращается необработанное состояние агента, которое включает в себя гораздо больше информации, чем просто текст чата, запоминаемый пользователем. Когда агент вызывает инструмент, LangGraph записывает сообщение ИИ с описанием вызова инструмента и отдельное сообщение с результатом. Это детали реализации, которые фронтенд не должен интерпретировать.

    Форматирование сообщений для отображения

    Вторая функция формирует интерфейс для пользователя:

    def get_conversation_messages_for_display(
        thread_id: str,
    ) -> list[dict[str, str]]:
        display = []
        for message in get_conversation_messages(
            thread_id,
        ):
            if isinstance(
                message,
                HumanMessage,
            ):
                content = _extract_text_content(
                    message.content,
                )
                if content.strip():
                    display.append(
                        {
                            "type": "human",
                            "content": content,
                        }
                    )
                continue
            if isinstance(
                message,
                AIMessage,
            ):
                content = _extract_text_content(
                    message.content,
                )
                if content.strip():
                    display.append(
                        {
                            "type": "ai",
                            "content": content,
                        }
                    )
        return display
    

    Она сохраняет только объекты HumanMessage и AIMessage, извлекает их текст, удаляет пустые объекты и возвращает простые словари с полями type и content. Сообщения инструментов никогда не отображаются, поскольку они не относятся ни к одному из двух допустимых типов. Также удаляются пустые сообщения ИИ, что важно, так как сообщение ИИ, содержащее лишь запрос на вызов инструмента, обычно не имеет текста.

    Для этой задачи используется вспомогательная функция _extract_text_content, которая здесь не показана. Её задача — возвращать содержимое без изменений, если оно представляет собой строку, а если это список частей содержимого — объединять эти части воедино. Также необходимо импортировать классы HumanMessage и AIMessage.

    Эндпоинт истории

    Отобразите вид представления через маршрут GET в файле routers/chat.py. Он берет тот же идентификатор потока, что и конечная точка чата, и возвращает его вместе с сообщениями.

    @chat_router.get("/current")
    def get_current_conversation(
        current_user: MockUser = Depends(
            get_current_user,
        ),
    ):
        thread_id = (
            f"user:{current_user.id}"
        )
        messages = (
            get_conversation_messages_for_display(
                thread_id,
            )
        )
        return {
            "thread_id": thread_id,
            "messages": messages,
        }
    

    Интерфейс чата может вызвать этот метод при открытии страницы и отобразить существующий диалог до того, как пользователь что-либо введет:

    GET /chat/current
    

    Ответ выглядит следующим образом:

    {
        "thread_id": "user:user-123",
        "messages": [
            {
                "type": "human",
                "content": "How much demo data do I have?"
            },
            {
                "type": "ai",
                "content": "You currently have 18 demo data."
            }
        ]
    }
    

    Почему не возвращать сырой состояние

    Внутреннее состояние агента и диалог, который видит пользователь, — это разные вещи. По мере добавления новых функций состояние заполняется вызовами инструментов, результатами их работы, промежуточными шагами, метаданными модели и другой информацией. Возвращение всего этого приведет к связи фронтенда с внутренним устройством агента и может привести к утечке результатов работы инструментов, которые не предназначались к отображению. Бэкенд должен определять, что считается публичной историей диалога, и возвращать только это.

    Как происходит обработка одного запроса от начала до конца

    Когда все компоненты на месте, важно четко понимать одну особенность: модель никогда не обращается к вашей базе данных или PDF-файлам. Она может лишь запросить запуск определенного инструмента. Этот инструмент, работающий как обычный код на Python с стандартными механизмами контроля доступа, выполняет операцию и возвращает текст, который затем используется моделью для формирования ответа. Именно это ограничение обеспечивает безопасность ассистента при встраивании в приложение с реальными данными.

    Попробовать в Swagger UI

    FastAPI автоматически генерирует интерактивную документацию, поэтому для тестирования не требуется отдельный клиент. Запустите сервер:

    python -m uvicorn main:app --reload
    

    Затем откройте интерактивную документацию API, доступную по адресу /docs, установите заголовок X-Demo-User и попробуйте три взаимодействия:

    • Вопрос о данных, например, о том, какие демо-данные у вас имеются. Агент должен определить, что ему нужен инструмент для демонстрации, вызвать его и ответить на основе полученных деталей проекта. В полноценном приложении аналогичный инструмент запрашивает реальные записи пользователя.
    • Вопрос о документации, например, кто может получать демо-данные. Агент должен выполнить ручной поиск, найти соответствующее правило из индекса и ответить, что только администраторы могут это сделать.
    • Эндпоинт истории, GET /chat/current, который должен возвращать ответы человека и ИИ по предыдущим двум вопросам без каких-либо сообщений от инструментов.

    Если первые два вопроса дают ответы, основанные на результатах работы инструментов, а третий показывает чистый текст переписки, значит все уровни функционируют корректно.

    Перед запуском в производство

    В демо-версии намеренно упрощены несколько компонентов. Именно их следует пересмотреть, прежде чем реальные пользователи начнут использовать сервис.

    Стабильное состояние разговора

    InMemorySaver идеален для разработки, но вся информация, которую он хранит, исчезает при перезапуске процесса, к тому же его нельзя использовать для обмена данными между несколькими экземплярами API, расположенными за балансировщиком нагрузки. Используйте механизм контрольных точек, поддерживаемый базой данных или другим надежным хранилищем, чтобы разговоры сохранялись после обновлений и каждый экземпляр видел одинаковое состояние. Что именно хранит механизм хранения в памяти и как это происходит, описано в статье блога о том, как InMemorySaver организует контрольные точки, записи и данные в формате blobs.

    Реальная развертка хранилища векторов

    Локальная директория Chroma подходит для демонстрации работы конвейера, но она не является производственной инфраструктурой. Запускайте Chroma в качестве постоянно действующего сервиса или перейдите на управляемую векторную базу данных, соответствующую вашей технологической стеку. Любой выбранный вариант должен быть постоянным, иметь резервное копирование и быть доступным из любой инстанции приложения.

    Индексация не входит в состав API

    В демо-версии уже принято важное решение: индексация — это отдельный скрипт, который запускается самостоятельно, а API лишь выполняет операции по получению данных.

    python -m scripts.index_manual
    

    Сервер никогда не загружает PDF, не разбивает его на части и не вычисляет эмбеддинги при запуске. Индексация осуществляется в автономном режиме; получение данных — это часть обработки запроса. Разделение этих процессов означает, что API не нужно отслеживать изменения в документации или пересоздавать что-либо.

    В производственной среде следует перейти к следующему этапу и запустить тот же код индексации в качестве отдельной задачи по загрузке данных, запускаемой из конвейера развертывания по расписанию или автоматически при загрузке новой документации. Архитектура остается прежней; задача становится автоматизированной, повторяемой и способной к независимому развертыванию. Таким образом, обязанности четко распределяются:

    • Задача по загрузке данных: загружает документы, разбивает их на части, встраивает эти части и обновляет векторный хранилище.
    • Сервис FastAPI: принимает запросы, находит соответствующие части документов и генерирует ответы.
    • Векторное хранилище: сохраняет индексированные представления данных, используемые при выполнении запросов.

    Помните о механизме раннего возврата в индексаторе при его автоматизации; задача, которая молча пропускает процедуру переиндексации, хуже, чем отсутствие такой задачи вовсе.

    Явная модель встраивания

    Использование стандартной функции встраивания Chroma позволяет избежать дополнительной настройки в демо-версии, но в производственной среде необходимо явно выбирать и настраивать модель встраивания. Это обеспечивает воспроизводимость результатов и позволяет контролировать качество, стоимость, задержку и место вычисления встраиваемых данных. Одно правило не подлежит обсуждению: для индексации и запросов должна использоваться одна и та же модель встраивания. Изменение её означает необходимость переиндексации всего контента.

    Наблюдаемость и обработка ошибок

    Как только агент начинает работу, важно видеть, что он делает, наравне с обеспечением его корректной работы. Один запрос может включать несколько вызовов моделей, один или несколько вызовов инструментов и этап получения данных перед предоставлением окончательного ответа. Запись только этого окончательного ответа почти ничего не говорит при возникновении проблем. Необходимо отслеживать весь процесс:

    • Вызовы инструментов: какие инструменты были использованы, с какими аргументами и сколько времени занял каждый из них.
  • Задержка модели: продолжительность каждого запроса к LLM.
  • Использование токенов и стоимость: важно, когда одно сообщение пользователя может запустить несколько вызовов модели.
  • Сбои инструментов: инструменты должны возвращать контролируемые сообщения об ошибках, а не прерывать выполнение запроса.
  • Сбои поставщика: необходимо грамотно справляться с ограничениями по частоте запросов, истечением времени и недоступностью моделей, желательно с использованием резервной модели.
  • Отслеживание выполнения: записывать последовательность вызовов модели, инструментов и получаемых ответов для каждой операции.
  • Качество поиска: если ручной поиск продолжает возвращать нерелевантные фрагменты, причина чаще всего кроется в способе разбиения текста на фрагменты, использовании эмбеддингов, формулировке запроса или настройках поиска, а не в самой LLM.
  • Цель заключается в том, чтобы агент никогда не был «черным ящиком». Для любой работы вы должны иметь возможность указать, что он сделал, какие инструменты использовал, сколько времени занял каждый шаг и где возникли сбои. Конкретные инструменты зависят от вашей технологической стековой сборки; принцип же остается неизменным.

    Основные выводы

    • Для добавления полезного помощника в приложение с большим объемом данных не требуется масштабная платформа ИИ. Начните с минимального набора компонентов, способных решить реальную проблему.
    • Рассматривайте поиск документации как один из инструментов среди прочих. Так агент будет иметь единый способ получения как информации о продукте, так и данных пользователей.
    • Храните информацию об идентичности в контексте выполнения программы, а не в сообщениях. Пользователь связан с запросом, и инструменты должны получать эту информацию оттуда.
    • Разделяйте компоненты HTTP, оркестрации, агента и инструментов. Каждый слой должен оставаться простым, а добавление новых функций подразумевает добавление нового инструмента.
  • Функция контрольных точек позволяет получать историю действий практически бесплатно, но предоставляет отфильтрованный вариант, а не сырой состояние агента.
  • Транслируйте ответы, индексируйте данные офлайн, фиксируйте модель вкладки и анализируйте каждый шаг до появления реальных пользователей.
  • Связанные материалы