Що насправді всередині AI-агента: розбираємося, створюючи його власноруч

💡 Усі статті, обговорення, новини про AI — в одному місці. Приєднуйтесь до AI спільноти!

Слово «агенти» зараз лунає звідусіль і стало базвордом року. Але якщо запитати пересічного ентузіаста, що ж таке агент, відповіді починають розпливатися досить швидко. Ледь не всі AI-компанії сьогодні стверджують, що вони створюють агентні продукти (проте не завжди вдаються в подробиці). Якщо ви розробник — фреймворки LangGraph, CrewAI та інші дають достатньо високорівневу абстракцію, щоб могти створювати агентів, не знаючи механізму їхньої роботи.

Blind Men and an Elephant

Торік я захотів ґрунтовніше розібратися в темі, написавши агентний застосунок для власної прикладної задачі. Але я вирішив це робити з нуля: без фреймворків, без хмарних сервісів, без вайбкоду — усе власноруч і локально. У цій статті я покажу, як це зробити та які неочевидні сюрпризи можуть виникнути.

Для наочності оберемо практичну задачу. Можна запозичити ідею з недавнього демо Claude Cowork: у користувача на робочому столі безлад, і нам потрібно його поприбирати, впорядкувавши файли за вмістом.

Навіщо писати агента з нуля?

Це слушне питання. Сьогодні агента можна написати в Claude Code за 5 хвилин, українською мовою. І він, цілком імовірно, працюватиме краще за самописного на Python. Або ж можна обрати будь-який із десятків популярних фреймворків, які вже багато проблем вирішують за нас.

Проте наша основна мета — не вирішити прикладну задачу, а зрозуміти, як ця технологія працює. І я не знаю кращого способу навчитися, ніж відтворити щось самостійно від початку до кінця. Хоч я давно професійно займаюся GenAI і машинним навчанням у цілому, але після цього експерименту я відчув, що пазл у моїй голові склався краще. Прихильниками такого підходу є і Річард Фейнман, і Андрей Карпати, і творець Claude Code Борис Черний.

Так само як знання HTTP робить вас кращим Django-розробником, а знання DOM — кращим React-розробником, так само і розуміння роботи агентів допоможе ефективніше застосовувати вашого улюбленого кодинг-агента.

Що таке агент

Усунемо перше непорозуміння в темі — дамо конкретне визначення агента. Класичне книжне визначення досі чудово працює:

Агент — це автономна сутність, яка сприймає своє середовище та діє на нього для досягнення певної кінцевої мети.

Кожна деталь має значення:

  1. «Автономна сутність»: людина-оператор не зобов’язана скеровувати агента на кожній ітерації, він здатен робити це самостійно.
  2. «Сприймає середовище»: агент не діє наосліп, а отримує зворотний зв’язок від середовища і адаптується до його стану.
  3. «Діє»: агент не лише говорить про дію, а й виконує її, впливаючи на стан середовища.
  4. «Для досягнення мети»: робота агента є цілеспрямованою.

Агенти працюють у постійному циклі сприйняття та дії (perception-action loop):

Perception-Action Loop

Таким чином, бачимо хибність деяких визначень:

  • LLM, яка підтримує інструменти, ще не є агентом, оскільки не обов’язково має цілеспрямовану поведінку, не взаємодіє з середовищем та не діє циклічно.
  • Самостійна та довготривала робота не обов’язково є адаптивною і цілеспрямованою.
  • Мислення, планування чи пам’ять не є обов’язковими елементами агентів. Агенти можуть бути рефлексними, евристичними, ґрунтуватися на політиках тощо.
  • Програми, які викликають LLM, не обов’язково працюють в агентному циклі сприйняття, адаптації та дії. Можливо, вони лише діють за фіксованим сценарієм (LLM workflow).

Якщо перенести це на нашу задачу прибирання файлів на робочому столі:

File Organizer: Perception-Action Loop

