Я переписал систему памяти для Go-фреймворка LLM-агентов tt-agent — за три дня выловил два настоящих бага
Автор переделал систему памяти в своём Go-фреймворке tt-agent для LLM-агентов с поддержкой нескольких протоколов: обрезка по бюджету, сохранение системных сообщений, сопоставление tool_calls и другая семантика теперь сосредоточены в memorystore.Store, драйверный слой только читает и пишет байты, а Redis, SQLite и PostgreSQL используют один общий набор контрактных тестов.
Автор нашёл баг уровня фреймворка, прогнав его на реальном проекте-потребителе, и приводит данные о слоях памяти, межпроцессной блокировке и производительности — всё это можно перенести в собственное LLM-приложение.
Я написал для своего Go-фреймворка LLM Agent «продакшен-систему памяти» и за три дня dogfooding'а выловил два настоящих бага
Начну с неловкого бага
Я поддерживаю мультипротокольный фреймворк для LLM-агентов на Go — tt-agent (адаптеры протоколов OpenAI/Anthropic/Gemini + цикл ReAct + оркестрация рабочих процессов). Однажды, перечитывая код, я обнаружил неловкую проблему:
Память сессии по умолчанию хранится только в памяти процесса.
Стоит процессу перезапуститься — и вся история диалогов всех пользователей исчезает. А развёртывание в несколько экземпляров? Два пода хранят каждый своё: пользователь задаёт вопрос на экземпляре A, следующий — попадает на B, и контекст мгновенно теряется.
Это общая болезнь многих LLM-приложений: на этапе демо всё летает, а при выходе в продакшен первым делом начинаешь латать память заплатками. Поэтому я решил переделать систему памяти как полноценного гражданина — в этой статье речь пойдёт о проектных решениях этой системы, реальных данных о производительности и двух настоящих багах, которые я выловил на собственном dogfooding'е.
Сначала об архитектуре: слои, но каждый слой отвечает лишь за один режим отказа
┌────────────────────────────────┐
│ core.Memory 接口 │
│ Add / Recent / Clear / Split │
└───────────────┬────────────────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
┌────────▼────────┐ ┌─────────▼─────────┐ ┌───────▼────────┐
│ memorystore.Store│ │ snapshot.Store │ │ ltm.Memory │
│ 预算/截断/摘要语义 │ │ 快照 + 安全回滚 │ │ 长期记忆(向量) │
└────────┬────────┘ └─────────┬─────────┘ └───────┬────────┘
│ │ │
└──────────┬──────────┴──────────┬──────────┘
│ Driver 契约 │
(只存取字节,不理解任何业务语义)
│
┌───────────────┼───────────────┐
│ │ │
┌────▼────┐ ┌──────▼──────┐ ┌─────▼─────┐
│ Redis │ │ SQLite │ │ PostgreSQL │
└─────────┘ └─────────────┘ └───────────┘
Три ключевых проектных решения:
Решение первое: драйвер хранит и извлекает только байты, вся семантика поднимается наверх. Обрезка по бюджету, сохранение системных сообщений, сопоставление tool_calls — вся эта легко дающая сбой логика реализуется единообразно в memorystore.Store, и четыре бэкенда по построению не могут вести себя по-разному. Как это гарантируется? Единый набор контрактных тестов прогоняется по всем бэкендам — если тест для Redis падает, можно даже не смотреть код, чтобы понять: виноват слой драйвера.
Решение второе: один драйвер Redis обслуживает два представления. Память сессии и снимки (путешествие во времени) используют один и тот же драйвер, общее хранилище, а межпроцессная блокировка включается автоматически:
driver := redis.New(client, redis.Options{Prefix: "myapp:", SessionTTL: 24 * time.Hour})
mem := memorystore.New(driver, memorystore.Options{Observer: bus})
snaps := snapshot.New(driver, snapshot.Options{MaxPerSession: 20})
Решение третье: шифрование висит на уровне кодека, а не драйвера. Меняешь один EncryptedCodec — и все четыре бэкенда автоматически получают шифрование AES-256-GCM для данных в покое, при этом ключ никогда не попадает в код драйвера. Измеренные накладные расходы — ~0.5µs на запись, то есть практически бесплатно.
Путешествие во времени: откатываем агента, который «сказал лишнее»
У LLM-приложений есть особая потребность: модель на каком-то ходу сорвалась и сказала то, чего говорить не следовало, и ты хочешь откатить сессию к состоянию до этого момента — как git revert.
快照 ──► 对话继续 ──► 模型说错话 ──► RollbackSafe(快照)
│
┌──────────────┤
▼ ▼
恢复到快照 自动留存"安全快照"
(坏轮次也保着,后悔还能再回滚)
Как в межпроцессном режиме не дать двум подам одновременно откатиться и наступить друг другу на пятки? Для трёх общих бэкендов есть свои решения:
| Бэкенд | Реализация блокировки | Устойчивость к сбоям |
|---|---|---|
| Redis | SET NX PX + освобождение с проверкой токена | Автоматический сбор по TTL аренды |
| PostgreSQL | Консультационная блокировка уровня транзакции | При разрыве соединения блокировка снимается автоматически |
| SQLite | Таблица блокировок + аренда | Просроченную аренду забирает следующий получатель |
Каждое решение проверено по-настоящему боевыми тестами — в случае с Postgres тест напрямую убивает процесс бэкенда через pg_terminate_backend, чтобы проверить освобождение блокировки.
Долговременная память: не меняем модель, меняем только контекст этого хода
Память сессии отвечает за «этот разговор», долговременная память — за «то, что помнится всегда» (пользователь вегетарианец, аллергия на арахис, есть кот по кличке Уголёк).
vecStore, _ := pgvector.New(pool, pgvector.Options{Dim: 768, Schema: "agent_ltm"})
mem := ltm.New(vecStore, ltm.Options{
Embedder: embeddings.NewOpenAICompatible(ollamaURL, "ollama", "nomic-embed-text"),
Extractor: extractor.New(chatLLM, extractor.Options{Lenient: true}),
})
mem.Remember(ctx, "user-42", "用户对花生严重过敏", nil) // 显式写入
mem.Learn(ctx, "user-42", conversation) // LLM 从对话里抽事实
facts, _ := mem.Recall(ctx, "user-42", "推荐午餐", 5) // 向量召回
prompt := ltm.Prompt(facts) // 渲染成提示词注入
Три продакшен-детали:
При сбое векторного поиска автоматически откатываемся к ключевым словам — если удалённый сервис эмбеддингов упал, факты не теряются, извлечение работает как обычно (качество падает, но процесс не прерывается)
Жёсткая изоляция пространств имён — извлечение для
user-42по построению не может вытащить ни одного факта изuser-43, для этого есть отдельный тест на утечку между пространствами имёнСобытия никогда не несут содержимое сообщений — система наблюдаемости отправляет только счётчики и ID, и даже если в webhook указан неверный URL, диалог не утечёт
Реальные данные о производительности (замеры на Intel Ultra 9185H)
| Операция | Redis | SQLite | PostgreSQL |
|---|---|---|---|
| Добавить одно сообщение | 122µs | 458µs | 1,584µs |
| Прочитать последние 1000 | 2,908µs | 2,159µs | 3,857µs |
| Обрезать 1000 | 1,957µs | 606µs | 1,049µs |
| Долговременная память | 100 фактов | 1,000 | 5,000 |
|---|---|---|---|
| Поиск по ключевым словам | 71µs | 989µs | 6,322µs |
| Векторный поиск (pgvector) | — | 1,184µs | 8,382µs |
(Цифры для Redis получены на miniredis без сетевого round-trip; с настоящим Redis добавится ещё один RTT)
Семантика конкурентности подтверждена тестами: 16 одновременных соединений × 10 сообщений бьют в одну сессию, 160 сообщений без потерь; два пути одновременно откатываются безопасно, блокировка сессии сериализует доступ, итоговое состояние точно совпадает.
Момент dogfooding: за три дня выловил два настоящих бага
Я специально написал потребительский проект (полноценный API-сервис на архитектуре Gin), чтобы есть собственный dogfood, и прогнал весь путь по реальной инфраструктуре (настоящий Redis, настоящий pgvector, настоящие DeepSeek и Zhipu GLM — два вендора). И уже в первый день выловил два бага уровня фреймворка:
Баг 1: SystemPrompt молча теряется. Конфигурация шла от HTTP handler → agent loop до самого конца, а при сборке запроса её забыли подставить. У всех, кто задал системный промпт, на деле не работал ни один. Почему не замечали раньше? Юнит-тесты проверяли только «параметры переданы правильно», а на реально отправляемый запрос никто не смотрел.
Баг 2: экстрактор захардкодил имя модели. Модуль извлечения фактов при отправке запроса жёстко писал Model: "extractor". Лояльные провайдеры игнорируют неизвестное имя модели, а DeepSeek со строгой валидацией сразу отдаёт 400: The supported API model names are deepseek-flash, deepseek-v4-pro, but you passed extractor. В лояльном окружении все тесты были зелёными — первый же реальный вызов рванул на месте.
Общее у обоих багов одно: их можно поймать только с реальным потребителем + реальным вендором + сквозными проверками. Это же мой совет всем, кто делает фреймворки: твой dogfood-проект должен начаться раньше, чем ты думаешь.
Потом спросил себя «а Redis Cluster поддерживается?», полез в код и обнаружил, что в ключах нет hash tag, а Lua-скрипт обрезки на шардированном кластере гарантированно упадёт с CROSSSLOT — третья яма. Уже исправлено (v1.3.0), но эта история показывает: не спросишь и не проверишь — пользователи найдут за тебя.
Подтверждение качества
Это не «по-моему, можно в прод», а вот это:
270+ 测试函数、27 个基准,-race 全绿
跨后端契约套件:四后端行为一致性是构造保证
故障路径测试:启动降级 / 运行中断网 / 同端口重启自愈
CI:push/PR 全量测试(-race + 真 pgvector 服务容器)
JSON wire 契约金样本:改一个 tag,CI 当场红给你看
双厂商实测:DeepSeek 15/15 + GLM 15/15 端到端
版本纪律:semver、Release 说明、破坏性变更显著标注
Быстрый старт
go get github.com/Lookfukc/[email protected]
llm, _ := adapters.NewLLM(cfg) // 任意 OpenAI 兼容厂商
mem := driver.Memory(memorystore.Options{})
loop := agent.NewLoop(llm, tools.NewRegistry(), mem, agent.Config{
Model: "deepseek-chat",
SystemPrompt: "你是一个简洁的助手",
})
msg, usage, _ := loop.Run(ctx, sessionID, "你好")
Смена бэкенда памяти сессии — одна строка (Redis ↔ SQLite ↔ PostgreSQL), бизнес-семантика не меняется. Двуязычный README (2600+ строк) содержит полное руководство по сборке в прод, шпаргалку по параметрам и предупреждение о трёх реальных ямах.
Честный график
Что ещё не сделано: адаптер Anthropic embedding не проверен на живом эндпоинте (региональные ограничения, в README честно отмечено); внешних пользователей ноль — все доказательства «продового уровня» идут от моего собственного потребительского проекта и тестов, ещё ни один чужой прод не отбил его как следует.
Проект здесь: github.com/Lookfukc/tt… . Если вы делаете LLM-приложение на Go, приходите с issue — быть битым реальными требованиями это единственный короткий путь сделать фреймворк лучше.
- Цзюэцзинь: «Я переписал систему памяти своего LLM-фреймворка и за три дня выловил два настоящих бага»
Источник: Juejin · Популярное об ИИ за неделю · juejin.cn