惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
Securelist
SecWiki News
SecWiki News
Help Net Security
Help Net Security
Attack and Defense Labs
Attack and Defense Labs
L
LINUX DO - 最新话题
H
Heimdal Security Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
H
Hacker News: Front Page
Hacker News - Newest:
Hacker News - Newest: "LLM"
Google DeepMind News
Google DeepMind News
V2EX - 技术
V2EX - 技术
Hacker News: Ask HN
Hacker News: Ask HN
O
OpenAI News
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
T
Threat Research - Cisco Blogs
A
Arctic Wolf
Simon Willison's Weblog
Simon Willison's Weblog
P
Privacy & Cybersecurity Law Blog
T
Threatpost
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
C
Cyber Attacks, Cyber Crime and Cyber Security
T
Tor Project blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
Spread Privacy
Spread Privacy
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
人人都是产品经理
人人都是产品经理
S
SegmentFault 最新的问题
www.infosecurity-magazine.com
www.infosecurity-magazine.com
P
Palo Alto Networks Blog
C
CERT Recently Published Vulnerability Notes
PCI Perspectives
PCI Perspectives
AWS News Blog
AWS News Blog
IT之家
IT之家
美团技术团队
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
T
Tailwind CSS Blog
M
MIT News - Artificial intelligence
V
Visual Studio Blog
Schneier on Security
Schneier on Security
T
The Exploit Database - CXSecurity.com
有赞技术团队
有赞技术团队
小众软件
小众软件
Google Online Security Blog
Google Online Security Blog
N
News | PayPal Newsroom
博客园_首页
T
Tenable Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
S
Secure Thoughts
I
Intezer

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Что под капотом у ИИ-агента для отдела продаж: архитектура, код и грабли
Юрий Гой · 2026-06-22 · via Все публикации подряд на Хабре

Что под капотом у ИИ-агента для отдела продаж: архитектура, код и грабли

Средний

14 мин

0

Про «ИИ-агента для продаж» пишут так, будто это одна кнопка: подключил, и он сам звонит, квалифицирует, дожимает. На демо так и выглядит. В проде между «агент послушал звонок» и «в CRM появилась правильная задача менеджеру» лежит десяток слоёв, и в каждом всё тихо ломается. Это разбор такого пайплайна по слоям, с кодом, цифрами и граблями, на которые мы наступали, пока доводили агента до боевого режима.

Сразу про границу ожиданий. Полностью автономный SDR, который сам ведёт сделку, пока миф. Реально работает агент-ассистент: снимает рутину (расшифровка и разбор звонков, черновики follow-up, обновление карточек, квалификация входящих), решение остаётся за человеком. И мерить его надо не «дилами за 30 дней», а стоимостью квалифицированной встречи на горизонте 90 дней. По этой метрике сразу видно, агент приносит деньги или просто шумит.

Архитектура целиком

Прежде чем нырять в слои, общая картина. За «агентом» стоит конвейер из очередей и воркеров, где на саму LLM приходится примерно треть кода, остальные две трети это обвязка вокруг.

Архитектура: пайплайн обработки звонка от вебхука до CRM

Архитектура: пайплайн обработки звонка от вебхука до CRM

Стек у нас: NestJS, очереди на BullMQ поверх Redis, Postgres + Prisma, S3 для записей, мульти-провайдерный слой над LLM. Почему очереди, а не «вызвать по цепочке в одном хендлере»: транскрипция длинного звонка это десятки секунд, разбор это секунды, запись в CRM упирается в лимиты. Если делать всё синхронно в одном запросе, первый же тяжёлый звонок забьёт пул и положит приём вебхуков. Каждый слой это отдельная очередь со своим конкуррентси, ретраями и DLQ.

Конкуррентси у слоёв разное, потому что узкие места разные. Транскрипция упирается в провайдера и в CPU на нарезке, держим её низкой. Извлечение это сетевые вызовы к LLM, тут можно шире. Запись в CRM ограничена лимитом аккаунта, и тут специально мало, с запасом под лимит:

