
Современные кодинг-помощники кажутся магией. Достаточно описать нужное вам на хотя бы немного понятными словами, после чего они сами читают файлы, редактируют проект и пишут работающий код.
Но вот что я вам скажу: в основе этих инструментов не лежит магия. Для них достаточно примерно двухсот строк простого Python.
Давайте с нуля напишем собственный функциональный кодинг-агент.
Ментальная модель
Прежде, чем приступать к написанию кода, надо разобраться, что же происходит, когда мы используем агента. По сути, это просто беседа с мощной LLM, обладающей набором инструментов.
Вы отправляете сообщение («Создай новый файл с функцией hello world»)
LLM решает, что ей нужен инструмент, и отвечает структурированным вызовом инструмента (или несколькими вызовами инструментов)
Ваша программа выполняет этот инструмент локально (создаёт файл)
Результат передаётся LLM
LLM использует этот контекст для дальнейшей работы или ответа
Вот и весь цикл. На самом деле LLM вообще никак не взаимодействует с вашей файловой системой. Она всего лишь просит выполнять действия, и ваш код выполняет их.
Три инструмента, которые нам понадобятся
Нашему кодинг-агенту необходимы три функции:
Чтение файлов, чтобы LLM могла видеть ваш код
Создание списка файлов, чтобы она могла ориентироваться в проекте
Редактирование файлов, чтобы можно было давать ему команды для создания и изменения кода
Вот и всё. У агентов продакшен-уровня наподобие Claude Code есть и другие инструменты, например, grep, bash, websearch и так далее, но, как мы увидим ниже, даже трёх инструментов достаточно для того, чтобы творить нечто невероятное.
Предварительная настройка
Начнём мы с базовых импортов и клиента API. Я буду пользоваться OpenAI, но подойдёт и любой другой сервис LLM:
import inspect
import json
import os
import anthropic
from dotenv import load_dotenv
from pathlib import Path
from typing import Any, Dict, List, Tuple
load_dotenv()
claude_client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])Добавим цветов терминала, чтобы вывод было удобнее читать:
YOU_COLOR = "\u001b[94m"
ASSISTANT_COLOR = "\u001b[93m"
RESET_COLOR = "\u001b[0m"И утилиту для ресолвинга файловых путей (чтобы file.py превращался в /Users/you/project/file.py):
def resolve_abs_path(path_str: str) -> Path:
"""
file.py -> /Users/you/project/file.py
"""
path = Path(path_str).expanduser()
if not path.is_absolute():
path = (Path.cwd() / path).resolve()
return pathРеализуем инструменты
Стоит отметить, что docstrings функций инструментов должны быть подробными, потому что они будут использоваться LLM для рассуждений о том, какие инструменты необходимо вызывать во время беседы. Детальнее мы разберём это чуть ниже.
Инструмент 1: чтение файлов
Самый простой инструмент. Получаем имя файла, возвращаем его содержимое:
def read_file_tool(filename: str) -> Dict[str, Any]:
"""
Gets the full content of a file provided by the user.
:param filename: The name of the file to read.
:return: The full content of the file.
"""
full_path = resolve_abs_path(filename)
print(full_path)
with open(str(full_path), "r") as f:
content = f.read()
return {
"file_path": str(full_path),
"content": content
}Мы возвращаем словарь, потому что LLM требуется структурированный контекст происходящего.
Инструмент 2: создание списка файлов
Ходим по папкам, создавая списки их содержимого:
def list_files_tool(path: str) -> Dict[str, Any]:
"""
Lists the files in a directory provided by the user.
:param path: The path to a directory to list files from.
:return: A list of files in the directory.
"""
full_path = resolve_abs_path(path)
all_files = []
for item in full_path.iterdir():
all_files.append({
"filename": item.name,
"type": "file" if item.is_file() else "dir"
})
return {
"path": str(full_path),
"files": all_files
}Инструмент 3: редактирование файлов
Это самый сложный инструмент, но всё равно достаточно понятный. Он обрабатывает два случая:
Создание нового файла, когда
old_strпустаЗамена текста нахождением
old_strи заменой её наnew_str
def edit_file_tool(path: str, old_str: str, new_str: str) -> Dict[str, Any]:
"""
Replaces first occurrence of old_str with new_str in file. If old_str is empty,
create/overwrite file with new_str.
:param path: The path to the file to edit.
:param old_str: The string to replace.
:param new_str: The string to replace with.
:return: A dictionary with the path to the file and the action taken.
"""
full_path = resolve_abs_path(path)
if old_str == "":
full_path.write_text(new_str, encoding="utf-8")
return {
"path": str(full_path),
"action": "created_file"
}
original = full_path.read_text(encoding="utf-8")
if original.find(old_str) == -1:
return {
"path": str(full_path),
"action": "old_str not found"
}
edited = original.replace(old_str, new_str, 1)
full_path.write_text(edited, encoding="utf-8")
return {
"path": str(full_path),
"action": "edited"
}Правило здесь такое: пустая old_str означает «создать этот файл». Если она не пуста, то нужно найти и заменить. Настоящие IDE добавляют сложное поведение при сбое в случае ненайденной строки, но и этого вполне достаточно.
Перечень инструментов
Нам нужно как-то находить инструменты по именам:
TOOL_REGISTRY = {
"read_file": read_file_tool,
"list_files": list_files_tool,
"edit_file": edit_file_tool
}Учим LLM пользоваться нашими инструментами
LLM должна знать, какие инструменты есть и как их вызывать. Мы генерируем это знание динамически из сигнатур функций и docstrings:
def get_tool_str_representation(tool_name: str) -> str:
tool = TOOL_REGISTRY[tool_name]
return f"""
Name: {tool_name}
Description: {tool.__doc__}
Signature: {inspect.signature(tool)}
"""
def get_full_system_prompt():
tool_str_repr = ""
for tool_name in TOOL_REGISTRY:
tool_str_repr += "TOOL\n===" + get_tool_str_representation(tool_name)
tool_str_repr += f"\n{'='*15}\n"
return SYSTEM_PROMPT.format(tool_list_repr=tool_str_repr)А также в самом системном промпте:
SYSTEM_PROMPT = """
Ты помощник в кодинге, цель которого - помогать в решении задач кодинга.
У тебя есть доступ к набору инструментов, которые ты можешь применять. Вот список инструментов:
{tool_list_repr}
Когда тебе нужно использовать инструмент, отвечай ровно одной строкой в таком формате: 'tool: TOOL_NAME({{JSON_ARGS}})' и больше ничем.
Используй компактный однострочный JSON с двойными кавычками. После получения сообщения tool_result(...) продолжай выполнение задачи.
Если инструмент не требуется, отвечай обычным образом.
"""И это здесь самое важное — мы просто говорим LLM: «Вот твои инструменты, вот формат для их вызова». LLM сама разберётся, когда и как их использовать.
Парсинг вызова инструментов
Когда LLM отвечает, нам нужно распознавать, что она просит нас запустить инструмент:
def extract_tool_invocations(text: str) -> List[Tuple[str, Dict[str, Any]]]:
"""
Return list of (tool_name, args) requested in 'tool: name({...})' lines.
The parser expects single-line, compact JSON in parentheses.
"""
invocations = []
for raw_line in text.splitlines():
line = raw_line.strip()
if not line.startswith("tool:"):
continue
try:
after = line[len("tool:"):].strip()
name, rest = after.split("(", 1)
name = name.strip()
if not rest.endswith(")"):
continue
json_str = rest[:-1].strip()
args = json.loads(json_str)
invocations.append((name, args))
except Exception:
continue
return invocationsЭто простой парсинг текста. Ищем строки, начинающиеся с tool:, извлекаем имя функции и JSON-аргументы.
Вызов LLM
Тонкая обёртка вокруг API:
def execute_llm_call(conversation: List[Dict[str, str]]):
system_content = ""
messages = []
for msg in conversation:
if msg["role"] == "system":
system_content = msg["content"]
else:
messages.append(msg)
response = claude_client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2000,
system=system_content,
messages=messages
)
return response.content[0].textЦикл агента
Теперь мы соединяем всё вместе. Именно тут и происходит «магия»:
def run_coding_agent_loop():
print(get_full_system_prompt())
conversation = [{
"role": "system",
"content": get_full_system_prompt()
}]
while True:
try:
user_input = input(f"{YOU_COLOR}You:{RESET_COLOR}:")
except (KeyboardInterrupt, EOFError):
break
conversation.append({
"role": "user",
"content": user_input.strip()
})
while True:
assistant_response = execute_llm_call(conversation)
tool_invocations = extract_tool_invocations(assistant_response)
if not tool_invocations:
print(f"{ASSISTANT_COLOR}Assistant:{RESET_COLOR}: {assistant_response}")
conversation.append({
"role": "assistant",
"content": assistant_response
})
break
for name, args in tool_invocations:
tool = TOOL_REGISTRY[name]
resp = ""
print(name, args)
if name == "read_file":
resp = tool(args.get("filename", "."))
elif name == "list_files":
resp = tool(args.get("path", "."))
elif name == "edit_file":
resp = tool(args.get("path", "."),
args.get("old_str", ""),
args.get("new_str", ""))
conversation.append({
"role": "user",
"content": f"tool_result({json.dumps(resp)})"
})Структура кода:
Внешний цикл: получаем пользовательский ввод, добавляем в беседу
Внутренний цикл: вызываем LLM, проверяем вызовы инструментов
Если инструменты не требуются, печатаем ответ и выходим из внутреннего цикла
Если инструменты нужны, исполняем их, добавляем результаты в беседу и начинаем цикл снова
Внутренний цикл продолжается, пока LLM отвечает, не запрашивая инструменты. Это позволяет агенту объединять в цепочку несколько вызовов инструментов (чтение файла, его редактирование и подтверждение изменений).
Запускаем нашего агента
if __name__ == "__main__":
run_coding_agent_loop()Теперь вы можете вести такие беседы:
Вы: Создай новый файл с именем hello.py и реализуй в нём hello world
Агент вызывает edit_file path="hello.py", old_str="", new_str="print(‘Hello World’)"
Помощник: Готово! Создан hello.py с реализацией hello world.
Или многоэтапные разговоры:
Вы: Отредактируй hello.py, добавив в него функцию перемножения двух чисел
Агент вызывает read_file для просмотра текущего содержимого, а затем вызывает edit_file для добавления функции.
Помощник: Добавлена функция умножения в hello.py.
Разница между нашей системой и продакшен-инструментами
Всего у нас получилось около 200 строк. В продакшен-инструментах наподобие Claude Code также имеется:
Более качественная обработка ошибок и поведения при сбоях
Потоковые ответы для улучшения UX
Более умное управление контекстом (суммаризация длинных файлов и так далее)
Дополнительные инструменты (выполнение команд, поиск по кодовой базе и так далее)
Процедуры подтверждения для деструктивных операций
Но их базовый цикл остаётся точно таким же, который создали мы. LLM решает, что делать, ваш код исполняет это, результаты передаются обратно. В этом и заключается вся архитектура.
Попробуйте сами
Полные исходники состоят из примерно 200 строк. В качестве домашнего задания подставьте в них тот сервис LLM, с которым вы работаете, настройте системный промпт, добавьте новые инструменты. Вас приятно поразит мощь этого простого паттерна.
