[DARFT] Привязка сессии для повторного использования KV-cache

+1 PR-поддержка и крутые сессии. Предложение:
Хэш HMAC-sha256 слишком медленный/тяжелый/ограниченный (без аппаратного ускорения)
предложение заменить его более быстрым хешем blake3 - дает ускорение в 5-15 раз, blake2b - в 3-8 раз github.com/zeebo/blake3 или lukechampine.com/blake3 - код go. или siphash (hash/mapsah) также хорошо ускоряет сеансы в 10-20 раз. тот же хороший математический разброс, <ins> более быстрый хеш, что важно для быстрой обработки сеансов </ins>.
улучшение2
session_id = hex(HMAC-SHA256(secret, escrow ‖ 0x00 ‖ cred ‖ 0x00 ‖ client)) замените на session_id = hex(BLAKE3.KeyedHash(secret[:32], escrow ‖ 0x00 ‖ cred ‖ 0x00 ‖ client)) .
- Ключ BLAKE3 — это именно PRF/MAC, оболочка HMAC не требуется.
- Секрет по-прежнему составляет 32 байта (как и сейчас).
- Выходные данные можно оставить размером 32 байта (64 шестнадцатеричных) или сократить до 16–24 байт, если размер провода имеет решающее значение.
- Производительность на коротких входах обычно в несколько раз выше, чем у SHA-256.
- Улучшение3.
Это следующий логический уровень: мягкая привязка с учетом географического положения и задержки + группы близких узлов + (потенциально) общий пул KV внутри участника. Его можно наложить поверх текущего дизайна, не нарушая его.
- Вместо чистой классической модели закрепленных сеансов с привязкой по последнему использованному сеансу расширьте эту идею, включив <ins>маршрутизацию на основе географической/сетевой близости/задержки </ins>, назначение узлов группам и учет их рабочей нагрузки/скорости ответа. (Это предполагает добавление маркировки и группировки узлов на основе оборудования.)
Предложение по улучшению: ввести концепцию «группы соответствия» — набора участников, между которыми физически не используется общий кэш KV, но чья задержка для клиента сопоставима (это можно сделать топологически по региону/AZ или измеренному RTT). Часть прохода уже существует (закрепленное попадание → несвязанный → любой совместимый) — добавьте промежуточный проход «любой член группы сродства предпочтительного узла» перед «любым совместимым». Тогда, если конкретный узел недоступен, вы не летите в случайную точку сети, а остаетесь рядом. Это меняет только сборщик (брокер) и не требует изменения логики HMAC/пространства имен — они остаются для каждого узла, что уже правильно с точки зрения модели безопасности PR.