Середовище — це файлова система. Ми сприймаємо стан середовища, зчитуючи її вміст і аналізуючи результати наших попередніх операцій. Ми впливаємо на середовище, створюючи директорії та переміщуючи файли.

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

Роль LLM в агентах

Поняття «AI-агент» не обов’язково означає використання LLM. Проте багато сучасних LLM мають дві здібності (набуті шляхом SFT/RLHF/DPO), що роблять їх ефективними для агентних задач:

  1. Міркування (reasoning): генерація «ходу думок» перед тим, як видати відповідь.
  2. Виклик інструментів (tool calling): здатність приймати описи допоміжних інструментів на вхід та запитувати виклики інструментів на виході.

LLM Inference: Input and Output Items

Важливо розуміти: LLM не виконують інструменти самостійно. LLM «просять» користувача викликати інструмент, а користувач повинен його виконати і надати LLM результат. Результатом може бути також відмова виконувати інструмент чи надання додаткового фідбеку («зроби не так, а інакше»).

Вихідні дані з першого виклику LLM разом із результатами виклику інструментів є вхідними даними (контекстом) для наступного виклику:

LLM Inference: Multi-Turn

Після певної кількості таких ітерацій LLM може вирішити, що задача виконана, і видати фінальну відповідь:

LLM Inference: Final Turn

Практичні нюанси реалізації

  • Ви, мабуть, помітили, що контекст зростає з кожним наступним запитом. Сумарна кількість токенів, оброблених впродовж усіх запитів, виглядає квадратичною від кількості ітерацій. На практиці inference-сервери мають KV-кеш і промпт-кеш, які дозволяють уникати повторного опрацювання попередніх даних і обробляти лише нову інформацію. Щоб кеш працював ефективно, важливо не змінювати попередній контекст, а лише додавати нові дані в кінець. Про стратегії роботи з контекстом ми ще поговоримо пізніше.
  • Часто нас цікавить не лише те, чи агент виконав завдання, а й отримати якийсь структурований результат: де лежать артефакти, яка помилка трапилася тощо. В системному запиті агента варто заздалегідь описати чіткий формат вихідних даних.
  • Поведінка Assistant Message може бути різною для різних LLM. Деякі моделі повертатимуть його лише після виконання задачі. Деякі — можуть видавати проміжні повідомлення, на кшталт «Now I have a complete understanding. Let me do (...) next». Завершенням задачі можемо вважати ситуацію, коли є повідомлення асистента, але нема викликів інструментів. Як альтернатива, деякі фреймворки роблять «завершення задачі» окремим інструментом.

Агентний каркас (agentic harness)

Як ми розібралися вище, LLM може виконувати роль «мозку» агента — аналізувати стан і вирішувати, що робити далі. Але викликів LLM недостатньо, щоб мати повноцінний агентний застосунок. Нам потрібно ще взаємодіяти з користувачем і середовищем:

Agentic App Sequence Diagram

Ці функції покладаються на agentic harness. Його роль:

  1. Відображати користувацький інтерфейс.
  2. Отримати завдання від користувача.
  3. Встановити початковий стан агента (system prompt, user message).
  4. Сформувати каталог інструментів (вбудовані, MCP-сервери тощо).
  5. Надсилати запити до LLM і розбирати її відповіді.
  6. Якщо у відповідях LLM є виклики інструментів — валідувати їх, перевіряти вимоги безпеки, запитувати дозвіл користувача.
  7. Виконувати інструменти та зберігати результати їхнього виконання в контекст.
  8. Керувати вмістом контексту, який передається до LLM (compaction, truncation тощо).
  9. «Крутити» агентний цикл, доки задача не буде виконана (або завершити цикл аварійно).
  10. Телеметрія, логування, аудит.

Хоч цей перелік може виглядати страшно, але agentic harness — це звичайнісінька детерміністична програма, базову версію якої можна реалізувати з нуля кількома сотнями рядків коду. А наші давні знайомі Claude Code і Codex — це такі ж самі agentic harness, які використовують хмарні LLM загального призначення (Opus, Sonnet, GPT) через API та мають вбудовану реалізацію кількох десятків нескладних інструментів.

