Перейти к содержимому
Оригинал
Juejin · Популярное об ИИ за неделю· 用户2129683526168·· 5 часов назадИзбранное редакциейОценка ИИ62

Я переписал систему памяти для 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) // 渲染成提示词注入

Три продакшен-детали:

  1. При сбое векторного поиска автоматически откатываемся к ключевым словам — если удалённый сервис эмбеддингов упал, факты не теряются, извлечение работает как обычно (качество падает, но процесс не прерывается)

  2. Жёсткая изоляция пространств имён — извлечение для user-42 по построению не может вытащить ни одного факта из user-43, для этого есть отдельный тест на утечку между пространствами имён

  3. События никогда не несут содержимое сообщений — система наблюдаемости отправляет только счётчики и 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