Без Ollama приложение больше не выглядит зависшим
Проверка работы с недоступной Ollama показала: если машина с ней просто выключена, пакеты уходят в никуда и каждый вызов честно ждёт свой таймаут. Замер: запись одной вещи — 92 секунды, разбор фразы — 154, правка — 30. Формально всё работало, но пользоваться этим невозможно. Предохранитель в ollama.py: - перед дорогим запросом быстрая проверка TCP-соединения (1.5 с вместо 120); - после неудачи 30 секунд к серверу вообще не обращаемся; - при успехе результат кешируется на 5 секунд, чтобы не проверять на каждом шаге; - смена адреса в настройках и проба адреса сбрасывают паузу, иначе новый сервер полминуты считался бы мёртвым. Замер после: те же операции отвечают мгновенно, первая проверка — 1.5 с. Проверено в двух режимах недоступности: закрытый порт и молчащий адрес. Тесты закрепляют поведение: запись, разбор фразы и поиск при мёртвом адресе обязаны укладываться в 3 секунды и работать по существу — вещь записывается без тегов, фраза разбирается регулярками, поиск идёт по словам. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -185,8 +185,9 @@ ollama pull bge-m3
|
|||||||
|
|
||||||
## Что если Ollama выключен
|
## Что если Ollama выключен
|
||||||
|
|
||||||
Если Ollama на отдельной машине, она бывает выключена. Приложение это переживает:
|
**Приложение работает и без Ollama вообще** — можно поставить и пользоваться,
|
||||||
наверху появляется предупреждение, а работает почти всё.
|
не устанавливая её. Наверху появится предупреждение со ссылкой в настройки,
|
||||||
|
а всё основное останется на месте.
|
||||||
|
|
||||||
| | Ollama есть | Ollama нет |
|
| | Ollama есть | Ollama нет |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -196,6 +197,14 @@ ollama pull bge-m3
|
|||||||
| Разбор свободных фраз | модель | упрощённо, регулярками |
|
| Разбор свободных фраз | модель | упрощённо, регулярками |
|
||||||
| Поиск по смыслу | да | нет |
|
| Поиск по смыслу | да | нет |
|
||||||
|
|
||||||
|
**Ожидания это не добавляет.** Если машина с Ollama выключена, пакеты уходят
|
||||||
|
в никуда, и без защиты каждый вызов честно ждал бы свой таймаут: замер показал
|
||||||
|
92 секунды на запись одной вещи и 154 на разбор фразы — выглядит как зависание.
|
||||||
|
Поэтому в `ollama.py` стоит предохранитель: перед дорогим запросом идёт быстрая
|
||||||
|
проверка соединения (полторы секунды вместо двух минут), а после неудачи
|
||||||
|
приложение полминуты вообще не тревожит сервер. Замер после: те же операции
|
||||||
|
отвечают мгновенно. Смена адреса в настройках сбрасывает паузу сразу.
|
||||||
|
|
||||||
Модели: `qwen2.5:7b` для разбора фраз (в конфиге — `CHAT_MODEL`).
|
Модели: `qwen2.5:7b` для разбора фраз (в конфиге — `CHAT_MODEL`).
|
||||||
Не `omnicoder-9b`: тот ризонер, пишет `<think>` прямо в ответ и медленнее.
|
Не `omnicoder-9b`: тот ризонер, пишет `<think>` прямо в ответ и медленнее.
|
||||||
|
|
||||||
|
|||||||
@@ -81,10 +81,12 @@ def api_config_probe():
|
|||||||
was = config.OLLAMA_HOST
|
was = config.OLLAMA_HOST
|
||||||
try:
|
try:
|
||||||
config.OLLAMA_HOST = host.rstrip("/")
|
config.OLLAMA_HOST = host.rstrip("/")
|
||||||
|
ollama.reset_breaker() # проверяем именно этот адрес, без памяти о прошлом
|
||||||
alive = ollama.check()
|
alive = ollama.check()
|
||||||
models = ollama.models() if alive else []
|
models = ollama.models() if alive else []
|
||||||
finally:
|
finally:
|
||||||
config.OLLAMA_HOST = was # проба не должна менять настройки
|
config.OLLAMA_HOST = was # проба не должна менять настройки
|
||||||
|
ollama.reset_breaker()
|
||||||
|
|
||||||
if not alive:
|
if not alive:
|
||||||
return jsonify({"ok": False, "models": [],
|
return jsonify({"ok": False, "models": [],
|
||||||
@@ -107,6 +109,9 @@ def api_config_save():
|
|||||||
|
|
||||||
if not changed:
|
if not changed:
|
||||||
return jsonify({"ok": True, "reply": "Менять нечего."})
|
return jsonify({"ok": True, "reply": "Менять нечего."})
|
||||||
|
# Адрес поменяли — прошлая неудача больше ничего не значит, иначе новый
|
||||||
|
# сервер полминуты считался бы мёртвым.
|
||||||
|
ollama.reset_breaker()
|
||||||
alive = ollama.check()
|
alive = ollama.check()
|
||||||
reply = "Сохранил в .env. " + ("Ollama отвечает." if alive else
|
reply = "Сохранил в .env. " + ("Ollama отвечает." if alive else
|
||||||
"Но Ollama по этому адресу молчит.")
|
"Но Ollama по этому адресу молчит.")
|
||||||
|
|||||||
+85
-3
@@ -5,7 +5,10 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
|
import socket
|
||||||
import logging
|
import logging
|
||||||
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
# Адрес и модели читаем через `config.X`, а не копией при импорте: их можно
|
# Адрес и модели читаем через `config.X`, а не копией при импорте: их можно
|
||||||
@@ -16,25 +19,95 @@ from .config import (CHAT_TIMEOUT, EMBED_TIMEOUT, MAX_OUTPUT_TOKENS,
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Предохранитель
|
||||||
|
#
|
||||||
|
# Если машина с Ollama просто выключена, пакеты уходят в никуда, и каждый
|
||||||
|
# запрос честно ждёт свой таймаут. Замер: запись одной вещи занимала 92 секунды,
|
||||||
|
# разбор фразы — 154. Пользователь решит, что приложение зависло.
|
||||||
|
#
|
||||||
|
# Поэтому: сначала быстрая проверка TCP (полтора секунды вместо двух минут),
|
||||||
|
# а после неудачи — пауза, в течение которой к серверу вообще не ходим.
|
||||||
|
# ============================================================
|
||||||
|
CONNECT_TIMEOUT = 1.5 # столько ждём само соединение
|
||||||
|
DOWN_COOLDOWN = 30.0 # столько не тревожим сервер после неудачи
|
||||||
|
UP_CACHE = 5.0 # столько верим, что сервер жив, без повторной проверки
|
||||||
|
|
||||||
|
_down_until = 0.0
|
||||||
|
_up_until = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _host_port():
|
||||||
|
parsed = urllib.parse.urlparse(config.OLLAMA_HOST)
|
||||||
|
return parsed.hostname or "localhost", parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||||
|
|
||||||
|
|
||||||
|
def available():
|
||||||
|
"""Стоит ли вообще пытаться. Быстро и без долгих ожиданий."""
|
||||||
|
global _down_until, _up_until
|
||||||
|
now = time.time()
|
||||||
|
if now < _up_until:
|
||||||
|
return True
|
||||||
|
if now < _down_until:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
host, port = _host_port()
|
||||||
|
socket.create_connection((host, port), timeout=CONNECT_TIMEOUT).close()
|
||||||
|
_up_until = now + UP_CACHE
|
||||||
|
_down_until = 0.0
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
_down_until = now + DOWN_COOLDOWN
|
||||||
|
_up_until = 0.0
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_down():
|
||||||
|
"""Запрос не удался — держим паузу, чтобы не ждать таймаут на каждом шаге."""
|
||||||
|
global _down_until, _up_until
|
||||||
|
_down_until = time.time() + DOWN_COOLDOWN
|
||||||
|
_up_until = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_up():
|
||||||
|
global _up_until
|
||||||
|
_up_until = time.time() + UP_CACHE
|
||||||
|
|
||||||
|
|
||||||
|
def reset_breaker():
|
||||||
|
"""Сбросить паузу — например, когда адрес поменяли в настройках."""
|
||||||
|
global _down_until, _up_until
|
||||||
|
_down_until = 0.0
|
||||||
|
_up_until = 0.0
|
||||||
|
|
||||||
|
|
||||||
def check():
|
def check():
|
||||||
"""Доступен ли сервер. Таймаут короткий: это дёргает баннер в UI."""
|
"""Доступен ли сервер. Отвечает быстро: это дёргает баннер в UI."""
|
||||||
|
if not available():
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(config.OLLAMA_HOST + "/api/tags")
|
req = urllib.request.Request(config.OLLAMA_HOST + "/api/tags")
|
||||||
with urllib.request.urlopen(req, timeout=3) as r:
|
with urllib.request.urlopen(req, timeout=3) as r:
|
||||||
return r.status == 200
|
if r.status == 200:
|
||||||
|
_mark_up()
|
||||||
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
|
_mark_down()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def models():
|
def models():
|
||||||
"""Список установленных моделей (для диагностики)."""
|
"""Список установленных моделей (для диагностики)."""
|
||||||
|
if not available():
|
||||||
|
return []
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(config.OLLAMA_HOST + "/api/tags")
|
req = urllib.request.Request(config.OLLAMA_HOST + "/api/tags")
|
||||||
with urllib.request.urlopen(req, timeout=5) as r:
|
with urllib.request.urlopen(req, timeout=5) as r:
|
||||||
data = json.loads(r.read().decode("utf-8"))
|
data = json.loads(r.read().decode("utf-8"))
|
||||||
|
_mark_up()
|
||||||
return [m.get("model", "") for m in data.get("models", [])]
|
return [m.get("model", "") for m in data.get("models", [])]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
_mark_down()
|
||||||
logger.warning("Список моделей не получен: %s", e)
|
logger.warning("Список моделей не получен: %s", e)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -51,6 +124,8 @@ def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS):
|
|||||||
"stream": False,
|
"stream": False,
|
||||||
"options": {"num_predict": num_predict, "temperature": 0},
|
"options": {"num_predict": num_predict, "temperature": 0},
|
||||||
}
|
}
|
||||||
|
if not available():
|
||||||
|
return None # сервер лежит: не ждём таймаут на каждой записи
|
||||||
data = json.dumps(payload).encode("utf-8")
|
data = json.dumps(payload).encode("utf-8")
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
config.OLLAMA_HOST + "/api/chat", data=data,
|
config.OLLAMA_HOST + "/api/chat", data=data,
|
||||||
@@ -59,8 +134,10 @@ def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS):
|
|||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
resp = json.loads(r.read().decode("utf-8"))
|
resp = json.loads(r.read().decode("utf-8"))
|
||||||
|
_mark_up()
|
||||||
return (resp.get("message") or {}).get("content", "")
|
return (resp.get("message") or {}).get("content", "")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
_mark_down()
|
||||||
logger.warning("Запрос к модели %s не удался: %s", model, e)
|
logger.warning("Запрос к модели %s не удался: %s", model, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -74,6 +151,8 @@ def embed(text, is_query=False):
|
|||||||
text = (text or "").strip()
|
text = (text or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
|
if not available():
|
||||||
|
return None
|
||||||
prefix = EMBED_QUERY_PREFIX if is_query else EMBED_DOC_PREFIX
|
prefix = EMBED_QUERY_PREFIX if is_query else EMBED_DOC_PREFIX
|
||||||
data = json.dumps({"model": config.EMBED_MODEL, "prompt": prefix + text}).encode("utf-8")
|
data = json.dumps({"model": config.EMBED_MODEL, "prompt": prefix + text}).encode("utf-8")
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
@@ -82,7 +161,10 @@ def embed(text, is_query=False):
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=EMBED_TIMEOUT) as r:
|
with urllib.request.urlopen(req, timeout=EMBED_TIMEOUT) as r:
|
||||||
return json.loads(r.read().decode("utf-8")).get("embedding")
|
vector = json.loads(r.read().decode("utf-8")).get("embedding")
|
||||||
|
_mark_up()
|
||||||
|
return vector
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
_mark_down()
|
||||||
logger.warning("Эмбеддинг не получен: %s", e)
|
logger.warning("Эмбеддинг не получен: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -177,6 +177,47 @@ config.DB_FILE.write_text("{ битый json", encoding="utf-8")
|
|||||||
r.check("битая база не роняет", c.get("/api/tree").status_code == 200)
|
r.check("битая база не роняет", c.get("/api/tree").status_code == 200)
|
||||||
r.check("пустой ввод не роняет", "reply" in j("POST", "/api/say", text=" "))
|
r.check("пустой ввод не роняет", "reply" in j("POST", "/api/say", text=" "))
|
||||||
|
|
||||||
|
print("\n== Работа без Ollama: не ждём таймаутов ==")
|
||||||
|
# Машина с Ollama бывает выключена, и тогда пакеты уходят в никуда. Без
|
||||||
|
# предохранителя запись одной вещи ждала 92 секунды, разбор фразы — 154:
|
||||||
|
# пользователь решит, что приложение зависло. 192.0.2.1 — из блока TEST-NET-1
|
||||||
|
# (RFC 5737), он гарантированно не отвечает.
|
||||||
|
import time as _time # noqa: E402
|
||||||
|
_was_host = config.OLLAMA_HOST
|
||||||
|
try:
|
||||||
|
config.OLLAMA_HOST = "http://192.0.2.1:11434"
|
||||||
|
ollama.reset_breaker()
|
||||||
|
|
||||||
|
t0 = _time.time()
|
||||||
|
alive = ollama.check()
|
||||||
|
first = _time.time() - t0
|
||||||
|
r.check("недоступность видна быстро", not alive and first < 5, "%.1f с" % first)
|
||||||
|
|
||||||
|
t0 = _time.time()
|
||||||
|
d = j("POST", "/api/put", name="вещь без ии")
|
||||||
|
put_time = _time.time() - t0
|
||||||
|
r.check("запись вещи не ждёт таймаут", d.get("ok") and put_time < 3,
|
||||||
|
"%.1f с, ответ: %s" % (put_time, d))
|
||||||
|
r.check("тегов нет, но вещь записана",
|
||||||
|
d.get("item", {}).get("aliases") == [], d.get("item"))
|
||||||
|
|
||||||
|
t0 = _time.time()
|
||||||
|
d = j("POST", "/api/say", text="положил кусачки в коробку D1")
|
||||||
|
say_time = _time.time() - t0
|
||||||
|
r.check("свободная фраза не ждёт таймаут", say_time < 3, "%.1f с" % say_time)
|
||||||
|
r.check("фраза разобрана регулярками", d.get("kind") == "put", d)
|
||||||
|
|
||||||
|
t0 = _time.time()
|
||||||
|
d = c.get("/api/find?q=вещь без ии").get_json()
|
||||||
|
r.check("поиск работает без ИИ", d["items"] and _time.time() - t0 < 3, d)
|
||||||
|
|
||||||
|
r.check("смена адреса сбрасывает паузу",
|
||||||
|
(ollama.reset_breaker() or True) and ollama._down_until == 0)
|
||||||
|
finally:
|
||||||
|
config.OLLAMA_HOST = _was_host
|
||||||
|
ollama.reset_breaker()
|
||||||
|
r.check("после возврата адреса связь есть", ollama.check() if HAS_AI else True)
|
||||||
|
|
||||||
print("\n== Батники: только ASCII ==")
|
print("\n== Батники: только ASCII ==")
|
||||||
# cmd.exe читает .cmd в системной кодировке. Кириллица внутри — даже
|
# cmd.exe читает .cmd в системной кодировке. Кириллица внутри — даже
|
||||||
# в комментарии rem — превращается в мусор, и cmd пытается выполнить обрывки
|
# в комментарии rem — превращается в мусор, и cmd пытается выполнить обрывки
|
||||||
|
|||||||
Reference in New Issue
Block a user