Чёрная распродажная пятница 13
Всё смешалось в доме у Обломова… На самом деле, оригинал этой крылатой фразы звучал как: «Все смешалось в доме Облонских», но тогда контекст будет каким-то уж больно невесёлым.
Все счастливые семьи похожи друг на друга, каждая несчастливая семья несчастлива по-своему. Все смешалось в доме Облонских. Жена узнала, что муж был в связи с бывшею в их доме француженкою-гувернанткой, и объявила мужу, что не может жить с ним в одном доме. © Л.Н. Толстой «Анна Кренина»

В общем, нынче чёрная пятница (распродажи, ога-ога, погнали покупать) смешалась с пятницей 13.

Так что, первым делом (но сразу после чая), словно в насмешку над самим собой, я пошёл на Озон и заказал очки для Эль. У старых вчера сломалась дужка, и теперь Эль гоняет в моих запасных.
А вот вторым — поправил CSS в той части, которая отвечает за зачёркнутый текст. Теперь зачёркивание переносится нормально.
.content del{
color: #344;
padding: 0 5px;
text-decoration: line-through;
text-decoration-color: #a00;
text-decoration-thickness: 4px;
position: relative;
}

Следующим шагом — редактировал десятый урок по ИИ на моём флагманском блоге. В этот раз писал про ИИ-агент, на простом примере. Вот только в процессе решил, что нужно попытаться выдать не только ядро агента, но и пример его полной версии. А значит, надо кодить и тестировать.
Увлёкся. На выходе получился вот такой ИИноагент.
"""
Минимальный ИИ-агент для Ollama (интерактивный, блокирует ввод во время ожидания, показывает спиннер).
Основной цикл:
messages = [system, user]
повторять до MAX_STEPS:
reply = chat(messages, tools) # POST /api/chat
if нет tool_calls: это финальный ответ
else: result = run_tool(args); messages += {'role': 'tool', ...}
Примеры инструментов: current_date (сегодняшняя дата) и days_between
(количество дней между двумя датами).
Использование:
python ollama_minimal.py # интерактивный режим (диалог с историей)
python ollama_minimal.py "вопрос" # один вопрос -- один ответ
Хост/модель: переменные окружения OLLAMA_HOST и OLLAMA_MODEL.
"""
import datetime
import json
import os
import re
import sys
import threading
import urllib.error
import urllib.request
# Тут указываю хост (адрес ollama)
HOST = os.environ.get('OLLAMA_HOST', 'http://192.168.0.114:11434')
# Здесь указываю модель, которую будет использовать ollama
MODEL = os.environ.get('OLLAMA_MODEL', 'qwen3:8b')
# Это лимит токенов (контекстное окно)
NUM_CTX = 16384
# Максимальное количество шагов (запросов к модели), чтобы модель не зациклилась
MAX_STEPS = 8
SYSTEM_PROMPT = 'Отвечай кратко по-русски. Даты узнавай инструментами, не угадывай.'
TOOLS = [
{'type': 'function', 'function': {
'name': 'current_date',
'description': 'Сегодняшняя дата в формате ГГГГ-ММ-ДД.',
'parameters': {'type': 'object', 'properties': {}}},
},
{'type': 'function', 'function': {
'name': 'days_between',
'description': 'Сколько дней от даты from до даты to (формат ГГГГ-ММ-ДД).',
'parameters': {'type': 'object',
'properties': {'from': {'type': 'string', 'description': 'начальная дата'},
'to': {'type': 'string', 'description': 'конечная дата'}},
'required': ['from', 'to']}},
},
]
def run_tool(name, args):
"""Выполняет инструмент по имени с заданными аргументами. Возвращает строковый результат."""
try:
if name == 'current_date':
return datetime.date.today().isoformat()
if name == 'days_between':
d1 = datetime.date.fromisoformat(args.get('from', ''))
d2 = datetime.date.fromisoformat(args.get('to', ''))
return str((d2 - d1).days)
return f'ОШИБКА: неизвестный инструмент {name}'
except Exception as e:
return f'ОШИБКА: {e}'
def chat(messages):
"""Отправляет историю сообщений в Ollama POST-запросом; возвращает словарь сообщения ассистента."""
payload = {'model': MODEL, 'messages': messages, 'tools': TOOLS, 'stream': False,
'options': {'num_ctx': NUM_CTX, 'temperature': 0.1}}
req = urllib.request.Request(HOST + '/api/chat', data=json.dumps(payload).encode('utf-8'),
headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=600) as resp:
return json.loads(resp.read().decode('utf-8'))['message']
# ---------------------------------------------------------------- спиннер
class Spinner:
"""Рисует точки в фоновом потоке, пока выполняется запрос.
Строка стирается с помощью '\\r' + пробелы + '\\r', чтобы не засорять вывод.
"""
def __init__(self, text='Ожидание ответа агента', interval=0.4):
self.text = text
self.interval = interval
self._stop = threading.Event()
self._thread = None
self._last_len = 0
self._lock = threading.Lock()
def _run(self):
dots = 0
while not self._stop.is_set():
frame = self.text + '.' * (dots % 4)
with self._lock:
pad = max(0, self._last_len - len(frame))
sys.stdout.write('\r' + frame + ' ' * pad)
sys.stdout.flush()
self._last_len = len(frame)
dots += 1
self._stop.wait(self.interval)
def __enter__(self):
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
return self
def __exit__(self, *exc):
self._stop.set()
if self._thread:
self._thread.join()
with self._lock:
sys.stdout.write('\r' + ' ' * self._last_len + '\r')
sys.stdout.flush()
self._last_len = 0
# ---------------------------------------------------------------- агент
def agent_step(messages):
"""Добавляет один ход ассистента в `messages`. Возвращает финальный текст.
Изменяет `messages` на месте (добавляет записи assistant и tool).
"""
for step in range(1, MAX_STEPS + 1):
msg = chat(messages)
# Некоторые reasoning-модели (например, qwen3) оборачивают размышления в <think>...</think>;
# убираем это перед сохранением видимого содержимого.
msg['content'] = re.sub(r'<think>.*?</think>', '', msg.get('content') or '', flags=re.S).strip()
stored = {'role': 'assistant', 'content': msg['content']}
calls = msg.get('tool_calls')
if calls:
stored['tool_calls'] = calls
messages.append(stored)
if not calls:
return msg['content']
for tc in calls:
name = tc['function']['name']
args = tc['function'].get('arguments') or {}
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
args = {}
result = run_tool(name, args)
print(f' шаг {step}: {name}({json.dumps(args, ensure_ascii=False)}) -> {result}')
messages.append({'role': 'tool', 'content': result, 'tool_name': name})
return '(лимит шагов исчерпан: модель не дала итог)'
def agent(question):
messages = [{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'user', 'content': question}]
return agent_step(messages)
def trim(messages, keep=12):
"""Оставляет системный промпт плюс последние `keep` сообщений.
Сдвигает точку обрезки назад к записи 'user', чтобы сообщение assistant
с tool_calls никогда не оказалось оторвано от своего результата tool.
"""
if len(messages) <= keep + 1:
return messages
start = len(messages) - keep
while start > 1 and messages[start].get('role') != 'user':
start -= 1
return [messages[0]] + messages[start:]
def interactive():
messages = [{'role': 'system', 'content': SYSTEM_PROMPT}]
print('Интерактивный режим. Пустая строка — пропуск ввода, exit / Ctrl+D — выход.')
while True:
try:
q = input('\nВы: ').strip()
except EOFError:
print('\nПока.')
return 0
except KeyboardInterrupt:
print('\nПрервано.')
return 130
if not q:
continue
if q.lower() in ('exit', 'quit', 'выход'):
return 0
messages.append({'role': 'user', 'content': q})
print(' (отправлено, жду ответа...)')
try:
# Ввод блокируется здесь: мы не вызываем input() снова, пока не придёт ответ.
with Spinner('Ожидание ответа агента'):
answer = agent_step(messages)
except urllib.error.HTTPError as e:
print(f'\nOllama вернула HTTP {e.code}: {e.read().decode("utf-8", "replace")[:300]}')
messages.pop()
continue
except urllib.error.URLError as e:
print(f'\nOllama недоступна на {HOST}: {e.reason}')
messages.pop()
continue
print('Агент:', answer)
messages = trim(messages)
if __name__ == '__main__':
if len(sys.argv) > 1:
try:
with Spinner('Ожидание ответа агента'):
print(agent(' '.join(sys.argv[1:])))
except urllib.error.HTTPError as e:
sys.exit(f'Ollama вернула HTTP {e.code}: {e.read().decode("utf-8", "replace")[:300]}')
except urllib.error.URLError as e:
sys.exit(f'Ollama недоступна на {HOST}: {e.reason}')
else:
sys.exit(interactive())