TRANSCRIBE = {"concurrency": 4}    # упирается в провайдера и нарезку
EXTRACT    = {"concurrency": 8}    # IO к LLM, можно шире
CRM_SYNC   = {"concurrency": 2, "rate_limit": "5/s"}  # держим запас под лимит аккаунта

Приём вебхуков не делает ничего тяжёлого: кладёт запись в S3 и ставит задачу в очередь. Поэтому всплеск звонков растит длину очереди transcribe, но не роняет приём. Тяжёлый слой притормаживает сам себя, остальной конвейер этого не замечает.

И последнее по архитектуре, без чего отладка в проде превращается в гадание. Через все очереди тащим один call_id в данных задачи. По нему потом собирается таймлайн одного звонка из логов:

call_id=4f3a ingest      ok   0.2s
call_id=4f3a transcribe  ok   14.1s  provider=turbo
call_id=4f3a extract     ok   3.0s   grounded=true
call_id=4f3a crm-sync    ok   0.5s   lead=88213

Без сквозного id вы видите, что «что-то падает на тысяче звонков», но не видите, что именно и на каком слое. Дальше по слоям.

Слой 1. Звонок в текст: транскрипция и диаризация

Первая ошибка новичка: думать, что распознавание речи решает задачу. Распознавание (ASR) даёт текст. Кто говорит, отдельная задача (диаризация), её решают pyannote, WhisperX, Sortformer и подобные, обычно с предварительной нарезкой по детектору голосовой активности (VAD). Без диаризации разбор звонка это каша, потому что половина ценности в том, кто произнёс «дорого» и кто пообещал «вышлю договор завтра». Менеджер сказал или клиент это разные выводы и разные задачи.

Сразу лайфхак, который мы вынесли из прода: первым делом смотрим, не отдаёт ли источник стерео. Многие телефонии и SIP пишут менеджера и клиента в разные каналы записи. Если так, диаризация бесплатна: левый канал это один говорящий, правый другой, pyannote вообще не нужен, и это самый надёжный вариант, потому что не зависит от качества кластеризации голосов. Облачная запись Zoom тоже отдаёт дорожки по участникам. Тяжёлую диаризацию по одному смешанному каналу включаем только там, где стерео нет.

Вторая грабля: качество на реальном звуке. На чистой записи Whisper Large-v3 даёт WER около 3 процентов. Колл это не студия: телефонная полоса 8 кГц, перебивания, фон опенспейса. На таком материале WER уезжает к 8-12 процентам, и деградация неравномерная. Хуже всего модель врёт на именах собственных, терминах и названиях продуктов, то есть как раз на том, что важно отделу продаж. «Тариф Прибой» превращается в «тариф приплыл», имя «Гульназ» в «Гульнара». Лечится двумя вещами: подсказкой словаря в промпт распознавания (initial_prompt с вашими продуктами, тарифами, частыми фамилиями) и постобработкой, где близкие по написанию варианты подменяются на канонические из справочника.

Тут есть нюанс, на котором легко обжечься. У whisper окно initial_prompt маленькое, порядка пары сотен токенов. Весь справочник продуктов и фамилий туда не влезет, и если набить под завязку, модель начинает галлюцинировать эти слова там, где их не было. Поэтому в промпт кладём только горячий короткий список (текущие тарифы, частые имена), а основную канонизацию делаем уже постобработкой по полному справочнику. И язык фиксируем явно (ru), иначе на коротких репликах whisper иногда перескакивает на другой язык и выдаёт транслит.

Третья грабля: длинные записи. Окно качественного распознавания около 30 секунд, часовой звонок приходится резать. Если резать встык, на границах теряются слова и иногда смена говорящего. Режем с перекрытием и сшиваем по таймкодам, отбрасывая дубли в зоне нахлёста:

WINDOW = 30.0      # сек
OVERLAP = 5.0      # сек нахлёста, чтобы не терять слова на стыке

