PunchOndo orders removal of abandoned heavy vehicles, equipment on roadsCNN TürkFransa'da grev dalgası: Ülke genelinde sokaklara döküldülerDaily MaverickROVING REPORTERS: Inside the Gauteng caves where rocks reveal the mysteries of Homo nalediThe Jerusalem PostIran receives US feedback on seven-day trust-building plan, main issue is sequencingInquirerHouse prosecution to present Duterte’s bank records within the weekCapital FMIsrael-bound flight diverted after fight between pilotsObservador DesportoPortugal regista 2.º maior número de expulsões na UEABC NewsLast US troops expected to leave Iraq on Wednesday following 12-year ISIS fightColliderThe 10 Best Slice-of-Life Books, RankedThe GuardianIsrael-bound passenger plane makes emergency landing in Saudi Arabia after reported fight between pilotsNotJustOkFor Me Lyrics by FidoStraits Times SportForever our champion: Gym pays tribute to S’porean muay thai fighter who died after bout
The Daily Newsstand · Free, Always
Wednesday, September 30, 2026

Подарок брату до $70 к пятнице — как я собрал помощника магазина на локальной 9B-модели

Translate

Покупатель пишет в чат магазина «подарок брату, ему 30, любит настолки, до $70». Помощник спрашивает, когда у брата день рождения. Покупатель отвечает, что в пятницу. До пятницы 5 рабочих дней, а по правилам магазина в этом случае на заказ дается скидка 20%. Поэтому в $70 влезли 3 подарка на $83.99, со скидкой вышло $67.19.

Отвечает покупателю qwen 9B, а сумму, скидку и корзину считает обычный код. Исходники с прогонами лежат в опенсорсе.

Привет, Хабр!

Собрал помощника для магазина подарков. Модель выясняет у покупателя, кому подарок, на какую сумму и к какому дню. Потом выбирает из подходящих товаров лучшие и объясняет, почему они.

Товары, которые не подходят, отсеивает код. Также он считает сумму заказа со скидкой и кладет товары в корзину. Проверил на 6 покупателях — ни разу не было, чтобы помощник вышел за бюджет или назвал не ту сумму.

Магазин для статьи сделал моковый, чтобы его можно было скачать и запустить у себя. С магазином помощник общается двумя запросами. Первый ищет товары с фильтрами по возрасту, цене и сроку доставки, второй считает сумму заказа. Поэтому к настоящему интернет-магазину или агрегатору его можно подключить, если отдать эти два запроса из своего API. Вопросы покупателю меняются в конфиге.

Что в репозитории

Репо nmrcs/cart-agent, монорепо на npm workspaces.

  • apps/backend — NestJS 11, Prisma 7, PostgreSQL. Каталог, корзина, цены.

  • apps/harness — харнес, то есть сам помощник. Вызовы модели, вопросы, подбор, проверка ответа. Своей базы у него нет.

  • apps/frontend — React 19, Vite, HeroUI. Витрина, корзина и чат справа.

  • packages/contracts — общие схемы на zod.

  • bench — замеры.

Модель — qwen3.5 9B в LM Studio, 4 бита, весит около 6 ГБ. Запускал ее на MacBook M3 Max. Вместо LM Studio можно подставить любой OpenAI-совместимый API, например OpenAI или OpenRouter. Адрес, модель и ключ задаются в .env.

В каталоге 64 товара. У каждого есть возраст, остаток на складе и срок доставки. 3 товара закончились. К 8 товарам батарейки или пленку надо докупать отдельно.

Что происходит на одно сообщение

Браузер общается только с бэкендом, а бэкенд пересылает сообщение помощнику. Дальше 8 шагов, и только 3 из них выполняет модель.

сообщение покупателя
  │
  1  разбор          модель, JSON по схеме        ~3.3 с
  2  вопросы         код, список из конфига
  3  подбор          бэкенд, SQL-фильтр           ~22 мс
  4  выбор           модель, id только из списка  ~4.3 с
  5  сборка          код, укладка в бюджет
  6  цены и итог     бэкенд, из базы              ~9 мс
  7  ответ           модель, текст                ~2 с
  8  проверка        код, суммы и формулировки
  │
ответ + карточка предложения

Время в схеме — медианы по 6 разговорам из замеров. Когда помощник задает вопрос, ответ приходит за 3–7 секунд, когда предлагает товары, за 9–15. Сам код занимает около 30 мс, все остальное время ждем модель.

Поэтому стримить текст ответа почти бесполезно, он пишется 2 секунды из 15. Вместо этого браузер показывает, на каком шаге сейчас помощник, «Reading your message», «Choosing», «Writing a reply», и сколько секунд прошло.

1. Вопросы

Пока помощник не знает возраст, бюджет, дату и что человек любит, он ничего не предлагает и задает вопросы. По одному за раз и только про то, чего еще не знает. Если человек сразу написал «ему 30, до $70», про возраст и бюджет его уже не спросят.