До речі, якщо вам цікаво подосліджувати, які інструменти викликає Claude Code під час роботи, то спробуйте просту утиліту Tool Call Log Viewer, яку я навайбкодив створив із навчальною метою. Вона дозволяє бачити конкретні вхідні та вихідні дані кожного виклику у зручному HTML-інтерфейсі.

Пишемо агента власноруч

Опишемо основні вимоги до нашого застосунку (назвемо його ordnung):

  1. Користувач вказує шлях до директорії, яку потрібно впорядкувати (наприклад, ~/Desktop).
  2. Агент повинен проаналізувати її вміст (за потреби — вміст окремих файлів) і придумати категорію для кожного файлу.
  3. Для кожної категорії потрібно створити піддиректорію та перемістити в неї релевантні файли.
  4. Агент повинен використовувати локальну відкриту LLM, яка має OpenAI-сумісний API. Наприклад, запущену на Ollama або llama-server. Про те, як працювати з локальними LLM, читайте в моїй попередній статті.
  5. Агент повинен мати консольний інтерфейс, схожий на Claude Code: пояснювати, що зараз робить агент, показувати виклики інструментів і їхні результати, запитувати дозвіл користувача перед виконанням інструментів.

Виглядає, що для реалізації цієї задачі нам знадобляться такі інструменти:

  1. ListDirectoryTool — перелічити вміст заданої директорії.
  2. ReadTextFileTool — прочитати вміст текстового файлу (припускаючи кодування UTF-8).
  3. ReadBinaryFileTool — прочитати вміст бінарного файлу як hex-рядок.
  4. CreateDirectoryTool — створити директорію з заданим шляхом.
  5. MoveFileOrDirectoryTool — перемістити файл чи директорію з одного шляху в інший.

Звісно, простіше мати один інструмент BashTool для всього, оскільки LLM чудово вміють писати bash-команди. Але ми цього не будемо робити з кількох причин:

  • З більш гранулярними інструментами нам буде простіше реалізувати систему безпеки.
  • Краще, щоб наш застосунок був кросплатформним і працював на Windows.
  • Так ми здобудемо більше практичних навиків.

Як невеличкий тизер, наш застосунок виглядатиме так:

Ordnung Screenshot

Основні модулі

Напишемо головну функцію organize, яка об’єднуватиме усі компоненти нашого агента. Початково вона може бути такою:


def organize(
    dir_path: Path,
    llm_api_base_url: str,
    llm_api_key: str,
    llm_name: str,
) -> OrganizeDirectoryResult:
    # Resolve relative paths and symlinks to absolute paths.
    dir_path = dir_path.resolve()

    # Specify the task for the agent.
    task_spec = OrganizeDirectoryTaskSpec(dir_path=dir_path)

    # Discover the tools.
    tools: list[type[Tool]] = [
        ListDirectoryTool,
        CreateDirectoryTool,
        MoveFileOrDirectoryTool,
        ReadTextFileTool,
        ReadBinaryFileTool,
    ]

    # Set up the environment.
    env = Environment(tools=tools)

    # Create the agent.
    llm_client = create_llm_client(
        base_url=llm_api_base_url,
        api_key=llm_api_key,
        model=llm_name,
    )
    agent = Agent(llm_client=llm_client, tools=tools)

    # Run the agent.
    result = agent.run_until_done(task_spec=task_spec, env=env)

    return result

Ми створюємо середовище, яке вміє виконувати наші 5 інструментів. Створюємо API-клієнт для виклику локальних LLM. Створюємо агента, який знає про існування цих 5 інструментів. Формулюємо задачу для агента і запускаємо агентний цикл, доки він не завершиться (успішно чи з помилкою).

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


