Почему 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 как хирургический инструмент
Ручные костыли с двойным парсингом - это путь в ад поддержки. Нужно решение, которое:
- Автоматически определяет double-stringify
- Корректно парсит JSON с учетом коерсии типов
- Валидирует результат против TypeScript-типов
- Генерирует понятные ошибки
Библиотека Typia (последняя версия на март 2026 - 7.0.3) делает все это из коробки. Она использует компилятор TypeScript для генерации схем валидации во время сборки, что дает нулевые накладные расходы в рантайме.
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/undefined | Qwen их ненавидит. Добавьте явные примеры в 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. У вас получится.