Современные кодинг-помощники кажутся магией. Достаточно описать нужное вам на хотя бы немного понятными словами, после чего они сами читают файлы, редактируют проект и пишут работающий код.
Но вот что я вам скажу: в основе этих инструментов не лежит магия. Для них достаточно примерно двухсот строк простого 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 для рассуждений о том, какие инструменты необходимо вызывать во время беседы. Детальнее мы разберём это чуть ниже.
Самый простой инструмент. Получаем имя файла, возвращаем его содержимое:
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 требуется структурированный контекст происходящего.
Ходим по папкам, создавая списки их содержимого:
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 }
Это самый сложный инструмент, но всё равно достаточно понятный. Он обрабатывает два случая:
Создание нового файла, когда 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 должна знать, какие инструменты есть и как их вызывать. Мы генерируем это знание динамически из сигнатур функций и 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-аргументы.
Тонкая обёртка вокруг 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()
Теперь вы можете вести такие беседы:
Агент вызывает edit_file path="hello.py", old_str="", new_str="print(‘Hello World’)"
Или многоэтапные разговоры:
Агент вызывает read_file для просмотра текущего содержимого, а затем вызывает edit_file для добавления функции.
Всего у нас получилось около 200 строк. В продакшен-инструментах наподобие Claude Code также имеется:
Более качественная обработка ошибок и поведения при сбоях
Потоковые ответы для улучшения UX
Более умное управление контекстом (суммаризация длинных файлов и так далее)
Дополнительные инструменты (выполнение команд, поиск по кодовой базе и так далее)
Процедуры подтверждения для деструктивных операций
Но их базовый цикл остаётся точно таким же, который создали мы. LLM решает, что делать, ваш код исполняет это, результаты передаются обратно. В этом и заключается вся архитектура.
Полные исходники состоят из примерно 200 строк. В качестве домашнего задания подставьте в них тот сервис LLM, с которым вы работаете, настройте системный промпт, добавьте новые инструменты. Вас приятно поразит мощь этого простого паттерна.
Источник


