Skip to content
Original
Juejin · Weekly AI trends· 用户2129683526168·· 5 hours agoSelectedAI score62

I rewrote the memory system for tt-agent, my Go LLM Agent framework, and hit two real bugs in three days.

AI overview

I rebuilt the memory system for my Go multi-protocol LLM Agent framework, tt-agent, moving semantics like budget truncation, system message retention, and tool_calls pairing down into memorystore.Store. The driver layer only reads and writes bytes, and Redis, SQLite, and PostgreSQL backends share one set of contract tests.

Why it matters

I ran a real consumer project against it, surfaced framework-level bugs, and came away with notes on memory tiering, cross-process locking, and performance numbers that you can apply to your own LLM apps.

Full text · AI translation

I built a "production-grade memory system" for my Go LLM agent framework, and three days of dogfooding surfaced two real bugs

Starting with an embarrassing bug

I maintain a multi-protocol LLM agent framework in Go, tt-agent (OpenAI/Anthropic/Gemini protocol adapters + ReAct loop + workflow orchestration). The other day I looked back at the code and found an embarrassing problem:

The default session memory is purely in-memory.

The moment the process restarts, every user's conversation history is gone. Multi-instance deployment? Two pods each keep their own records — the user's previous turn lands on instance A, the next turn on instance B, and the context instantly loses its memory.

This is a common ailment of many LLM apps: demos fly high, but the first thing you do in production is patch the memory layer over and over. So I decided to rebuild the memory system as a first-class citizen — this article covers the design decisions behind that system, real performance numbers, and the two real bugs I dug up while dogfooding it myself.

First, the architecture: layered, but each layer is responsible for exactly one failure mode

┌────────────────────────────────┐

│ core.Memory 接口 │

│ Add / Recent / Clear / Split │

└───────────────┬────────────────┘

│

┌─────────────────────┼─────────────────────┐

│ │ │

┌────────▼────────┐ ┌─────────▼─────────┐ ┌───────▼────────┐

│ memorystore.Store│ │ snapshot.Store │ │ ltm.Memory │

│ 预算/截断/摘要语义 │ │ 快照 + 安全回滚 │ │ 长期记忆(向量) │

└────────┬────────┘ └─────────┬─────────┘ └───────┬────────┘

│ │ │

└──────────┬──────────┴──────────┬──────────┘

│ Driver 契约 │

(只存取字节,不理解任何业务语义)

│

┌───────────────┼───────────────┐

│ │ │

┌────▼────┐ ┌──────▼──────┐ ┌─────▼─────┐

│ Redis │ │ SQLite │ │ PostgreSQL │

└─────────┘ └─────────────┘ └───────────┘

Three key design decisions:

Decision one: drivers only store and retrieve bytes; all semantics move up. Budget truncation, system message retention, tool_calls pairing — all that logic that's easy to get wrong is implemented uniformly by memorystore.Store, so the four backends are structurally incapable of behaving inconsistently. How is that guaranteed? A shared contract test suite runs against every backend — if Redis's test fails, you know it's the driver layer's fault without even looking at the code.

Decision two: one Redis driver feeds two views. Session memory and snapshots (time travel) wrap the same driver, share storage, and cross-process locks take effect automatically:

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})

Decision three: encryption lives in the codec layer, not the driver layer. Swap in a different EncryptedCodec and all four backends automatically get AES-256-GCM encryption at rest, with keys never entering driver code. Measured overhead: ~0.5µs per entry — essentially free.

Time travel: rolling back an agent that "said the wrong thing"

LLM apps have a unique need: when the model goes off the rails on some turn and says something it shouldn't, you want to roll the conversation back to before that, just like git revert.

快照 ──► 对话继续 ──► 模型说错话 ──► RollbackSafe(快照)

│

┌──────────────┤

▼ ▼

恢复到快照 自动留存"安全快照"

(坏轮次也保着,后悔还能再回滚)

How do you keep two pods from rolling back simultaneously and stepping on each other across processes? Each of the three shared backends has its own approach:

| Backend | Lock implementation | Crash safety |

|---|---|---|

| Redis | SET NX PX + token-verified release | Lease TTL auto-reclaims |

| PostgreSQL | Transaction-level advisory lock | Lock dissolves automatically when the connection drops |

| SQLite | Lock table + lease | Expired lease reclaimed by the next acquirer |