class Agent:
    def __init__(
        self,
        llm_client: LLMClient,
        tools: Sequence[type[Tool]],
    ) -> None:
        self.llm_client = llm_client
        self.system_prompt = self._get_system_prompt()
        self.tool_schemas = [tool.to_schema() for tool in tools]
        self.conversation_context = []

    def _get_system_prompt(self) -> str:
        current_file_path = Path(__file__)
        system_prompt_path = current_file_path.parent / "system_prompt.md"
        return system_prompt_path.read_text(encoding="utf-8")

    def run_until_done(
        self,
        task_spec: OrganizeDirectoryTaskSpec,
        env: Environment,
    ) -> OrganizeDirectoryResult:
        initial_message = f'Input directory: "{task_spec.dir_path}"'
        self.conversation_context.append(
            {"role": "user", "content": initial_message}
        )

        # Run the agentic loop until we receive a final result.
        while not (final_result := self._act(env)):
            pass

        return final_result

    def _act(self, env: Environment) -> OrganizeDirectoryResult | None:
        # Run LLM inference.
        response = self._call_llm()

        # Append LLM output to the context to use it as input for the next turn.
        self.conversation_context += response.raw_output

        has_tool_calls = any(
            isinstance(item, LLMToolCall)
            for item in response.items
        )

        # Handle each response item type individually.
        for item in response.items:
            match item:
                case LLMReasoning():
                    self._handle_reasoning(item)
                case LLMToolCall():
                    self._handle_tool_call(item, env)
                case LLMContentMessage() if not has_tool_calls:
                    return self._extract_final_result(item)
                case LLMContentMessage():
                    self._handle_content_response(item)

Спочатку ми зчитуємо system prompt із Markdown-файлу. Він містить загальний опис задачі і вимоги до поведінки агента.

Отримавши завдання від користувача (у вигляді об’єкта OrganizeDirectoryTaskSpec, який має єдиний атрибут dir_path), ми формуємо первинне користувацьке повідомлення. Ми «програмуємо» LLM природною мовою, тому користувацьке повідомлення може мати вигляд:


Input directory: "/home/user/Desktop"

Ключову роль має атрибут self.conversation_context. Тут ми будемо зберігати внутрішній стан агента: історію «переписки» з LLM і результати викликів інструментів. Зауважте, що коли ми отримуємо відповідь від LLM, ми одразу запам’ятовуємо її в контексті. LLM за своєю природою безстанові, тому всю історію ми повинні передавати їм у запиті.

system_prompt.md виглядатиме так:


You are an agent responsible for organizing the files on the user's local filesystem.

You are given an input directory. Analyze its contents, then come up with a plan for organizing it into subdirectories according to the nature of the contents.

Once you have defined the categories, create one subdirectory for each category and move the files there.

# Requirements

1. Use only one level of categories, no subcategories allowed.
2. In the end, there must be no uncategorized files: every file must belong to exactly one category. Listing the input directory should only show directories, one directory per category.
3. IMPORTANT: if the filename is ambiguous, prefer examining file contents rather than assuming the category from the filename.

# Output Format

If you have successfully completed the task according to the requirements above, output ONLY this JSON string and nothing else:

{"agent_succeeded": true}

If you could not accomplish the task, output ONLY this JSON string and nothing else:

{"agent_succeeded": false, "error": "%The reason you could not accomplish the task%"}

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

Зверніть увагу на розділ Output Format: ми просимо, щоб агент повернув результат у вигляді JSON-об’єкта, який нам буде легко потім розібрати. Інакше LLM може звітувати нам у довільній формі природною мовою, а це незручно аналізувати. Багато моделей/API також підтримують структурований вивід, де тип результату можна обмежити в запиті.

Інструменти

Для виклику LLM нам потрібно описати кожен інструмент у тілі HTTP-запиту. Для кожного інструмента треба вказати його назву, опис і JSON-схему усіх його аргументів. Для цього ми скористаємося популярною бібліотекою pydantic, оскільки вона вміє автоматично генерувати JSON-схему класів-моделей. Назвою інструмента буде назва його класу, а описом — його docstring.

Базовий інтерфейс інструмента може виглядати так:


