Создаём простого ИИ-агента с нуля. Часть 2. python.. python. Блог компании Cloud4Y.. python. Блог компании Cloud4Y. искусственный интеллект.. python. Блог компании Cloud4Y. искусственный интеллект. код.. python. Блог компании Cloud4Y. искусственный интеллект. код. Машинное обучение.. python. Блог компании Cloud4Y. искусственный интеллект. код. Машинное обучение. Программирование.

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

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

Что такое инструменты?

Инструмент — это программа или функция, которую вы предоставляете модели (LLM), чтобы она могла вызывать её самостоятельно. Инструмент может быть как совсем простым — обычной функцией на Python в том же коде агента, — так и сложным, например MCP-сервером (Model Context Protocol), который делает HTTP-запрос к API, читающему или обновляющему базу данных.

Как агенты используют инструменты?

Большие языковые модели выдают текст — так как же они могут пользоваться инструментами? Первые реализации вызова инструментов полагались на то, чтобы «подсказать» LLM вывести текст вроде Action: web_fetch, после чего обвязка агента (agent harness) разбирала этот текстовый вывод и запускала соответствующую функцию. Такой подход был не слишком надёжным: модель порой не вполне точно следовала ожидаемому от неё формату.

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

Улучшаем агента с помощью инструментов

За основу мы возьмём простого агента, которого собрали в прошлый раз.

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

В коде агента мы создадим подмодуль tools. В нём мы реализуем все инструменты и их схемы.

Для начала — инструмент bash:

def run_bash(command: str) -> str:
    """Run a bash command and return its output."""
    result = subprocess.run(
        command, shell=True, text=True, capture_output=True
    )
    output = result.stdout
    if result.stderr:
        output += f"nSTDERR:n{result.stderr}"
    return output or "(no output)"

Это самый мощный инструмент. Позволив агенту выполнять команды bash, мы дадим ему возможность делать на компьютере что угодно. С одной стороны, это хорошо: нам не придётся реализовывать отдельный инструмент для каждой программы, которую можно просто запустить через bash и которой LLM и так умеет пользоваться. С другой стороны, это и самый опасный инструмент (по той же причине — он позволяет делать на компьютере что угодно). В дальнейшем мы вплотную займёмся безопасностью, чтобы избежать проблем.

Следующий инструмент — чтение файла (read file):

def read_file(path: str, offset: int = 1, limit: int = 200) -> str:
    """Read lines from a file, with optional offset and limit."""
    p = Path(path)
    if not p.exists():
        return f"Error: file not found: {path}"
    lines = p.read_text(errors="replace").splitlines()
    selected = lines[offset - 1: offset - 1 + limit]
    return "n".join(f"{offset + i}: {line}" for i, line in enumerate(selected))

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

Следующий инструмент — поиск файлов по шаблону (glob files):

def glob_files(pattern: str, path: str = ".") -> str:
    """Find files matching a glob pattern inside a directory."""
    matches = glob_module.glob(f"{path}/**/{pattern}", recursive=True)
    matches += glob_module.glob(f"{path}/{pattern}")
    unique = sorted(set(matches))
    return "n".join(unique) if unique else "(no matches)"

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

Следующий инструмент — grep:

def grep(pattern: str, path: str = ".", include: str = "*") -> str:
    """Search file contents for a regex pattern, optionally filtering by filename glob."""
    results = []
    for filepath in glob_module.glob(f"{path}/**/{include}", recursive=True):
        fp = Path(filepath)
        if not fp.is_file():
            continue
        try:
            for i, line in enumerate(fp.read_text(errors="replace").splitlines(), 1):
                if re.search(pattern, line):
                    results.append(f"{filepath}:{i}: {line}")
        except OSError:
            pass
    return "n".join(results) if results else "(no matches)"

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

Следующий инструмент — запись файла (write file):

