ESPNSeptember surprises: Our unexpected all-portal teamThe Jerusalem PostFlights must be open to all or to no one, Iranian adviser says following Iraqi airport suspensionDaily MaverickFreedom from the ANC could be our real emancipationPunchLady apologises for AI-generated Itel power tank explosion imageUN NewsThe Takeaway: UN General Assembly debate Day 3RTP DesportoGP de Portugal antecipado para outubro em 2027, Argentina regressa e Hungria saiInquirerMarcos signs BSKE postponement into lawBollywood HungamaA true MASTERSTROKE: Avengers Endgame: Encore has MORE surprises beyond the leaked scenes; Marvel saves its BIGGEST twist for theatres (SPOILERS ahead)SCMP ChinaXi Jinping marks second Mid-Autumn Festival in US during state visitRadio Times10 Questions with Amol Rajan and Hannah FryBillboardMusic Venue Trust Teams Up With Drowned In Sound to Launch Live Music TitleSouth China Morning PostItaly to ban burkas in schools, with 30% cap on pupils with poor Italian
The Daily Newsstand · Free, Always
Friday, September 25, 2026

Что внутри ИИ-ассистента BILLmanager: LLM, SSE через POST и инкрементальный Markdown

Translate

Предыстория

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

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

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

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

Общая схема работы сервиса

Общая схема работы сервиса

Разберёмся, почему выбрали именно эти решения, где были сложности и что в итоге получилось. Дальше — к реализации!

Адаптеры для разных LLM

На старте проекта сразу становится понятно одно: мы не знаем заранее, какие LLM выберет клиент и какими захочет пользоваться. Разные LLM от разных компаний вполне могут требовать различные API-запросы. Также клиенту может понадобиться своя логика поверх — отфильтровать промпт перед отправкой, подключить инструменты, сходить во внутренний сервис.

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

export interface AiAdapter {
  /** Get name of adapter */
  readonly getName: () => string;
  /** Get ai completion without sse */
  readonly getCompletion: (
    params: AdapterCompletionParams,
  ) => Promise<ModelResponse>;
  /** Get ai completion with sse */
  readonly getCompletionStream: (
    params: AdapterCompletionParams,
  ) => AsyncIterable<ModelStreamEvent>;
  /** Get mock object for additional params  */
  readonly getAdditionalParamsScheme?: () => IJsonObject;
}

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

export interface AdapterCompletionParams {
  readonly apiKey: string;
  readonly input: ModelMessage[];
  readonly abortSignal: AbortSignal;
  readonly apiUrl?: string;
  readonly model?: string;
  readonly systemPrompt?: string;
  readonly additionalParams?: IJsonObject;
}

К ключу, истории сообщений, адресу API, модели и системному промпту вопросов, я думаю, нет. Это всё стандартные параметры для запросов к ИИ. Интереснее два оставшихся поля.

abortSignalпробрасывается провайдеру для использования его в запросах к ИИ и возможных запросах в сторонние сервисы. Благодаря ему адаптер узнаёт, что клиент остановил генерацию ответа или прервал запрос. В таком случае адаптеру также стоит прервать созданные им запросы.

additionalParams — это произвольный объект. У разных провайдеров свой набор параметров генерации и предугадать, какие параметры вообще понадобятся клиенту, также нельзя. Однако те, кто пишет адаптер, могут частично предположить или описать, какие свойства могут присутствовать в этих дополнительных параметрах. Для этого и создан метод getAdditionalParamsScheme, который возвращает моковый объект — его можно отобразить визуально для понимания, какие параметры поддерживаются.

getAdditionalParamsScheme() {
  return {
    model: '<CUSTOM MODEL NAME>',
    temperature: null,
    max_tokens: null,
    top_p: null,
  };
}

Единый формат потоковых событий

Ответ LLM может быть потоковым, и правда залипательно смотреть на постепенно растущий текст. Осталось этот поток доставить и описать, каким образом адаптер должен его отдавать. 

Для передачи потока от LLM использовать WebSocket избыточно: поток у нас односторонний, а реализовывать WebSocket сложнее, чем альтернативу — Server-Sent Events. Эта технология позволяет серверу однонаправленно отправлять текст в виде потока структурированных событий. При этом реализуется она значительно проще, чем WebSocket. Подробнее про само использование SSE на клиенте и сервере описано ниже. Сейчас же нам нужно определиться с тем, какие события будет отдавать адаптер.

