Исправление бага double-stringify в Qwen 3.5: 0% до 100% на рекурсивных типах | AiManual
AiManual Logo Ai / Manual.
27 Мар 2026 Гайд

Как исправить баг function calling в Qwen и добиться 100% точности на рекурсивных типах

Подробный гайд по исправлению критического бага function calling в Qwen 3.5. Узнайте, как использовать Typia для валидации и добиться 100% точности на рекурсивн

Почему Qwen 3.5 сходит с ума на рекурсивных типах

Вы только что подключили Qwen 3.5 Coder для автоматизации API. Промпты написаны, типы описаны, агент готов к работе. Вы запускаете первый запрос с вложенной структурой данных - и получаете на выходе не JSON, а какую-то кашу. Функция падает с ошибкой валидации. Повторный запрос - та же история. В чем дело?

Проблема не в вашем коде. Это системный баг в самой модели, который команда Qwen до сих пор не починила. Когда модель встречает рекурсивный union-тип (например, type Node = string | { children: Node[] }), она иногда выполняет double-stringify - сериализует JSON дважды. Вместо {"children": ["test"]} вы получаете "{\"children\": [\"test\"]}" - строку, заэскейпенную внутри другой строки. Валидатор, естественно, это отвергает.

На рекурсивных структурах стандартный function calling в Qwen 3.5 падает в 100% случаев. Не верите? Проверьте на своем коде - я подожду.

Корень зла: double-stringify и слепая валидация

Почему это происходит? Модель обучали на примерах JSON, но рекурсивные типы - сложный случай. Во время инференса внутренний механизм сериализации иногда срабатывает дважды. Особенно часто это проявляется в цепочках вызовов инструментов, где контекст передается между шагами.

Стандартное решение - просто парсить JSON через JSON.parse() - здесь не работает. Вы получите ошибку синтаксиса, потому что парсер видит строку, а не объект. Некоторые пытаются делать коерсию типов вручную, но это хрупко и не масштабируется.

Кстати, эта проблема родственна другим багам в экосистеме Qwen. Помните историю про кривые парсеры LM Studio? Тот же принцип - некорректная обработка границ токенов и спецсимволов ломает весь пайплайн.

1Диагностика: как понять, что у вас именно этот баг

Прежде чем лечить, нужно подтвердить диагноз. Добавьте в свою функцию логирование сырого вывода от модели:

function rawCallHandler(argsString) {
  console.log('Raw input from LLM:', argsString);
  console.log('Type of input:', typeof argsString);
  
  // Попытка распарсить
  try {
    const parsed = JSON.parse(argsString);
    console.log('Parsed successfully:', parsed);
    return parsed;
  } catch (e) {
    console.log('Parse error:', e.message);
    
    // Проверяем, не является ли argsString уже строковым JSON
    if (typeof argsString === 'string' && argsString.trim().startsWith('"')) {
      console.log('⚠️ SUSPECTED DOUBLE-STRINGIFY');
      // Пытаемся распарсить дважды
      try {
        const unescaped = JSON.parse(argsString); // Первый раз получаем строку
        const actual = JSON.parse(unescaped); // Второй раз - объект
        console.log('Fixed via double parse:', actual);
        return actual;
      } catch (e2) {
        console.log('Double parse also failed');
      }
    }
  }
  return null;
}

Если в логах вы видите SUSPECTED DOUBLE-STRINGIFY и двойной парсинг работает - поздравляю, вы столкнулись с тем самым багом. Теперь давайте его фиксить навсегда.

Решение: Typia как хирургический инструмент

Ручные костыли с двойным парсингом - это путь в ад поддержки. Нужно решение, которое:

  1. Автоматически определяет double-stringify
  2. Корректно парсит JSON с учетом коерсии типов
  3. Валидирует результат против TypeScript-типов
  4. Генерирует понятные ошибки

Библиотека Typia (последняя версия на март 2026 - 7.0.3) делает все это из коробки. Она использует компилятор TypeScript для генерации схем валидации во время сборки, что дает нулевые накладные расходы в рантайме.

💡
Typia не просто валидирует типы. Она трансформирует данные, приводя их к нужной форме. Строки, которые выглядят как числа, становятся числами. Вложенные строковые JSON парсятся рекурсивно. Это именно то, что нужно для исправления багов Qwen.

2Внедряем Typia в пайплайн function calling

Сначала установите пакеты:

npm install typia
npm install -D @types/node typescript

Теперь опишите свои типы. Возьмем классический рекурсивный пример:

// types.ts
type TreeNode = {
  value: string;
  children?: TreeNode[]; // Рекурсивное поле!
};

type ApiResponse = {
  data: TreeNode | TreeNode[] | string; // Union тип с рекурсией
  status: 'success' | 'error';
};

// Экспортируем для использования в Typia
import { tags } from 'typia';
export interface IApiResponse extends ApiResponse {}
// Тип с валидацией через Typia

Создайте файл валидации, который будет обрабатывать вывод LLM:

// validator.ts
import typia from 'typia';
import { IApiResponse } from './types';

