- BrainTools - https://www.braintools.ru -
Всем привет! Я Никита, Principal Engineer в стартапе AG2, мейнтейнер одноименного фреймворка для разработки AI агентов (AG2 [1]), автор фреймворка FastStream [2] и просто опенсорс и AI энтузиаст.
И, как любой разработчик, я иногда запускаю пет-проекты.
Один из таких проектов, который я запустил после новогодних праздников – это AI ассистент по подбору подарков (с интегрированным вишлистом) Дарий [3]
На его примере я хочу рассказать о протоколе AG-UI и на практике показать, как разработать ChatGPT-like агентное приложение за пару минут. Рассмотрим как бекенд (python), так и фронтенд (NextJS).
Важное уточнение: это реальный проект, с которым вы можете взаимодействовать. Это не разбор искусственных hello-world примеров.
После прочтения статьи у вас будет подробное руководство по разработке интерактивных chat-based приложений с элементами Generative UI.
Как многие программисты-социофобушки, я испытываю стресс [4] в период праздников. Мне тяжело подбирать подарки – боюсь ошибиться, подарить что-то не то, да и с фантазией все плохо. Но с другой стороны – я очень заботливый и хочу порадовать своих близких отличным подарков. И эта забота только усиливает стресс…
Решением для меня стал ChatGPT. На удивление он отлично генерирует идеи для подарков. На Новый Год я пошел по следующему пути:
Подготовил небольшое “досье” на близких людей: что им нравится, не нравится, ссылки на их вишлисты (при наличии), социальные профили, описание наших отношений и контекст ближайших событий.
Закинул эту простыню текста в GPT и попросил накидать идеи подарков – чтобы не совсем из вишлиста (я же хочу приятно удивить человека), но и “в тему”
ChatGPT накинул интересные варианты, мы с ним еще поразгоняли и остановились на наборе, который меня устраивает
Попросил GPT найти ссылки на конкретные позиции для покупки – он быстренько прошерстил интернет и я все заказал
Суммарно это заняло 10 минут на человека и избавило меня от большошо количества стресса [5] и демотивации выбором. Наоборот – подарки было интересно выбирать. GPT убрал весь стресс [6] из этого процесса.
Было настолько удобно, что я решил завернуть этот сценарий в сервис – Дарий [7].
Единственное отличие – чтобы не рассказывать о человеке каждый раз заново, в приложении можно вести свой профиль и вишлист.
Но сценарий, когда аккаунта пользователя нет на платформе, тоже заложен.
Если хотите полную историю “зачем” – у Дария есть свой манифест [8]
В Дарии пользователь может заполнять свой профиль – немного информации о себе, что нравится и не нравится. А также вести вишлист – конкретный список вещей, которые можно подарить.
Даритель (или сам пользователь) может попросить Дария предложить идеи для подарков. Дарий изучит профиль пользователя, его вишлист, задаст пару уточняющих вопросов, прошерстит интернет – и предложит конкретные варианты.
Для удобства взаимодействия в проекте активно используется Generative UI (когда нейросеть сама рисует компоненты в UI) – Дарий предлагает варианты ответов табами, а также рисует свои предложения в виде карточек товара прямо в чате.
Выглядит все это следующим образом:
Итак, исходя из сценария, Дарий должен:
понимать, что за пользователь к нему обращается
распознавать упоминание профиля подопечного в чате
уметь доставать из БД информацию о профиле и его вишлисте
уметь искать в интернете (с учетом геолокации пользователя / подопечного)
предлагать пользователю стандартные варианты ответов табами в UI
рисовать карточки товаров в чате
В общем, дел на 20 минут. Зашли и вышли.
AG-UI [9] (Agent to UI) – это открытый протокол для реализации взаимодействия пользователя с агентным приложением.
Использование открытого протокола позволяет не реализовывать все взаимодействие самостоятельно, а воспользоваться готовыми решениями как для Frontend, так и для Backend части.
До реализации Дария я каждый раз писал что-то похожее на вебсокетах самостоятельно. Тот еще геморой. Поэтому для своего пет-проекта я решил попробовать
новую технологию – и не прогадал.
AG-UI строго регламентирует формат сообщений, которыми обмениваются серверная и клиентская часть агентного приложения, а также предлагает готовые реализации транспорта: HTTP-SSE и HTTP-Binary. Однако он совместим с любой реализацией интерфейса
interface RunAgent {
(input: RunAgentInput): Observable<BaseEvent>;
}
Т.е. нам просто нужен метод, который принимает на вход параметры “запуска агента”, а возвращает поток AG-UI событий.
На практике это позволяет вам не думать об общении с агентом как таковым, а просто писать БЛ вашего сценария в чате. Нюансы реализации
берут на себя разработчики конкретных библиотек.
В общем, отличия AG-UI и обычных решений на вебсокетах:
|
Обычно |
С AG-UI |
|---|---|
|
WebSocket логика [11] |
Observable событий |
|
Свой формат сообщений |
Стандарт |
|
UI и агент склеены |
Развязаны |
Для реализации проекта я выбрал PydanticAI [12], а не свой фреймворк AG2, по одной простой причине – у них есть поддержка
AG-UI, а у нас – нет. Точнее не было :) Благо, я могу закидывать задачки в роадмап фреймворка. Так что после работы над Дарием я
затащил поддержку этого протокола в AG2 – изменения уже в main ветке, релиз будет на днях.
Чтож, для того, чтобы наш серверный агент стал доступен для AG-UI взаимодействия, нам нужно всего ничего:
Пишем своего агента
from pydantic_ai import Agent
agent = Agent(
tools=[...],
instructions="Ты - Дарий - помощник по подбору подарков. Вот и помогай!"
)
Заворачиваем его в AG-UI адаптер и SSE Endpoint HTTP фреймворка:
from ag_ui.core import RunAgentInput
from fastapi import APIRouter, Header
from fastapi.responses import StreamingResponse
from pydantic_ai.models import Model
from pydantic_ai.ui import SSE_CONTENT_TYPE
from pydantic_ai.ui.ag_ui import AGUIAdapter
from settings import model
router = APIRouter(prefix="/chat", tags=["chat"])
@router.post("/")
async def run_agent(
run_input: RunAgentInput,
accept: str = Header(SSE_CONTENT_TYPE),
) -> StreamingResponse:
adapter = AGUIAdapter(
agent=agent, # наш PydanticAI агент
run_input=run_input,
accept=accept,
)
event_stream = adapter.run_stream(
model=model # в реальном проекте модель для подключения передается через DI
)
sse_event_stream = adapter.encode_stream(event_stream)
return StreamingResponse(sse_event_stream, media_type=accept)
Этого уже достаточно, чтобы “чатиться” с нашим агентом. Инструменты докинем в него чуть позже.
Подход с использованием SSE стрима напрямую имеет существенное преимущество – HTTP логика строго отделена от логики самого агента.
Поэтому мы можем использовать стандартные решения для нашего HTTP приложения: например, авторизация у нас реализуется через стандартный fastapi.Depends
@router.post("/")
async def run_agent(
# переиспользуем Depends из обычного приложения
current_user: Annotated[User | None, Depends(get_user)],
run_input: RunAgentInput,
accept: str = Header(SSE_CONTENT_TYPE),
) -> StreamingResponse:
...
Передача модели при запуске, а не как параметер агента, также очень важна:
event_stream = adapter.run_stream(
model=model
)
Используя такой подход мы можем легко реализовать следующий функционал:
запускать агента с моделью текущего пользователя (если мы даем возможность привязывать свои API ключи)
запускать разные модели для платных / бесплатных / анонимных пользователей
конфигурировать модель в рантайме без глобальных переменных
Для реализации самого чата в браузере возьмем фреймворк CopilotKit [13] – я считаю, он незаслуженно обделен вниманием [14] в ру комьюнити.
Этот фреймворк полностью реализует AG-UI протокол и позволяет вам использовать готовые React компоненты для чата.
На выбор есть следующие варианты [15]:
Сам чат отдельной страницей
Sidebar чат
Popup чат
Headless UI (для тех, кто хочет “совсем свое”)
В стандартных чатах вам остается только докидывать свои кастомные компоненты для стилей и описывать непосредственно бизнес-логику.
В нашем случае возьмем обычную чат-страницу
// app/chat/page.tsx
"use client";
import { CopilotChat } from "@copilotkit/react-ui";
import { CopilotKit } from "@copilotkit/react-core";
export default function ChatPage() {
return <div
className="min-h-[calc(100vh-8rem)]
px-4 sm:px-6 lg:px-8
flex flex-col relative overflow-x-hidden"
>
{/* провайдер CopilotKit с указанием
адреса и имени CopilotKit агента (не PydanticAI)*/}
<CopilotKit
runtimeUrl="/api/copilot"
agent="dariy"
>
{/* сам CopilotKit чат
с кастомными компонентами для отрисовки */}
<CopilotChat
className="flex-1 flex flex-col relative h-full overflow-hidden w-full md:w-[70%] mx-auto"
disableSystemMessage={true}
labels={{
title: "Дарий",
initial: `👋 Привет! Я Дарий, ваш помощник по подбору подарков!
Расскажи мне о своем подопечном, и я помогу тебе подобрать лучший подарок.`,
}}
UserMessage={CustomUserMessage}
AssistantMessage={CustomAssistantMessage}
Input={CustomInput}
>
</CopilotChat>
</CopilotKit>
</div>
}
Обратите внимание, что CopilotKit подключается не к нашему PydanticAI агенту, а к своему адаптеру. Этот адаптер располагается на серверной части того же NextJS приложения и общается с UI компонентами в своем формате.
Он выглядит следующим образом:
// app/api/copilot/route.ts
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
import { NextRequest } from "next/server";
import { API_BASE_URL } from "@/lib/consts";
const serviceAdapter = new ExperimentalEmptyAdapter();
const pydanticAgent = new HttpAgent({
// адрес нашего FastAPI эндпоинта
url: `${API_BASE_URL}/chat/`,
});
export const POST = async (req: NextRequest) => {
const runtime = new CopilotRuntime({
agents: {
// имя агента из CopilotKit
dariy: pydanticAgent,
},
});
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
// адрес, который мы указали в CopilotKit
endpoint: "/api/copilot",
});
return handleRequest(req);
};
Все! Этого уже достаточно, чтобы получить полностью функциональный чат с вашим серверным агентом. Теперь мы можем приступать к разработке бизнес-логики.
Теперь нам нужно добавить агенту возможность изучать профиль пользователя и его вишлист.
Тут можно пойти двумя путями:
дать агенты read доступ напрямую в БД
дать агенту фиксированные методы для получения данных
Мы пойдем вторым путем. Фиксированная логика не позволит агенту случайно выдать информацию, к которой пользователь не имеет доступ. Да и в принципе не даст агенту сойти с ума от избытка информации.
Во всех фреймворках инструменты реализуются довольно просто. С Pydanitc AI у нас получится что-то такое:
from dataclasses import dataclass
from datetime import date
from pydantic_ai import RunContext, Tool
# наш класс для определения "зависимостей"
from .dependencies import Dependencies
@dataclass
class UserBio:
name: str
birthday: date | None
bio: str | None
interests: list[str]
async def get_user_profile(ctx: RunContext[Dependencies], user_id: str) -> UserBio:
session = await ctx.deps.session_maker()
user = await get_user(user_id, session)
return UserBio(...)
@dataclass
class WishItem:
wish_id: str
title: str
description: str | None
price: float | None
url: str | None
categories: list[str]
async def get_user_wishes(ctx: RunContext[Dependencies], user_id: str) -> list[WishItem]:
session = await ctx.deps.session_maker()
wishes = await get_user_wishes(user_id, session)
return [WishItem(...) for w in wishes]
agent = Agent[Dependencies, str](
tools=[
Tool[Dependencies](
get_user_profile,
name="get_user_profile",
takes_ctx=True,
description=(
"Get user bio, birthday, interests and other information about the user. "
"The user is identified by @`user_id` or @`user_slug`."
),
),
Tool[Dependencies](
get_user_wishes,
name="get_user_wishes",
takes_ctx=True,
description=(
"Get user's wishlist items including product details. "
"The user is identified by @`user_id` or @`user_slug`."
),
)
]
)
В примере выше у нас есть класс Dependencies, который представляет собой те “зависимости”, которые мы хотим передать в наши инструменты.
Указать их можно при запуске агента:
@dataclass
class Dependencies:
session_maker: async_sessionmaker
user_id: str | None = None
@router.post("/")
async def run_agent(
session_maker: Annotated[async_sessionmaker, Depends(get_session_maker)],
current_user: Annotated[User | None, Depends(get_user)],
run_input: RunAgentInput,
accept: str = Header(SSE_CONTENT_TYPE),
) -> StreamingResponse:
adapter = AGUIAdapter(
agent=agent,
run_input=run_input,
accept=accept,
)
# формируем зависимости
request_deps = Dependencies(
session_maker=session_maker,
user_id=current_user.id,
)
event_stream = adapter.run_stream(
deps=request_deps, # передаем зависимости в запрос к агенту
model=model
)
sse_event_stream = adapter.encode_stream(event_stream)
return StreamingResponse(sse_event_stream, media_type=accept)
Теперь наш агент может принимать решения более взвешено. Однако работать он стал тоже значительно дольше. Чтобы пользователь не скучал и понимал, что происходит прямо сейчас, давайте отрисовывать вызовы инструментов прямо в UI.
AG-UI подразумевает отправку событий о запуске и завершении инструментов агентом. Мы можем использовать эту информацию для отображения компонентов в бразуере.
Для этого в CopilotKit есть хук useRenderToolCall – он позволяет захватить запуск инструментов на бекенде и отрисовать их как React компоненты в чате.
С его помощью удобно делать progressive трекеры или отрисовывать результаты работы инструментов красивыми карточками
"use client";
import { CopilotChat } from "@copilotkit/react-ui";
import { CopilotKit } from "@copilotkit/react-core";
function Chat() {
useRenderToolCall({
name: "get_user_bio", // название бекенд инструмента
render: ({ status }) => { // React компонент
if (status !== "complete") {
return (
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm py-2">
<div className="w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
Изучаю профиль пользователя...
</div>
);
}
return <></>;
},
});
useRenderToolCall({
name: "get_user_wishes", // название бекенд инструмента
render: ({ status }) => { // React компонент
if (status !== "complete") {
return (
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm py-2">
<div className="w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
Изучаю желания пользователя...
</div>
);
}
return <></>;
},
});
return <CopilotChat
className="flex-1 flex flex-col relative h-full overflow-hidden w-full md:w-[70%] mx-auto"
disableSystemMessage={true}
labels={{
title: "Дарий",
initial: `👋 Привет! Я Дарий, ваш помощник по подбору подарков!
Расскажи мне о своем подопечном, и я помогу тебе подобрать лучший подарок.`,
}}
>
</CopilotChat>
}
export default function ChatPage() {
return <div
className="min-h-[calc(100vh-8rem)]
px-4 sm:px-6 lg:px-8
flex flex-col relative overflow-x-hidden"
>
{/* провайдер CopilotKit */}
<CopilotKit runtimeUrl="/api/copilot" agent="dariy">
<Chat/>
</CopilotKit>
</div>
}
Теперь при вызове инструментов агентом на сервере, пользователь увидит соответствующие сообщения в чате. Но мы этим не ограничены – вы вольны отображать компоненты любой сложности, если этого требуют сценарий.
Обратите внимени, что все CopilotKit хуки должен находится внутри CopilotKit провайдера.
По такому же принципу добавим инструмент для поиска в интернете (и его UI отображение). Благо, во все
фреймворки по умолчанию включен DuckDuckGo-поисковый инструмент
Единственный нюанс – нам нужно задавать регион и локацию основывясь на локации текущего пользователя
import functools
import anyio.to_thread
from ddgs.ddgs import DDGS
from pydantic_ai import RunContext, Tool
from pydantic_ai.common_tools.duckduckgo import DuckDuckGoResult, duckduckgo_ta
from .dependencies import Dependencies
async def duckduckgo_search(
ctx: RunContext[Dependencies],
query: str,
max_results: int | None = 10,
) -> list[DuckDuckGoResult]:
# попробуем получить локацию пользователя из IP запроса
# и поместим ее в зависимости сессии
region = ctx.deps.location or "ru-ru"
search = functools.partial(
DDGS().text,
region=region,
max_results=max_results,
)
try:
results = await anyio.to_thread.run_sync(search, query)
except Exception:
return [DuckDuckGoResult(title="Web search failed", href="", body="")]
return duckduckgo_ta.validate_python(results)
search_tool = Tool[Dependencies](
duckduckgo_search,
name="duckduckgo_search",
takes_ctx=True,
description="Searches DuckDuckGo for the given query and returns the results.",
)
Теперь перейдем в браузер и сделаем его немного веселее.
Практически все фичи CopilotKit основаны на простой идее – FrontendTools.
Т.е. клиент передает с запросом к агенту список инструментов, которые доступны в браузере.
LLM не видит разницы между такими инструментами и обычными серверными. Агент же, когда видит вызов FrontendTool моделью, отправляет соответствующее AG-UI событие, а CopilotKit запускает нужный инструмент в браузере пользователя.
Таким образом можно:
отрисовывать UI компоненты (Generative UI)
запрашивать пользовательский ввод (HITL)
вызывать браузерный API (например, доступ к геолокации пользователя)
делать любые вещи, которые допускает JS и ваша фантазия
Например, для отрисовки вариантов ответов табами, мы используем HITL хук
useHumanInTheLoop({
// Описание инструмента для LLM
name: "suggest_next_steps",
description: `Suggest next steps to the user`,
// JSON Schema для описания параметров инструмента
parameters: [
{
name: "suggestion_answers",
type: "string[]",
description: `Array of suggestion titles to display to the user as answer options`,
required: true,
},
{
name: "question",
type: "string",
description: "Question to the user to clarify the situation",
required: true,
},
],
// Обычный React компонент для рендеринга
render: ({ args, respond }) => {
const { suggestion_answers, question } = args as {
suggestion_answers: string[],
question: string
};
return {respond && <div className="flex flex-wrap gap-2">
{suggestion_answers.map((suggestion) =>
<SuggestionButton
key={suggestion}
suggestion={{ title: suggestion, message: suggestion }}
onSuggestionClick={(message: string) => {
respond(message);
}}/>
)}
</div>}
},
});
Теперь агент может задавать пользователю вопросы и предлагать варианты ответов табами.
Пришлось немного поприседать, чтобы сделать захват пользовательского ввода легитимным ответом на вызов инструмента наравне с нажатием на таб. Но эта проблема имеет множество решений.
Для отрисовки карточек предлагаемого подарка добавим еще один FrontendTool
useFrontendTool({
// Описание инструмента для LLM
name: "display_gift_ideas",
description: "Use this tool to show recommended gifts",
// JSON Schema для описания параметров инструмента
parameters: [
{
name: "ideas",
type: "object[]",
description: "Array of recommended gifts",
required: true,
properties: [
{ name: "title", type: "string", description: "Gift title", required: true },
{ name: "description", type: "string", description: "Why this gift is a good choice", required: true },
{ name: "url", type: "string", description: "URL to the product page", required: true, },
{
name: "price",
type: "object",
description: "Price with amount and currency",
required: false,
properties: [
{ name: "amount", type: "number", description: "Price amount", required: true },
{ name: "currency", type: "string", description: "Price currency", required: true },
],
},
],
},
],
// отвечаем модели на вызов
handler: async ({ ideas }) => {
return { displayed: Array.isArray(ideas) ? ideas.length : 0 };
},
// рендерим React компонент
render: ({ status, args }) => {
if (!args || status == "inProgress" || status === "executing") {
return <></>;
}
const ideas = (args.ideas || []).map((idea) => ({
title: idea.title,
description: idea?.description || idea?.why,
url: idea?.url || idea?.link,
price: idea.price,
}));
return <WishCards wishes={ideas} />;
},
});
Теперь агент будет предлагать не просто текстовое описание подарка, но и рендерить интерактивную карточку прямо в чате
Больше всего меня напрягает сильно размазанный промпт между разными частями системы. Тебе нужно составить качественное описание Backend инструментов, Frontend инструментов, их параметров – но это не помогает. Для нормальной работы агента нужно еще и в системном промпте повторить все это + рассказать, в каком порядке и в каких случаях вызывать эти инструменты. Хотели разделить агента и UI, но система все равно получается достаточно связной.
Пока нет поддержки Thinking. Т.е. сами Thinking Events уже в драфте, а даже внедрены в ряде бекенд фреймворков, но пока нет поддержки в CopilotKit. Ждемс…
Пока это однонаправленный канал общения. События стримятся от агента к пользователю, но не наоборот. Хотелось бы иметь возможность отправлять события от пользователя к агенту в процессе его функционирования. Но это пока задача, которую никто не знает как решать.
Еще пару лет назад такие интерфейсы выглядели как “магия”. Сейчас это уже обычная инженерная задача. Использование AG-UI как транспорта позволяет сфокусироваться на сценариях и бизнес-логике агента, а не на бесконечном склеивании стримов, форматов сообщений и UI-состояний.
На примере Дария я собрал агентное приложение, которое:
работает с пользовательскими данными без прямого доступа LLM к БД
использует инструменты как основной механизм принятия решений
умеет искать в интернете с учетом контекста пользователя
отображает процесс работы агента и результаты через Generative UI
И все это занимает минимум кода, который описывает, в основном, бизнес-логику происходящего, а не низкоуровневые детали.
Часть кода в примерах была опущена, сокращена или упрощена ради читабельности, но сама композиция инструментов и взаимодействий полностью рабочая. Основная сложность здесь не в инфраструктуре, а в сценариях и промптинге: нужно объяснить агенту, какие инструменты ему доступны, в каком порядке их вызывать и как обрабатывать нестандартные кейсы. Именно это и есть основная точка оптимизации.
Если вы сейчас проектируете агентное приложение или собираетесь это делать, мой совет простой: не пишите транспорт руками. Используйте готовые протоколы и тратьте время на то, что действительно отличает ваш продукт.
Напомню, что Дарий [3] – это настоящий функционирующий и развивающийся проект, который вы можете опробовать сами.
Единственное, я еще не проводил полное тестирование агента через garak [16] и не выстраивал защиту от промпт-инъекций. Поэтому агента можно забавно “сломать”
А поддержку AG-UI в AG2 [17] мы выпустим уже в конце этой недели, так что я смогу переписать Дария на свой родной инструментарий :)
Если вам интересно читать про такие активности, вы можете подписаться на
мой Telegram канал [18]
Где я рассказываю об опенсорсе, AI, продуктивности и всему, что мне интересно.
Увидимся!
Автор: Propan671
Источник [19]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/25249
URLs in this post:
[1] AG2: https://github.com/ag2ai/ag2
[2] FastStream: https://github.com/ag2ai/faststream
[3] Дарий: https://xn--80ahne7a.com?utm_source=habr.com
[4] стресс: http://www.braintools.ru/article/9548
[5] стресса: http://www.braintools.ru/article/9041
[6] стресс: http://www.braintools.ru/article/6151
[7] Дарий: https://xn--80ahne7a.com/chat?utm_source=habr.com
[8] манифест: https://xn--80ahne7a.com/blog/dariy-philosophy?utm_source=habr.com
[9] AG-UI: https://docs.ag-ui.com/introduction
[10] Image: https://sourcecraft.dev/
[11] логика: http://www.braintools.ru/article/7640
[12] PydanticAI: https://ai.pydantic.dev/
[13] CopilotKit: https://docs.copilotkit.ai/
[14] вниманием: http://www.braintools.ru/article/7595
[15] следующие варианты: https://docs.copilotkit.ai/pydantic-ai/agentic-chat-ui
[16] garak: https://github.com/NVIDIA/garak
[17] AG-UI в AG2: https://docs.ag2.ai/latest/docs/user-guide/ag-ui/
[18] мой Telegram канал: https://t.me/fastnewsdev
[19] Источник: https://habr.com/ru/articles/992866/?utm_source=habrahabr&utm_medium=rss&utm_campaign=992866
Нажмите здесь для печати.