Потоковый метод отдаёт события получения кусочка текста, ошибки, завершения сообщения. А также может сообщить о вызове инструмента.

export type ModelStreamEvent =
  | ModelStreamCompleted
  | ModelStreamFailed
  | ModelStreamTextDelta
  | ModelStreamToolCall;

Поток должен завершаться событием completed, содержащим полный ответ.

Вот как это выглядит в адаптере DeepSeek, который просто использует официальный openai пакет:
async *getCompletionStream(params) {
  const client = this.getClient(params.apiKey, params.apiUrl);
  const stream = await client.chat.completions.create(
    {
      model: params.model || 'deepseek-chat',
      messages: [systemPromptMessage, ...params.input].filter(Boolean),
      ...params.additionalParams,
      stream: true,
      stream_options: { include_usage: true },
    },
    { signal: params.abortSignal },
  );

  let accumulatedText = '';
  let totalTokens = 0;
  for await (const chunk of stream) {
    if (chunk.usage?.total_tokens !== undefined) {
      totalTokens = chunk.usage.total_tokens;
    }

    const delta = chunk.choices[0]?.delta?.content;
    if (delta) {
      accumulatedText += delta;
      yield { type: 'text_delta', delta };
    }
  }

  yield {
    type: 'completed',
    response: {
      outputText: accumulatedText,
      totalTokens,
      output: [
        {
          role: 'assistant',
          content: [{ type: 'text', text: accumulatedText }],
          status: 'completed',
        },
      ],
    },
  };
}

Минимальный адаптер целиком

Написать свой адаптер и правда несложно и получается довольно компактным.

Вот работающий вариант под произвольный OpenAI-совместимый API, только с потоковой реализацией:
class MyLlmAdapter {
  getName() {
    return 'my-llm';
  }