Резюме
Вывод направляется на псевдослучайный обслуживающий узел при каждом вызове, что справедливо для справедливости, но выбрасывает кэш KV/префиксов — для многоповоротного чата перерасчет общего префикса является доминирующей задержкой. Этот PR добавляет ограниченную, добровольную привязку сеансов для обоих участков маршрутизации, а также явную область совместного использования кэшированных блоков, поскольку концентрация трафика одного клиента на одном графическом процессоре без такового расширит существующий боковой канал синхронизации, а не закроет его.
- Управляли двумя прыжками, а не одним. Сеанс возвращается к тому же участнику (шлюзу) и тому же узлу внутри него (брокеру-участнику). При управлении только переходом 1 кэш остается холодным всякий раз, когда участник выбирает другой графический процессор.
- Пространство имен кэша, привязанное к аутентифицированному удостоверению. Идентификатор сеанса связи — HMAC (секрет процесса, строка условного депонирования учетных данных клиента) — фиксированный 64-шестнадцатеричный дайджест. Собственная строка клиента никогда не покидает шлюз, и угадывание строки другого клиента больше не достигает его кэшированных блоков.
- Предпочтение, а не бронирование. Средство выбора учитывает закрепленность только в том случае, если предпочтительный участник может обслуживать сейчас и запрос не устарел более 200 мс. Ноль одноразовых номеров не сжигается, и ни один запрос не ожидает от закрытого, регулируемого или простаивающего участника.
- Prompt_cache_key действительно работает. Документированный первичный ключ удалялся до того, как affinity прочитал его, поэтому активным был только пользователь — одно значение для всего клиента.
- Ограничено дважды. Обе карты привязки ограничены 50 000 записями с вытеснением, а оба поля-кандидата проверяются по типу и ограничиваются 512 байтами на границе шлюза.
- 66 новых тестов, каждый из которых проверен на наличие мутаций. Два независимых рецензента повторно сломали 21 и 12 охраняемых строк соответственно; всех поймали.
По умолчанию на обоих концах выключено — коммутатор шлюза и коммутатор каждого участника независимы, а при выключенном переключателе шлюза идентификатор сеанса вообще не определяется. ---
Проблема → Решение
1. Теплый тайник сбрасывается на каждом ходу.
Раньше. Шлюз привязывает каждый nonce к участнику как nonce mod group_size, а брокер участника передает каждый вывод его наименее загруженному узлу. Ни один из переходов не знает о сеансах, а кэш KV предназначен для каждого графического процессора, поэтому ход *n+1* диалога почти всегда повторно заполняет весь префикс из холодного состояния. После. Ключ сеанса, считанный из тела запроса (prompt_cache_key, резервный пользователь), направляет сеанс обратно к его последнему использовавшемуся участнику и, внутри этого участника, к его последнему использовавшемуся узлу mlnode для ограниченного окна.
2. Пространство имен кэша было получено из угадываемой строки.
Раньше. Пространство имен получено из собственного пользовательского значения клиента. Угадайте пользователя другого арендатора, и вы попадете в пространство имен его кэша, где сообщенное количество кэшированных токенов подскажет вам, какие префиксы являются резидентными — опубликованная атака по времени с использованием кэша подсказок (https://arxiv.org/abs/2502.07776) передала ключ. После. Шлюз получает идентификатор проводного сеанса в виде HMAC через условное депонирование, учетные данные носителя вызывающего абонента и строку клиента под секретом, полученным из crypto/rand при запуске и никогда не сохраняемым. Обслуживающий узел вычисляет пространство имен на основе своего собственного идентификатора условного депонирования и идентификатора этого сеанса, поэтому шлюз не может объединить два пространства имен условного депонирования. Эскроу разделяет арендаторов разных эскроу; учетные данные разделяют аутентифицированных клиентов внутри одного.
3. Прилипчивость сжигала одноразовые номера и ограничивала запросы
Раньше. Сходство было реализовано путем инвертирования набора исключений средства выбора — исключения каждого участника, кроме прикрепленного, — плюс тайм-аута удержания. Зафиксированный nonce, запрос которого отказался переместить, — это сжигание, а сжигание — это деньги, потраченные на условное депонирование, ни на что. После. Предпочтение попадает в сборщик как предпочтение. Три прохода: липкий удар, затем несвязанный, затем любой совместимый. Новый закрепленный запрос никогда не обгоняет несвязанный запрос, ожидавший превышения порога устаревания, а более узкая повторная попытка вообще никогда не обгоняется. Оба направления голодания покрываются тестами, которые терпят неудачу в коде префикса.
4. Документированный первичный ключ был мертв.
Раньше. Prompt_cache_key удаляется при предварительной проверке, а affinity считывает нормализованное тело — поэтому поле никогда не будет видно в рабочей среде. Работал только пользователь, что для приложения является одним значением для всего его трафика: липкость привязывала бы к одному хосту целый тенант. Юнит-тест пройден, потому что он тестировал экстрактор изолированно, а не путь поставки. После. Ключ извлекается из уже проанализированного документа *до* полосы — один поиск по карте, а не второй анализ тела, которое может занимать 10 МБ. Поле продолжает сниматься с тела провода, поскольку обслуживающий механизм его не учитывает. Регрессионный тест теперь управляет реальным конвейером нормализации.
5. Один переключатель не закрыл функцию, другой закрыл слишком сильно
Раньше. Коммутатор участника обнулил идентификатор сеанса *до* отметки пространства имен, и по умолчанию он отключен. В наиболее вероятной конфигурации поля (оператор условного депонирования включает сходство, участники запускают стандартную конфигурацию) привязка работала, хотя пространство имен ничего не было, оставляя оракул в рабочем состоянии, оставляя злоумышленнику только дополнительный шаг. После. Штамп пространства имен используется с любым непустым идентификатором сеанса; Переключение участника управляет только привязкой к множественному узлу, поскольку привязка изменяет собственное планирование графического процессора этого оператора, в то время как изоляция защищает клиента, который не участвует в выборе. Коммутатор шлюза остается настоящим мастером: если он выключен, идентификатор сеанса не выводится, поэтому нет ни перехода, ни штампа.
6. Пути повторной попытки прервали сеанс
Раньше. Путь получения-вызова и обе полезные нагрузки тайм-аута шлюза создавали полезную нагрузку своего хоста без идентификатора сеанса, поэтому при любом повторном выполнении приглашение жертвы попадало в общее пространство имен, возвращая в точности ту изоляцию, которую удалось получить при первой попытке. После. Каждый путь, который повторно отправляет приглашение, содержит это поле, и два литерала тайм-аута были свернуты в один конструктор, чтобы они не могли снова разойтись.
7. Ничего не наблюдалось
Раньше. Ни метрики, ни журнала. Оператор, включивший эту функцию, не смог ответить, делает ли она что-нибудь. После. Счетчики попаданий/выходов/промахов на обоих прыжках плюс индикатор карты привязки. О переходе 2 сообщается отдельно, поскольку переход 1 может работать идеально, пока участник запускает сеанс на другом графическом процессоре, а кеш в любом случае остается холодным. ---
Дизайн
session_id = hex(HMAC-SHA256(секрет процесса, escrow_id ‖ 0x00 ‖ учетные данные носителя ‖ 0x00 ‖ строка клиента))
namespace = sha256(escrow_id ‖ 0x00 ‖ session_id) // вычисляется обслуживающим узлом на основе его собственного идентификатора условного депонирования
Соединение 0x00 однозначно. Идентификаторы условного депонирования назначаются по цепочке и не содержат NUL, HTTP-сервер Go отклоняет NUL в значении заголовка, поэтому учетные данные не могут его содержать, а единственное поле, поддерживающее NUL, является последним — никакая граница не может смещаться. Секрет заключается в каждом процессе, а не сохраняется. Его нет в репозитории, образе или среде, поэтому токен не может быть повторно вычислен вне коробки; без этого HMAC был бы автономным оракулом для подбора ключей API. Цена состоит в том, что перезапуск шлюза делает каждую привязку недействительной, и следующий раунд становится холодным. Привязка запроса была получена, а не угадана. Доля «горячего удара» внутри привязки равна (N-1)/N, поэтому 32 покупает 97% против 90% при 10. Это не параметр безопасности: выборка проверки основывается на идентификаторе вывода и долях слотов, при этом исполнитель удаляется из знаменателя, удерживая ожидаемые проверки на вывод по ставке условного депонирования (10 % по умолчанию) независимо от того, кто выполнил. Концентрация тоже ничего не приносит — покупатель платит собственные средства за выполненную работу, а вес зависит от PoC, а не от объема вывода. Что ограничивает N, так это степень детализации ключа: при использовании Prompt_cache_key ключ — это один разговор, а высокая граница только увеличивает дисперсию, но для пользователя один арендатор — это один ключ. Для второго случая выбрано 32. Оба срока жизни привязки являются круглыми числами, и об этом говорится в PR. Привязка полезна только до тех пор, пока обслуживающий механизм все еще удерживает блоки, что зависит от емкости кэша при поступлении токена на этот графический процессор — неизвестно заранее. В предложении документируется измерение, которое решает проблему: воспроизвести один префикс с растущим интервалом простоя и найти, где сообщаемое количество кэшированных токенов падает до нуля. ---
Валидация
Методология
- Каждый новый тест проверялся на мутацию его автором: разрывайте точную защищенную строку, подтверждайте неудачу, возвращайтесь.
- Два независимых рецензента проверили всю ветку на предмет статического дифференциала и не приняли на веру заявления о покрытии — они повторно взломали 21 и 12 защищенных строк соответственно. Все 33 человека были пойманы в ходе запланированного теста с предполагаемым сообщением.
- Проверка мертвого кода сравнила неиспользованные результаты с базовым коммитом в отдельном рабочем дереве: наборы идентичны, поэтому ветка не добавила ни одного. ineffassign/unparam также не дает новых результатов.
Что было проверено
| Недвижимость | Как | |---|---| | Подписанная полезная нагрузка без изменений | Штамп пространства имен, применяемый после проверки полезных данных, внутри закрытия выполнения; сохраненное и канонизированное приглашение является оригинальным | | Валидатор воспроизводит без пространства имен | Специальный тест; мутация, добавляющая одну, не получается | | Пространство имен не может пересекать условное депонирование | Обслуживающий узел использует свой собственный идентификатор условного депонирования, а не идентификатор перевода; удаление мутации провалило два теста | | В журналах, метках метрик, заголовках или ответах нет клиентской строки или токена | Grep каждого нетестового использования плюс проверка рецензента; метки — только devshard_id/decision/model | | Клиент не может внедрить собственное пространство имен | Неизвестные поля верхнего уровня отклоняются на шлюзе | | Оба направления голодания | Воспроизведен префикс (0 подан против 1731 за 2 с), затем закрыт | | Никаких новых рас | -гонка зеленого цвета на путях отслеживания, выбора и брокера |
Что не было измерено
Никаких показателей попаданий в кеш или задержек. По умолчанию эта функция отключена и не работает при рабочем трафике; утверждать, что здесь есть ускорение, значит изобретать его. Наблюдаемость, добавленная в этот PR, является тем, что решает проблему, и в предложении указаны конкретные измерения для значений по умолчанию за весь срок службы. ---
Конфигурация
| Параметр | По умолчанию | Ручка, если хотите… | |---|---|---| | DEVSHARD_AFFINITY_ENABLED | выключен | включите всю функцию для этого шлюза; если он отключен, идентификатор сеанса вообще не выводится | | DEVSHARD_AFFINITY_MAX_REQUESTS | 32 | торговля «горячей» долей в зависимости от того, как долго один ключ может занимать хост | | DEVSHARD_AFFINITY_TTL_MS | 120000 | сопоставить измеренную резидентность кэша на ваших графических процессорах | | DEVSHARD_AFFINITY_MAX_ENTRIES | 50000 | ограничить карту привязки (~ 14 МБ на каждое условное депонирование при заполнении) | | DAPI_MLNODE_AFFINITY_ENABLED | выключен | пусть этот участник снова направляет сеансы на тот же графический процессор; штамп пространства имен от этого не зависит | | DAPI_MLNODE_AFFINITY_MAX_REQUESTS | 64 | как указано выше, переход 2 | | DAPI_MLNODE_AFFINITY_TTL_MS | 600000 | как указано выше, переход 2 | | DAPI_MLNODE_AFFINITY_MAX_ENTRIES | 50000 | как указано выше, переход 2 | DAPI_MLNODE_AFFINITY_ENABLED читается двумя процессами на стороне участника — devshardd и брокером — и оба должны быть установлены для того, чтобы произошла липкость hop-2. Все три переключателя теперь анализируются одинаково, поэтому значение, содержащее пробелы из файла компоновки, больше не включает одну половину, а не другую. ---
Наблюдаемость
- devshard_gateway_affinity_decision_total{devshard_id,decision} — попадание, когда основной объект приземлился на прикрепленном участнике, сдача, когда предпочтение существовало, но сборщик обслуживал кого-то другого, промах, когда предпочтений не было. Тихий, когда функция выключена.
- devshard_gateway_affinity_bindings{devshard_id} — датчик карты привязки hop-1, для наблюдения за давлением ограничения.
- decentrized_api_mlnode_affinity_decision_total{decision,model} — эквивалент прыжка-2.
- Никакие значения «без ограничений условного депонирования», «сеанса» или «nonce» не отображаются ни в одной метке.
- Два пути, закрывающихся при сбое, которые молчали, теперь регистрируются один раз для каждого процесса: идентификатор сеанса, выходящий за границу участника, и токен, который перерос формат проводной связи.
---
Внедрение
По умолчанию без изменений. Оба переключателя поставляются выключенными. Без ключа клиента ничего нигде не происходит — ни идентификатора сеанса, ни закрепления, ни отметки пространства имен — что является сегодняшним поведением побайтно. Две независимые подписки. Оператор шлюза включает переход 1; каждый участник включает переход 2 для своих собственных графических процессоров. Включение только шлюза по-прежнему обеспечивает кэширование пространства имен и липкость hop-1; включение только участника ничего не дает, поскольку идентификатор сеанса не поступает. Однострочный откат. Снимите настройку DEVSHARD_AFFINITY_ENABLED и перезапустите шлюз. Привязки участников устаревают самостоятельно в течение 10 минут; ни одно государство нигде не сохраняется. Одно изменение, видимое клиенту, даже когда оно выключено: нестроковый запрос Prompt_cache_key размером более 512 байт теперь возвращает 400 вместо того, чтобы автоматически удаляться — то же самое, что пользователь уже получает. ---
Известные пределы, указанные в предложении
- Режим открытого доступа не может изолировать клиентов одного эскроу. Без каких-либо учетных данных два анонимных вызывающих абонента, отправляющих одну и ту же строку, являются одним и тем же клиентом шлюза. Для изоляции требуется доступ к api_key с одним ключом для каждого клиента.
- Повторы проверки не имеют пространства имен, поэтому префикс выборочного вывода попадает в общее пространство имен на графическом процессоре валидатора. Изоляция является вероятностной с частотой выборки для проверки. Пространство имен по этому пути поместило бы поле, зависящее от версии обслуживающего механизма, в путь консенсуса для условного депонирования, которые никогда не давали согласия.
- Идентификатор сеанса стабилен в течение всего времени существования процесса шлюза и идентичен для каждого участника, который видит запрос, поэтому участник может связывать запросы одного конечного пользователя между моделями. Чтобы удалить это, потребуется деривация для каждого пункта назначения, которую в настоящее время блокирует путь тайм-аута широковещательной передачи.
---
План испытаний
[x] go build ./... зеленый в обоих модулях
- [x] go test -count=1 зеленый: devshard/{cmd/devshardctl,cmd/devshardd/...,host,user}, decentrized-api/{broker,nodemanager,observability}
- [x] перейдите к тестированию - гонка зеленого цвета на путях отслеживания сходства, средства выбора сеанса и брокера.
[x] 66 новых тестов, каждый из которых проверен на мутации; 33 повторно проверено независимо двумя рецензентами
[x] unused/ineffassign/unparam отличаются от базового коммита — новых результатов нет
[x] gofmt очищает каждый файл, которого касается эта ветка
- [ ] bufgenerate — здесь не запускать: этот PR редактирует комментарий в nodemanager.proto и не содержит номеров полей, а сгенерированный файл сопоставляется вручную. Перед слиянием требуется настоящая регенерация.
[ ] /run-интеграция

+1 PR-поддержка и крутые сессии. Предложение:
Хэш HMAC-sha256 слишком медленный/тяжелый/ограниченный (без аппаратного ускорения)
предложение заменить его более быстрым хешем blake3 - дает ускорение в 5-15 раз, blake2b - в 3-8 раз github.com/zeebo/blake3 или lukechampine.com/blake3 - код go. или siphash (hash/mapsah) также хорошо ускоряет сеансы в 10-20 раз. тот же хороший математический разброс, <ins> более быстрый хеш, что важно для быстрой обработки сеансов </ins>.
улучшение2
session_id = hex(HMAC-SHA256(secret, escrow ‖ 0x00 ‖ cred ‖ 0x00 ‖ client)) замените на session_id = hex(BLAKE3.KeyedHash(secret[:32], escrow ‖ 0x00 ‖ cred ‖ 0x00 ‖ client)) .
- Ключ BLAKE3 — это именно PRF/MAC, оболочка HMAC не требуется.
- Секрет по-прежнему составляет 32 байта (как и сейчас).
- Выходные данные можно оставить размером 32 байта (64 шестнадцатеричных) или сократить до 16–24 байт, если размер провода имеет решающее значение.
- Производительность на коротких входах обычно в несколько раз выше, чем у SHA-256.
- Улучшение3.
Это следующий логический уровень: мягкая привязка с учетом географического положения и задержки + группы близких узлов + (потенциально) общий пул KV внутри участника. Его можно наложить поверх текущего дизайна, не нарушая его.
- Вместо чистой классической модели закрепленных сеансов с привязкой по последнему использованному сеансу расширьте эту идею, включив <ins>маршрутизацию на основе географической/сетевой близости/задержки </ins>, назначение узлов группам и учет их рабочей нагрузки/скорости ответа. (Это предполагает добавление маркировки и группировки узлов на основе оборудования.)
Предложение по улучшению: ввести концепцию «группы соответствия» — набора участников, между которыми физически не используется общий кэш KV, но чья задержка для клиента сопоставима (это можно сделать топологически по региону/AZ или измеренному RTT). Часть прохода уже существует (закрепленное попадание → несвязанный → любой совместимый) — добавьте промежуточный проход «любой член группы сродства предпочтительного узла» перед «любым совместимым». Тогда, если конкретный узел недоступен, вы не летите в случайную точку сети, а остаетесь рядом. Это меняет только сборщик (брокер) и не требует изменения логики HMAC/пространства имен — они остаются для каждого узла, что уже правильно с точки зрения модели безопасности PR.

Summary
Inference is routed to a pseudo-random serving node on every call, which is right for fairness but throws away the KV/prefix cache — for multi-turn chat, recomputing the shared prefix is the dominant latency. This PR adds bounded, opt-in session affinity across both routing hops, plus an explicit sharing scope for cached blocks, because concentrating one client's traffic on one GPU without one would widen an existing timing side channel rather than close it.
- Two hops steered, not one. A session returns to the same participant (gateway) and the same mlnode within it (participant broker). Steering only hop 1 leaves the cache cold whenever the participant picks a different GPU.
- Cache namespace bound to authenticated identity. The wire session id is HMAC(process secret, escrow ‖ credential ‖ client string) — a fixed 64-hex digest. The client's own string never leaves the gateway, and guessing another client's string no longer reaches their cached blocks.
- Preference, not reservation. The picker honours stickiness only while the preferred participant can serve now and the request has not aged past 200 ms. Zero nonces are burned and no request waits on a gated, throttled, or idle participant.
- prompt_cache_key actually works. The documented primary key was being stripped before affinity read it, so only user — one value for a whole tenant — was live.
- Bounded twice. Both binding maps are capped at 50 000 entries with an eviction sweep, and both candidate fields are type-checked and capped at 512 bytes at the gateway boundary.
- 66 new tests, each mutation-verified. Two independent reviewers re-broke 21 and 12 guarded lines respectively; every one was caught.
Off by default at both ends — the gateway switch and each participant's switch are independent, and with the gateway switch off no session id is derived at all. ---
Problem → Solution
1. The warm cache is discarded on every turn
Before. The gateway binds each nonce to a participant as nonce mod group_size, and a participant's broker hands each inference to its least-busy mlnode. Neither hop knows about sessions, and the KV cache is per-GPU, so turn *n+1* of a conversation almost always re-prefills the whole prefix from cold. After. A session key read from the request body (prompt_cache_key, fallback user) steers the session back to its last-used participant and, inside that participant, its last-used mlnode, for a bounded window.
2. The cache namespace was derived from a guessable string
Before. The namespace came from the client's own user value. Guess another tenant's user and you land in their cache namespace, where the reported cached-token count tells you which prefixes are resident — the published prompt-cache timing attack (https://arxiv.org/abs/2502.07776), handed a key. After. The gateway derives the wire session id as an HMAC over escrow, the caller's bearer credential, and the client string, under a secret drawn from crypto/rand at start and never persisted. The serving node computes the namespace from its own escrow id and that session id, so a gateway cannot merge two escrows' namespaces. Escrow separates tenants of different escrows; the credential separates authenticated clients within one.
3. Stickiness burned nonces and starved requests
Before. Affinity was implemented by inverting the picker's exclude set — exclude every participant except the sticky one — plus a hold timeout. A committed nonce whose request refused to move is a burn, and burns are escrow money spent on nothing. After. The preference rides into the picker as a preference. Three passes: sticky hit, then unbound, then any compatible. A fresh sticky request never overtakes an unbound one that has waited past the staleness threshold, and a narrower retry is never overtaken at all. Both starvation directions are covered by tests that fail on the pre-fix code.
4. The documented primary key was dead
Before. prompt_cache_key is stripped at PreValidation, and affinity read the normalized body — so the field could never be seen in production. Only user worked, which for an application is one value for its entire traffic: stickiness would pin a whole tenant to one host. The unit test passed because it tested the extractor in isolation, never the shipped path. After. The key is lifted from the already-parsed document *before* the strip — one map lookup, not a second parse of a body that may be 10 MiB. The field keeps being stripped from the wire body, since the serving engine does not honour it. The regression test now drives the real normalization pipeline.
5. One switch did not close the feature, the other closed too much
Before. The participant's switch zeroed the session id *before* the namespace stamp, and it defaults off. In the likeliest field configuration — escrow operator enables affinity, participants run stock config — stickiness worked while nothing was namespaced, leaving the oracle live with only an extra step for the attacker. After. The namespace stamp rides with any non-empty session id; the participant's switch governs mlnode stickiness only, because stickiness changes that operator's own GPU scheduling while isolation protects a client who is not party to the choice. The gateway switch remains the true master: with it off, no session id is derived, so neither hop and no stamp.
6. Retry paths dropped the session
Before. The receipt-challenge path and both gateway timeout payloads built their host payload without the session id, so any re-execution ran the victim's prompt into the shared namespace — giving back exactly the isolation the first attempt bought. After. Every path that re-sends a prompt carries the field, and the two timeout literals were collapsed into one constructor so they cannot drift apart again.
7. Nothing was observable
Before. No metric, no log. An operator enabling the feature could not answer whether it was doing anything. After. Hit/yield/miss counters at both hops plus a binding-map gauge. Hop 2 is reported separately because hop 1 can be working perfectly while the participant lands the session on a different GPU and the cache stays cold either way. ---
Design
session_id = hex(HMAC-SHA256(process secret, escrow_id ‖ 0x00 ‖ bearer credential ‖ 0x00 ‖ client string))
namespace = sha256(escrow_id ‖ 0x00 ‖ session_id) // computed by the serving node from its own escrow id
The 0x00 join is unambiguous. Escrow ids are chain-assigned and NUL-free, Go's HTTP server rejects NUL in a header value so a credential cannot contain one, and the only NUL-capable field is last — no boundary can shift. The secret is per-process, not persisted. It is not in the repo, the image, or the environment, so the token cannot be recomputed off-box; without that, the HMAC would be a direct offline oracle for brute-forcing API keys. The cost is that a gateway restart invalidates every binding and the next round runs cold. The request bound was derived, not guessed. The warm-hit share inside a binding is (N-1)/N, so 32 buys 97% against 90% at 10. It is not a safety parameter: validation sampling draws on the inference id and slot shares with the executor removed from the denominator, holding the expected validations per inference at the escrow's rate (10% by default) regardless of who executed. Concentration earns nothing either — the buyer pays its own funds for work performed, and weight comes from PoC, not inference volume. What does bound N is key granularity: with prompt_cache_key a key is one conversation and a high bound only raises variance, but with user one tenant is one key. 32 is chosen for that second case. Both binding lifetimes are round numbers, and the PR says so. A binding is useful only while the serving engine still holds the blocks, which depends on cache capacity over token ingress on that GPU — not knowable in advance. The proposal documents the measurement that settles it: replay one prefix with a growing idle gap and find where the reported cached-token count falls to zero. ---
Validation
Methodology
- Every new test was mutation-verified by its author: break the exact guarded line, confirm the failure, revert.
- Two independent reviewers audited the whole branch against a static diff, and did not take coverage claims on trust — they re-broke 21 and 12 guarded lines respectively. All 33 were caught by the intended test with the intended message.
- Dead-code sweep compared unused findings against the base commit in a separate worktree: the sets are identical, so the branch added none. ineffassign/unparam likewise show no new findings.
What was verified
| Property | How | |---|---| | Signed payload unchanged | Namespace stamp applied after payload verification, inside the execution closure; the stored and canonicalized prompt is the original | | Validator replays without a namespace | Dedicated test; mutation adding one fails it | | Namespace cannot cross escrows | Serving node uses its own escrow id, never the wire's; mutation removing it fails two tests | | No client string or token in logs, metric labels, headers, or responses | Grep of every non-test use plus reviewer sweep; labels are devshard_id/decision/model only | | Client cannot inject its own namespace | Unknown top-level fields are rejected at the gateway | | Both starvation directions | Reproduced pre-fix (0 served against 1731 in 2 s), then covered | | No new races | -race green on the affinity trackers, picker, and broker paths |
What was not measured
No cache-hit or latency numbers. The feature is off by default and has not run under production traffic; claiming a speedup here would be inventing one. The observability added in this PR is what settles it, and the proposal names the specific measurement for the lifetime defaults. ---
Configuration
| Parameter | Default | Knob if you want to … | |---|---|---| | DEVSHARD_AFFINITY_ENABLED | off | turn the whole feature on for this gateway; with it off no session id is derived at all | | DEVSHARD_AFFINITY_MAX_REQUESTS | 32 | trade warm-hit share against how long one key may occupy a host | | DEVSHARD_AFFINITY_TTL_MS | 120000 | match measured cache residency on your GPUs | | DEVSHARD_AFFINITY_MAX_ENTRIES | 50000 | cap the binding map (~14 MB per escrow when full) | | DAPI_MLNODE_AFFINITY_ENABLED | off | let this participant steer sessions back to the same GPU; the namespace stamp does not depend on it | | DAPI_MLNODE_AFFINITY_MAX_REQUESTS | 64 | as above, hop 2 | | DAPI_MLNODE_AFFINITY_TTL_MS | 600000 | as above, hop 2 | | DAPI_MLNODE_AFFINITY_MAX_ENTRIES | 50000 | as above, hop 2 | DAPI_MLNODE_AFFINITY_ENABLED is read by two processes on the participant side — devshardd and the broker — and both must have it set for hop-2 stickiness to happen. All three switches now parse the same way, so a value carrying whitespace from a compose file no longer enables one half and not the other. ---
Observability
- devshard_gateway_affinity_decision_total{devshard_id,decision} — hit when the primary landed on the sticky participant, yielded when a preference existed but the picker served someone else, miss when there was no preference. Silent when the feature is off.
- devshard_gateway_affinity_bindings{devshard_id} — gauge of the hop-1 binding map, for watching cap pressure.
- decentralized_api_mlnode_affinity_decision_total{decision,model} — the hop-2 equivalent.
- No escrow-unbounded, session, or nonce values appear in any label.
- Two fail-closed paths that were silent now log once per process: a session id past the participant's bound, and a token that outgrew the wire format.
---
Rollout
Default unchanged. Both switches ship off. With no client key nothing happens anywhere — no session id, no stickiness, no namespace stamp — which is byte-for-byte today's behaviour. Two independent opt-ins. The gateway operator enables hop 1; each participant enables hop 2 for its own GPUs. Enabling only the gateway still gets namespaced caches and hop-1 stickiness; enabling only a participant does nothing, because no session id arrives. One-line rollback. Unset DEVSHARD_AFFINITY_ENABLED and restart the gateway. Participant bindings age out on their own bound within 10 minutes; no state is persisted anywhere. One client-visible change even while off: a non-string or over-512-byte prompt_cache_key now returns 400 instead of being silently stripped — the same treatment user already gets. ---
Known limits, stated in the proposal
- Open access mode cannot isolate clients of one escrow. With no credential to fold in, two anonymous callers sending the same string are the same client to the gateway. Isolation requires api_key access with one key per client.
- Validation replays are unnamespaced, so a sampled inference's prefix lands in the shared namespace on the validator's GPU. Isolation is probabilistic at the validation-sampling rate. Namespacing that path would put a serving-engine-version-dependent field on the consensus path for escrows that never opted in.
- The session id is stable for the gateway's process lifetime and identical for every participant that sees the request, so a participant can link one end-user's requests across models. Removing this needs a per-destination derivation, which the broadcast timeout path currently blocks.
---
Test plan
[x] go build ./... green in both modules
- [x] go test -count=1 green: devshard/{cmd/devshardctl,cmd/devshardd/...,host,user}, decentralized-api/{broker,nodemanager,observability}
[x] go test -race green on the affinity trackers, session picker, and broker paths
[x] 66 new tests, each mutation-verified; 33 re-verified independently by two reviewers
[x] unused/ineffassign/unparam diffed against the base commit — no new findings
[x] gofmt clean on every file this branch touches
- [ ] buf generate — not run here: this PR edits a comment in nodemanager.proto and no field numbers, and the generated file was matched by hand. Needs a real regen before merge.
[ ] /run-integration

+1 PR support and sticky sessions. Suggestion:
HMAC-sha256 hash is too slow/heavy/limited (non-hw accelerated)
suggestion to replace it with faster blake3 hash - brings 5-15x speed-up, blake2b 3-8x github.com/zeebo/blake3 or lukechampine.com/blake3 - go code. or siphash (hash/mapsah) also good one 10-20x speed up on sessions. same good math spread,<ins> faster hash critical for fast sessions handling </ins>.
improvement2
session_id = hex(HMAC-SHA256(secret, escrow ‖ 0x00 ‖ cred ‖ 0x00 ‖ client)) replace to session_id = hex(BLAKE3.KeyedHash(secret[:32], escrow ‖ 0x00 ‖ cred ‖ 0x00 ‖ client)) .
- BLAKE3 keyed is exactly PRF/MAC, no HMAC wrapper needed.
- Secret is still 32 bytes (as it is now).
- The output can be left at 32 bytes (64 hex) or shortened to 16–24 bytes if the wire size is critical.
- Performance on short inputs is usually several times higher than that of SHA-256.
- Improvment3.
This is the next logical layer: geo-/latency-aware soft affinity + groups of close nodes + (potentially) a shared KV pool within a participant. This can be layered on top of the current design without breaking.
- Instead of the pure classic sticky-session "last-used affinity" model, expand the idea to include <ins>routing based on geographic/network proximity/ latency </ins>, assigning nodes to a groups and taking into account their workload/response speed. (This suggests adding node tagging and grouping based on hardware.)
Improvement proposal: Introduce the concept of an "affinity group"—a set of participants between which the KV cache isn't physically shared, but whose latency to the client is comparable (this can be done topologically by region/AZ or measured RTT). Part of the pass already exists (sticky-hit → unbound → any compatible) - add an intermediate pass "any member of the preferred node's affinity group" before "any compatible." Then, if the exact node is unavailable, you don't fly to a random point in the network, but remain nearby. This only changes the picker (broker) and doesn't require changing the HMAC/namespace logic—they remain per-node, which is already correct from the perspective of the PR security model.
Резюме
Вывод направляется на псевдослучайный обслуживающий узел при каждом вызове, что справедливо для справедливости, но выбрасывает кэш KV/префиксов — для многоповоротного чата перерасчет общего префикса является доминирующей задержкой. Этот PR добавляет ограниченную, добровольную привязку сеансов для обоих участков маршрутизации, а также явную область совместного использования кэшированных блоков, поскольку концентрация трафика одного клиента на одном графическом процессоре без такового расширит существующий боковой канал синхронизации, а не закроет его.
По умолчанию на обоих концах выключено — коммутатор шлюза и коммутатор каждого участника независимы, а при выключенном переключателе шлюза идентификатор сеанса вообще не определяется. ---
Проблема → Решение
1. Теплый тайник сбрасывается на каждом ходу.
Раньше. Шлюз привязывает каждый nonce к участнику как nonce mod group_size, а брокер участника передает каждый вывод его наименее загруженному узлу. Ни один из переходов не знает о сеансах, а кэш KV предназначен для каждого графического процессора, поэтому ход *n+1* диалога почти всегда повторно заполняет весь префикс из холодного состояния. После. Ключ сеанса, считанный из тела запроса (prompt_cache_key, резервный пользователь), направляет сеанс обратно к его последнему использовавшемуся участнику и, внутри этого участника, к его последнему использовавшемуся узлу mlnode для ограниченного окна.
2. Пространство имен кэша было получено из угадываемой строки.
Раньше. Пространство имен получено из собственного пользовательского значения клиента. Угадайте пользователя другого арендатора, и вы попадете в пространство имен его кэша, где сообщенное количество кэшированных токенов подскажет вам, какие префиксы являются резидентными — опубликованная атака по времени с использованием кэша подсказок (https://arxiv.org/abs/2502.07776) передала ключ. После. Шлюз получает идентификатор проводного сеанса в виде HMAC через условное депонирование, учетные данные носителя вызывающего абонента и строку клиента под секретом, полученным из crypto/rand при запуске и никогда не сохраняемым. Обслуживающий узел вычисляет пространство имен на основе своего собственного идентификатора условного депонирования и идентификатора этого сеанса, поэтому шлюз не может объединить два пространства имен условного депонирования. Эскроу разделяет арендаторов разных эскроу; учетные данные разделяют аутентифицированных клиентов внутри одного.
3. Прилипчивость сжигала одноразовые номера и ограничивала запросы
Раньше. Сходство было реализовано путем инвертирования набора исключений средства выбора — исключения каждого участника, кроме прикрепленного, — плюс тайм-аута удержания. Зафиксированный nonce, запрос которого отказался переместить, — это сжигание, а сжигание — это деньги, потраченные на условное депонирование, ни на что. После. Предпочтение попадает в сборщик как предпочтение. Три прохода: липкий удар, затем несвязанный, затем любой совместимый. Новый закрепленный запрос никогда не обгоняет несвязанный запрос, ожидавший превышения порога устаревания, а более узкая повторная попытка вообще никогда не обгоняется. Оба направления голодания покрываются тестами, которые терпят неудачу в коде префикса.
4. Документированный первичный ключ был мертв.
Раньше. Prompt_cache_key удаляется при предварительной проверке, а affinity считывает нормализованное тело — поэтому поле никогда не будет видно в рабочей среде. Работал только пользователь, что для приложения является одним значением для всего его трафика: липкость привязывала бы к одному хосту целый тенант. Юнит-тест пройден, потому что он тестировал экстрактор изолированно, а не путь поставки. После. Ключ извлекается из уже проанализированного документа *до* полосы — один поиск по карте, а не второй анализ тела, которое может занимать 10 МБ. Поле продолжает сниматься с тела провода, поскольку обслуживающий механизм его не учитывает. Регрессионный тест теперь управляет реальным конвейером нормализации.
5. Один переключатель не закрыл функцию, другой закрыл слишком сильно
Раньше. Коммутатор участника обнулил идентификатор сеанса *до* отметки пространства имен, и по умолчанию он отключен. В наиболее вероятной конфигурации поля (оператор условного депонирования включает сходство, участники запускают стандартную конфигурацию) привязка работала, хотя пространство имен ничего не было, оставляя оракул в рабочем состоянии, оставляя злоумышленнику только дополнительный шаг. После. Штамп пространства имен используется с любым непустым идентификатором сеанса; Переключение участника управляет только привязкой к множественному узлу, поскольку привязка изменяет собственное планирование графического процессора этого оператора, в то время как изоляция защищает клиента, который не участвует в выборе. Коммутатор шлюза остается настоящим мастером: если он выключен, идентификатор сеанса не выводится, поэтому нет ни перехода, ни штампа.
6. Пути повторной попытки прервали сеанс
Раньше. Путь получения-вызова и обе полезные нагрузки тайм-аута шлюза создавали полезную нагрузку своего хоста без идентификатора сеанса, поэтому при любом повторном выполнении приглашение жертвы попадало в общее пространство имен, возвращая в точности ту изоляцию, которую удалось получить при первой попытке. После. Каждый путь, который повторно отправляет приглашение, содержит это поле, и два литерала тайм-аута были свернуты в один конструктор, чтобы они не могли снова разойтись.
7. Ничего не наблюдалось
Раньше. Ни метрики, ни журнала. Оператор, включивший эту функцию, не смог ответить, делает ли она что-нибудь. После. Счетчики попаданий/выходов/промахов на обоих прыжках плюс индикатор карты привязки. О переходе 2 сообщается отдельно, поскольку переход 1 может работать идеально, пока участник запускает сеанс на другом графическом процессоре, а кеш в любом случае остается холодным. ---
Дизайн
session_id = hex(HMAC-SHA256(секрет процесса, escrow_id ‖ 0x00 ‖ учетные данные носителя ‖ 0x00 ‖ строка клиента)) namespace = sha256(escrow_id ‖ 0x00 ‖ session_id) // вычисляется обслуживающим узлом на основе его собственного идентификатора условного депонированияСоединение 0x00 однозначно. Идентификаторы условного депонирования назначаются по цепочке и не содержат NUL, HTTP-сервер Go отклоняет NUL в значении заголовка, поэтому учетные данные не могут его содержать, а единственное поле, поддерживающее NUL, является последним — никакая граница не может смещаться. Секрет заключается в каждом процессе, а не сохраняется. Его нет в репозитории, образе или среде, поэтому токен не может быть повторно вычислен вне коробки; без этого HMAC был бы автономным оракулом для подбора ключей API. Цена состоит в том, что перезапуск шлюза делает каждую привязку недействительной, и следующий раунд становится холодным. Привязка запроса была получена, а не угадана. Доля «горячего удара» внутри привязки равна (N-1)/N, поэтому 32 покупает 97% против 90% при 10. Это не параметр безопасности: выборка проверки основывается на идентификаторе вывода и долях слотов, при этом исполнитель удаляется из знаменателя, удерживая ожидаемые проверки на вывод по ставке условного депонирования (10 % по умолчанию) независимо от того, кто выполнил. Концентрация тоже ничего не приносит — покупатель платит собственные средства за выполненную работу, а вес зависит от PoC, а не от объема вывода. Что ограничивает N, так это степень детализации ключа: при использовании Prompt_cache_key ключ — это один разговор, а высокая граница только увеличивает дисперсию, но для пользователя один арендатор — это один ключ. Для второго случая выбрано 32. Оба срока жизни привязки являются круглыми числами, и об этом говорится в PR. Привязка полезна только до тех пор, пока обслуживающий механизм все еще удерживает блоки, что зависит от емкости кэша при поступлении токена на этот графический процессор — неизвестно заранее. В предложении документируется измерение, которое решает проблему: воспроизвести один префикс с растущим интервалом простоя и найти, где сообщаемое количество кэшированных токенов падает до нуля. ---
Валидация
Методология
Что было проверено
| Недвижимость | Как | |---|---| | Подписанная полезная нагрузка без изменений | Штамп пространства имен, применяемый после проверки полезных данных, внутри закрытия выполнения; сохраненное и канонизированное приглашение является оригинальным | | Валидатор воспроизводит без пространства имен | Специальный тест; мутация, добавляющая одну, не получается | | Пространство имен не может пересекать условное депонирование | Обслуживающий узел использует свой собственный идентификатор условного депонирования, а не идентификатор перевода; удаление мутации провалило два теста | | В журналах, метках метрик, заголовках или ответах нет клиентской строки или токена | Grep каждого нетестового использования плюс проверка рецензента; метки — только devshard_id/decision/model | | Клиент не может внедрить собственное пространство имен | Неизвестные поля верхнего уровня отклоняются на шлюзе | | Оба направления голодания | Воспроизведен префикс (0 подан против 1731 за 2 с), затем закрыт | | Никаких новых рас | -гонка зеленого цвета на путях отслеживания, выбора и брокера |
Что не было измерено
Никаких показателей попаданий в кеш или задержек. По умолчанию эта функция отключена и не работает при рабочем трафике; утверждать, что здесь есть ускорение, значит изобретать его. Наблюдаемость, добавленная в этот PR, является тем, что решает проблему, и в предложении указаны конкретные измерения для значений по умолчанию за весь срок службы. ---
Конфигурация
| Параметр | По умолчанию | Ручка, если хотите… | |---|---|---| | DEVSHARD_AFFINITY_ENABLED | выключен | включите всю функцию для этого шлюза; если он отключен, идентификатор сеанса вообще не выводится | | DEVSHARD_AFFINITY_MAX_REQUESTS | 32 | торговля «горячей» долей в зависимости от того, как долго один ключ может занимать хост | | DEVSHARD_AFFINITY_TTL_MS | 120000 | сопоставить измеренную резидентность кэша на ваших графических процессорах | | DEVSHARD_AFFINITY_MAX_ENTRIES | 50000 | ограничить карту привязки (~ 14 МБ на каждое условное депонирование при заполнении) | | DAPI_MLNODE_AFFINITY_ENABLED | выключен | пусть этот участник снова направляет сеансы на тот же графический процессор; штамп пространства имен от этого не зависит | | DAPI_MLNODE_AFFINITY_MAX_REQUESTS | 64 | как указано выше, переход 2 | | DAPI_MLNODE_AFFINITY_TTL_MS | 600000 | как указано выше, переход 2 | | DAPI_MLNODE_AFFINITY_MAX_ENTRIES | 50000 | как указано выше, переход 2 | DAPI_MLNODE_AFFINITY_ENABLED читается двумя процессами на стороне участника — devshardd и брокером — и оба должны быть установлены для того, чтобы произошла липкость hop-2. Все три переключателя теперь анализируются одинаково, поэтому значение, содержащее пробелы из файла компоновки, больше не включает одну половину, а не другую. ---
Наблюдаемость
---
Внедрение
По умолчанию без изменений. Оба переключателя поставляются выключенными. Без ключа клиента ничего нигде не происходит — ни идентификатора сеанса, ни закрепления, ни отметки пространства имен — что является сегодняшним поведением побайтно. Две независимые подписки. Оператор шлюза включает переход 1; каждый участник включает переход 2 для своих собственных графических процессоров. Включение только шлюза по-прежнему обеспечивает кэширование пространства имен и липкость hop-1; включение только участника ничего не дает, поскольку идентификатор сеанса не поступает. Однострочный откат. Снимите настройку DEVSHARD_AFFINITY_ENABLED и перезапустите шлюз. Привязки участников устаревают самостоятельно в течение 10 минут; ни одно государство нигде не сохраняется. Одно изменение, видимое клиенту, даже когда оно выключено: нестроковый запрос Prompt_cache_key размером более 512 байт теперь возвращает 400 вместо того, чтобы автоматически удаляться — то же самое, что пользователь уже получает. ---
Известные пределы, указанные в предложении
---
План испытаний
[x] go build ./... зеленый в обоих модулях
[x] 66 новых тестов, каждый из которых проверен на мутации; 33 повторно проверено независимо двумя рецензентами
[x] unused/ineffassign/unparam отличаются от базового коммита — новых результатов нет
[x] gofmt очищает каждый файл, которого касается эта ветка
[ ] /run-интеграция