Большое ревью и рефакторинг проекта¶
Шаг 1¶
За три модуля наш учебный проект заметно вырос. Сначала в нём был один запрос к модели, затем появились отдельный LLM-клиент, конфигурация, сервисы, история диалога, резюме, потоковая генерация, структурированные ответы и инструменты. На каждом этапе мы добавляли код, необходимый для понимания новой темы.
Теперь в проекте есть не только действующие части приложения, но и более ранние решения, которые уже выполнили свою учебную задачу. Например отдельный демонстрационный файл помогал разобраться со структурированными ответами, а специализированный помощник для статуса заказа показывал первый вызов инструмента. Позднее эти возможности были перенесены в общие сервисы.
Такая ситуация совершенно нормальна при разработке реального проекта. Архитектура редко появляется сразу в окончательном виде. Сначала разработчик проверяет идею самым простым способом, затем требования растут, появляется более общее решение, а прежний код постепенно перестаёт использоваться. Это не обязательно означает, что старый код был плохим, ведь зачастую он был правильным для того этапа, на котором создавался.
Поэтому этот шаг будет в основном теоретическим и новый код мы пока писать не будем. А сначала мы познакомимся с ревью и рефакторингом, научимся различать действующий, временный и устаревший код, а также обсудим, по каким признакам можно принимать решение об изменении структуры проекта. Это позволит в следующих шагах выполнять рефакторинг осознанно, а не просто механически перемещать и удалять файлы.
Проблема начинается не в момент появления старого кода, а тогда, когда его бесконечно оставляют рядом с новой реализацией. Через некоторое время становится непонятно, какой класс используется приложением, какой файл нужен только для демонстрации, а какой уже можно удалить. Разработчик может исправить старую реализацию, но не увидеть никакого изменения при запуске, потому что приложение давно работает через другой сервис.
Поэтому рефакторинг — это обычная часть разработки, а не признак неудачного проекта. Периодически команда останавливает добавление новых возможностей, смотрит на накопившуюся структуру и приводит её в соответствие с текущим устройством приложения.
Что будем делать в этом разделе?¶
В этом разделе мы не добавляем новую AI-функцию. Вместо этого проведём ревью уже написанного проекта. Сначала определим, какие файлы действительно участвуют в запуске, а какие были заменены более новыми решениями. Затем удалим устаревший демонстрационный код и упростим main.py, вынеся сборку приложения в отдельный модуль.
Важно, что рефакторинг не должен менять поведение программы. До и после него пользователь по-прежнему сможет общаться с чатом, анализировать обращение, узнавать статус заказа, получать правила возврата и подтверждать создание заявки. Мы изменим организацию кода, но не его пользовательские возможности.
Во время ревью полезно разделить код на несколько групп.
Активный код вызывается из текущей точки входа или нужен другим действующим модулям. Например ChatService, BoundedToolLoop, ToolExecutor и LLMClient образуют рабочую цепочку обычного сообщения.
Временный эксперимент создаётся, чтобы отдельно проверить технологию. Например мы создавали файл structured_demo.py и он был полезен, когда мы впервые подключали структурированные ответы, ведь он позволял не смешивать новую тему с большим чатом. А уже после переноса анализа обращения в SupportRequestAnalyzer и команду /analyze отдельный запуск потерял назначение.
Заменённая реализация раньше использовалась приложением, но её задачу теперь решает более общий механизм. Например OrderStatusAssistant умел работать только с одним инструментом. А после появления реестра, исполнителя и ограниченного цикла инструментов статус заказа обрабатывается через общую архитектуру.
История версий может не вызываться напрямую по имени, но всё равно является частью проекта. Например, старые версии промпта support_chat мы сохраняем намеренно. Они нужны для сравнения поведения, evaluation-запусков и возможного возврата к предыдущей версии. Это не мёртвый код, а управляемая история конфигурации. То есть, если файл не импортируется текущей версией проекта, это не всегда означает что файл нужно немедленно удалить.
Как понять, участвует ли файл в работе приложения?¶
Начинать нужно не с предположений, а с точки входа. В нашем проекте приложение запускается через main.py. Этот файл создаёт основные объекты и передаёт их в run_console(). Затем по импортам и вызовам можно пройти всю активную цепочку.
В IDE можно выбрать имя класса или функции и воспользоваться поиском использований — обычно команда называется Find Usages. Дополнительно полезен поиск по всему проекту. Мы проверяем не только прямой импорт в main.py, но и возможные импорты из других действующих модулей.
Если имя встречается только в файле, где оно объявлено, это важный признак неиспользуемого кода, но ещё не окончательное доказательство. У проекта могут быть дополнительные точки запуска, команды, служебные скрипты или тесты. Поэтому перед удалением задаём три вопроса:
- Откуда запускается этот код?
- Какой текущий модуль решает ту же задачу?
- Потеряет ли пользователь какую-либо возможность после удаления?
Для нашего учебного проекта точки запуска известны, поэтому ответить на эти вопросы несложно. В большом рабочем проекте к поиску добавляют автоматические тесты, анализ покрытия, логи использования и обсуждение с командой.
Много файлов — не обязательно плохо¶
При первом взгляде может показаться, что нужно разнести каждый dataclass, перечисление, исключение и класс по отдельным файлам. Но количество файлов само по себе ничего не говорит о качестве архитектуры. Если сделать отдельный модуль для каждой небольшой сущности, разработчику придётся постоянно переходить между десятками файлов, чтобы понять один сценарий.
Гораздо важнее связность, а именно насколько элементы одного файла относятся к одной задаче. Например ToolExecutionResult, статусы выполнения, записи аудита и ToolExecutor описывают единую границу выполнения инструментов. Их совместное размещение в execution.py понятно. Ошибки цикла, результат цикла и BoundedToolLoop также относятся к одному сценарию.
Длинный файл действительно может указывать на проблему, но сначала нужно понять, не смешаны ли в нём разные обязанности. В нашем случае основная путаница вызвана не dataclass-моделями и классами ошибок, а тем, что рядом лежат одновременно старые и новые реализации одной задачи, а main.py знает слишком много деталей сборки приложения.
То есть в нашем проекте основная проблема заключается в нескольких устаревших файлах, возможности которых уже перенесены в новые сервисы, и в перегруженном main.py, где сейчас собираются все зависимости приложения.
И начнём мы с первой проблемы. В следующем шаге найдём заменённые и демонстрационные файлы, убедимся, что они больше не участвуют в работе приложения, и только после этого безопасно удалим их.
Шаг 2¶
В предыдущем шаге мы определили главную проблему текущей структуры, ведь в нашем проекте одновременно находятся действующие сервисы, учебные эксперименты и старые реализации, которые уже заменены общими механизмами. Теперь аккуратно удалим только те файлы, для которых можем объяснить замену.
Удаление кода во время рефакторинга — не самоцель. Чем меньше кода, тем меньше его нужно читать, поддерживать и проверять. Однако удаление безопасно только тогда, когда мы понимаем путь выполнения приложения и уверены, что пользовательская возможность сохранена в другом месте.
Что проверяем перед удалением?¶
Откройте проект в IDE и по очереди выполните поиск использований для имени удаляемого класса или функции. Например в PyCharm нажмите правой кнопкой мыши на имя класса или функции и выберите Find Usages. Также можно установить курсор на нужное имя и нажать Option + F7 на macOS или Alt + F7 на Windows и Linux. IDE покажет все места проекта, в которых используется выбранный объект. Также проверьте обычным поиском по проекту имя файла, класса и связанного промпта. Нас интересуют импорты из main.py, app/console.py и действующих сервисов.
Не ориентируйтесь только на то, что файл давно не открывался. Например app/config.py почти не меняется, но используется при каждом запуске. И наоборот, большой и хорошо написанный класс можно удалить, если приложение полностью перешло на другое решение.
Удаляем structured_demo.py¶
Файл structured_demo.py появился раньше, чем структурированный анализ стал частью основного приложения. В нём мы отдельно создавали клиент, отправляли обращения и смотрели на результат. Такой демонстрационный запуск позволил спокойно изучить JSON Schema и Pydantic, не изменяя чат.
Позднее мы создали SupportRequestAnalyzer, зарегистрировали версионируемый промпт и подключили анализ к консольной команде /analyze. Теперь наш рабочий путь выглядит так:
run_console()
→ команда /analyze
→ SupportRequestAnalyzer.analyze()
→ LLMClient.generate_structured()
→ Pydantic-модель и семантическая проверка
Отдельный файл больше не является единственным способом проверить структурированные ответы и не используется из main.py. Поэтому удалите из корня проекта файл structured_demo.py, он нам больше не нужен.
Удаляем специализированный помощник статуса заказа¶
OrderStatusAssistant был нужен, когда в проекте существовал единственный инструмент get_order_status. Этот сервис самостоятельно передавал схему модели, разбирал ToolCall, проверял аргументы и запускал функцию. На его примере мы впервые увидели весь механизм Tool Calling.
Затем появились ToolRegistry, ToolExecutor и BoundedToolLoop. Они решают ту же задачу для любого зарегистрированного инструмента. В консоли больше нет отдельной команды /order, ведь пользователь задаёт обычный вопрос, а общий чат при необходимости вызывает get_order_status.
А значит после проверки использований удалите:
Каталог промпта удаляется вместе со старым сервисом, потому что действующий общий цикл его не читает. Для выбора инструментов теперь используется developer-инструкция чата и описания зарегистрированных функций.
Удаляем самый ранний SupportService¶
SupportService появился в первом модуле и решал первую прикладную задачу, а именно он принимал одно обращение и просил модель подготовить краткую сводку. На том этапе он помог отделить сценарий продукта от SDK провайдера.
Во втором и третьем модулях проект вырос в чат с историей и отдельный структурированный анализатор. Текущий main.py не создаёт SupportService, а консоль не вызывает его методы. Его задача теперь покрывается более подходящими сценариями, где за диалог с пользователем отвечает ChatService, за структурированный разбор обращения отвечает SupportRequestAnalyzer, а за сжатие длинной истории диалога отвечает ConversationSummaryService.
После поиска использований удалите:
Не перепутайте support_summary со chat_history_summary. Первый промпт относился к старому SupportService и создавал сводку одного обращения. Второй используется действующим ConversationSummaryService для сжатия истории чата, поэтому app/prompts/chat_history_summary/ нужно сохранить.
Что оставляем?¶
Не удаляйте каталог evals. Evaluation-сценарии по-прежнему полезны для сравнения версий промпта и оценки поведения настоящей модели. Также оставьте все версии внутри действующих наборов промптов:
Например файл support_chat/v1_0_0.py может не выбираться текущей конфигурацией, но это сохранённая версия промпта, а не случайно забытая реализация. Реестр позволяет явно выбрать её для сравнения или отката.
Также не нужно выносить все модели результатов и исключения из execution.py, tool_loop.py и chat_service.py. Эти определения используются рядом с классами, чей контракт описывают. Сейчас такое размещение сохраняет смысловые границы и не мешает навигации.
Как изменилось дерево проекта?¶
До очистки рядом с активным кодом находились три завершённых этапа обучения:
ai-support-desk/
├── structured_demo.py # временный запуск
└── app/
├── services/
│ ├── order_status_assistant.py # заменён общим циклом
│ ├── support_service.py # ранний сценарий
│ ├── chat_service.py
│ └── support_request_analyzer.py
└── prompts/
├── order_status_assistant/ # промпт старого сервиса
├── support_summary/ # промпт старого сервиса
├── support_chat/
├── chat_history_summary/
└── support_request_analysis/
После удаления остаются только действующие сценарии и намеренно сохранённые версии:
ai-support-desk/
└── app/
├── services/
│ ├── chat_service.py
│ ├── conversation_summary_service.py
│ ├── support_request_analyzer.py
│ ├── support_request_review.py
│ └── tool_loop.py
└── prompts/
├── support_chat/
├── chat_history_summary/
└── support_request_analysis/
И в конце этого шага, давайте я покажу вам как проверить то, что импорты в наших файлах не повреждены. Для этого можно выполнить быструю проверку Python-файлов без обращения к ProxyAPI:
Команда компилирует Python-файлы и помогает обнаружить синтаксические ошибки. Она не запускает приложение и не отправляет запрос в ProxyAPI, поэтому баланс не расходуется. В результате мы получим:
(.venv) ~/PycharmProjects/AI_Course_Developer/ai-support-desk git:[master] python -m compileall app main.py
Listing 'app'...
Listing 'app/llm'...
Listing 'app/models'...
Listing 'app/prompts'...
Listing 'app/prompts/chat_history_summary'...
Listing 'app/prompts/support_chat'...
Listing 'app/prompts/support_request_analysis'...
Listing 'app/schemas'...
Listing 'app/services'...
Listing 'app/tools'...
Compiling 'main.py'...
После этого отдельно проверьте цепочку импортов:
Эта команда загружает main.py как модуль, но не вызывает функцию main(). Поэтому она обнаружит оставшийся импорт удалённого файла, не запуская консоль и не обращаясь к модели.
Затем ещё раз найдите по всему проекту строки OrderStatusAssistant, SupportService, order_status_assistant и support_summary. Если остались импорты рабочего кода, удаление было преждевременным или какой-то импорт нужно обновить. Упоминания в учебных материалах проекта, если они хранятся отдельно, на выполнение приложения не влияют.
Пока мы только удалили дублирующие пути. Основной main.py всё ещё создаёт множество связанных объектов и знает порядок их сборки. В следующем шаге мы вынесем эту ответственность в отдельный модуль, не меняя саму цепочку работы приложения.
Шаг 3¶
В предыдущем шаге мы удалили демонстрационные и специализированные реализации, возможности которых уже перенесены в действующие сервисы. Теперь продолжим рефакторинг и разберём ещё два места, которые возможно вы заметили после удаления лишнего кода.
Я имею ввиду то, а почему SupportRequestAnalysis находится в отдельном пакете app/models, а рядом существует единственный файл app/schemas/support_request.py.
SupportRequestAnalysis — это Pydantic-модель результата анализа обращения. Она описывает поля, которые должны вернуться от модели: категорию обращения, номер заказа, товар, проблему, срочность, недостающие сведения и оценку уверенности.
Эта модель не принадлежит только одному методу. Её используют сразу несколько частей приложения:
Поэтому отдельный файл app/models/support_request.py оправдан. Он хранит общий контракт данных, который находится на границе нескольких модулей. Если оставить модель внутри SupportRequestAnalyzer, остальным частям приложения пришлось бы импортировать тип данных из конкретного сервиса, хотя сам объект анализа нужен не только этому сервису.
С моделями инструментов ситуация отличается. Например CreateReturnRequestArguments и CreateReturnRequestResult используются прежде всего функцией create_return_request() и объектом RETURN_REQUEST_TOOL. Поэтому они находятся рядом с обработчиком в app/tools/return_request.py.
То есть мы группируем код по назначению и области использования, а не только по тому, от какого базового класса он наследуется. Не нужно переносить все классы BaseModel в одну папку, если после этого контракт инструмента окажется далеко от его функции.
А теперь поговорим о том, а почему осталась отдельная папка app/schemas? И вот тут есть один момент, данный пакет app/schemas появился из-за последовательности обучения. Сначала мы изучали JSON Schema и создали техническую схему структурированного ответа вручную. Затем познакомились с Pydantic, перенесли правила в SupportRequestAnalysis и стали получать JSON Schema автоматически:
Теперь файл app/schemas/support_request.py содержит только техническое имя схемы и результат преобразования Pydantic-модели:
from app.models.support_request import SupportRequestAnalysis
SUPPORT_REQUEST_SCHEMA_NAME = "support_request"
SUPPORT_REQUEST_SCHEMA = SupportRequestAnalysis.model_json_schema()
Это не ошибка, просто файл отражает предыдущий этап развития проекта. Но сейчас отдельный пакет уже не создаёт полезной границы. У нас нет набора вручную написанных схем, а единственный потребитель этих двух констант только SupportRequestAnalyzer.
У инструментов тоже есть JSON Schema, но отдельные файлы для неё не нужны. Ведь ToolRegistry получает схему непосредственно из Pydantic-модели аргументов. А если вынести каждую автоматически созданную схему в app/schemas, один контракт окажется разделён между двумя файлами. Pydantic-модель будет лежать рядом с инструментом, а её техническое представление — в другом пакете. Это добавит переходы между файлами, но не даст нового уровня защиты.
Перейдём к практике. Откройте app/services/support_request_analyzer.py. В начале файла уже импортируется SupportRequestAnalysis:
Поэтому отдельный импорт из app.schemas.support_request больше не понадобится. Удалите этот блок:
Ниже импортов, перед уже существующим DEVELOPER_INSTRUCTION, добавьте только две новые константы:
SUPPORT_REQUEST_SCHEMA_NAME = "support_request"
SUPPORT_REQUEST_SCHEMA = SupportRequestAnalysis.model_json_schema()
Строки DEVELOPER_INSTRUCTION и MAX_REPAIR_ATTEMPTS оставьте ниже без изменений. В результате схема будет создана один раз при загрузке модуля и затем использована каждым вызовом анализатора.
После переноса найдите по всему проекту строку app.schemas. Если активных импортов больше нет, удалите файл app/schemas/support_request.py, а затем app/schemas/__init__.py и опустевший каталог app/schemas.
После удаления лишнего пакета дерево проекта стало последовательнее, но в main.py по-прежнему находится большой блок сборки объектов. Точка входа знает, как создать LLM-клиент, реестр, исполнитель, цикл инструментов, политику истории, сервис суммаризации, чат и анализатор обращения.
Пока компонентов было два или три, такое решение оставалось простым. По мере развития проекта зависимостей стало больше, и теперь для понимания запуска приходится читать детали каждого конструктора. Это ещё один нормальный результат постепенной разработки и прежний main.py не был ошибкой, просто проект вырос из его первоначального размера. И main.py сейчас знает:
- как прочитать настройки
- как создать LLMClient
- как собрать инструменты
- как настроить историю
- как связать сервисы
- как запустить консоль
- в каком порядке закрыть ресурсы
Оставим в main.py только обязанности точки входа, прочитать конфигурацию, собрать приложение, запустить интерфейс и гарантированно освободить ресурсы. Все подробности создания объектов перенесём в новый модуль.
Такое разделение особенно пригодится дальше, когда мы начнём создавать бэкенд на FastAPI. Консоль и HTTP API — это разные способы взаимодействия с одним приложением, но им понадобятся те же сервисы, LLM-клиент, реестр и исполнитель инструментов. Благодаря отдельной функции сборки мы сможем использовать готовые зависимости в бэкенде и не копировать туда большой блок кода из main.py.
И перед тем как переходить к практике, давайте обсудим, а что такое сборка приложения? Наши классы не создают зависимости самостоятельно. Например ChatService не вызывает внутри себя LLMClient(Settings()), а получает готовые BoundedToolLoop, SlidingWindowHistory и ConversationSummaryService через конструктор. Такой подход называется внедрением зависимостей.
Где-то всё равно должен находиться код, который создаёт объекты и соединяет их друг с другом. Это место называют точкой сборки приложения, или composition root. До сих пор эту роль целиком выполнял main.py. Теперь вынесем саму сборку в функцию build_application(). Благодаря этому точку входа можно прочитать сверху вниз как короткий сценарий запуска.
Перейдем к практике, и теперь создайте файл app/application.py, он будет главным контейнером нашего приложения и сначала добавьте импорты всех объектов, необходимых для сборки:
from dataclasses import dataclass
from app.chat_history import SlidingWindowHistory, TokenCounter
from app.config import Settings
from app.llm.client import LLMClient
from app.services.chat_service import ChatService
from app.services.conversation_summary_service import (
ConversationSummaryService,
)
from app.services.support_request_analyzer import (
SupportRequestAnalyzer,
)
from app.services.tool_loop import BoundedToolLoop
from app.tools.default_registry import build_default_tool_registry
from app.tools.execution import ToolExecutor
Далее создайте небольшой объект, который будет хранить собранные части приложения:
@dataclass
class ApplicationContainer:
settings: Settings
chat_service: ChatService
request_analyzer: SupportRequestAnalyzer
llm_client: LLMClient
tool_executor: ToolExecutor
def close(self) -> None:
try:
self.tool_executor.close()
finally:
self.llm_client.close()
ApplicationContainer можно перевести как «контейнер приложения». В данном случае это не Docker-контейнер и не отдельный фреймворк. Это обычный dataclass, который держит несколько уже созданных объектов, необходимых точке входа.
В контейнер попадают не все локальные переменные сборки. ToolRegistry, BoundedToolLoop и политика истории уже доступны через сервисы, которым были переданы. После сборки main.py не обращается к ним напрямую. А chat_service и request_analyzer нужны консоли, поэтому сохраняются как публичные поля.
Отдельно сохраняем llm_client и tool_executor, потому что они владеют ресурсами, которые нужно завершить. У клиента может быть открыто сетевое соединение, а исполнитель управляет пулом потоков.
Метод close() задаёт единое место завершения приложения. Конструкция try ... finally гарантирует попытку закрыть LLM-клиент, даже если при остановке пула инструментов возникнет исключение.
Ниже ApplicationContainer добавьте функцию сборки:
def build_application(settings: Settings) -> ApplicationContainer:
llm_client = LLMClient(settings)
tool_registry = build_default_tool_registry()
tool_executor = ToolExecutor(tool_registry)
tool_loop = BoundedToolLoop(
client=llm_client,
registry=tool_registry,
executor=tool_executor,
max_steps=settings.tool_max_steps,
)
token_counter = TokenCounter()
history_policy = SlidingWindowHistory(
token_counter=token_counter,
input_token_budget=settings.chat_input_token_budget,
)
summary_service = ConversationSummaryService(llm_client)
chat_service = ChatService(
tool_loop=tool_loop,
history_policy=history_policy,
summary_service=summary_service,
)
request_analyzer = SupportRequestAnalyzer(llm_client)
return ApplicationContainer(
settings=settings,
chat_service=chat_service,
request_analyzer=request_analyzer,
llm_client=llm_client,
tool_executor=tool_executor,
)
Этот код почти полностью повторяет прежний блок из main.py. Новое здесь не поведение, а граница ответственности. Функция получает уже проверенные настройки, создаёт зависимости в правильном порядке и возвращает готовое приложение.
Разберём последовательность простыми словами. Сначала создаётся один LLMClient. Тот же экземпляр используется циклом инструментов, сервисом суммаризации и анализатором обращения. Благодаря этому каждый сервис не открывает собственное соединение и не хранит отдельную копию настроек.
Затем создаётся реестр разрешённых инструментов. ToolExecutor умеет безопасно запускать объекты из этого реестра, а BoundedToolLoop связывает ответы модели с исполнителем и ограничивает количество шагов.
После этого настраивается работа с историей. TokenCounter оценивает размер сообщений, SlidingWindowHistory укладывает контекст в установленный бюджет, а ConversationSummaryService сжимает старую часть диалога при необходимости.
Только когда нижние зависимости готовы, создаются два прикладных сервиса. ChatService получает весь сценарий диалога и инструментов, а SupportRequestAnalyzer использует тот же клиент для структурированного анализа обращения.
Функция build_application() готова, поэтому сразу подключим её к точке входа. Откройте main.py. Большая часть его импортов больше не нужна: создание сервисов, истории и инструментов теперь находится в app/application.py.
Полностью замените содержимое main.py следующим кодом:
from pydantic import ValidationError
from app.application import build_application
from app.config import Settings
from app.console import run_console
def main() -> None:
try:
settings = Settings()
except ValidationError as error:
print("Ошибка конфигурации:")
for issue in error.errors():
field = ".".join(str(part) for part in issue["loc"])
print(f"- {field}: {issue['msg']}")
return
application = build_application(settings)
try:
run_console(
application.chat_service,
application.request_analyzer,
app_env=application.settings.app_env,
configured_model=application.settings.model,
)
finally:
application.close()
if __name__ == "__main__":
main()
Теперь main() читается как короткий сценарий запуска. Сначала создаются и проверяются настройки. Если обязательного параметра нет, пользователь получает понятное сообщение, а остальные объекты не создаются.
После успешной конфигурации build_application() возвращает готовый контейнер. Консоль получает только chat_service и request_analyzer, которыми действительно пользуется. Ей не нужно знать, как внутри были собраны история, реестр и исполнитель инструментов.
Блок finally выполняется и при обычном выходе, и при исключении во время работы. Раньше main.py отдельно закрывал tool_executor и llm_client, а теперь вызывает единый application.close(). То есть если позднее у приложения появится ещё один ресурс, который нужно завершать, мы изменим ApplicationContainer.close(), а точка входа по-прежнему будет знать только один способ корректного завершения.
Такое разделение особенно пригодится, когда мы начнём создавать бэкенд на FastAPI. Консоль и HTTP API будут разными способами взаимодействия с одним приложением, но им понадобятся те же сервисы, LLM-клиент, реестр и исполнитель инструментов. Благодаря build_application() мы сможем использовать готовую сборку в бэкенде и не копировать туда большой блок конструкторов из прежнего main.py.
Не забываем про evaluation-запуск¶
Мы обновили основной main.py, но это не единственный файл, через который запускается код проекта. Во втором модуле мы создали отдельную команду для сравнения версий промпта:
Файл evals/run_chat_evals.py тоже собирает ChatService, но делает это независимо от main.py. После добавления вызова инструментов конструктор сервиса изменился, ведь раньше он получал LLMClient напрямую, а теперь ему нужен готовый BoundedToolLoop.
Если сейчас запустить evaluation-набор, приложение завершится ошибкой:
Это ещё один пример того, почему после архитектурных изменений важно искать не только использования изменённого класса, но и дополнительные точки запуска. Основное приложение уже использует новую сборку, а evaluation-файл всё ещё создаёт чат старым способом.
Откройте файл evals/run_chat_evals.py. и добавьте импорты:
from app.services.tool_loop import BoundedToolLoop
from app.tools.default_registry import build_default_tool_registry
from app.tools.execution import ToolExecutor
Теперь полностью замените функцию build_chat_service():
def build_chat_service(
settings: Settings,
llm_client: LLMClient,
prompt: PromptTemplate,
) -> tuple[ChatService, ToolExecutor]:
tool_registry = build_default_tool_registry()
tool_executor = ToolExecutor(tool_registry)
tool_loop = BoundedToolLoop(
client=llm_client,
registry=tool_registry,
executor=tool_executor,
max_steps=settings.tool_max_steps,
)
token_counter = TokenCounter()
history_policy = SlidingWindowHistory(
token_counter=token_counter,
input_token_budget=settings.chat_input_token_budget,
)
summary_service = ConversationSummaryService(llm_client)
chat_service = ChatService(
tool_loop=tool_loop,
history_policy=history_policy,
summary_service=summary_service,
prompt=prompt,
)
return chat_service, tool_executor
Функция по-прежнему получает настройки, общий LLM-клиент и конкретную версию промпта. Однако теперь перед созданием ChatService она собирает всю необходимую цепочку инструментов.
Теперь перейдите к функции main(). Внутри цикла найдите создание сервиса:
Замените его получением двух объектов:
chat_service, tool_executor = build_chat_service(
settings=settings,
llm_client=llm_client,
prompt=prompt,
)
После этого текущий блок запуска evaluation-набора:
print(f"\n=== {prompt.prompt_id}@{prompt.version} ===")
results = run_suite(chat_service)
report_path = save_report(settings, prompt, results)
print_summary(prompt, results, report_path)
summaries[version] = count_passed_cases(results)
оберните в try ... finally:
try:
print(f"\n=== {prompt.prompt_id}@{prompt.version} ===")
results = run_suite(chat_service)
report_path = save_report(
settings,
prompt,
results,
)
print_summary(prompt, results, report_path)
summaries[version] = count_passed_cases(results)
finally:
tool_executor.close()
Блок finally выполнится и после успешной проверки, и при возникновении ошибки. Поэтому пул потоков текущего исполнителя будет закрыт перед переходом к следующей версии промпта.
И у нас остался один очень важный момент, сейчас evaluation-запуск проверяет только версии, перечисленные в константе PROMPT_VERSIONS:
Чтобы добавить более новые версии, откройте evals/run_chat_evals.py и расширьте кортеж:
Все перечисленные версии должны существовать в реестре app/prompts/support_chat.
Теперь main.py и evals/run_chat_evals.py соответствуют текущему конструктору ChatService. В следующем шаге мы проверим основной запуск, evaluation-команду и остальные пользовательские сценарии после завершённого рефакторинга.
Шаг 4¶
Рефакторинг завершён. Мы удалили заменённые демонстрационные и специализированные решения, сохранили общую Pydantic-модель анализа обращения, убрали ставший лишним пакет app/schemas и вынесли сборку зависимостей в app/application.py. В конце предыдущего шага новый способ сборки уже был подключён к main.py. И теперь нам нужно убедиться, что организация проекта изменилась, а его поведение осталось прежним.
Такая проверка обязательна после любого рефакторинга. Даже если мы не меняли бизнес-логику намеренно, можно случайно удалить нужный импорт, забыть передать зависимость или закрыть ресурс не в том месте.
Откройте терминал в корне проекта и выполните:
На этом этапе Python компилирует модули в байт-код. Команда помогает быстро найти синтаксические ошибки, но не выполняет импорты и не запускает приложение. Пользовательский запрос в ProxyAPI не отправляется, поэтому баланс не расходуется. В результате мы получим:
(.venv) ~/PycharmProjects/AI_Course_Developer/ai-support-desk git:[master] python -m compileall app main.py
Listing 'app'...
Compiling 'app/application.py'...
Listing 'app/llm'...
Listing 'app/models'...
Listing 'app/prompts'...
Listing 'app/prompts/chat_history_summary'...
Listing 'app/prompts/support_chat'...
Listing 'app/prompts/support_request_analysis'...
Listing 'app/services'...
Compiling 'app/services/support_request_analyzer.py'...
Listing 'app/tools'...
Compiling 'main.py'...
Теперь отдельно проверьте, что вся цепочка импортов доступна:
При таком запуске Python загрузит main.py и импортируемые им модули, но условие if __name__ == "__main__" не выполнится. Функция main() не будет вызвана, настройки и LLM-клиент не создадутся, а запрос к ProxyAPI не уйдёт.
Успешная компиляция и импорт ещё не доказывают правильность поведения, но подтверждают, что синтаксис и связи между Python-модулями не повреждены.
Теперь запустите проект через main.py.
Для пользователя запуск не должен измениться. В консоли появятся окружение, настроенная модель, идентификатор сессии и команды. Однако внутри приложение теперь проходит немного другую цепочку сборки.
В предыдущем разделе мы подробно прошли все ветки подтверждения, поэтому сейчас не нужно повторять каждый пример. Нам достаточно короткой проверки основных границ, которых коснулся рефакторинг.
Сначала отправьте обычное приветствие. Если чат ответил, значит консоль получила ChatService, цикл обратился к общему LLMClient, а результат вернулся пользователю.
Затем проверьте инструмент, который только получает данные:
Ожидаем вызов get_order_status без запроса подтверждения.
Этот сценарий показывает, что после удаления OrderStatusAssistant возможность не исчезла. Её действительно выполняет общий путь:
После этого создайте новый диалог и попросите оформить возврат:
Приложение должно показать проверенные аргументы и предложить /confirm или /cancel.
Можно выбрать /cancel: для проверки рефакторинга важно убедиться, что запрос подтверждения по-прежнему проходит от исполнителя до консоли.
Наконец, выполните структурированный анализ:
Если консоль выводит поля структурированного результата, значит удаление structured_demo.py не повлияло на рабочую AI-функцию. Анализ выполняется через SupportRequestAnalyzer, который был создан в build_application().
Формулировки настоящей модели могут отличаться, поэтому не сравнивайте текст посимвольно. В этой проверке нас интересуют маршруты, а именно обычный ответ, выполнение разрешённого инструмента, подготовка изменяющего действия и структурированный анализ.
После проверки основного приложения убедимся, что дополнительная точка запуска тоже использует актуальную сборку ChatService. Выполните в корне проекта:
Команда обращается к ProxyAPI и расходует баланс, поскольку evaluation-набор проверяет поведение настоящей модели. Приложение должно последовательно запустить сценарии для версий из PROMPT_VERSIONS, сохранить отчёты в evals/results и вывести итоговое сравнение:
=== support_chat@1.0.0 ===
...
=== support_chat@1.1.0 ===
...
Сравнение автоматических проверок:
- 1.0.0: 4/5 сценариев
- 1.1.0: 4/5 сценариев
- 1.2.0: 4/5 сценариев
- 1.3.0: 4/5 сценариев
Теперь для каждой версии создаётся отдельный ToolExecutor, который закрывается после выполнения её сценариев, а общий LLMClient завершает работу после проверки всех версий.
Итоговая структура¶
После рефакторинга основная часть проекта должна выглядеть примерно так:
ai-support-desk/
├── app/
│ ├── __init__.py
│ ├── application.py
│ ├── chat_history.py
│ ├── config.py
│ ├── console.py
│ ├── llm/
│ │ ├── __init__.py
│ │ ├── client.py
│ │ └── errors.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── support_request.py
│ │ └── support_request_validation.py
│ ├── prompts/
│ │ ├── __init__.py
│ │ ├── template.py
│ │ ├── chat_history_summary/
│ │ │ ├── __init__.py
│ │ │ ├── registry.py
│ │ │ └── v1_0_0.py
│ │ ├── support_chat/
│ │ │ ├── __init__.py
│ │ │ ├── registry.py
│ │ │ └── файлы версий промпта
│ │ └── support_request_analysis/
│ │ ├── __init__.py
│ │ ├── registry.py
│ │ └── v1_0_0.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── chat_service.py
│ │ ├── conversation_summary_service.py
│ │ ├── support_request_analyzer.py
│ │ ├── support_request_review.py
│ │ └── tool_loop.py
│ └── tools/
│ ├── __init__.py
│ ├── default_registry.py
│ ├── execution.py
│ ├── order_status.py
│ ├── registry.py
│ ├── return_policy.py
│ └── return_request.py
├── evals/
│ ├── __init__.py
│ ├── chat_cases.py
│ ├── chat_checks.py
│ ├── chat_models.py
│ └── run_chat_evals.py
├── .env
├── .env.example
└── main.py
И теперь давайте подведем этого раздела. В этом разделе мы прошли ещё один настоящий этап разработки — обслуживание выросшего проекта. Мы увидели, что старый код появляется естественно, эксперимент превращается в рабочую функцию, специализированный класс уступает место общему механизму, а первоначальная точка входа становится слишком подробной.
Важно не пытаться заранее построить идеальную архитектуру, а регулярно сравнивать структуру с текущими требованиями. Мы нашли заменённые реализации, доказали, что их возможности сохранены, удалили лишние файлы и создали явную точку сборки приложения.
На этом третий модуль завершён. Приложение умеет получать данные по строгому контракту, проверять их через Pydantic, вызывать только зарегистрированные функции, ограничивать цикл инструментов и запрашивать подтверждение перед изменением данных.
В следующем модуле перейдём к документам, таблицам и изображениям. И новые сервисы будут подключаться к уже очищенной структуре, а build_application() останется единым местом, где зависимости превращаются в готовое приложение.
Шаг 5¶
Когда файл безопаснее считать кандидатом на удаление при рефакторинге?
Правильный ответ
- ✅ Когда нет активного пути использования и его возможность сохранена другой реализацией
Шаг 6¶
Зачем выносить сборку зависимостей в build_application()?
Правильный ответ
- ✅ Чтобы разные точки входа использовали одну согласованную сборку сервисов и ресурсов
Шаг 7¶
Почему закрытие ресурсов помещают в finally?
Правильный ответ
- ✅ Чтобы оно выполнялось и при успехе, и при исключении
Шаг 8¶
Выберите все проверки, которые дополняют друг друга после большого рефакторинга.
Правильный ответ
- ✅ Проверка цепочки импортов
- ✅ Запуск отдельной evaluation-команды
- ✅ Компиляция модулей
- ✅ Короткие smoke-сценарии основных маршрутов
Шаг 9¶
Гарантированное закрытие ресурсов приложения¶
Контейнер приложения владеет исполнителем инструментов и LLM-клиентом. Оба объекта могут удерживать ресурсы, поэтому при завершении работы нужно попытаться закрыть каждый из них. Ошибка первого ресурса не должна помешать вызову close() у второго.
Задача¶
Создайте класс ApplicationContainer с конструктором (tool_executor, llm_client) и методом close(). Метод должен закрывать ресурсы в установленном порядке и не скрывать возникшие исключения.
Требования¶
- Не изменяйте имя класса
ApplicationContainer. - Метод
__init__(tool_executor, llm_client)должен сохранить оба переданных объекта в экземпляре класса. - Создайте метод
close()без дополнительных аргументов. - Сначала вызовите
self.tool_executor.close(). - После этого обязательно вызовите
self.llm_client.close(). - Используйте конструкцию
try ... finally, чтобыllm_client.close()выполнялся даже тогда, когдаtool_executor.close()выбросил исключение. - Не перехватывайте и не подавляйте исключение первого ресурса. Если первый вызов завершился с ошибкой, а второй прошёл успешно, исходное исключение должно выйти из
close()наружу.
Пример результата¶
Если оба ресурса закрываются успешно:
Порядок вызовов:
Если первый вызов выбросил RuntimeError, второй всё равно должен выполниться, после чего тот же RuntimeError должен выйти наружу.
Проверяющая система передаёт два фейковых объекта с методом close() и записывает порядок их вызовов. Настоящие сетевые соединения не используются.
Правильный ответ
Шаг 10¶
Последовательный запуск evaluation для версий промпта¶
После рефакторинга нужно убедиться, что разные версии промпта по-прежнему проходят evaluation-набор. Для каждой версии создаётся отдельный сервис и отдельный исполнитель инструментов. Исполнитель необходимо закрыть сразу после проверки своей версии — как при успехе, так и при ошибке.
Задача¶
Реализуйте функцию run_evaluations(versions, build_service, run_suite). Она должна последовательно построить сервис для каждой версии, запустить его проверку, сохранить результат и гарантированно закрыть связанный исполнитель инструментов.
build_service(version) возвращает пару (service, tool_executor). Функция run_suite(service) запускает evaluation и возвращает результат, который нужно сохранить.
Требования¶
- Не изменяйте имя функции и её аргументы.
- Создайте пустой словарь результатов.
- Обрабатывайте версии последовательно и в том порядке, в котором они находятся в
versions. - Для каждой версии вызовите
build_service(version)ровно один раз и распакуйте результат вserviceиtool_executor. - Передайте полученный
serviceвrun_suite(service). - Если
run_suite()завершился успешно, сохраните возвращённое значение в словаре под ключом текущей версии. - Для каждой построенной пары гарантированно вызовите
tool_executor.close()в блокеfinally. - Закрывайте исполнитель текущей версии до перехода к следующей версии.
- Если
run_suite()выбросил исключение, текущий исполнитель всё равно должен быть закрыт. После этого исключение должно выйти наружу, а следующие версии запускать нельзя. - После успешной обработки всех версий верните словарь результатов. Порядок ключей должен соответствовать порядку входного списка.
Пример результата¶
Пусть для версии 1.0 строится сервис service:1.0, а evaluation возвращает result:service:1.0:
Результат:
Последовательность действий для каждой версии: создать сервис, запустить evaluation, закрыть исполнитель, перейти к следующей версии.
Проверяющая система передаёт список версий и фейковые функции build_service и run_suite. Настоящие сервисы и внешние ресурсы не запускаются.