def make_chunks(duration):
    t, chunks = 0.0, []
    while t < duration:
        chunks.append((t, min(t + WINDOW, duration)))
        t += WINDOW - OVERLAP
    return chunks

def stitch(segments):
    # segments: распознанные куски с абсолютными таймкодами слов
    out = []
    for seg in segments:
        for w in seg.words:
            # слово уже попало из предыдущего окна в зоне нахлёста, пропускаем
            if out and w.start < out[-1].end - 0.2:
                continue
            out.append(w)
    return out

Четвёртая грабля: один провайдер это гарантированный простой. У любого ASR-провайдера бывает рост латентности или молчаливый обрезанный ответ. Держим цепочку фоллбэка с явной проверкой результата, а не только на HTTP-ошибку:

async def transcribe(audio, expected_min_chars):
    for provider in (PRIMARY, FALLBACK):           # напр. быстрый turbo → точный whisper-1
        try:
            text = await provider.run(audio, timeout=90)
            if len(text) >= expected_min_chars:    # подозрительно короткий = тоже сбой
                return text, provider.name
        except (Timeout, ProviderError):
            continue
    raise TranscriptionFailed()

expected_min_chars прикидываем от длительности (грубо, по средней скорости речи 9-12 символов в секунду). Без этой проверки система считает обрезанный ответ успехом, и дальше по конвейеру едет полупустой звонок. Эта проверка на «подозрительно коротко» ловила у нас больше реальных сбоев, чем ловля HTTP-ошибок: провайдер чаще отвечает 200 с огрызком, чем честным пятисотым.

Слой 2. Текст в структуру: извлечение фактов

Из расшифровки нужно достать факты в поля: следующий шаг, дата, сумма, возражение, стадия. Свободный текст от LLM тут противопоказан, его не положить в CRM и не проверить. Нужен строгий вывод по схеме через function calling или structured output, где для каждого поля заданы тип и ограничения, а constrained decoding не даёт модели придумать поля или сломать формат.

Ключевой приём: в схему добавляем не только значения, но и evidence, цитату из расшифровки, и confidence. Без опоры на текст поле потом не проверить.

{
  "name": "extract_call_facts",
  "schema": {
    "type": "object",
    "properties": {
      "next_step":   {"type": ["string","null"]},
      "due_date":    {"type": ["string","null"], "format": "date"},
      "amount_rub":  {"type": ["number","null"]},
      "objection":   {"type": ["string","null"]},
      "stage":       {"type": "string", "enum": ["new","qualify","demo","proposal","won","lost"]},
      "evidence":    {"type": "string"},
      "confidence":  {"type": "number", "minimum": 0, "maximum": 1}
    },
    "required": ["stage","evidence","confidence"],
    "additionalProperties": false
  }
}

Пара деталей, без которых этот слой шумит. Температуру держим 0 и фиксируем seed: разбор должен быть воспроизводимым, иначе вы не отличите регресс промпта от обычного разброса модели, и eval превращается в рулетку. Stage делаем enum, а не свободной строкой, чтобы модель не изобретала «почти-демо» и «предпроекта». Few-shot в промпт берём не случайный, а пару трудных примеров из размеченного эталона, из тех типов звонков, где модель чаще ошибалась.

Бывает, что даже structured output отдаёт невалид (на больших схемах это редко, но случается). На этот случай один повторный вызов с текстом ошибки валидации в промпте, и если опять мимо, поле уходит человеку, а не молча теряется:

async def extract(transcript):
    raw = await llm.call(EXTRACT_SCHEMA, transcript, temperature=0, seed=7)
    ok, err = validate(raw, EXTRACT_SCHEMA)
    if not ok:
        raw = await llm.call(EXTRACT_SCHEMA, transcript, temperature=0, seed=7,
                             repair=err)        # один повтор с описанием ошибки
        ok, _ = validate(raw, EXTRACT_SCHEMA)
    return raw if ok else needs_review(transcript)

