MVP — Конвейер агентов, который не выдумывает факты: критик, детерминированный merge данных и грабли на пути
Здравствуйте, коллеги!
Три недели назад я взялся за задачу, которая звучит просто: сделать бота‑ассистента, который помогает IT‑джуну собрать резюме, и при этом не выдумывает того, что соискатель не описывал. И задача оказалась не про prompts. Ниже — архитектура, которая получилась и 3 инженерные истории, на которых я застревал по‑настоящему.
Всё, что описано, код и цифры — проверялось на живом MVP.
Вопрос: «Что мешает джунам сразу напрямую в ChatGPT собрать себе нормальное резюме?»;
Утверждения: «У джунов сейчас другие реальные проблемы — бесполезные платные курсы, на которых обещают золотые горы» и «Джуны и вчерашние вкатуны не нужны, никому»
Подобное оставляем за скобками данной статьи.
Почему одним prompt’ом не получается
Поначалу идея реализации может казаться очевидной: взять знакомую нейросеть, скормить ей резюме и попросить «сделай мне красиво». Но проблема в том, что она охотно привирает, пытаясь приукрасить, дописать навыки, метрики, стек проектов, которых у джуниор разработчика по факту нет.
Просить «не выдумывай» в prompt’е — не надежная гарантия. Значит контроль фактов надо закладывать на этапе архитектуры. Отсюда вытекает сквозной принцип GROUNDING: система может переформулировать, структурировать и усилить, но не выдумать факт, которого не было во входных данных.
Архитектура: конвейер из агентов
Разбил задачу на конвейер из 6 специализированных агентов, каждый с узкой ответственностью и строгим контрактом input‑output:
A1 Parser — извлекает структурированный Profile из текста резюме (только факты).
A2 Track — классифицирует карьерный трек (Industry / Research / Education / Startup).
A3 Matcher — проводит gap‑анализ навыков под целевую роль have/partial/missing (если указана, если нет — она выясняется в диалоге).
A4 Turn — ведёт диалог, дозаполняет Profile, по вопросу за ход.
A5 Rewriter — переписывает резюме под структуру hh.ru
A6 Critic — проверяет фактологичность результатов.
Всё крутится над каноническим Profile (Json, валидация pydantic). Каждый агент возвращает структурированный ответ по схеме — это отлавливает львиную долю проблем на этапе валидации, а не в runtime.
Что даёт разбиение на узкие роли (кроме чистоты и гигиены):
тестируемость — можно измерять качество каждого шага отдельно (accuracy классификации трека, например).
дешёвая маршрутизация — разным агентам можно давать разные модели.
локализация ошибок — можно отловить, какой именно агент сломался, если что.
Клиент: force‑JSON, repair, fallback по тирам
Провайдер — агностический клиент, поверх OpenAI‑совместимого API. Три вещи нужно закладывать сразу:
Force‑JSON.
response_format={"type": "json_object"}резко снижает количество мусора на выходе.Repair‑проход. Если модель уже вернула невалидный JSON или не попала в схему — даём ей шанс исправиться, скормив собственную ошибку:
try:
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
temperature=temperature,
response_format={"type": "json_object"},
extra_headers=extra_headers if provider == "provider_c" else None,
)
if not resp.choices or resp.choices[0].message.content is None:
raise ValueError(f"\nNo content in response from {model}\n")
raw = resp.choices[0].message.content or "{}"
data = json.loads(raw)
_log_usage(agent, model, resp)
return schema.model_validate(data)
except RateLimitError as e:
logger.error("\n[%s] Rate limit exceeded for %s: %s\n", agent, model, e)
raise e
except APIStatusError as e:
logger.warning("\n[%s] API error %s for %s: %s\n", agent, e.status_code, model, e.message)
last_error = e
continue
except (json.JSONDecodeError, ValidationError) as e:
logger.info("\n[%s] Parsing error for %s, attempting repair: %s\n", agent, model, e)
last_error = e
try:
repair_user = (
f"Предыдущий ответ был невалидным JSON или не соответствовал схеме. "
f"Исправь и верни ТОЛЬКО валидный JSON.\n"
f"Ошибка: {e}\n"
f"Текст: {raw}"
)
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": repair_user},
],
temperature=temperature,
response_format={"type": "json_object"},
extra_headers=extra_headers if provider == "provider_c" else None,
)
if not resp.choices or resp.choices[0].message.content is None:
raise ValueError(f"\nRepair - No content in response from {model}\n") from None
raw = resp.choices[0].message.content or "{}"
data = json.loads(raw)
_log_usage(agent, model, resp)
return schema.model_validate(data)
Fallback по тирам. Модели описаны списками, агент привязан к тиру. Если модель на провайдере недоступна — идем к следующей, не роняя pipeline:
# часть config.py
MODEL_TIERS = {
"light": [
{"provider": "provider_a", "model": "model_1"},
{"provider": "provider_b", "model": "model_2"},
],
"heavy": [
{"provider": "provider_a", "model": "model_1"},
{"provider": "provider_b", "model": "model_2"},
{"provider": "provider_c", "model": "model_3"},
],
}
AGENT_TIER = {
"track": "light",
"parser": "heavy",
"matcher": "heavy",
"turn": "heavy",
"rewriter": "heavy",
"critic": "heavy",
}Такая конструкция стоит недорого, а окупается сразу: если провайдер возвращает ошибки 401/404/429 — легко увидеть в логах. Дополнительный эффект — можно проводить эксперимент разных агентов на разных моделях без правки кода этих агентов.
Я попробовал разные варианты: в итоге на всех ролях осталась одна модель — она устроила по качеству и цене.
Агент критик == LLM‑as‑a-judge
Ключевое решение против галлюцинаций. Перечитывает сгенерированное A5 Rewriter резюме и сверяет каждое утверждение с исходными фактами из Profile и истории диалога. Возвращает структуру с grounding_ok с подозрительными утверждениями и рекомендациями по исправлению.
# часть schemas.py
class Critique(BaseModel):
grounding_ok: bool
fabricated_claims: list[str]
completeness: float = Field(ge=0, le=1)
format_ok: bool
fixes: list[str]Тут три неочевидных момента:
Критику A6 нужен полный контекст, иначе он врёт в обратную сторону.
1я версия получала на вход только Profile и итоговое резюме. В результате критик исправно помечал как «выдумку» факты, которые пользователь сообщал в диалоге, но которые не успели попасть в Profile. Ложные срабатывания порождали лишние перегенерации. Поэтому правильный input критика должен включать всё, что видел генератор: Profile + история диалога + сгенерированный текст. Банально, но на этом можно обжечься.
Логика цикла работы A6 живёт в коде, а не в prompt’е.
Если A6 поднял флаг (обнаружил противоречия и что‑то, чего пользователь не говорил) — запускается ровно 1 повторный проход A5 с замечаниями. Никаких бесконечных петель/непредсказуемого числа проходов и зря сожженных токенов: максимум 2 вызова на A5 Rewriter . На честных прогонах A6 стабильно подтверждает grounding — Rewriter не раздувает даже скромные ответы.
resume_output = rewrite_resume(
profile=profile,
track=track,
gap=gap,
history=st.session_state.messages
)
# Раунд 2: A6 (Critic)
with st.spinner("Проверяем на соответствие фактам..."):
critique = critique_resume(
profile=profile,
history=st.session_state.messages,
content_markdown=resume_output.content_markdown
)
st.session_state.critique = critique.model_dump()
# Раунд 3: Повторный A5, если есть галлюцинации
if not critique.grounding_ok:
with st.spinner("Исправляем замечания критика..."):
resume_output = rewrite_resume(
profile=profile,
track=track,
gap=gap,
history=st.session_state.messages,
fixes=critique.fixes
)Критик не волшебная палочка, а нижняя граница.
A6 как вероятностная модель тоже ошибается. Он не даёт гарантии «фактов нет», а повышает цену попадания выдумки в результирующее резюме. Гарантий в этой архитектуре не бывает. Есть только снижение вероятности и его можно и надо измерять (об этом в блоке про eval).
Почему слияние данных — не работа LLM
Merge нового ответа пользователя со старым Profile делает детерминированный код, не модель. Потому что здесь цена ошибки высока: легко затереть уже сохраненные навыки/факты или потерять часть bullets проекта при обновлении Profile. Поэтому логика сляния живёт в core/merge.py и покрыта unit‑тестами. Модель предлагает патч — код его аккуратно принимает.
def test_add_skill_no_overwrite():
"""Новый навык добавляется, не затирая прежние."""
profile = {"skills": ["Python"]}
patch = {"skills": ["Docker"]}
result = merge_profile(profile, patch)
assert "Python" in result["skills"]
assert "Docker" in result["skills"]
def test_update_project_keeps_bullets():
"""Обновление проекта сохраняет полный список буллетов, не дублируя элемент."""
profile = {
"projects": [
{"name": "App", "bullets": ["b1", "b2"]}
]
}
patch = {
"projects": [
{"name": "App", "bullets": ["b1", "b2", "b3"]}
]
}
result = merge_profile(profile, patch)
assert len(result["projects"]) == 1
assert len(result["projects"][0]["bullets"]) == 3
def test_contacts_preserved():
"""Добавление ссылки в contacts не затирает email и телефон."""
profile = {"contacts": {"email": "a@b.ru", "phone": "123"}}
patch = {"contacts": {"email": "a@b.ru", "phone": "123", "github": "url"}}
result = merge_profile(profile, patch)
assert result["contacts"]["email"] == "a@b.ru"
assert result["contacts"]["github"] == "url"
def test_empty_patch():
"""Пустой патч не изменяет профиль."""
profile = {"full_name": "Ivan"}
result = merge_profile(profile, {})
assert result == profileТри истории, где я реально застревал. Именно они, а не prompt’ы, съели немало времени.
Грабли № 1: кириллица в PDF и библиотека, которая криво поднималась.
Первую версию PDF‑рендера собрал на weasyprint, хотел сразу красивый HTML/CSS, но при деплое на Streamlit Cloud возвращала OSError: cannot load library libgobject-2.0.0. weasyprint тянет системные C‑библиотеки (pango, cairo, glib) и даже добавление их в отдельный packages.txt не принесло результатов.
Поэтому не стал долго бороться с окружением и переехал на fpdf2 — чистый python без системных зависимостей. Шрифты кириллицы скачал и подключил через DejaVuSans.ttf.
font_path = "fonts/DejaVuSans.ttf"
if not os.path.exists(font_path):
font_path = os.path.join(os.path.dirname(__file__), "..", "fonts", "DejaVuSans.ttf")
pdf.add_font("DejaVu", "", font_path)
pdf.set_font("DejaVu", size=12)После успешного рендера резюме с кириллицей поймал баг с «поехавшими шрифтами» — он оказался в дефолтных настройках последней версии fpdf2 (2.8.7) — курсор по умолчанию уезжал вправо. Пофиксил строкой с явным возвращением курсора.
Было:
pdf.multi_cell(0, 8, line) # курсор уезжает вправо, ширина схлопываетсяСтало:
from fpdf.enums import XPos, YPos
...
if line.startswith("###"):
pdf.set_font("DejaVu", size=14)
pdf.multi_cell(pdf.epw, 10, _clean(line.lstrip("# ").strip()),
new_x=XPos.LMARGIN, new_y=YPos.NEXT)
pdf.set_font("DejaVu", size=12)Отлаживать корректность PDF, каждый раз проходя диалог с LLM, дорого по времени и токенам. Проще завести скрипт для каждого проблемного модуля. Ноль вызовов API, мгновенная интеграция.
...
SAMPLE_OUT = """
# Желаемая должность
Python-разработчик
О себе
Python-разработчик с 2 годами практики и опытом реализации пет-проектов на Django и Flask.
Ключевые навыки
* Django
* Flask
* PostgreSQL
## Опыт работы
*
Проекты
**Backend интернет-магазина на Django**
* Разработал каталог товаров, корзину и оформление заказов.
* Реализовал обработку платежей через API эквайринга на Python.
**Telegram-бот для отслеживания цен на Flask**
* Парсинг цен через BeautifulSoup и Selenium.
* Уведомления пользователю при снижении цены.
"""
def main():
pdf_bytes = generate_pdf(SAMPLE_OUT)
with open("test_resume.pdf", "wb") as f:
f.write(pdf_bytes)
print("PDF создан: test_resume.pdf")
if __name__ == "__main__":
main()Мелочи рендера, всплывающие только на реальных данных
a) Markdown‑разделители попадают в bullets. Генератор выдаёт --- между блоками, наивный парсер видит строку, начинающуюся с -, и печатает • --. Фикс — пропускать строки, состоящие только из символов разметки:
# пропускаем markdown-разделители (---, ***, ___)
if set(line) <= {"-", "*", "_"} and len(line) >= 2:
pdf.ln(3)
continueНаивная чистка markdown ломает данные. Убрать * можно простым replace. А вот убрать тем же способом _ значит покалечить ivan_petrov@mail.ru и github.com/some_user. Нужна регулярка, снимающая только парную разметку:
def _clean(text: str) -> str:
"""Убирает markdown-звёздочки (**bold**, *italic*, _underscore_) — fpdf2 их не парсит."""
text = text.replace("**", "").replace("*", "")
text = re.sub(r'(?<!\w)_(.+?)_(?!\w)', r'\1', text)
return textb) Пустые блоки (опыт работы, достижения) печатаются заголовками.
Модель недетерминированно то опускает пустую секцию, то печатает её заголовок, то дописывает «не указано». Корректировать prompt'ом можно бесконечно. Надёжнее выкинуть заголовок в коде, заглянув вперёд:
# ── Постобработка: убрать пустые блоки (заголовок без содержимого) ──
block_titles = {"Опыт работы", "Проекты", "Образование", "Контакты",
"Ключевые навыки", "Знание языков", "Достижения", "О себе"}
raw_lines = [ln.strip() for ln in markdown_text.split("\n")]
lines = []
for i, ln in enumerate(raw_lines):
# если строка — название блока, проверяем, есть ли под ним содержимое
clean_ln = ln.lstrip("# ").strip().rstrip(":")
if clean_ln in block_titles:
# ищем следующую непустую строку
nxt = next((raw_lines[j].lstrip("# ").strip().rstrip(":")
for j in range(i + 1, len(raw_lines)) if raw_lines[j].strip()), "")
if nxt in block_titles or nxt == "":
continue
lines.append(ln)
Грабли № 2: ссылки, которых «нет» в исходном резюме.
В некоторых резюме бывает, что ссылки на контакты типа GitHub/Telegram/Linkedin сделаны гиперссылками-якорями: видимый текст — только слово «GitHub», а его URL спрятан в аннотации PDF (слой /Annots). А используемый мной pdfplumber хватает только видимое слово при парсинге. И тут важно понимать, что правкой prompt’а A1 затык не решить — LLM не извлечёт того, чего нет во входных данных.
Настоящий фикс (в будущем) — доставать URL из аннотаций (pypdf) и приклеивать к тексту. Но это отдельная таска с тестами, поэтому её отправил в backlog, а для MVP сделал обходной путь: A4 Turn в начале диалога распознаёт подобные заглушки‑якоря и переспрашивает у пользователя корректные ссылки. Они попадают в Profile и в финальном PDF становятся кликабельными (на больших резюме может не сработать с 1го раза, опять же из‑за не детерминизма LLM).
Грабля № 3: Streamlit и исчезающий экран.
Под конец добавил кнопку «Новое резюме» с логикой: очистить session_state, перезапустить. И начал получать пустой экран.
Самое интересное — почти идентичная кнопка сброса диалога рядом работала. Разница обнаружилась не сразу. После del скрипт продолжал выполняться до конца функции и обращается к ключам, которых уже нет. Починил сбросом состояния только через модалку/изолированный контекст, не inline.
@st.dialog("Вернуться на главную")
def return_home_dialog():
st.write("Возвращаемся на стартовую страницу, ваш диалог сбросится.")
col1, col2 = st.columns(2)
with col1:
if st.button("Отмена", type="secondary", use_container_width=True):
st.rerun()
with col2:
if st.button("Да, возвращаемся", type="primary", use_container_width=True):
for key in list(st.session_state.keys()):
del st.session_state[key]
st.rerun()
...
with col_new:
if st.button("🆕 Новое резюме", type="secondary", use_container_width=True):
return_home_dialog()Измеримое качество: eval и честный error‑analysis
Для этого собрал небольшой набор реальных резюме, размеченных вручную по трекам (Industry / Research / Education / Startup) и скрипт, который прогоняет классификацию и считает метрики accuracy определения трека, стабильности, grounding‑rate, latency.
1й прогон дал accuracy трека 80% при 100% стабильности ответов. Разбор ошибок показал, что они лежат на границе треков Research / Education, и посмотрев датасет внимательнее, я понял, что в этих случаях модель была права, а не моя ручная разметка. После уточнения разметки и усиления prompt’a A2 accuracy поднялась до 90–100%. Grounding‑rate выдумок не выявила.
Замер latency:
A1 (parser) ~12 с, A2 (track) ~2.5 с, A5 (rewriter) ~9 с, A6 (critic) ~2.6 с.
Детерминированное ядро (merge ответов со старым Profile и PDF I/O) я покрыл unit‑тестами через pytest и повесил на GitHub Actions прогоном на каждый push. Агентов не покрывал unit‑тестами сознательно — они не детерминированы и требуют вызовов API.
Выводы, при разработке похожих приложений
Разбивка задачи на агентов с узкими контрактами: это тестируем и управляемо.
Контроль фактов встраивать в архитектуру: LLM‑as‑a-judge + логика цикла в коде, а не надежда на один длинный prompt.
Детерминированное — в код, не детерминированное — в модель: слияние состояний слишком важно, чтобы делегировать его LLM.
Тест‑скрипты изолированы: для проблемных модулей отслеживание состава PDF или работы парсера без запуска всего pipeline дёшево и быстро.
Данные важнее prompt’а: если модель не видит факты, сначала проверьте, доходят ли они до неё вообще.
Повторю ещё раз, что это MVP и есть, что улучшать. Поэтому буду рад вопросам и критике в комментариях, особенно по архитектуре.
Ссылка на репо https://github.com/AlekseyYudin-161/JunMate