Свежий репорт: неудачный вызов становится проблемой следующего запуска
Поводом для разбора стала дискуссия №4387 в репозитории deepseek-ai/deepseek-harness. Её автор описывает среду с deepseek-harness 0.1.1-rc.2, Windows 11 и OpenAI-совместимым gateway. В этой конфигурации один из streaming-чанков, по утверждению автора, пришёл с пустым id и привёл к записи tool/result, в которой source.callId и toolCallId равны пустой строке. Это пользовательское сообщение, а не независимый тест DeepSeek: в доступных материалах нет публичного ответа мейнтейнера, merged PR или релизной заметки об исправлении.
Другой пользовательский репорт, опубликованный в тот же день, описывает сходный результат: сессии с затронутыми событиями перестают возобновляться. Два сообщения в GitHub не позволяют судить о распространённости дефекта, количестве затронутых инсталляций или утверждать, что некорректное поле появляется именно на официальном endpoint DeepSeek. В описаниях упоминаются совместимые шлюзы и адаптеры, и это существенно ограничивает выводы о первопричине.
Кейс важен не только как пример единичного некорректного ответа API. В документации DeepSeek function calling и потоковая передача входят в рабочий контур взаимодействия модели с внешними действиями. Когда агент должен переживать перезапуск, журнал перестаёт быть факультативным архивом и становится входными данными следующего шага.
- Факт: 24 августа опубликован пользовательский bug report о SessionPersistenceCorruptionError после пустого callId.
- Подтверждено кодом: в текущем репозитории существует путь, при котором пустая строка может сохраниться, а затем быть отвергнута загрузчиком.
- Не подтверждено: что официальный DeepSeek API систематически возвращает пустой id или что проблема уже исправлена.
Почему идентификатор вызова не техническая мелочь
В агентном цикле модель может запросить инструмент: поиск по базе, выполнение кода, обращение к CRM, чтение файла или другое внешнее действие. callId связывает части одной операции: блок вызова в сообщении ассистента, событие tool/call и результат tool/result. По этому идентификатору система определяет, какой результат относится к какому запросу модели. Без такой связи история становится набором похожих, но семантически неоднозначных записей.
В streaming-режиме ответ собирается из последовательности delta-чанков. Это ускоряет начало работы и удобно для интерфейса, но требует аккуратно собирать состояние: ранний чанк может содержать ID и имя инструмента, а поздние содержать только фрагменты аргументов. Адаптер, gateway или провайдер способны нарушить ожидаемую форму delta, например передать пустую строку там, где поле должно отсутствовать. Для программы пустая строка и отсутствие значения являются разными состояниями: первое нередко трактуется как конкретное, хотя и недопустимое значение, второе запускает механизм значения по умолчанию.
На этой границе проявляется инженерная деталь. В сборщике BlockAssembler идентификатор присваивается безусловно: partial.toolCallId = chunk.id. Для имени инструмента предусмотрена проверка truthy-значения, а для ID такой защиты нет. Поэтому поздний chunk.id со значением пустой строки, пришедший после корректного ID, может затереть уже собранную идентичность вызова.
- callId является ключом связи между запросом инструмента и его результатом, а не служебной подписью.
- Streaming требует различать ситуации «поле не пришло» и «пришла пустая строка».
- Потеря ID опасна прежде всего для состояния агента, а не для отображения одного ответа в интерфейсе.
Как пустая строка проходит через сборщик
После присваивания chunk.id сборщик формирует итоговый ID с fallback-выражением partial.toolCallId ?? CallId(call-${index}). Оператор ?? подставляет запасное значение только для null или undefined. Пустая строка не относится ни к одному из этих случаев, поэтому синтетический call-${index} не создаётся. Это не предположение о намерениях разработчиков, а наблюдаемое следствие семантики кода.
Цепочка состоит из трёх шагов. Сначала обычный streaming-чунк приносит непустой ID. Затем последующий дефектный чунк перезаписывает его на пустую строку. Наконец, fallback не срабатывает: в JavaScript пустая строка остаётся определённым значением. В результате агентный цикл получает tool-call block с пустым ID, хотя в начале потока идентичность вызова могла быть корректной.
Этим объясняется, почему проблема особенно заметна в интеграциях через OpenAI-совместимые прослойки. Документация DeepSeek заявляет совместимость API-форматов, однако совместимость endpoint не означает, что каждый прокси, SDK и streaming-адаптер точно воспроизводит все особенности промежуточных чанков. Проверять следует не только финальный объект tool call, но и последовательность событий, из которой он был собран.
- Защита должна сохранять ранее валидный ID, если поздний чунк содержит пустое значение.
- Fallback на synthetic ID нужен и для пустой строки, а не только для null и undefined.
- Это интеграционный риск на границе provider, gateway, адаптера и Harness; он не доказывает сбой одного конкретного участника цепочки.
Из runtime-сбоя в durable state: где возникает «отравление» журнала
Далее ошибка перестаёт быть кратковременной. В agent loop block.id напрямую используется при записи вызова инструмента и создании его результата. Между сборкой streaming-ответа и сохранением событий пустое значение не нормализуется. Если инструмент не найден или завершается с ошибкой, в журнал всё равно может попасть tool/result с пустым callId; именно такой фрагмент приведён в пользовательском отчёте. JSON из дискуссии следует считать иллюстрацией автора, а не официальным fixture проекта.
Persistence в Harness устроен как долговечный append-only event log: журнал, в который события последовательно добавляются. Документация отдельно различает оборванный хвост незавершённой записи и уже подтверждённую повреждённую запись. Первый случай может быть отброшен при восстановлении; второй нельзя молча игнорировать, поскольку пропуск события способен изменить смысл истории агента. Такая строгость оправданна: нельзя незаметно потерять результат инструмента и считать последующее состояние достоверным.
Строгая политика чтения безопасна лишь при симметричной политике записи. Если writer сохраняет форму, которую reader затем отвергает, коммит в журнал становится точкой невозврата. В этом и состоит главный практический вывод из репорта: persistence не создаёт дефект, но делает его долговечным, перенося отказ с текущего вызова на будущую попытку resume.
- Runtime-ошибку ещё можно обработать в текущем запуске; повреждённый committed log влияет и на будущие запуски.
- Проблему нельзя решить молчаливым пропуском tool/result: это разрушит причинную связь в истории агента.
- Критический дефект заключается в асимметрии write/read-инвариантов, а не в самом факте строгой валидации.
Цитата в переводе«Автор записи принимает то, что его же читатель отвергает.»
Почему resume, replay и расследование ломаются одновременно
Валидация сессии требует, чтобы у tool/result значение source.kind было равно tool, source.callId представлял собой непустую строку, а ID в контенте результата совпадал с ID источника. Если форма не соблюдена, загрузчик выбрасывает ошибку с сообщением message must have tool source. Поэтому запись с пустым callId, уже попавшая в историю, не считается второстепенным дефектом, который можно безопасно пропустить.
Последствия не ограничиваются неработающей кнопкой «продолжить». Resume невозможен, поскольку новый агентный процесс не получает прежний контекст. Replay затруднён: штатный путь не восстанавливает цепочку сообщений и результатов. Ухудшается и расследование: команде сложнее отделить сбой модели от ошибки шлюза, несовместимости SDK, неверного вызова инструмента или дефекта сериализации. Байты файла при этом не обязательно утрачены, но для официального загрузчика история остаётся недоступной.
Автор дискуссии №4387 сообщает, что смог открыть журнал после ручной замены пустых значений на синтетический непустой ID в нескольких связанных местах. Это полезный сигнал для диагностики, но не готовая процедура восстановления. Ручной ремонт меняет доказательную историю исполнения. Кроме того, замена ID в одном tool/result не решит проблему, если в связанных событиях указаны другие идентификаторы.
- «Неразгружаемая» сессия не означает автоматически безвозвратно удалённую сессию.
- Штатное восстановление блокируется до того, как агент сможет использовать сохранённый контекст.
- Ручное редактирование журнала допустимо только как аварийная операция с резервной копией и журналированием изменений.
Цитата в переводе«Сессии с затронутыми событиями становятся навсегда невозобновляемыми.»
Строгий loader прав, но писать надо ещё строже
На первый взгляд решение очевидно: разрешить пустой callId при чтении, чтобы не блокировать всю сессию. Это слабое и потенциально опасное исправление. Пустой ключ не восстанавливает связь между вызовом и результатом: он лишь заставляет загрузчик принять историю с утраченной семантикой. Для агента, который использует результаты инструментов в последующих решениях, такое «восстановление» может оказаться хуже честного отказа.
Другой распространённый совет состоит в генерации нового ID при ошибке. Он полезен лишь при сохранении целостности всей связанной группы событий. Synthetic ID должен единообразно появиться в assistant tool-call block, tool/call, tool/result и во всех полях, которые сравнивает loader. Если добавить его только в результат инструмента, проверка согласованности всё равно завершится ошибкой или, что хуже, создаст ложную связь.
Более надёжный подход начинается раньше: на этапе сборки пустой поздний delta не должен перетирать валидный ID. Перед commit в persistent log следует проверять те же инварианты, что и при restore. Такая write-time validation не делает внешние протоколы идеальными, но не позволяет плохому событию пережить процесс и стать скрытой миной при следующем запуске.
- Не ослабляйте read-side validation без механизма сохранения семантической связи.
- Проверяйте ID до записи в журнал по тем же правилам, что и при загрузке.
- Синтетический ID допустим как предохранитель, если он согласованно применяется ко всем связанным событиям.
Что проверить командам, работающим с DeepSeek и совместимыми API
Первое правило для production-контуров: streamed tool-call event не следует считать валидным только потому, что он пришёл по сети. До записи нужно проверять непустой ID, наличие имени инструмента там, где оно обязательно, а также соответствие toolCallId полю source.callId в результате. Тестировать нужно не только «счастливый путь», но и последовательности delta: валидный ID, пустой ID, отсутствующее поле, null, повторный ID и несколько параллельных вызовов.
Второе правило состоит в разделении raw stream и канонического event log. Сырые чанки нужны для отладки провайдера или gateway: они показывают, что фактически пришло в поток. Однако хранить их как единственную основу для resume рискованно. Канонический журнал должен содержать нормализованные и проверенные события; событие, не соответствующее контракту, лучше направить в quarantine с диагностикой, чем коммитить в историю агента. Это сохранит материал для расследования, не превращая его в обязательную часть будущего контекста.
Третье правило относится к поставщикам совместимых интерфейсов. DeepSeek документирует OpenAI- и Anthropic-совместимые способы работы с function calling, однако каждой команде следует запускать контрактные тесты на собственной связке: модель, endpoint, прокси, SDK, версия Harness и набор инструментов. Российским командам этот чек-лист нужен в той же мере, если они используют корпоративный прокси, self-hosted gateway или локальный слой оркестрации. Публичных данных о подтверждённых production-инцидентах в России в предоставленных материалах нет.
- Логируйте raw streaming-чанки отдельно от нормализованной истории сессии.
- Добавьте pre-commit проверку пустых строк, а не только null и undefined.
- Проверяйте resume после ошибок инструмента, а не только после успешных tool calls.
- Храните резервные копии логов и фиксируйте любые ручные repair-операции.
Как должен выглядеть устойчивый ремонт
Для самого Harness приоритетным выглядит исправление на границе сборки: пустое значение из позднего чанка не должно заменять ранее подтверждённый непустой ID. Если ID отсутствует с начала потока, система может создать synthetic identifier, однако до сохранения обязана распространить его по всем связанным записям. Нужны и тесты для четырёх состояний поля: отсутствует, null, пустая строка и непустая строка. Это инженерная рекомендация, следующая из опубликованных правил кода, а не объявленный план DeepSeek.
Следующий уровень защиты состоит в транзакционной границе вокруг persistence. Некорректное событие не должно становиться committed record без явного решения системы: нормализации, отклонения или помещения в quarantine. Для уже повреждённых логов полезен offline repair-инструмент: он мог бы строить отчёт о несогласованных связях, создавать резервную копию и предлагать контролируемое восстановление из последней согласованной контрольной точки. В рассмотренной документации такой механизм не заявлен, поэтому утверждать о его наличии нельзя.
Нужен и ясный операционный статус ошибки. Сообщение SessionPersistenceCorruptionError не должно выглядеть как абстрактный сбой загрузки. Оператору полезно видеть ID сессии, смещение или номер повреждённого события, тип нарушения, путь к резервной копии и безопасный режим экспорта raw-данных. Строгую валидацию это не отменяет, но сокращает время расследования и не оставляет команду один на один с неоткрываемым журналом.
- Предотвращайте перезапись валидного ID пустым значением на этапе assembler.
- Проверяйте событие до commit по тому же контракту, который применяется при resume.
- Предусмотрите quarantine и offline repair для старых журналов, не выдавая их за исходную неизменённую историю.
Что это означает на практике
Надёжность агентной системы определяется не только качеством ответа модели, но и тем, как платформа переживает несовершенные события на границе протоколов. Публичный репорт пока не доказывает массовый дефект и не позволяет связывать его с официальным DeepSeek API. Однако исходный код подтверждает инженерный урок: если система записывает некорректную связь между вызовом инструмента и его результатом, строгая загрузка закономерно превратит ошибку потока в отказ resume.
Цель не в том, чтобы научить loader принимать любые данные. Нужно не допускать семантически невалидные события в долговечный журнал, сохранять сырой материал для расследования и иметь контролируемый путь восстановления уже повреждённых сессий.
- Сопоставьте write-time и read-time валидацию: событие, которое loader не примет, не должно попадать в committed event log.
- В streaming-сборщике не перезаписывайте корректный callId пустой строкой из позднего delta; различайте undefined, null и пустую строку.
- Генерируйте synthetic ID последовательно для всей цепочки tool call и tool result. Частичный ремонт одной записи создаёт новые несоответствия.
- Храните raw stream отдельно, а в каноническую историю записывайте нормализованные события. Некорректные записи направляйте в quarantine.
- Добавьте интеграционные тесты resume после ошибочного tool call для каждого gateway и OpenAI-совместимого адаптера в вашей инфраструктуре.
- Не редактируйте журналы вручную без резервной копии: это может помочь диагностике, но меняет историю исполнения.
Источники
- DeepSeek Harness GitHub Discussion №4387: пользовательский bug report о пустом callId и недоступной сессии
- DeepSeek Harness GitHub Discussion №4385: пользовательский репорт о невозобновляемых сессиях
- Документация DeepSeek API: Function Calling
- DeepSeek Harness: BlockAssembler
- DeepSeek Harness: валидация и загрузка сессии
- DeepSeek Harness: обработка tool calls в agent loop
- DeepSeek Harness: документация session persistence
Практический чек-лист
Чек-лист: как защитить сессии агента от пустого callId
Материал предназначен для отдельной выгрузки в downloads/checklist.pdf. В DOCX чек-лист следует оформить штатными заголовками и маркированными списками Word без ссылки на файл.
До записи в журнал
- Проверить, что идентификатор tool call является непустой строкой.
- Не считать пустую строку эквивалентом отсутствующего значения.
- Не позволять позднему streaming-чанку перезаписывать ранее валидный ID пустым значением.
- Проверить соответствие toolCallId и source.callId в результате инструмента.
- Применять к событию перед commit те же инварианты, которые проверяет loader при restore.
Для streaming и интеграций
- Протестировать последовательности: валидный ID, пустой ID, отсутствующее поле, null, повторный ID и несколько параллельных вызовов.
- Проверять resume после ошибки инструмента, а не только после успешного tool call.
- Запускать контрактные тесты для собственной связки модели, endpoint, прокси, SDK, версии Harness и набора инструментов.
- Логировать raw streaming-чанки отдельно от нормализованной истории сессии.
При инциденте
- Не ослаблять read-side validation только ради открытия журнала.
- Сделать резервную копию до любой ручной repair-операции.
- Фиксировать все изменения, внесённые в журнал вручную.
- Не исправлять один tool/result изолированно: проверить связанные tool-call события и поля, которые сопоставляет loader.
- Направлять некорректные события в quarantine с диагностикой, а не коммитить их в каноническую историю.
Вопросы и ответы
Доказано ли, что официальный DeepSeek API возвращает пустой callId?
Нет. В доступных материалах есть пользовательские репорты, где упомянуты OpenAI-совместимые gateway и адаптеры. Они не позволяют установить, на каком участке цепочки появляется некорректное значение.
Почему пустой callId мешает восстановить сессию?
Loader требует непустой идентификатор и согласованность ID между вызовом инструмента, источником и результатом. Запись с пустым значением не проходит эту проверку, поэтому штатный resume прекращается до загрузки контекста.
Можно ли просто разрешить пустой callId при чтении журнала?
Это небезопасно: пустой ключ не восстанавливает семантическую связь между вызовом и результатом. Правильнее не допускать такое событие до commit и проверять запись тем же контрактом, что применяется при restore.
Как снизить риск в интеграции с DeepSeek Harness?
Нужно отдельно хранить raw stream и канонический event log, проверять непустой ID до записи, тестировать валидный ID, пустую строку, отсутствующее поле и null, а также проверять resume после ошибок инструментов.