Но валидной по схеме структуры мало. Модель спокойно вернёт JSON, где написано «клиент согласился на демо в четверг», хотя в звонке этого не было. Формат правильный, факт придуман, и ловить такое дороже всего: в потоке звонков глазами этого не заметишь. Поэтому валидация в два этапа: сначала схема, потом проверка, что значение реально выводится из источника. Дешёвый вариант проверки опоры это нечёткое вхождение цитаты в расшифровку:

from rapidfuzz import fuzz

def grounded(fact, transcript, threshold=85):
    if not fact.get("evidence"):
        return False
    # цитата должна почти дословно встречаться в исходнике
    return fuzz.partial_ratio(fact["evidence"], transcript) >= threshold

def accept(fact, transcript):
    if not grounded(fact, transcript) or fact["confidence"] < 0.6:
        fact["stage"] = "needs_review"   # не пишем в карточку, поднимаем человеку
    return fact

Про порог confidence < 0.6. Подбирали его по эталону, а не наугад. Модельный self-reported confidence сам по себе врёт и почти всегда завышен, поэтому границу искали по размеченным звонкам: брали то значение, на котором в needs_review уходит максимум реальных ошибок и минимум нормальных разборов. На нашем потоке этот шаг отправляет в review заметную долю разборов, и почти все они при ручной проверке оказывались либо реальной неоднозначностью звонка, либо тем тихим додумыванием. То есть фильтр срабатывает там, где и должен. Где нужна не текстовая, а смысловая проверка опоры (перефраз), на спорные поля добавляем второй вызов LLM-валидатора, но только на них, а не на весь вывод, иначе впустую удваиваем стоимость каждого разбора.

Слой 3. Структура в CRM: где всё дублируется

Слой, который недооценивают сильнее всего. По открытым прикидкам команды занижают полную стоимость интеграции на 40-60 процентов, а поддержка синхронизации потом съедает 4-8 часов в месяц: мониторинг ошибок, разбор конфликтов, починка после обновлений платформы. Главный симптом плохой интеграции это битая аналитика: карточки без данных, дубли, неверные теги после авто-квалификации. Сломанная атрибуция дороже, чем кажется, потому что по ней принимают решения о бюджете.

Грабли на примере AmoCRM, но похоже почти у всех CRM.

Вебхуки врут и дублируются. Один ивент может прийти дважды, с задержкой или не в том порядке, плюс есть лимит около 10 вебхуков в секунду. Приём обязан быть идемпотентным, иначе один звонок превращается в три задачи менеджеру:

def idem_key(event):
    # стабильный ключ из сущности и типа события, без времени доставки
    return f"{event['entity']}:{event['id']}:{event['type']}:{event['modified_at']}"

async def handle(event):
    key = idem_key(event)
    if not await redis.set(key, 1, nx=True, ex=86400):   # SET NX = первый выигрывает
        return                                            # дубль, тихо игнорируем
    await queue.add("crm-sync", event)

Тонкость в ключе: в него входит modified_at. Если убрать, то схлопнутся не только дубли доставки, но и легитимное повторное изменение той же сущности через минуту. А если взять время доставки, наоборот, дубли перестанут схлопываться. Ключ должен быть стабильным для одного логического события и разным для двух разных.

Лимиты на запись жёсткие, считанные запросы в секунду на аккаунт. Наивный цикл по сделкам ловит 429 и теряет данные. Очередь с ограничением скорости, ретраями и экспоненциальным бэкоффом:

@worker("crm-sync", concurrency=2, rate_limit="5/s")   # держим запас под лимит аккаунта
async def sync(job):
    for attempt in range(5):
        try:
            return await amo.patch_lead(job.lead_id, job.payload)
        except RateLimited:
            await sleep(min(2 ** attempt, 30) + random()*0.5)  # бэкофф + джиттер
    raise   # уходит в DLQ, не теряем молча

Рассинхрон состояния. Пока агент думал, человек руками подвинул сделку. Писать «поставить стадию demo» опасно, можно затереть свежий ручной ход. Пишем по принципу compare-and-set: меняем только если текущее состояние то, которое мы видели при разборе, иначе не затираем, а поднимаем конфликт на ручное решение.