def write_file(path: str, content: str) -> str:
    """Write content to a file, creating it if it does not exist."""
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(content)
    return f"Wrote {len(content)} bytes to {path}"

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

Следующий инструмент — редактирование файла (edit file):

def edit_file(path: str, old_string: str, new_string: str) -> str:
    """Replace the first occurrence of old_string with new_string in a file."""
    p = Path(path)
    if not p.exists():
        return f"Error: file not found: {path}"
    original = p.read_text()
    if old_string not in original:
        return f"Error: string not found in {path}"
    p.write_text(original.replace(old_string, new_string, 1))
    return f"Edited {path}"

Если write_file заменяет всё содержимое файла целиком, то edit_file выполняет точечную замену строки. Это гораздо безопаснее, когда агенту нужно внести лишь небольшое изменение в существующий файл, поскольку так он не перезапишет случайно то содержимое, которое не читал. Это основной инструмент для кодинг-агентов, которым нужно править конкретные строки, не переписывая всё заново.

Последний инструмент — webfetch:

def webfetch(url: str) -> str:
    """Fetch a URL and return its full plain-text content (up to 2 MB)."""
    parsed = urlparse(url)
    if parsed.scheme not in ("http", "https"):
        return f"Error fetching {url}: unsupported scheme '{parsed.scheme}'."
    req = urllib.request.Request(url, headers={"User-Agent": "agent/1.0"})
    with urllib.request.urlopen(req, timeout=15) as resp:
        raw = b"".join(...).decode(charset, errors="replace")
    soup = BeautifulSoup(raw, "html.parser")
    text = soup.get_text(separator="n", strip=True)
    return re.sub(r"n{3,}", "nn", text).strip()

Этот инструмент загружает публичную веб-страницу и возвращает её содержимое в виде обычного текста. Он использует BeautifulSoup, чтобы убрать всю HTML-разметку, и модель получает только читаемый текст — это сохраняет контекст чистым и экономит токены. Инструмент работает только с URL по http и https и ограничивает ответ размером 2 МБ, чтобы не переполнять контекстное окно огромными страницами.

Когда все инструменты реализованы, нужно сообщить агенту об их существовании. Кроме того, агент должен знать, что делает каждый инструмент и какие параметры он принимает. Для этого мы определяем схему инструментов (tool schema) для модели:

def get_tool_schemas():
    return [
        {
            "type": "function",
            "function": {
                "name": "run_bash",
                "description": "Run a bash command on the user's machine and return the output.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "command": {
                            "type": "string",
                            "description": "The bash command to execute.",
                        }
                    },
                    "required": ["command"],
                },
            },
        },
        {
            "type": "function",
            "function": {
                "name": "read_file",
                "description": "Read lines from a file. Returns lines prefixed with line numbers.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "path": {"type": "string", "description": "Absolute or relative path to the file."},
                        "offset": {"type": "integer", "description": "First line to read (1-indexed). Defaults to 1."},
                        "limit": {"type": "integer", "description": "Maximum number of lines to return. Defaults to 200."},
                    },
                    "required": ["path"],
                },
            },
        },
        {
            "type": "function",
            "function": {
                "name": "glob_files",
                "description": "Find files matching a glob pattern (e.g. '**/*.py') inside a directory.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "pattern": {"type": "string", "description": "Glob pattern to match against file names."},
                        "path": {"type": "string", "description": "Root directory to search in. Defaults to '.'."},
                    },
                    "required": ["pattern"],
                },
            },
        },
        {
            "type": "function",
            "function": {
                "name": "grep",
                "description": "Search file contents for a regex pattern and return matching lines with file paths and line numbers.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "pattern": {"type": "string", "description": "Regular expression to search for."},
                        "path": {"type": "string", "description": "Directory to search in. Defaults to '.'."},
                        "include": {"type": "string", "description": "Filename glob to restrict which files are searched (e.g. '*.py'). Defaults to '*'."},
                    },
                    "required": ["pattern"],
                },
            },
        },
        {
            "type": "function",
            "function": {
                "name": "write_file",
                "description": "Write content to a file, creating it (and any missing parent directories) if it does not exist.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "path": {"type": "string", "description": "Path of the file to write."},
                        "content": {"type": "string", "description": "Full content to write to the file."},
                    },
                    "required": ["path", "content"],
                },
            },
        },
        {
            "type": "function",
            "function": {
                "name": "edit_file",
                "description": "Replace the first occurrence of a string in a file with a new string.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "path": {"type": "string", "description": "Path of the file to edit."},
                        "old_string": {"type": "string", "description": "Exact string to find and replace."},
                        "new_string": {"type": "string", "description": "String to replace it with."},
                    },
                    "required": ["path", "old_string", "new_string"],
                },
            },
        },
        {
            "type": "function",
            "function": {
                "name": "webfetch",
                "description": (
                    "Fetch a public URL (http/https only) and return its full plain-text content (up to 2 MB)."
                ),
                "parameters": {
                    "type": "object",
                    "properties": {
                        "url": {"type": "string", "description": "The URL to fetch (http/https)."},
                    },
                    "required": ["url"],
                },
            },
        },
    ]