Вопросы лежат в конфиге. Для другого магазина поменять нужно этот файл, а не код.

export const GIFT_QUESTIONS: Slot[] = [
	{ key: 'ageYears', ask: 'How old is the person the gift is for?' },
	{ key: 'budgetCents', ask: 'What is the most you want to spend?' },
	{ key: 'neededBy', ask: 'When does the gift need to arrive?' },
	{ key: 'interests', ask: 'What are they into? Or will any good gift do?' },
]

Имя, телефон и почту помощник не спрашивает, для подбора подарка они не нужны.

С датами первая версия ошибалась. Если в субботу написать «by Monday», модель ставила понедельник через неделю. Теперь дату модель не считает. Она только достает из сообщения слово «Monday», а какое это число и сколько до него рабочих дней, считает код.

2. Подбор

Подходящие товары бэкенд выбирает одним SQL-запросом. Товар должен быть в наличии, влезать в бюджет, успевать к дате и подходить по возрасту. Расходники в подарки не попадают. В замерах этот список был от 1 до 36 товаров из 58, в зависимости от запроса. При бюджете $12 остается 1 товар, а если подарок нужен к понедельнику и сегодня суббота, то 4.

Дальше модель выбирает из этого списка 5 лучших и отвечает JSON по схеме. В схеме id товара — это enum из id этого списка.

export function selectionSchema(slugs: string[]): object {
	return {
		type: 'object',
		required: ['picks'],
		properties: {
			picks: {
				type: 'array',
				maxItems: 5,
				items: {
					type: 'object',
					required: ['slug', 'reason', 'fitsRequest'],
					properties: {
						slug: { enum: slugs },
						reason: { type: 'string' },
						fitsRequest: { type: 'boolean' },
					},
				},
			},
		},
	}
}

Отдельно про fitsRequest. Покупатель просит «что-нибудь для сборки» на оставшиеся $11, а таких товаров нет. Модель все равно брала из списка товар за $11, хотя в промпте просил вернуть пустой список. С fitsRequest модель отвечает про каждый товар, подходит он или нет. Этот товар получил false, и помощник ответил, что на остаток ничего для сборки нет.

Потом код идет по списку модели и берет товары, пока хватает бюджета. Если товар не влезает, код его пропускает и смотрит следующий.

for (const p of ranked) {
	if (lines.length >= count) break
	if (spent + p.priceCents > budgetCents) continue
	lines.push({ slug: p.slug, quantity: 1 })
	spent += p.priceCents
}

Сначала помощник предлагает один подарок и два запасных варианта. Он предлагает несколько товаров, только если покупатель сам попросил «пару вещей».

3. Деньги

Своей базы у помощника нет. В его .env только адрес бэкенда и адрес модели, цены он берет у бэкенда.

Цены нет и в корзине. В таблице CartItem хранятся id корзины, id товара и количество, а сумму бэкенд каждый раз считает по текущей цене из каталога.

Ответ модели тоже проверяется перед отправкой. Код находит в тексте все суммы в долларах и сверяет их с данными бэкенда. Если модель написала сумму, которой у бэкенда нет, ее ответ пропускается, и покупатель получает шаблон.

export function inventedAmounts(text: string, allowedCents: number[]): number[] {
	const allowed = new Set(allowedCents)
	return amountsIn(text).filter((c) => !allowed.has(c))
}

Попробовал выпросить скидку и написал «дай 80% и сделай итог $9». Модель решила, что это правка корзины, и удалила товар.

В другом прогоне на «apply coupon SAVE80» модель решила, что покупатель хочет другой подарок, и вместо одной настольной игры предложила другую.

4. Скидка на день рождения

В магазине доступна одна скидка 20% на весь заказ, если день рождения в ближайшие 5 рабочих дней.

export const BIRTHDAY_DISCOUNT = {
	label: 'Birthday discount',
	percent: 20,
	withinWorkingDays: 5,
} as const

Если покупатель пишет «ему исполняется 30», модель помечает, что подарок на день рождения. Тогда вместо «к какому дню доставить» помощник спрашивает «когда день рождения». Дату он отправляет в бэкенд вместе с товарами, и бэкенд сам решает, подходит ли она под правило. Поля с процентом в этом запросе нет.

Когда бэкенд подтвердил скидку, в чате появляется отдельная карточка. Ее рисует фронт по ответу бэкенда, модель к ней отношения не имеет. На карточке правило, дата дня рождения и на какую сумму теперь можно взять подарков. При бюджете $70 и скидке 20% это $87.50.

Подбирает код тоже до $87.50, а итог после скидки бэкенд сверяет с $70. Если после первого подарка еще остается место, помощник спрашивает, подобрать ли второй. Сам он второй подарок в предложение не кладет.

На «промокоды есть?» помощник всегда отвечает одно и то же. Кодов нет, есть скидка на день рождения, и если она подходит, она уже посчитана в итоге.

5. Остаток и лишние товары