class Tool(BaseModel, ABC):
    @classmethod
    def get_name(cls) -> str:
        return cls.__name__

    @classmethod
    def get_description(cls) -> str:
        return cls.__doc__ or ""

    @classmethod
    def to_schema(cls) -> dict:
        schema = cls.model_json_schema()
        return {
            "name": cls.get_name(),
            "description": cls.get_description(),
            "parameters": schema,
        }

    @abstractmethod
    def run(self, sec_policy: ToolSecurityPolicy) -> dict:
        pass

Реалізуємо кілька інструментів. Зверніть увагу: docstrings і описи аргументів потраплять у запит до LLM. Вони фактично є частиною промпту, тому до написання документації інструментів варто ставитися не менш уважно, ніж до інших параметрів запиту. LLM не повинна заплутатись у тому, як викликати ваш інструмент!


class ListDirectoryTool(Tool):
    """Lists the contents of the specified directory."""

    dir_path: Path = Field(description="The path to the directory to list the contents of.")

    def run(self, sec_policy: ToolSecurityPolicy) -> dict:
        sec_policy.validate_path_access(self.dir_path)
        output_items = []
        for item in self.dir_path.iterdir():
            if item.is_file():
                item_type = "file"
            elif item.is_dir():
                item_type = "directory"
            else:
                item_type = "unknown"

            item_output = {
                "type": item_type,
                "name": item.name,
                "size_bytes": item.stat().st_size if item.is_file() else None,
            }
            output_items.append(item_output)

        return {"output": output_items}

class ReadTextFileTool(Tool):
    """Read the contents of a text file, assuming UTF-8 encoding."""

    _MAX_LIMIT = 65536

    file_path: Path = Field(description="The path of the file to read.")
    offset: int = Field(
        default=0,
        description="The character offset to start reading from (default 0).",
    )
    limit: int = Field(
        default=4096,
        description=f"The number of characters to read from the offset (max {_MAX_LIMIT}).",
    )

    def run(self, sec_policy: ToolSecurityPolicy) -> dict:
        if self.limit > self._MAX_LIMIT:
            raise RuntimeError(
                f"The specified limit is greater than the maximum allowed limit ({self._MAX_LIMIT})"
            )
        sec_policy.validate_path_access(self.file_path)

        with open(self.file_path, encoding="utf-8") as fd:
            # We cannot `seek` cleanly to character boundaries in case of multibyte UTF characters,
            # so we'll read and discard the first `offset` characters.
            fd.read(self.offset)
            chunk_contents = fd.read(self.limit)

        return {"contents": chunk_contents}

Зверніть увагу: в інструментах, які зчитують вміст файлу, ми додали аргументи offset/limit, щоб уникнути читання великих файлів повністю. Інакше ми можемо вмить переповнити контекст LLM і їй «зірве дах». Спочатку я забув додати ці параметри. LLM прочитала великий файл, після чого забула всю задачу і... почала генерувати мені код на C#.

Виконання інструментів

Настав час реалізувати логіку середовища, яке виконує інструменти за запитом від агента.

Корисною аналогією для середовища може бути ядро операційної системи, яке виконує привілейований код, коли користувацькі програми ініціюють системні виклики. Таким чином, агент здійснює «системний виклик» інструмента, а середовище («ядро») перевіряє вимоги безпеки та виконує «системний» код (реалізацію інструмента).

Виклики інструментів від LLM будуть містити назву інструмента і його аргументи (у вигляді JSON-рядка). Наприклад, "name": "ListDirectoryTool", "arguments": "{\"dir_path\": \"/home/user/Desktop\"}". Але варто пам’ятати, що LLM можуть галюцинувати і викликати інструменти/аргументи, яких не існує. Тому ми опрацьовуємо виклики максимально обережно.