async def safe_stage(lead_id, expected, new):
    cur = await amo.get_stage(lead_id)
    if cur != expected:
        return await flag_conflict(lead_id, cur, new)   # человек решает
    return await amo.patch_lead(lead_id, {"status_id": new})

Ещё две вещи, которые экономят часы разборов потом.

Первое, маппинг полей плывёт. Кастомные поля в AmoCRM это числовые id, и они меняются, когда кто-то правит воронку или пересобирает поля. Если зашить id в код, в один день агент начнёт молча писать сумму в поле «комментарий». Держим маппинг имён на id в конфиге и проверяем его на старте сервиса: если ожидаемого поля в аккаунте нет, падаем громко при деплое, а не тихо в проде.

Второе, outbox. Между «разобрали звонок» и «записали в CRM» сервис может упасть, и разбор потеряется. Поэтому факт разбора пишем в свою БД транзакционно вместе с пометкой «надо синхронизировать», а отдельный воркер читает эту таблицу и толкает в CRM. Падение между шагами больше не теряет данные, воркер просто доберёт недоставленное:

async def on_extracted(call_id, facts):
    async with db.tx():
        await db.facts.save(call_id, facts)
        await db.outbox.add(call_id, kind="crm-sync")   # в той же транзакции
    # отдельный воркер потом читает outbox и синкает; падение тут не теряет разбор

Скучный слой, но именно он отделяет демо от продакшена. Красивый разбор звонка ничего не стоит, если он лёг в дубль карточки.

Как агент действует наружу: MCP против браузера

Понять звонок это половина. Агенту надо ещё подействовать наружу: обновить сделку, поставить задачу, иногда опубликовать или собрать аналитику из внешнего сервиса. И тут два принципиально разных пути, между которыми важно не ошибиться.

Действия агента наружу: MCP против эмуляции браузера

Действия агента наружу: MCP против эмуляции браузера

Первый путь, эмулировать человека в браузере (Playwright и подобные). Он кажется универсальным: что человек кликает, то и агент кликнет. На практике он оказывается самым хрупким. Капча и антибот, риск бана аккаунта, недетерминированность, и главное, любое изменение вёрстки ломает сценарий. Селектор, который работал вчера, сегодня указывает в пустоту, и агент молча перестаёт что-то делать.

Второй путь, ходить в типизированный API напрямую или через MCP-слой над ним. Тулы с понятными аргументами, OAuth вместо подбора паролей, никакой капчи, всё в рамках условий сервиса, и это детерминированно и тестируемо. Под нашу CRM мы подняли свой MCP-слой над внутренним API: агент вызывает инструмент update_lead(...), а не кликает по интерфейсу. Тул можно покрыть тестом, у клика по верстке такой роскоши нет.

# то, что хочет агент: «обнови сделку 88213, стадия demo, задача на пятницу»

через браузер:  открыть amo → войти → пройти антибот → найти сделку →
                кликнуть стадию → выбрать demo → создать задачу → ...
                (ломается на любом из шагов при смене верстки)

через MCP/API:  update_lead(id=88213, stage="demo",
                            task={due:"fri", text:"прислать договор"})
                (типизировано, идемпотентно, тестируемо)

Так уже делают на практике. У сервисов всё чаще есть официальный MCP: например, у Metricool есть managed MCP для соцсетей, и постинг идёт через их API, а не через эмуляцию клика в их вебе. Правило простое: если у платформы есть API или MCP, всегда берём его, а не браузер.

Браузер остаётся ровно для одного случая, когда API физически нет (старая панель какого-нибудь сервиса, у которого интеграций не предусмотрено). И там мы не строим из себя невидимку: антибот не обходим, на капче сидит человек, а не самописный солвер. Попытка обхитрить защиту это прямой путь к бану аккаунта, который потом дороже любой ручной рутины.

Артефакт для человека: follow-up и КП

