Хранилище
Где живёт чат как источник записи — надёжный разговор, который рендерит UI и который агент вычитывает обратно.
Postgres, за agent/chat
Чат — это транзакционные, реляционные данные: Chat → Message, привязанные к Agent / Team. Им нужна строгая консистентность и чистая пагинация для UI → Postgres — правильный дом.
model Chat {
id String @id // chat-{uuid}
agentId String
channel String // widget | telegram | api …
messages Message[]
createdAt DateTime @default(now())
@@index([agentId, createdAt])
}
model Message {
id String @id // msg-{uuid}
chatId String
role String // user | assistant | tool | system
content Json // text + tool refs + attachments
tokens Int?
createdAt DateTime @default(now())
chat Chat @relation(fields: [chatId], references: [id])
@@index([chatId, createdAt])
}Slice agent/chat владеет этим за gateway, поэтому физическое хранилище заменяемо без правок вызывающего кода.
Чтение истории: страница, а не весь тред
Все три чтения истории пагинированы курсором, и попросить всё нельзя никак:
GET /agents/:id/chat/messages?limit=&cursor=
GET /agents/:id/chat/threads?limit=&cursor=
GET /agents/:id/chat/threads/:threadId/messages?limit=&cursor=limitпо умолчанию 50 и ограничен сверху 200.- Ответ —
{ items, nextCursor, hasMore },itemsидут от старых к новым.hasMoreвынесен отдельным полем намеренно: вывод изitems.length === limitошибается ровно один раз на тред — на последней странице, которая оказалась полной. cursorнепрозрачен и идёт назад, в более старые строки. Курсор, который этот api не выдавал, отвергается, а не игнорируется молча.
Это не украшение. Агент с автозапуском пишет в свой тред круглосуточно, поэтому тред растёт без всякого участия человека.
Начать заново — черта в треде, а не второй тред
Разговору некуда было закончиться: что-то обсудили, через неделю пришли с другим — а модель тащила за собой весь первый предмет. «Начать заново» проводит в том же треде строку-границу. От неё модель не получает ничего из того, что было раньше, а человек сохраняет все прежние сообщения и может к ним прокрутить.
Тред по-прежнему ровно один на (агента, канал, человека) — так говорит ключ, — и новой сущности не появилось: граница это тот же маркер, который ставит компакция, заменяя старые ходы пересказом, только поставленный сознательно, а не по мере роста треда. Нажатие кнопки дважды не рисует вторую черту.
Чего сброс не касается — это память агента. Заметки, которые он записал о человеке, лежат не в треде, поэтому после сброса он их по-прежнему знает. Выглядит как поломка, но ею не является — поэтому экран говорит об этом прямо, а не оставляет человека выяснять это самому.
Что не сохраняется
- Ход, завершившийся ошибкой, не сохраняет ничего. Ход, который остановил пользователь, — другое дело: уже доставленный текст сохраняется с пометкой «остановлено», поэтому тред после перезагрузки совпадает с тем, что было на экране. См. Что завершает ход.
Объём — Postgres справляется
«Значительный объём» — это нормальный случай для таблицы Message; рычаги:
- Партиционируй
Message(по времени, или поagentId/teamId), чтобы горячие диапазоны оставались небольшими. - Индексируй под реальные запросы (
chatId, createdAt); избегай широких сканов. - Архивируй холодные разговоры в объектное хранилище (строка хранит указатель); живая таблица остаётся компактной.
- Сырые блобы трейсов/промптов сюда НЕ кладутся — они уходят в Debug и трейсинг. Именно вынос телеметрии держит эту таблицу быстрой.
Старт с общего → разделяй, когда заслужит
- Сейчас: чаты живут в основном Postgres (одна БД, проще всего).
- На масштабе: перенеси данные
agent/chatна выделенный инстанс Postgres, когда write-IO чатов начнёт конкурировать с транзакционной нагрузкой. Поскольку это за gateway, это изменение конфига, а не переписывание — ровно тот путь «сначала в api, отдельная база потом».
См. также
- Debug и трейсинг — почему промпты/трейсы держатся вне этой таблицы.
- Память — поисковый индекс, производный от этих сообщений.
- Схема БД —
Chat/Messageв более широкой модели.