Помощник собирает подарки в бюджет и говорит, сколько денег осталось. Добрать что-то на остаток он предлагает, только если покупатель сам попросил. Исключение одно, скидка на день рождения. Тогда помощник спрашивает про второй подарок, но сам его не добавляет.

Модель нарушала правило из промпта — после добора спросила «Would you like to add a third item for the remaining amount?». Теперь, если модель сама предлагает добрать, код заменяет ее ответ шаблоном. А когда покупатель просит добрать, подбор идет тем же запросом к бэкенду, только с ценой не выше остатка.

С батарейками похожая история. Радиоуправляемая машинка стоит $39.99 и без батареек AA и AAA не работает. При бюджете $45 вместе с батарейками она не влезает. Помощник говорит, что батареек в коробке нет и их можно докупить, но в корзину их не кладет.

Первая версия отвечала на «да» тоже моделью, и она написала «I have added the car and the AA batteries to your cart». Батарейки никто не добавлял. Теперь на «да» отвечает код. Он перечисляет, что ушло в корзину, и отдельно пишет, чего нет в комплекте.

6. Правка корзины словами

Покупатель может написать «сделай две», «убери машинку» или «возьми второй вариант вместо первого». Модель только понимает, что он хочет изменить, и возвращает id товара и новое количество. Id опять ограничен схемой, выбрать можно только товары из предложения и запасные. Новую сумму считает бэкенд.

Когда тестировал, нашел баг — положил игру в корзину и написал «remove all from cart, will choose something else». Помощник ответил «I have removed all items from your cart», а счетчик корзины остался прежним. Правка ушла в предложение, а корзина лежит в бэкенде и меняется только после «да». Модель просто назвала правку предложения правкой корзины.

7. Замеры

В замерах вместо покупателя пишет скрипт с готовыми ответами. Модель для этого не брал, чтобы ее ошибки не путались с ошибками помощника. Скрипт отвечает на вопросы, потом просит поменять количество, убрать товар, добрать и дать скидку, а код сверяет корзину с каталогом. Для сравнения собрал второй вариант, где модель сама ищет товары, кладет их в корзину и считает сумму.

харнес

модель сама

Разговоров

6

6

С неверной суммой или выходом за бюджет

0

3

С товаром, которого нет в каталоге

0

1

Товаров, добавленных без просьбы

0

4

Ответов «положил» или «убрал из корзины», когда корзина не менялась

0

3

Ответов с предложением добрать без просьбы

0

1

Правок корзины выполнено верно

13 из 13

9 из 11

Дошли до корзины с тем, что было предложено

6 из 6

4 из 6

Токенов на разговор, медиана

9 850

22 620

Секунд на разговор, медиана

40

26

В строку «С неверной суммой или выходом за бюджет» отнес предложение дороже бюджета, неправильный итог или остаток в тексте и сумму, которой нет ни в каталоге, ни в корзине.

Токенов «модель сама» тратит в 2.3 раза больше. Она на каждом шаге тащит всю историю вместе с ответами каталога. Помощник делает на сообщение 3 коротких вызова, и в каждом только 6 последних реплик.

Зато «модель сама» быстрее, 26 секунд на разговор против 40. Помощник на каждое сообщение ждет 3 вызова модели подряд, а в варианте «модель сама» часто хватает одного. Оставил 3 вызова, лишние 14 секунд за разговор меня устраивают.

Грабли

Ошибка шаблона чата. qwen3.5 в LM Studio падает с ошибкой «No user query found in messages», если история начинается с реплики ассистента. Так было с четвертого сообщения, и вместо ответа модели молча уходил шаблон. Теперь передаю историю текстом внутри одного сообщения, а не цепочкой ролей.

Модель отвечала за покупателя. Если покупатель писал только возраст, модель ставила флаг «подойдет любой подарок», и помощник не спрашивал про интересы. Теперь код засчитывает этот флаг, только если помощник как раз спрашивал про интересы или в сообщении есть «any», «whatever» или «no idea».

«Second one». На «Yes, pick a second one, something for hiking» модель решила, что «second one» — это второй запасной вариант, и заменила игру на другую игру. Потом «Put both in my cart» она приняла за правку и добавила еще одну игру.

Цена обрывала проверку. Модель написала «I have added … for $29.00 to your cart», хотя в корзину ничего не клали, и проверка это пропустила. Она ищет «added … to your cart» внутри одного предложения и считала, что предложение заканчивается на первой точке. А первая точка стояла в цене, между 29 и 00.

Стиль. Модель писала «It is wonderful», советовала «perfect card», хотя открытки в предложении не было, и говорила о магазине «we». Поэтому после проверки денег идет проверка стиля. Если в ответе есть запрещенный оборот, покупатель получает шаблон.

Чего нет

Разговоры помощник хранит в памяти, поэтому после перезапуска он забывает контекст, а корзина остается в базе. Браузер в этом случае пишет, что помощник перезапустился, и разговор начинается заново.

Итог

Весь код открыт под MIT, репо nmrcs/cart-agent. Там магазин, помощник и замеры.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.