Соблазн отдать всё модели вреден по двум причинам. Факты: сгенерированный follow-up легко придумывает условия, которых не было. Прогоняем черновик через тот же grounding-чек против расшифровки и держим человека в цикле на отправке клиенту. Почерк: текст, который пахнет нейросетью, бьёт по доверию сильнее опечатки, поэтому черновик проходит детерминированную проверку на ИИ-стиль (тире, канцелярит, ровный ритм, штампы) до того, как его увидит менеджер. Стоит копейки, а убирает «здравствуйте, надеюсь, это письмо застанет вас в добром здравии».

Как понять, что агент не врёт

Главный вопрос продакшена не «работает ли на демо», а «как мы узнаем, что качество не просело после смены модели или правки промпта». Ручную проверку не масштабировать, и тянет поставить вторую LLM судьёй. Это работает, но с двумя оговорками.

Первая: у судьи на той же модели системная слепота. Он завышает оценку текста, похожего на собственный стиль, и путает узнаваемость с качеством. Эффект растёт почти линейно с тем, насколько судья узнаёт свой почерк (у Eugene Yan есть подробный разбор смещений LLM-судей). Лечится тем, что генератор и судья из разных семейств моделей.

Вторая: судью надо калибровать об эталон. Размечаем руками сотню звонков, гоняем судью против них и подмешиваем эти контрольные пары в каждый прогон:

GOLDEN = load_labeled_cases()   # ~100 звонков, размечены руками

def eval_run(model):
    agree = sum(judge(model(c.input)) == c.label for c in GOLDEN) / len(GOLDEN)
    # бинарный вердикт (ок / не ок), не шкала 1-10: шкала шумит
    return agree

# гейт в CI: не выкатываем модель/промпт, если согласие с эталоном упало
assert eval_run(candidate) >= 0.85

Бинарный вердикт вместо шкалы 1-10 мы выбрали по простой причине: на шкале две модели дают разброс в полтора-два балла на одном ответе, на бинарном «прошло или нет» согласие куда стабильнее. В открытых разборах автоэвалов согласие судьи с человеком поднимали с ~0.71 до ~0.86 именно калибровкой и переходом на бинарные критерии. И главная метрика качества разбора у нас не «понравилось ли судье», а доля выводов, подтверждённых цитатой из источника. Её видно без всякого судьи.

Наблюдаемость: какой слой упал и почему

Конвейер из очередей без наблюдаемости это чёрный ящик, который иногда теряет звонки, и вы не знаете где. Поэтому наблюдаемость не «потом допилим», а часть архитектуры.

По сквозному call_id (про него выше) собираем воронку звонка как бизнес-метрику: принято / расшифровано / разобрано / записано / в review. Разрыв между соседними этапами показывает, где течёт. Если «расшифровано» сильно меньше «принято», проблема в ASR или в фоллбэке. Если «записано» меньше «разобрано», копаем CRM-синк и DLQ.

На каждую очередь вешаем метрики (у нас Prometheus плюс дашборды): длина очереди, время ожидания, p95 обработки и доля задач, ушедших в DLQ. Алертим не на «появилась ошибка» (ошибки будут всегда), а на тренд: растёт DLQ или поехал p95.

DLQ помогает разбираться с инцидентами только если падения в нём разложены по классам. RateLimited это чаще про лимит аккаунта, лечится политикой скорости. ValidationFailed это чаще баг схемы или промпта, лечится кодом. ProviderError это про внешний сервис, лечится фоллбэком. Свалив всё в один «failed», вы теряете эту диагностику и каждый инцидент разбираете с нуля.

Стоимость и латентность: где утекают деньги

Где реально утекают деньги и секунды.

Reasoning-токены. У reasoning-моделей на один видимый токен ответа уходит два-три скрытых на рассуждение. Публичные калькуляторы считают по видимым, поэтому смета и факт расходятся в разы. Грубый пример на один разбор звонка: вход 4000 токенов расшифровки, видимый выход 400 токенов JSON, но реально модель потратила ещё около 1000 скрытых. Если считать только по видимым 400, недооценка выхода в 3.5 раза. На потоке в тысячи звонков это уже заметная статья расходов. В SLA и в расчёт ёмкости закладываем отдельный множитель на рассуждение.