После этого мы можем интегрировать инструменты в наш прежний агентный цикл:

TOOL_REGISTRY = get_tool_registry()
TOOL_SCHEMAS = get_tool_schemas()

def handle_tool_calls(tool_calls, messages):
    """Execute each tool the LLM requested and append the results to messages."""
    for tool_call in tool_calls:
        name = tool_call.function.name
        args = json.loads(tool_call.function.arguments)

        print(f"  [tool] {name}({args})")

        if name not in TOOL_REGISTRY:
            result = f"Error: unknown tool '{
                name}'. Available tools: {list(TOOL_REGISTRY.keys())}"
        else:
            result = TOOL_REGISTRY[name](**args)

        print(f"  [tool result] {result[:200]}{
              '...' if len(result) > 200 else ''}")

        messages.append({
            "role": "tool",
            "tool_call_id": tool_call.id,
            "content": result,
        })


def agent_loop(client):
    messages = [
        {
            "role": "system",
            "content": (
                "You are a helpful assistant. You have tools to read and write files, "
                "search the file system, and fetch web pages. Use them to help the user."
            ),
        }
    ]

    while True:
        user_input = input("You: ")
        if user_input.lower() == "\exit":
            break

        messages.append({"role": "user", "content": user_input})

        while True:
            response = client.chat.completions.create(
                model="gemma4",
                messages=messages,
                tools=TOOL_SCHEMAS,
                temperature=0.7,
            )

            message = response.choices[0].message

            messages.append(message)

            if message.tool_calls:
                handle_tool_calls(message.tool_calls, messages)
            else:
                print(f"Assistant: {message.content}")
                break

Что у нас получилось

Теперь у нас есть агент с вызовом инструментов, и он уже весьма мощный. Если попросить его сделать что-то за вас, он сможет задействовать все эти базовые инструменты для решения довольно сложных задач. По сути, это уже можно использовать как кодинг-агента или ассистента — и это действительно работает. Ему пока не хватает многих возможностей, которые есть у Claude Code или Hermes Agent, но мы постепенно к этому идём.

Что дальше?

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

Поэкспериментировать с таким агентом можно уже сейчас — но стоит помнить, что он умеет выполнять произвольные команды и переписывать файлы, то есть фактически делать на машине что угодно. Запускать автономного «исполнителя» прямо на рабочем ноутбуке рискованно: одна неудачная команда может задеть ваши же данные. Безопаснее держать его в изолированном окружении — например, развернуть агента и модель на ML-платформе Cloud4Y: отдельный контур с нужными ресурсами, где даже неудачный вызов bash не затронет ничего лишнего.

Автор: Cloud4Y

Источник