class Environment:
    def __init__(
        self,
        tools: Sequence[type[Tool]],
        sec_policy: ToolSecurityPolicy,
    ) -> None:
        self.tool_registry = {tool.get_name(): tool for tool in tools}
        self.sec_policy = sec_policy

    def run_tool(self, name: str, args_raw: str) -> dict:
        # The LLM may hallucinate and request invalid tool names or arguments.
        # We parse the inputs very carefully here.
        if name not in self.tool_registry:
            return {"error": f"Tool {name} does not exist"}
        try:
            parsed_args = json.loads(args_raw)
            tool_cls = self.tool_registry[name]
            tool_obj = tool_cls(**parsed_args)
        except Exception as e:
            return {"error": f"Invalid arguments: {e}"}

        # Check if the security policy requires user approval of the tool call.
        # If yes, present the approval prompt.
        if name not in self.sec_policy.approved_tool_names:
            approval_result = approval_prompt().lower()
            match approval_result:
                case "y":
                    # User approved the tool call: do nothing (let the tool run).
                    pass
                case "a":
                    # User approved this and all future calls of this tool:
                    # add the tool name to the allow-list and let the tool run.
                    self.sec_policy.approved_tool_names.add(name)
                case "f":
                    # User provided steering/guidance feedback: reject the call
                    # and return the feedback as a tool call result.
                    user_feedback = user_feedback_prompt()
                    return {"error": f"User provided feedback: {user_feedback}"}
                case "n":
                    # User rejected the tool call without providing any feedback.
                    return {"error": "Tool call rejected by user"}
                case "q":
                    # User decided to quit the session.
                    raise KeyboardInterrupt()

        try:
            tool_output = tool_obj.run(self.sec_policy)
        except Exception as e:
            return {"error": f"Tool call failed: {e}"}

        return tool_output

LLM не вимагають від нас конкретної структури результатів викликів інструментів. Я обрав шлях завжди повертати JSON-dict. Якщо трапляється помилка (некоректний аргумент, користувач відхилив виклик, exception під час виконання самого інструмента тощо), агент отримує відповідне повідомлення про помилку в тілі результату і може автоматично коригувати свою поведінку в наступних ітераціях.

Політика безпеки (ToolSecurityPolicy) має дві функції:

  • пам’ятати, які інструменти користувач підтвердив назавжди;
  • функцію перевірки, чи шлях, з яким інструмент хоче працювати, не виходить за межі початкової директорії (filesystem jail).

@dataclass
class ToolSecurityPolicy:
    """Represents the security configuration for tool execution."""

    # The root path the agent is allowed to operate within.
    # Any attempts to perform file operations outside this path
    # must be rejected by the tool implementations.
    fs_root_jail: Path

    # The names of the tools the user has auto-approved in the current session.
    approved_tool_names: set[str] = field(default_factory=set)

    def validate_path_access(self, path: Path) -> None:
        """Check whether the tool is allowed to access the specified path."""
        path = path.resolve()
        if not path.is_relative_to(self.fs_root_jail):
            msg = (
                f"The requested path ({path}) is outside "
                f"the task root path ({self.fs_root_jail}). "
                "Rejected by security policy."
            )
            raise RuntimeError(msg)

Така примітивна система безпеки працює тому, що у нас доволі прості інструменти, де кожен аргумент ми можемо перевірити. Якби у нас були більш загальні інструменти на кшталт BashTool чи WebFetchTool, тоді доцільніше було б користуватися безпековими функціями операційної системи (filesystem / network sandboxing).

Виконавши інструмент, прикріплюємо його результат до контексту в коді агента:


def _handle_tool_call(self, item: LLMToolCall, env: Environment) -> None:
    tool_result = env.run_tool(item.name, item.arguments)

    # Append the tool call result to the context, linking it by `call_id`.
    tool_result_item = self.llm_client.make_tool_result(
        call_id=item.call_id,
        output=json.dumps(tool_result),
    )
    self.conversation_context.append(tool_result_item)

Загалом, усі найважливіші складові ми реалізували. Решта коду агента стосується інтерфейсу та парсингу відповідей LLM. Давайте подивимося, як це спрацювало на практиці.

Запуск і результати