Every one of these has been verified with real tests — the Postgres one kills the backend process directly with pg_terminate_backend in the test to verify lock reclamation.

Long-term memory: don't change the model, just change this turn's context

Session memory handles "this conversation"; long-term memory handles "things it always remembers" (the user is vegetarian, allergic to peanuts, has a cat named Coalball).

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) // 渲染成提示词注入

Three production-grade details:

  1. Vector failure automatically degrades to keywords — if the remote embedding service goes down, facts aren't lost and recall keeps working (lower quality, but no interruption)

  2. Hard namespace isolation — recall in user-42 is structurally incapable of pulling out any fact from user-43, with a dedicated cross-namespace leak test

  3. Events never carry message content — the observability system only emits counts and IDs, so even a misconfigured webhook URL won't leak the conversation

Real performance data (measured on Intel Ultra 9185H)

| Operation | Redis | SQLite | PostgreSQL |

|---|---|---|---|

| Append one message | 122µs | 458µs | 1,584µs |

| Read the latest 1000 | 2,908µs | 2,159µs | 3,857µs |

| Trim 1000 | 1,957µs | 606µs | 1,049µs |

| Long-term memory | 100 facts | 1,000 | 5,000 |

|---|---|---|---|

| Keyword recall | 71µs | 989µs | 6,322µs |

| Vector recall (pgvector) | — | 1,184µs | 8,382µs |

(The Redis numbers are based on miniredis with no network round trip; real Redis adds one RTT)

The concurrency semantics are backed by tests: 16 concurrent connections × 10 messages hitting the same session, 160 with zero loss; two paths roll back safely at the same time, the session lock serializes them, and the final state is exactly consistent.

Dogfooding moment: three days, two real bugs

I wrote a dedicated consumer project (a full API service built on Gin) to dogfood my own framework, hitting real infrastructure end to end (real Redis, real pgvector, real DeepSeek/Zhipu GLM dual vendors). It bit out two framework-level bugs on day one:

Bug 1: SystemPrompt silently dropped. The config was passed all the way from the HTTP handler → agent loop, and then forgotten when assembling the request. Every user who set a system prompt had none of it take effect. Why wasn't this caught earlier? The unit tests only asserted "the arguments were passed correctly"—nobody looked at the request actually being sent.

Bug 2: The extractor hardcoded the model name. The fact extraction module hardcoded Model: "extractor" when sending requests. Lenient providers ignore unknown model names; DeepSeek validates strictly and returns 400: The supported API model names are deepseek-flash, deepseek-v4-pro, but you passed extractor. Under lenient conditions all tests were green—the first real call blew up on the spot.

What both bugs have in common: only a real consumer + real vendors + end-to-end assertions can catch them. That's also my advice to anyone building a framework: your dogfood project should start earlier than you think.

Later I asked myself "does it support Redis Cluster?" and checked the code: the keys had no hash tag, so the Lua trim script would definitely blow up with CROSSSLOT on a sharded cluster—the third pit. Fixed (v1.3.0), but the lesson stands: if you don't ask and don't check, your users will find it for you.

Quality credentials

Not "I think it's production-ready," but these:

270+ 测试函数、27 个基准,-race 全绿

跨后端契约套件:四后端行为一致性是构造保证

故障路径测试:启动降级 / 运行中断网 / 同端口重启自愈

CI:push/PR 全量测试(-race + 真 pgvector 服务容器)

JSON wire 契约金样本:改一个 tag,CI 当场红给你看

双厂商实测:DeepSeek 15/15 + GLM 15/15 端到端

版本纪律:semver、Release 说明、破坏性变更显著标注

Quick start

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, "你好")

Switching the session memory backend is a one-line change (Redis ↔ SQLite ↔ PostgreSQL), with zero change to business semantics. The bilingual README (2600+ lines) has a complete production assembly guide, a parameter cheat sheet, and warnings for three real-world pits.

An honest timeline

What's not done yet: the Anthropic embedding adapter hasn't been tested against the live endpoint (regional restrictions; the README says so honestly); zero external users—all the "production-grade" credentials come from my own consumer project and tests, and no stranger's production environment has hammered it yet.

The project is here: github.com/Lookfukc/tt… . If you're building LLM apps in Go, come file an issue—being hammered by real needs is the only shortcut to a better framework.

  • Juejin: "I rewrote the memory system for my own LLM framework, then bit out two real bugs in three days"

Source: Juejin · Weekly AI trends · juejin.cn