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