Я попросив Claude Code згенерувати мені хаос на робочому столі й отримав суміш із 50 файлів різного типу та вмісту. Потім я запустив ordnung. Наш агент зазвичай робить ~60—70 викликів інструментів (5–15 на аналіз файлів, ~55 — на їхню реорганізацію) і впорядковує всі файли:

Ordnung: Result Screenshots

В середньому опрацювання такого робочого столу використовувало 400k input / 5k output tokens і займало одну хвилину для моделі gpt-oss:20b на llama-server із CUDA. Велика кількість вхідних токенів також пояснюється тим, що ця LLM виконувала лише один інструмент за ітерацію, хоча можна було повертати одразу багато викликів, як це роблять сучасніші хмарні моделі.

Приємним сюрпризом було те, що коли дати агенту PDF-файл без розширення, він викликає на ньому ReadBinaryFileTool і за магічними байтами в заголовку коректно визначає, що це PDF.

З використанням gpt-oss:20b агент мав відсоток успіху близько 85% (17 / 20 успішних запусків). Всі неуспішні спроби були пов’язані з тим, що LLM формувала некоректні початкові шляхи з зайвими пробілами і робила помилковий висновок, що в неї немає доступу до файлової системи.

Інші практичні нюанси

Щоб код у статті був менш громіздким, я опустив деякі аспекти, однак про них варто поговорити окремо.

Робота з локальними LLM

Оскільки передові хмарні LLM набагато більші та працюють на значно серйознішому залізі, очікуйте, що маленькі локальні LLM працюватимуть гірше. Вони будуть більше помилятися у викликах інструментів, більше галюцинувати, гірше дотримуватися інструкцій, використовувати більше ітерацій. Для порівняння можете «націлити» агента на хмарну LLM від OpenAI чи Anthropic за допомогою аргументів командного рядка.

Якщо ви плануєте використовувати LLM саме для агентних застосунків, іноді для цього існують спеціалізовані гіперпараметри запуску моделі, на яких вона поводиться краще. Наприклад, порівняйте різні варіанти рекомендованих параметрів для GLM 4.7 Flash. Також переконайтеся, що ваш inference-сервер налаштований на достатньо великий розмір контексту. Наприклад, Ollama за замовчуванням використовує 4K токенів, що може бути замало. Для розробки я налаштовував LLM-сервери на 32—128K залежно від апаратури.

Існують також різні види LLM API. Найбільш традиційним і кросвендорним є OpenAI Chat Completions API, однак є новіший підвид API — OpenAI Responses API (і стандарт OpenResponses). Очікуйте, що новіша функціональність LLM може бути доступна лише в Responses API. Код, викладений на GitHub, підтримує обидва діалекти та абстрагує це від агента.

Надійність агента

Якщо LLM галюцинує або зайшла в глухий кут при виконанні задачі, вона може застрягнути в патологічній поведінці: викликати одні й ті самі інструменти нескінченно. В реальному житті це може потягнути суттєві грошові витрати на LLM API. Доцільно ввести додатковий параметр — максимальну кількість ітерацій агента, при перевищенні якої він завершуватиметься аварійно. Код на GitHub підтримує цей параметр.

Також наша «наївна» реалізація зберігає всю історію взаємодії з LLM впродовж роботи агента. Після великої кількості ітерацій контекст LLM може вичерпатися. Існують техніки для уникнення цієї проблеми (compaction via LLM summarization, sliding window truncation, selective pruning). Усі вони мають плюси та мінуси, і не існує найкращої техніки, яка працює у всіх випадках. Для простоти розуміння коду я не реалізовував цю логіку. Але інженерія контексту є однією з найцікавіших тем у розробці агентів, і ви, напевно, самі спостерігали, як впливає на якість роботи агента заповнений контекст.

Що фреймворки роблять за вас