p95, а не средняя. Под нагрузкой батчинг даже поднимает суммарный throughput, но хвост задержек первым ломает ощущение «тормозит». Менеджер не чувствует среднее, он чувствует тот звонок, который разбирался 40 секунд вместо 8. Гарантию даём по p95 на целевом числе параллельных диалогов, а не по среднему.

Prefix caching. Экономит заметную долю на TTFT, но только если общий префикс байт в байт одинаковый. Любое волатильное поле в начале промпта (время, имя менеджера, id сессии) убивает попадание в кэш целиком:

[ системный промпт + схема + few-shot ]  ← стабильно, кэшируется
[ {{дата}} {{имя}} ]                      ← ЛОМАЕТ кэш, если в начале
[ расшифровка звонка ]                    ← переменное, в хвост

Стабильный системный блок в начало, всё переменное в хвост. На длинном системном промпте это бесплатные десятки процентов TTFT, которые легко потерять одной строкой.

Русский токен-налог. Один и тот же смысл по-русски стоит в полтора-два раза больше токенов, чем по-английски. Системные инструкции и схему держим на английском, пользовательский текст оставляем на русском. Качество разбора не страдает, а постоянная часть контекста, которая едет в каждый запрос, заметно дешевеет.

Общий принцип, который сэкономил нам больше всего: роутить по цене ошибки, а не по «модель посильнее везде». Триаж и черновик отдаём дешёвой модели, дорогую с проверкой включаем только там, где действие необратимо или дешёвый чек уже упал.

Что дал каждый слой

Слой

Грабля

Решение

Эффект

ASR

WER 8-12% на телефонии, врёт на именах

стерео-каналы где есть, глоссарий + постобработка, нахлёст 5с

меньше мусора в разборе, не теряем стыки

Extract

валидный JSON с выдуманным фактом

схема + grounding по цитате, temperature 0, retry-on-invalid

спорные уходят в review, а не в карточку

CRM

дубли и потеря данных на вебхуках/лимитах

идемпотентность + очередь с бэкоффом + CAS + outbox

один звонок = одна задача, аналитика не врёт

Действия наружу

браузер ломается от капчи и смены вёрстки

MCP/API вместо эмуляции, браузер только где нет API

детерминированно, тестируемо, без банов

Eval

качество тихо просело после правки

контрольные пары + кросс-модельный судья

регресс ловится в CI до выката

Наблюдаемость

звонки теряются, непонятно где

сквозной call_id, воронка, DLQ по классам

видно протекающий этап за секунды

Cost

смета мимо в разы

учёт reasoning-токенов, prefix caching, роутинг

предсказуемая стоимость на звонок

Чего не делать

Не доверять выводу модели без объективного чека. Тесты, схема, цитата из источника, контрольные пары. Ревью на глаз пропускает как раз те ошибки, которые модель делает уверенно и гладко.

Не гнаться за полной автономией. Самый дешёвый способ обжечься это убрать человека с шага, где ошибка необратима: письмо клиенту, изменение стадии сделки. Человек на выходе, машина на рутине.

Не эмулировать браузер там, где есть API. Кажется, что «агент как человек» это гибко, но в проде это постоянные поломки и риск бана. Типизированный тул всегда надёжнее клика по верстке.

Не мерить успех активностью. Письма и дилы за первые 30 дней это метрика движения, а не результата. Честная метрика это стоимость квалифицированной встречи на 90 днях.

ИИ-агент для продаж держится на аккуратном конвейере из распознавания, извлечения, интеграции и проверки, где половина работы приходится даже не на модель, а на обвязку вокруг неё. Зато когда обвязка собрана аккуратно, рутина реально уходит с людей.

Если у вас был свой опыт с разбором звонков или синхронизацией с CRM, особенно интересны ваши грабли на диаризации, на вебхуках и на выборе между API и эмуляцией браузера. Поделитесь в комментариях, сверим решения.