И вот казалось бы, только проснулся, а уже 14:00. Этот кодинг — натуральная машина времени. Но оно того стоило. Бы. Если бы в продаже вдруг появились дешёвые компьютеры с GPU хотя бы на 8 Gb, и при этом не требующие активного охлаждения. Даже если бы они были размером с советский ламповый телевизор Радуга 716Д, это имело был смысл для локальных ИИноагентов.
А что? Поставил куда-нибудь, и используешь по локальной сети, даже если весь интернет в мире отключили. Останется добавить распознавание/генерацию голоса, собственные инструменты — и плов готов и вот тебе готовая умная колонка, умеющая, к тому же, управлять освещением, шторами, и всем таким прочим.

Но… Не завезли пока в нынешнюю реальность таких полезных девайсов. Так что, пользуем то что есть. Вот в моём детстве система умного дома с голосовым управлением работала на детях. Говоришь такому: переключи на 8-й канал (каналов в Ленинграде было 3: первый, пятый и восьмой), и он встаёт, идёт к телевизору, щёлкает тугим переключателем…

Была у нас в детстве присказка: «Подарки не отдарки». Т.е. если что-то подарил, то назад не клянчи. Но то детство. Например у mail.ru всё по взрослому.
Подарили несколько лет назад 1 Тб облачного хранилища бесплатно?
Ну как подарили, так и отобрали назад. Делов то. Ещё и кнопочку «Увеличить место» добавили. Кликаешь, а там…
Ты только посмотри КАКИЕ скидки!

Я бы и не узнал, если бы не тестировал сегодня проблемы с почтой у одного моего заказчика.
Напишите комментарий