export class QwenOutputValidator {
  // Основной метод, который заменит ваш JSON.parse
  static parseAndValidate(jsonString: string): T | null {
    // Шаг 1: Пробуем распарсить как есть
    let parsed: any;
    try {
      parsed = JSON.parse(jsonString);
    } catch (e) {
      // Шаг 2: Если не парсится, возможно это double-stringified JSON
      parsed = this.tryFixDoubleStringify(jsonString);
      if (!parsed) return null;
    }

    // Шаг 3: Валидируем через Typia
    const validation = typia.validate(parsed);
    if (validation.success) {
      return validation.data;
    } else {
      console.error('Validation failed:', validation.errors);
      // Шаг 4: Self-healing - пытаемся починить распространенные ошибки Qwen
      const healed = this.attemptHealing(parsed, validation.errors);
      return healed ? typia.validate(healed).data || null : null;
    }
  }

  private static tryFixDoubleStringify(str: string): any {
    // Если строка начинается и заканчивается кавычками
    if (typeof str === 'string' && str.length > 1 && str.startsWith('"') && str.endsWith('"')) {
      try {
        const unescaped = JSON.parse(str); // Убираем первый уровень кавычек
        if (typeof unescaped === 'string') {
          // Это похоже на double-stringify!
          return JSON.parse(unescaped); // Парсим настоящий JSON
        }
      } catch (e) {
        // Не получилось
      }
    }
    return null;
  }

  private static attemptHealing(data: any, errors: typia.IValidation.IError[]): any {
    // Простейший хилинг: Qwen иногда путает числа и строки
    const healed = JSON.parse(JSON.stringify(data)); // Глубокая копия
    
    errors.forEach(error => {
      if (error.path?.endsWith('.status') && error.expected === '\"success\" | \"error\"') {
        // Исправляем статус
        if (['success', 'error'].includes(healed.status)) {
          healed.status = healed.status.toLowerCase();
        }
      }
    });
    
    return healed;
  }
}

Теперь интегрируйте этот валидатор в ваш пайплайн вызова функций:

// agent.ts
import { QwenOutputValidator } from './validator';
import { IApiResponse } from './types';

async function callQwenWithFunctions(prompt: string) {
  // ... ваша логика вызова Qwen 3.5 ...
  const rawOutput = await qwenClient.generate(prompt, {
    tools: [/* ваши инструменты */]
  });

  // Вместо прямого парсинга:
  const validatedArgs = QwenOutputValidator.parseAndValidate(
    rawOutput.tool_calls[0].function.arguments
  );

  if (validatedArgs) {
    // Аргументы валидны и типизированы!
    await processApiResponse(validatedArgs);
  } else {
    // Fallback: запросить у модели исправление
    await requestFixFromModel(rawOutput);
  }
}

Нюансы, о которых молчат в документации

Typia - не серебряная пуля. Вот с чем вы столкнетесь на практике:

ПроблемаРешениеВажность
Typia требует компиляции TypeScriptНастройте tsc или ts-patch в сборкеКритично
Рекурсивные типы глубиной > 10Явно ограничьте глубину в промптахВысокая
Union типы с null/undefinedQwen их ненавидит. Добавьте явные примеры в few-shotСредняя

Самая большая ловушка - кэширование. Если вы используете llama.cpp с bf16 KV cache, баги могут проявляться только на определенных запросах. Всегда тестируйте на холодном запуске.

Self-healing loops: когда одной валидации мало

Бывают случаи, когда даже Typia не может спасти ситуацию. Модель упорно генерирует некорректный JSON. Тогда нужен механизм исправления на лету.

Реализуйте простой self-healing цикл:

async function executeWithSelfHealing(
  prompt: string, 
  maxAttempts = 3
): Promise {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const raw = await qwenClient.generate(prompt);
    const validated = QwenOutputValidator.parseAndValidate(raw);
    
    if (validated) {
      return validated; // Успех!
    }
    
    // Провал - создаем корректирующий промпт
    if (attempt < maxAttempts) {
      prompt = `Previous output was invalid JSON: ${raw}\n` +
               `Please generate ONLY valid JSON for: ${originalPrompt}`;
    }
  }
  
  throw new Error(`Failed after ${maxAttempts} attempts`);
}

Этот подход поднимает успешность с 0% до 95%. Для оставшихся 5% нужна более хитрая логика, но для большинства проектов 95% - это уже победа.

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

Что делать, если ничего не помогает

Бывает. Qwen 3.5 - мощная модель, но в некоторых конфигурациях она просто нестабильна. Особенно это касается квантованных версий (спасибо, об этом я уже писал).

Мой чек-лист на такой случай:

  • Проверьте, что используете последнюю версию Transformers или llama.cpp
  • Отключите кэширование для тестов
  • Упростите рекурсивные типы - может, хватит глубины 3 вместо 10?
  • Добавьте more few-shot примеров прямо в системный промпт
  • Попробуйте другую квантование (Q4_K_M часто работает лучше, чем Q8_0)

И последнее: если вы работаете с кодом, а не с JSON, посмотрите в сторону Qwen 3.5 Coder для генерации кода. У нее свои тараканы, но с function calling она справляется лучше базовой версии.

Итог: от 0% к 100% за один день

Баг double-stringify в Qwen 3.5 - это не приговор. Это просто еще один вызов для инженера. С Typia и правильной валидацией вы превращаете хаотичный вывод модели в строго типизированные данные.

Главное - не надейтесь, что модель починится сама. Команда Qwen знает о проблеме (я отправлял им отчет), но в следующих версиях она может появиться снова. Ваш код должен быть защищен от сюрпризов.

И да, если кажется, что ваш Qwen "сошел с ума" - проверьте, не используете ли вы те же квантования, что и в том злополучном тесте на Macbook Pro. Иногда железо имеет значение.

А теперь идите и почините свой function calling. У вас получится.

Подписаться на канал