1. Проблема и исторический нарратив: два счёта за болтливость
В феврале 2024 года гражданский трибунал в Канаде поставил точку в споре пассажира Джейка Моффатта с Air Canada: суд обязал перевозчика выплатить 812 долларов. Мужчина покупал срочный билет на похороны бабушки, а чат-бот поддержки уверенно объяснил: подайте заявку в течение 90 дней после перелёта — и получите частичный возврат по тарифу скорби. Регламент перевозчика запрещал перерасчёт задним числом. Юристы Air Canada в суде настаивали, что чат-бот — отдельный сервис и за свои слова отвечает сам. Трибунал решил иначе: говорит компания — платит компания. Ровно та же логика работает в первом касании B2B-лида: платформа First Touch строит диалог так, чтобы каждое утверждение бота опиралось на утверждённый документ. Вместо этого на «здравый смысл» языковой модели.
Через месяц случился второй вещдок — уже в Британии. 19 января 2024 года музыкант Эшли Бошам, 30 лет, не мог добиться от чат-бота DPD информации о потерянной посылке и попросил его написать стихотворение о том, насколько плоха DPD. Бот не отказался. Он выдал рифмованный текст про худшую службу доставки в мире и добавил непечатное слово. Скриншоты ушли в X и разлетелись за сутки; DPD в тот же день отключил ИИ-элемент чата и объяснил поведение «ошибкой после обновления системы».
Задержитесь на этой формулировке: ошибка после обновления. Никто не взламывал модель и не подбирал prompt-инъекцию через имя получателя. В прод просто уехала другая сборка — и никто не проверил, как она ведёт себя на вопросах вне сценария «где моя посылка».
Цена в двух кейсах разная: 812 канадских долларов по решению суда и сутки репутационного пожара с отключением функции. Причина одна: генеративная модель получила право говорить от лица компании, не имея обязательства сверяться с документом и не имея права промолчать. Для CTO и CISO это не история про плохой промпт. Это история про отсутствие инженерного контура вокруг модели.
2. Архитектура: заморозка весов, детерминированное декодирование, обязательная цитата
Промышленный контур отличают от «болтливого ассистента» три решения.
1. Заморозка весов. В прод едет конкретный артефакт с digest sha256. Смена весов — это релиз с ревью, red-team прогоном и подписью владельца продукта. Вместо этого «модель подтянулась ночью».
2. Детерминированный пайплайн. temperature=0.0, top_p=1.0, фиксированный seed, фиксированные батч и железо, guided decoding по JSON-схеме. Ответ не «творится» — он собирается и проверяется.
3. Право на отказ и обязательная цитата. Каждое утверждение несёт chunk_id из неизменяемого снапшота документов. Нет цитаты — нет утверждения. Нет документа — отказ и эскалация живому менеджеру.
Telegram/чат → API-шлюз → Guardrails (policy-as-code, Rego/Pydantic)
→ Retriever (snapshot_id, immutable index)
→ vLLM (frozen weights, temp=0, guided JSON)
→ Citation Verifier → Refusal / Escalation
→ Audit Log (хэш-цепочка решений + digest весов)
| Слой | Технология | Что фиксируется в логе |
|---|---|---|
| Инференс | vLLM self-host, OpenAI-совместимый API | digest весов, версия образа, тип GPU |
| Декодирование | temperature=0, seed, JSON schema | все параметры сэмплинга |
| Контекст | индекс со snapshot_id | хэш корпуса документов |
| Политика | OPA/Rego + Pydantic-валидаторы | версия правил и дата ревью |
| Наблюдаемость | OpenTelemetry, audit log | полная цепочка решения |
vLLM в приватном контуре даёт PagedAttention, prefix caching и tensor parallel — латентность и стоимость, сопоставимые с внешним API, но без выхода персональных данных за периметр и без чужого роадмапа по весам. Ключевая структура данных — не «документ», а Chunk(doc_id, section, doc_version, sha256). Ответ без ссылки на такой чанк физически не может покинуть систему.
3. Машиночитаемый стандарт базы знаний
Модель не должна «читать сайт». Она должна читать машинный контракт. Для этого база знаний публикуется в формате спецификации llms.txt: заголовок, краткое описание корпуса, разбиение по разделам со ссылками на полные тексты. Рядом живёт llms-full.txt — плоский корпус для офлайн-индексации.
Пайплайн обновления строится на хэшах: ночной фетч → diff по sha256 → заявка на изменение → ревью владельца документа → новый snapshot_id → только потом переиндексация. Между «юрист поменял абзац» и «бот начал так говорить» стоит человеческое подтверждение и запись в audit log. Это скучно, и именно поэтому работает.
4. Реализация: рабочий класс с Pydantic и asyncio
from __future__ import annotations
import json
import re
from typing import Literal
import httpx
from pydantic import BaseModel, Field
class Chunk(BaseModel):
doc_id: str
section: str
doc_version: str
text: str
sha256: str
class Claim(BaseModel):
statement: str
chunk_ids: list[str] = Field(min_length=1)
class Answer(BaseModel):
text: str
claims: list[Claim]
snapshot_id: str
weights_digest: str
refused: bool = False
temperature: Literal[0.0] = 0.0
SYSTEM_PROMPT = (
'Ты отвечаешь от лица компании. Любое утверждение обязано опираться '
'на фрагмент из CONTEXT и содержать его chunk_id. Если в CONTEXT нет '
'прямого ответа, верни {"refused": true}. Не пересказывай общие знания. '
'Формат: {"answer": str, "claims": [{"statement": str, '
'"chunk_ids": [str]}]}'
)
class FrozenLocalLLM:
# Шлюз к self-hosted vLLM: замороженные веса, temp=0, обязательные цитаты.
def __init__(self, base_url: str, model: str, weights_digest: str,
snapshot_id: str, chunks: list[Chunk],
min_overlap: float = 0.15) -> None:
self._url = base_url.rstrip('/') + '/v1/chat/completions'
self._model = model
self._digest = weights_digest
self._snapshot = snapshot_id
self._chunks = {c.sha256: c for c in chunks}
self._min_overlap = min_overlap
self._client = httpx.AsyncClient(timeout=25.0)
async def aclose(self) -> None:
await self._client.aclose()
async def _retrieve(self, query: str, k: int = 6) -> list[Chunk]:
q = set(re.findall(r'\w{4,}', query.lower()))
scored: list[tuple[float, Chunk]] = []
for chunk in self._chunks.values():
words = set(re.findall(r'\w{4,}', chunk.text.lower()))
overlap = len(q & words) / (len(q) or 1)
if overlap >= self._min_overlap:
scored.append((overlap, chunk))
scored.sort(key=lambda p: p[0], reverse=True)
return [c for _, c in scored[:k]]
def _refuse(self, text: str) -> Answer:
return Answer(text=text, claims=[], snapshot_id=self._snapshot,
weights_digest=self._digest, refused=True)
def _verify(self, claims: list[Claim]) -> bool:
for claim in claims:
for cid in claim.chunk_ids:
if cid not in self._chunks:
return False # цитата на удалённый или чужой чанк
return True
async def answer(self, query: str) -> Answer:
ctx = await self._retrieve(query)
if not ctx:
return self._refuse('Нет утверждённого документа по этому вопросу. '
'Передаю менеджеру.')
context_block = '\n\n'.join(
f'[{c.sha256}] {c.doc_id}/{c.section} v{c.doc_version}: {c.text}'
for c in ctx
)
payload = {
'model': self._model,
'temperature': 0.0,
'top_p': 1.0,
'seed': 7,
'max_tokens': 400,
'response_format': {'type': 'json_object'},
'messages': [
{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'user', 'content': f'CONTEXT:\n{context_block}\n\nВОПРОС: {query}'},
],
}
resp = await self._client.post(self._url, json=payload)
resp.raise_for_status()
raw = resp.json()['choices'][0]['message']['content']
data = json.loads(raw)
if data.get('refused'):
return self._refuse('Вопрос вне утверждённого регламента. Передаю менеджеру.')
claims = [Claim(**c) for c in data.get('claims', [])]
if not claims or not self._verify(claims):
return self._refuse('Не могу подтвердить ответ документом.')
return Answer(text=data['answer'], claims=claims,
snapshot_id=self._snapshot, weights_digest=self._digest)
Класс решает три задачи одновременно: физически не даёт ответить без контекста, ломает ответ при ссылке на несуществующий чанк и оставляет след из digest весов в каждом ответе. Дальше поверх него ставится любая обвязка — Telegram, почта, CRM.
5. Практический продакшен-кейс: цифры из контура [First Touch](https://aifirsttouch.ru)
Внутри платформы тот же принцип применён к B2B-рассылке: бот пишет по вашей базе с ваших аккаунтов, отвечает на вопросы и передаёт менеджеру заинтересованных. Замеры за 6 месяцев эксплуатации на 8×L40S:
Именно последняя строка — то, за что платят. Не «модель умная», а «мы знаем, когда она поумнела не туда».
6. Подводные камни и надёжность в эксплуатации
1. Квантизация меняет личность модели. 4-bit AWQ на policy-критичных узлах сдвигал формулировки про сроки возврата. Оставили FP16/FP8 для узла цитат, квантизацию — только для черновиков.
2. Seed не гарантирует бит-в-бит. Разные GPU, размер батча и версия ядра дают разные logits. Фиксируйте железо и размер батча, иначе «детерминизм» — иллюзия.
3. Prefix caching ломается от правки системного промпта. Любое изменение SYSTEM_PROMPT — это инвалидция кэша и всплеск латентности. Выкатывайте в окно низкой нагрузки.
4. Индекс без версии убивает цитаты. Документ удалили — ссылка висит. Верификатор обязан проверять chunk_id по снапшоту. Вместо этого по «текущему» индексу.
5. Red-teaming шире, чем jailbreak. 80% реальных провалов — скучные вопросы: тарифы, сроки, гарантии, возвраты. Гоняйте 150–300 проб именно по ним.
6. Fallback обязателен. Если верификатор недоступен — система не отвечает. Молчание дешевле судебного иска.
7. Люди. Менеджер должен видеть цитату рядом с ответом бота, иначе он не сможет за него ответить.
7. Дорожная карта внедрения за 4 спринта
| Спринт | Что делаем | Definition of Done |
|---|---|---|
| 1 | Инвентаризация документов, публикация llms.txt, снапшот корпуса, baseline-замеры | snapshot_id присвоен, хэши сходятся, есть 100 эталонных вопросов |
| 2 | vLLM в приватном контуре, заморозка digest, temp=0, guided JSON, верификатор цитат | ни один ответ не проходит без валидного chunk_id |
| 3 | Guardrails, red-team CI на 200+ проб, отказ и эскалация | регрессии ловятся в CI, не в проде |
| 4 | Пилот на 10% трафика, A/B, юнит-экономика, подписи CISO и юриста | p95 < 3 с, стоимость на диалог посчитана, инцидентов 0 |
8. Вывод и CTA
Главный жизненный и бизнес-вывод из этой истории
Air Canada заплатила 812 долларов не за ошибку модели, а за попытку объявить модель самостоятельным субъектом. Трибунал эту попытку отверг: инструмент, который говорит с клиентом, говорит от лица владельца дела, и никакая формулировка «так посчитала программа» ответственности не снимает. Это применимо буквально: чем быстрее в компании появляется генеративный посредник — в поддержке, продажах, найме, юридическом согласовании, — тем быстрее на владельца ложится всё, что этот посредник сказал.
Практический перенос на сегодня — три управленческих решения, которые можно принять на этой неделе. Первое: заморозить веса и превратить любое их обновление в релиз с подписью и red-team прогоном — тогда «ошибка после обновления» перестаёт быть объяснением. Второе: сделать цитату обязательным полем структуры ответа. Вместо этого украшением — если утверждение нельзя привязать к параграфу утверждённого документа, его не существует. Третье: дать боту право отказаться и передать человеку — отказ стоит ноль, вежливая выдумка стоит репутации и денег.
Это и есть суть перехода на локальные LLM с замороженными весами: не про экономию на токенах, а про то, чтобы каждое слово, сказанное клиенту, можно было предъявить в суде вместе с хэшем документа, из которого оно взято.
Если вы строите первое касание с B2B-клиентом и не хотите однажды объяснять инвестору, почему бот пообещал лишнее, — начните с First Touch: бот пишет по вашей базе с ваших аккаунтов, отвечает строго по документам и передаёт менеджеру заинтересованных клиентов.
Чужой сервис может поменять характер за одну ночь без предупреждения: фиксируй проверенную сборку у себя и устраивай ей жесткий экзамен до встречи с клиентами.