  async *getCompletionStream(params) {
    const response = await fetch(`${params.apiUrl}/v1/chat/completions`, {
      method: 'POST',
      signal: params.abortSignal,
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${params.apiKey}`,
      },
      body: JSON.stringify({
        model: params.model,
        stream: true,
        messages: params.systemPrompt
          ? [{ role: 'system', content: params.systemPrompt }, ...params.input]
          : params.input,
        ...params.additionalParams,
      }),
    });

    let accumulatedText = '';
    for await (const delta of readSseDeltas(response.body)) {
      accumulatedText += delta;
      yield { type: 'text_delta', delta };
    }

    yield {
      type: 'completed',
      response: {
        outputText: accumulatedText,
        output: [
          {
            role: 'assistant',
            content: [{ type: 'text', text: accumulatedText }],
            status: 'completed',
          },
        ],
      },
    };
  }
}

module.exports = { MyLlmAdapter };

Загрузка адаптеров

Мы выбрали популярные варианты использования ИИ и сделали для них дефолтные адаптеры, которые встроены в исходник проекта. Пользовательские же адаптеры ищутся при запуске сервера в определённой директории:

const adapterFiles = (await readdir(ADAPTERS_DIR)).filter(files =>
  files.endsWith('.adapter.js'),
);

const adapters: AiAdapterRegistry = {};
for (const adapterFileName of adapterFiles) {
  const module = await import(join(ADAPTERS_DIR, adapterFileName));
  const AdapterClass = findAiAdapterClassInModule(module);
  if (!AdapterClass) {
    continue;
  }

  const adapter = new AdapterClass();
  const adapterName = adapter.getName();
  if (adapterName in adapters) {
    logger?.warn(`Adapter with name ${adapterName} already registered!`);
    continue;
  }

  adapters[adapterName] = adapter;
}

Плюсы JS в таком подходе позволяют ещё и вот что. В адаптере доступны любые зависимости: достаточно положить рядом свой node_modules, и импорт из него отработает без каких-либо доработок. Как и сразу же будут тянуться node_modules самого проекта. Поэтому отдельно устанавливать openai-пакет, который уже установлен в рамках проекта, не нужно. Осталось подложить файл в контейнер. Лучше это сделать сборкой своего образа на основе базового:

FROM docker-registry.ispsystem.com/ispsystem5/chat:1.1.1
WORKDIR /app
COPY ./my-llm.adapter.js ./server/adapters/my-llm.adapter.js

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

Ограничения

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

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

  • Число сообщений в сутки. Сутки отсчитываются от полуночи по UTC. Когда лимит исчерпан, сервер возвращает ошибку со временем сброса, и виджет показывает её пользователю.

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

  • Глубина истории. Она определяет, сколько последних пар «вопрос — ответ» сервер отправляет модели, что позволяет ограничить максимальную длину диалога, передаваемого LLM.

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

В потоке: SSE через POST

Теперь обсудим, как реализовывалась работа с SSE на клиенте и сервере. Всё просто, казалось бы, давайте возьмём EventSource из браузерного API. Однако, присмотревшись, понимаем, что он не совсем подходит.

Во-первых, EventSource отправляет GET-запрос, и это нельзя поменять. Значит, все данные надо класть в query-параметры, а передать нужно как минимум промпт пользователя, а также модель и выбранный адаптер. Упереться в 414 Request-URI Too Large на длинном промпте — вполне себе возможный кейс.

Во-вторых, он сам переустанавливает разорванное соединение. Endpoint на запрос к ИИ запускает генерацию. Повтор такого запроса — это неявно лишние запросы к ИИ и лишняя трата токенов.

На уровне протокола ничего не мешает

SSE вообще устроен предельно просто. От обычного HTTP-ответа его отличают заголовок Content-Type: text/event-stream и формат тела. Примерный вид сообщений:

event: message
data: {"delta":"Привет"}

event: message
data: {"delta":", мир"}

event: done
data: {"totalTokens":42}

Протоколу безразлично, каким методом инициирован запрос. Ограничение живёт исключительно в браузерной реализации поддержки SSE.

Реализация SSE на сервере

На серверной части же всё проще. NestJS-декоратор @Sse принимает метод, так что POST-эндпоинт со стримом описывается как любой другой endpoint:

@Sse('completion/stream', { method: RequestMethod.POST })
completionStream$(
  @Req() req: Request,
  @Body() dto: SessionCreateCompletionDtoSchema,
): Observable<MessageEvent>

Тело читается как обычно, валидация как обычно. Требуется только вернуть Observable, отдающий события.

И если на сервере всё просто, то что делать с клиентом?

Тут два варианта, которые не сильно отличаются друг от друга. Различаются они только способом получения данных.

Первый — это XMLHttpRequest. Смотрим в заголовках Content-Type: text/event-stream, дальше в обработчике progress получаем чанки текста.

Второй, в целом схожий, — это fetch с Readable Streams: читаем response.body через reader по мере поступления данных.

Вся дальнейшая логика остаётся одинаковой для обоих вариантов: копим чанки текста в буфере, режем по \n\n, разбираем поля event: и data:, склеиваем текстовые дельты в итоговый ответ. То есть сборка итогового текста ответа запроса и парсинг SSE-формата целиком ложатся на наши плечи. Но это и не так сложно, поэтому использовать какие-то готовые npm-пакеты посчитали излишними.

Инкрементальный рендеринг Markdown в Angular-компоненты

Модель отвечает в Markdown, и его нужно отобразить в интерфейсе. На клиенте использовали Angular. Хотелось бы иметь полный контроль над отображаемыми элементами Markdown, т. к. некоторые из них вполне могут иметь логику, например, кнопка «копировать» в блоке кода, подсветка синтаксиса и подобное. Поэтому подход «Превратить Markdown в HTML и вставить его через [innerHTML]» не настолько удобен.

И здесь мы использовали вот что: мы вполне можем получить AST-дерево для Markdown-текста, почему бы нам тогда просто не размапить элементы дерева в конкретные компоненты Angular? Для получения AST уже решили использовать готовые пакеты: парсинг через unified с remark-parse, добавляя некоторые расширения для Markdown в виде GFM.

export function parseMarkdown(text: string): RootContent[] {
  return unified()
    .use(remarkParse)
    .use(remarkGfm)
    .use(remarkDefinitionList)
    .parse(text).children;
}

Из AST — в Angular-компоненты

Теперь для связки элементов дерева с конкретными шаблонами была сделана директива:

@Directive({ selector: '[markdownTemplate]' })
export class MarkdownTemplateDirective {
  readonly templateRef = inject(TemplateRef<unknown>);
  readonly markdownTemplateS = input.required<MarkdownTemplateType>({
    alias: 'markdownTemplate',
  });
}

И сервис, который хранит все соответствия шаблонов типам узлов:

Сервис для шаблонов
@Injectable()
export class MarkdownRendererTemplatesService {
  private readonly templatesMap = new Map<
    MarkdownTemplateType,
    TemplateRef<unknown>
  >();

  private mapNodeTypeToTemplateType(node: RootContent): MarkdownTemplateType {
    switch (node.type) {
      case 'heading':
        return `heading${node.depth}`;
      case 'list':
        return node.ordered ? 'orderedList' : 'unorderedList';
      case 'html':
        if (/<br\s*\/?>/.test(node.value.trim())) {
          return 'break';
        }
        return 'text';
      default:
        return node.type as MarkdownTemplateType;
    }
  }

  getTemplate(node: RootContent): TemplateRef<unknown> | null {
    return this.templatesMap.get(this.mapNodeTypeToTemplateType(node)) ?? null;
  }

  registerTemplate(
    type: MarkdownTemplateType,
    template: TemplateRef<unknown>,
  ): void {
    this.templatesMap.set(type, template);
  }
}

Здесь, как видим, нет ничего сложного. Есть только дополнительный маппинг: в AST заголовки — это один тип с полем depth, нам же удобнее иметь под них отдельные шаблоны/компоненты. Список также разделяется на нумерованный и ненумерованный по полю ordered. В итоге мы получаем полный контроль над тем, как мы вообще хотим отрисовывать markdown.

Далее в шаблоне описываем, во что превращается каждый тип узла:

<ng-template let-node="node" markdownTemplate="paragraph">
  <p [ispMarkdownNode]="node"></p>
</ng-template>
<ng-template let-node="node" markdownTemplate="code">
  <div class="md-code" [codeNode]="node"></div>
</ng-template>

Шаблоны собираются через viewChildren и регистрируются в сервисе. Теперь нужно обойти дерево, используя следующий компонент.

Компонент отрисовки:
@Component({
  selector: 'isp-markdown-node, [ispMarkdownNode]',
  template: `
    @for (child of nodeS().children; track $index + child.type) {
      <ng-container
        [ngTemplateOutlet]="getTemplate(child)"
        [ngTemplateOutletContext]="{ node: child }"
      />
    }
  `,
  imports: [NgTemplateOutlet],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MarkdownNodeComponent {
  private readonly markdownRendererTemplatesService = inject(
    MarkdownRendererTemplatesService,
  );

  readonly nodeS = input.required<Parent>({ alias: 'ispMarkdownNode' });

  getTemplate(node: RootContent): TemplateRef<unknown> | null {
    return this.markdownRendererTemplatesService.getTemplate(node);
  }
}

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

Получение потока, или где здесь подвох

Всё это работает. Однако что теперь насчёт потокового получения Markdown-текста? Первое, что приходит в голову: получить кусок текста, приклеить к накопленному, распарсить заново, получить AST, отдать Angular. На коротких ответах это работает замечательно, а вот на длинных очень быстро становятся заметны просадки.

Наивный подход: сплошные long tasks во второй половине генерации

Наивный подход: сплошные long tasks во второй половине генерации

Красные отметки — это long tasks. К середине ответа они идут сплошняком, лаги сильно заметны. Что в целом логично: на каждый чанк мы парсим весь накопленный текст. Ответ растёт — растёт и стоимость парсинга.

Текст всегда дописывается в конец

В оптимизации нам очень поможет то, что на руках у нас AST, а не строка HTML. Идея, применённая для оптимизации, не нова — называется инкрементальным парсингом и существует давно. Используется она также, например, в IDE для оптимизации перестройки AST, когда пользователь его редактирует как угодно. Но там всё куда сложнее, так как изменения могут быть в любом месте текста. У нас же случай проще, потому что текст всегда дописывается в конец.

В комбинации с Markdown это даёт сильное упрощение. В спецификации CommonMark явно описано:

At each point in processing, the document is represented as a tree of blocks. The root of the tree is a document block. The document may have any number of other blocks as children. These children may, in turn, have other blocks as children. The last child of a block is normally considered open, meaning that subsequent lines of input can alter its contents. (Blocks that are not open are closed.)

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

Реализация инкрементального парсинга

Теперь, имея эту информацию, реализуем класс.

Класс парсера:
export class MarkdownIncrementalParser {
  private astCache: RootContent[] | null = null;

  private offset = 0;

  parsePartText(text: string): RootContent[] {
    const tailText = text.slice(this.offset);
    const rootChildren = parseMarkdown(tailText);

    const stableTailChildren = rootChildren.slice(0, -2);
    const lastStableInTail = stableTailChildren.at(-1);
    const lastStableEndOffsetInTail =
      lastStableInTail?.position?.end.offset ?? 0;

    if (!this.astCache) {
      if (stableTailChildren.length === 0) {
        return rootChildren;
      }
      // create first cache because we have stable children
      this.astCache = stableTailChildren;
      this.offset += lastStableEndOffsetInTail;
      return rootChildren;
    }

    const lastCachedPosition = this.astCache.at(-1)?.position;
    if (lastCachedPosition) {
      correctNodesPosition(rootChildren, lastCachedPosition);
    }

    const combinedTree: RootContent[] = this.astCache.concat(rootChildren);
    if (stableTailChildren.length > 0) {
      this.astCache = this.astCache.concat(stableTailChildren);
      this.offset += lastStableEndOffsetInTail;
    }

    return combinedTree;
  }
}

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

Склейка деревьев

Хвост парсится как отдельный документ, поэтому его узлы начинаются с первой строки и нулевого смещения. Склейка заключается в простом сдвиге этих смещений.

Функция корректировки
function correctNodePosition(
  node: Node | Parent,
  positionOffset: MarkdownNodePosition,
): void {
  if (!node.position) {
    return;
  }

  const { start, end } = node.position;
  start.line += positionOffset.end.line - 1;
  end.line += positionOffset.end.line - 1;

  if (start.offset !== undefined) {
    start.offset += positionOffset.end.offset || 0;
  }
  if (end.offset !== undefined) {
    end.offset += positionOffset.end.offset || 0;
  }

  if ('children' in node) {
    node.children.forEach(child => correctNodePosition(child, positionOffset));
  }
}

Результат оптимизации: вдвое меньше времени на скрипты

Инкрементальный парсинг: тот же объём ответа, от long tasks остались единичные в самом начале

Инкрементальный парсинг: тот же объём ответа, от long tasks остались единичные в самом начале

Сравнение на ответах одинакового размера:

Наивный подход

Инкрементальный

Scripting

5 465 мс

2 601 мс

Main thread (1st party)

4 457 мс

1 692 мс

Генерация в обоих случаях длится одинаково, а вот времени на скрипты уходит вдвое меньше, и long tasks практически исчезли. И самое главное, что теперь мы не зависим от объёма текста. Сколько бы модель ни писала, мы каждый раз парсим лишь небольшой хвост.

Ии, напоследок: что у нас получилось

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

Интерфейс ИИ-консультанта

Интерфейс ИИ-консультанта

Схема с кастомными адаптерами тоже пригодилась нам самим. Для чата на my.ispsystem.com/billmgr был написан собственный адаптер, который по function calling вызывает инструмент поиска по нашей документации.

И немного о планах. В будущих релизах мы планируем предоставить модели инструменты для работы с BILLmanager по API и доступ к актуальным данным из БД, чтобы расширять функционал без изменения костяка всей системы. ИИ-ассистент сможет давать ответы клиентам, ориентируясь на их реальные данные. Также наш инструмент позволит подбирать и заказывать конкретные услуги из чата, управлять и отслеживать уже подключенные, а также решать другие подобные задачи.

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

Для нашей команды это был замечательный опыт! Мы поняли, из чего состоят подобные чаты для работы с ИИ и какие основные проблемы возникают при разработке таких сервисов.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.