Базовий агентний цикл — це ~100 рядків коду. Але навколо нього є багато інженерних задач, які фреймворки беруть на себе. LangGraph моделює агента як граф із типізованим станом, чекпойнтами та паралельним виконанням гілок. CrewAI організовує кілька агентів у «команди» з ролями, вбудованою векторною пам’яттю та різними стратегіями координації. OpenAI Agents SDK дає мінімалістичні примітиви — handoffs між агентами, guardrails для валідації, вбудований трейсинг тощо. Для одноагентної системи з кількома інструментами, як наш ordnung, це надлишково, але фреймворки починають виправдовувати свою складність, коли потрібна персистентність стану між сесіями, кілька агентів, що співпрацюють, або production-grade моніторинг.

Тестування агентних застосунків

Агенти дають змогу якісно виконувати нечітку задачу, проте жертвуючи контрольованістю. До розробки агентних застосунків слід ставитися як до ML-систем, де методика контролю якості може включати як автоматизовані детерміністичні тести, так і model evals, LLM-as-a-judge чи комітети з експертів-людей. Детальніше — рекомендую статтю від Anthropic.

Висновки

Як бачите, під капотом агентного застосунку немає жодної магії. Claude Code, Codex, Cursor Agent — усі вони побудовані за такою ж схемою, лише з більшою кількістю інструментів, евристик і з відполірованим інтерфейсом.

Якщо після цієї статті вам захочеться спробувати самостійно — ось виклик: візьміть будь-яку іншу прикладну задачу (сортування пошти, аудит залежностей на CVE, аналіз Git-репозиторію) і напишіть для неї агента з нуля. Гарантую: після цього ви будете дивитися на свого кодинг-агента зовсім іншими очима і краще розумітимете, як коригувати його поведінку, коли щось іде не так.

Більшість цього проєкту я реалізував наживо протягом двох епізодів технічно-популярного подкасту «Шо по коду?». Код агента доступний на GitHub.

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось33
До обраногоВ обраному17
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Стаття хороша як пояснення базового agent loop, але я б не погодився, що це вже повне визначення агента.

Так, твердження про perceive -> act -> feedback справедливе як мінімум. Так само справедливо, що LLM сама по собі ще не агент, а лише може бути частиною агентної системи.

Але далі починається спрощення. Бо якщо звести агента до «LLM + tools + harness + цикл», то під це визначення потрапляє майже будь-який workflow-скрипт із function calling.

На практиці справжній агент — це не просто цикл дій, а керований runtime-виконавець з явною операційною моделлю:
1. у нього є ідентичність і версія;
2. є роль і межі відповідальності;
3. є дозволені інструменти, memory policy, model policy;
4. є observability, evaluation profile, risk profile;
5. є approval / verification / rollback / governance;
6. є контракти handoff’ів у multi-agent середовищі.

Тобто агент — це не «мозок у циклі», а керована одиниця виконання всередині системи.

Саме тому «tool-calling LLM, яка щось крутить у loop» — це ще не достатня умова. Це лише нижній поріг агентності.

Якщо система не має явних меж повноважень, політик, перевірки, слідів виконання й контрольованого handoff між ролями — це радше agentic workflow, ніж повноцінний агент.
Іншими словами:
— стаття правильно пояснює, з чого агент починається;
— але це ще не пояснення, чим агент є насправді у серйозній інженерній системі.

Агент починається з perception-action loop.
Справжній агент починається там, де з’являються роль, політика, контроль, верифікація і відповідальність за дію.

Під класичне визначення агента формально підпадає навіть PID-регулятор у домашньому термостаті чи круїз-контроль в авто. Я згідний, що описані продакшн-аспекти обов’язкові для продакшину, але не згідний, що вони обов’язково входять у визначення агента. Інакше можна так само казати, що комп’ютерна програма — це не комп’ютерна програма, якщо для неї нема автоматичних тестів, CI/CD-пайплайну і SRE runbook.

З іншого боку, AI-індустрія давно відома тим, що сама не може стандартизувати визначення, що таке агент. Той же Anthropic пише «In Building effective AI agents, we highlighted the differences between LLM-based workflows and agents. Since we wrote that post, we’ve gravitated towards a simple definition for agents: LLMs autonomously using tools in a loop.»

Підписатись на коментарі