Как прикрутить AI к табуретке — Хабр

• Zod• Yup• JSON Schema• Ajv Задача простая: модель должна вернуть объект, который точно подходит под схему. Если нет — либо ретрай, либо fallback-модель. Это критично для продуктов, где результат не может быть «примерным». Часто ответ дальше участвует в бизнес-логике приложения, где ожидается объект с чёткой структурой. Если структура окажется неверной — можно легко получить белый экран. Минимальный Node.js-бэкенд (рабочая заготовка) Вот пример самого маленького сервиса с прокси, нормализацией и схемой. // server.js import express from "express"; import fetch from "node-fetch"; import { z } from "zod"; const app = express(); app.use(express.json()); // схема ожидаемого ответа от модели const ResponseSchema = z.object({ result: z.string(), }); app.post("/api/llm", async (req, res) => { const userInput = req.body.text; const payload = { model: "openai/gpt-4o-mini", messages: [ { role: "system", content: "Return JSON: {\\"result\\": \\"…\\"}" }, { role: "user", content: userInput }, ], }; try { const response = await fetch("<https://api.openrouter.ai/v1/chat/completions>", { method: "POST", headers: { Authorization: `Bearer ${process.env.OPENROUTER_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(payload), }); const data = await response.json(); // нормализация ответа const raw = data.choices?.[0]?.message?.content || ""; const cleanJson = extractJson(raw); // твоя функция парсинга // валидация const parsed = ResponseSchema.parse(cleanJson); res.json(parsed); } catch (err) { console.error("Error:", err); res.status(500).json({ error: "LLM error" }); } }); function extractJson(text) { try { const start = text.indexOf("{"); const end = text.lastIndexOf("}"); return JSON.parse(text.substring(start, end)); } catch { return { result: "" }; // fallback, лучше сделать ретрай } } app.listen(3000, () => console.log("Server started on 3000")); Этот каркас: • получает текст,• отправляет его в модель,• нормализует ответ,• валидирует по схеме,• возвращает чистый JSON. Дальше можно добавить: • кэширование,• ретраи,• fallback-модели,• логирование,• метрики,• ограничения на размер запроса. Промпт-инженерия без магии: как писать промпты, которые работают Когда мы общаемся с ChatGPT, мы пишем промпты в свободной форме — и нас понимают. Если модель что-то не уловит, она уточнит детали и продолжит думать. Но при использовании LLM внутри продуктов всё гораздо сложнее. От ответа напрямую зависит бизнес-логика. И уточнять у пользователя ничего не получится — у нас буквально одна попытка максимально подробно, но при этом ёмко объяснить, что именно нам нужно. Поэтому хорошая промпт-инженерия — это просто хорошая спецификация. System vs User: что куда писать и почему это важно Разделение system/user — это база стабильного поведения модели. Что помещать в system: • строгие правила поведения модели;• формат ответа;• ограничения (без лишнего текста, без пояснений, только JSON и т.п.);• роль и контекст («Ты — сервис, который исправляет текст…»). System — это фундамент стабильного поведения. Он задаётся один раз и не меняется. Что помещать в user: • конкретный запрос пользователя;• данные, которые нужно обработать;• уточнения, относящиеся только к этому запросу;• примеры, если задача может быть неоднозначной. Если говорить по-человечески: • system = правила игры• user = что нужно сделать прямо сейчас Это разделение защищает от ситуаций, когда модель решает: «в этот раз можно не возвращать JSON, давайте просто поговорим». Простой пример: { "model": "google/gemini-2.0-flash", "messages": [ { "role": "system", "content": `Ты — сервис поиска фильмов по актёрам. Пользователь будет присылать только имя актёра. Ты должен вернуть список известных фильмов с его участием. Отвечай строго в JSON-формате: {"actor": "имя актёра", "movies": ["фильм1", "фильм2", …]} Никаких пояснений или текста вне JSON.` }, { "role": "user", "content": "Tom Hanks" } ] } Ожидаемый ответ модели: { "actor": "Tom Hanks", "movies": [ "Forrest Gump", "Saving Private Ryan", "Cast Away", "The Green Mile", "Catch Me If You Can" ] } Ну и дальше в пользовательском интерфейсе вместо Tom Hanks можно подставить, например, результат ввода пользователя. А после ответа от модели уже работать со списком фильмов — например, красиво отобразить их на странице. Структура хорошего промпта Вот проверенная структура: • Кто ты (роль модели)• Что нужно делать• Что НЕ нужно делать• Формат вывода с примером• Правила для неоднозначных случаев• Указание, что пользовательский текст может быть с ошибками• Инструкция не добавлять пояснений и лишних слов Модель чувствует себя гораздо увереннее, когда вы задаёте ей строгий каркас. Пример промпта для получения соджестов в моём приложении: [ { role: "system", content: `You are an autocomplete generator for the English language. Return ONLY a valid JSON object with one key: "suggestions". Value is an array of strings. Definitions: — "start" means index 0 of the suggestion. — "exact match" means the Prefix (trimmed, case-insensitive) is a valid standalone word/lemma in English. HARD CONSTRAINTS: — All suggestions MUST start exactly with the given Prefix (case-insensitive). — If an exact match exists, it MUST be suggestion[0] (echo the word itself). This rule OVERRIDES all other rules (length, popularity, etc.). — CRITICAL: All suggestions MUST be real, existing words in English. DO NOT invent, create, or make up words. Only return words that actually exist in the English vocabulary. If you are unsure if a word exists, do not include it. — Suggestions should be natural and common in real English usage. — Max 30 characters per suggestion. No trailing punctuation. No duplicates. — If nothing reasonable exists, return {"suggestions": []}. SORTING PRIORITY: 1) EXACT MATCH FIRST (if it exists) — the exact word equal to the Prefix (preserve user casing if possible). 2) Then, by highest user-typing likelihood (most common first). SELF-CHECK BEFORE OUTPUT: — Verify every suggestion is a real, existing word in English. Remove any invented or non-existent words. — If an exact match exists and suggestions[0] is not exactly that word, fix it so it is.`, }, { role: "user", content: `Prefix: "${query}" Return a JSON like: {"suggestions": ["…"]}`, }, ]; Скрин из VibeLing: экран поиска слов Он используется на этом экране и возвращает самые вероятные продолжения введённой части слова или фразы, выдавая примерно такой результат: // Пользователь вводит "Краси" // Модель возвращает ответ { suggestions: ["Красивый", "Красить", "Краситель", …] } В начале промпт был простым, а потом постепенно оброс дополнительными правилами. Почему промпт стал таким: • “If an exact match exists, it MUST be suggestion[0] (echo the word itself)” — это правило появилось, когда нужно было ставить существующее слово на первое место. До этого модель, например, ставила “go back” первым, когда я вводил “go”. А нужно было, чтобы было именно “go”, потому что это слово уже существует. • “All suggestions MUST be real, existing words in English” — появилось после того, как модель начала выдумывать слова. Например, я вводил “краси…”, а модель придумывала что-то вроде “красифицировать” или “красивенный”. • “Suggestions should be natural and common in real English usage.” — это правило возникло, когда модель стала подсовывать редкие и малоупотребимые слова, а мне нужны были самые распространённые варианты. И примерно так появились почти все остальные пункты. Почти каждый промпт в приложении прошёл свою эволюцию — от простого запроса до полноценной спецификации. После этого стабильность выросла почти до идеала. Как бороться с галлюцинациями Несколько реальных методов, которые действительно помогают: 1. Явное запрещение творчества «Do not add anything not present in the input.»«Do not invent facts.»«If unsure — say you are unsure.» 2. Чёткие правила поведения при сомнении Например: «If the text is unclear, choose the most likely meaning used by native speakers.» Если модель сомневается, она может предпочесть вернуть пустой результат, потому что не любит ошибаться. Поэтому нужно явно указать ей, что делать в таких ситуациях. 3. Дублирование ключевых инструкций Модели хорошо реагируют на повторение. Если что-то критично — скажите это дважды. 4. Примеры “плохого” и “хорошего” ответа Если дать явные примеры, то это сильно повышает точность. 5. Сужение задачи Чем уже задача, тем меньше шансов, что модель начнёт фантазировать. В том же примере с саджестами из скрина выше переводы этих саджестов я вынес в отдельный запрос. Потому что модель сильно тупила, когда ей нужно было и сгенерировать саджесты с вероятным продолжением слова, и одновременно перевести их в одном промпте. Как снизить затраты Мне пока сложно судить о затратах, потому что приложением пользуется меньше 1000 человек, и за месяц уходит всего 1-2 доллара. Но у меня есть пару советов, как максимально сэкономить на токенах. 1. Умное кэширование Пользователи часто отправляют похожие запросы. И если проанализировать промпты, может оказаться, что большая часть запросов — это повторяющиеся действия. В таких случаях простой Redis творит чудеса. 2. Выбор моделей под сценарии, а не «лучшую модель всегда» Это распространённая ошибка: отправлять всё в одну самую дорогую модель. Правильнее делить: • лёгкие запросы → дешёвые модели• сложные, требующие логики → более дорогие• структурные задачи → наиболее стабильные• длинные тексты → модели с большим контекстом Комбинация = оптимальная цена. Source: https://habr.com/ru/articles/975512/