一、要解决的问题
大模型本身是无状态的:每次请求都是全新的,它不记得上一轮你说过什么。而业务往往要求它“记住刚才的话、接着往下聊”。此外还有两个绕不过去的现实:
- 上下文窗口有限。把全部历史每轮都塞进 Prompt,token 成本线性膨胀,延迟也越来越高。
- 服务是多实例的。Nginx 把请求轮流分给多台机器,如果记忆存在进程内存里,用户下一句话被分到另一台机器就“失忆”了。
所以需要一层外部、共享、能自动过期的存储。Redis 正好三样都满足。
二、为什么是 Redis
对应到记忆场景,就一句话:Redis 管当前会话(短期),postgres/向量库管跨会话(长期),把真正需要的信息组装进 Prompt 交给模型。
三、Redis 实例:从建连到销毁
很多人写 Agent 记忆时直接到处 redis.get(),结果连接数爆炸、程序退不出去。正确做法是全程序共用一个客户端实例。
3.1 创建实例
import Redis from 'ioredis'
const redis = new Redis({
host: 'localhost',
port: 6380, // 注意:容器 6379 映射到宿主机 6380
db: 0, // 选 0 号库
})
三个要点:
- new Redis() 创建即连接,不是 lazy 的。这行执行完,连接就已经在建立了。
- 构造函数里能配的远不止这些:password、maxRetriesPerRequest、retryStrategy、enableReadyCheck。没配 requirepass 就不要传 password,否则会报 ERR AUTH called without any password configured。
- db 是逻辑库,0~15。同一个 Redis 里不同 db 数据隔离,SELECT 切库。生产上建议用 key 前缀做隔离,而不是靠 db。
3.2 事件监听
redis.on('connect', () => console.log('Redis 连接成功'))
redis.on('error', (err) => console.log('Redis 连接失败', err))
- error 必须监听。ioredis 在连接失败时会 emit error,Node 的 EventEmitter 对未监听的 error 事件会直接抛异常终止进程。
- connect 只代表 TCP 通了;真正可用还可以监听 ready。
- 断线重连是 ioredis 内置的,你不用手动写重试逻辑。
3.3 命令调用:都是 Promise
await redis.set('agent:short_memory:demo_user_001:messages', payload, 'EX', 1800)
const raw = await redis.get('agent:short_memory:demo_user_001:messages')
const ttl = await redis.ttl('agent:short_memory:demo_user_001:messages')
await redis.del('agent:short_memory:demo_user_001:messages')
几个容易踩的点:
- 参数是位置参数,不是对象。set(key, value, 'EX', 1800) 这四个参数就是 Redis 原生命令 SET key value EX 1800。别写成 { ex: 1800 }。
- EX 单位是秒,PX 是毫秒,NX 是“不存在才写”,XX 是“存在才写”。
- ttl 的返回值:-1 表示 key 存在但没设过期,-2 表示 key 不存在。
- 同一个客户端上的命令会排队串行执行,不需要你自己加锁。
3.4 优雅退出
await redis.quit() // 发 QUIT 命令,等已排队的命令执行完再断
// redis.disconnect() // 立刻断,不等
不调用就会导致 Node 进程不退出——因为连接是活跃句柄。这是新手最常遇到的“脚本跑完了但终端卡住”的原因。
补充:SCAN 代替 KEYS *。KEYS 会阻塞整个 Redis,生产环境禁用;排查用 redis-cli --scan --pattern 'agent:short_memory:*'。
四、Store 实例:把 Redis 命令封装成“会话仓储”
直接在各处写 redis.get 会让 key 规则散落一地,改前缀要改十几处。所以抽一个类,只负责消息的读写,不掺业务逻辑。
class RedisMessageStore {
constructor({ redis, keyPrefix, ttlSeconds }) {
this.redis = redis // 复用外部连接实例
this.keyPrefix = keyPrefix // 如 'agent:short_memory:'
this.ttlSeconds = ttlSeconds
}
messagesKey(sessionId) {
return `${this.keyPrefix}${sessionId}:messages`
}
async loadMessages(sessionId) {
const raw = await this.redis.get(this.messagesKey(sessionId))
if (!raw) return []
return mapStoredMessagesToChatMessages(JSON.parse(raw))
}
async saveMessage(sessionId, messages) {
const payload = JSON.stringify(mapChatMessagesToStoredMessages(messages))
await this.redis.set(this.messagesKey(sessionId), payload, 'EX', this.ttlSeconds)
}
async ttl(sessionId) {
return this.redis.ttl(this.messagesKey(sessionId))
}
}
const store = new RedisMessageStore({
redis, // 上面创建的那个客户端
keyPrefix: 'agent:short_memory:',
ttlSeconds: 1800,
})
三个方法对应三条 Redis 命令:
注意 SET 是整值覆盖,不是追加。所以每轮必须把“历史 + 新消息 + 模型回复”的完整数组写回去,漏一条就丢上下文。
为什么用 String 而不是 List? 因为 LangChain 的消息需要整体序列化/反序列化,一个 JSON 数组最直观;用 List 就得 LPUSH/LRANGE 管理下标,还要处理裁剪,反而更复杂。数据量小的会话记忆,String 是最优解。
五、Agent 实例与模型实例
const model = new ChatOpenAI({
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: { baseURL: process.env.OPENAI_API_BASE_URL }, // 指向 DeepSeek
temperature: 0,
})
const agent = createAgent({
model,
tools: [],
systemPrompt: '你是会话助手,记住用户提到的事实……',
middleware: [
summarizationMiddleware({
model,
summaryPrompt,
trigger: { messages: 8 },
keep: { messages: 4 },
})
]
})
- ChatOpenAI 走的是 OpenAI 兼容协议,只要把 baseURL 指向 https://api.deepseek.com/v1 就能用 DeepSeek。baseURL 大小写敏感,写成 baseurl 会静默失效。
- createAgent 返回的 agent 本身不存状态。它每次 invoke 只处理你传进去的 messages,处理完就忘。记忆完全靠外部的 Redis 手动 load/save——这既是负担,也是自由度(换存储只改 Store)。
六、一轮对话的完整闭环
async function invokeWithMemory(agent, store, sessionId, userText) {
const history = await store.loadMessages(sessionId) // 1. 读
const result = await agent.invoke(
{ messages: [...history, new HumanMessage(userText)] }, // 2. 拼
{ recursionLimit: 30 }
)
await store.saveMessage(sessionId, result.messages) // 3. 写
const ttl = await store.ttl(sessionId) // 4. 查
console.log('过期时间:', ttl)
return result
}
四步各有细节:
- 读:GET 拿到的是字符串,JSON.parse 后还要过 mapStoredMessagesToChatMessages 才能变回带方法的 BaseMessage 实例。
- 拼:历史在前,新的 HumanMessage 在后。顺序错了模型会理解成“用户先说了新话”。
- 写:存的是 result.messages,不是你传进去的数组。因为模型回复也在里面,摘要中间件压缩后的结果也在里面。
- 查:TTL 每次写都会重置回 1800,这就是“活跃会话永不过期,闲置半小时自动清理”。
七、两处格式转换:为什么不能直接存对象
这是最容易糊涂的地方,记住两个边界:
ChatOpenAI / Agent ──(mapChatMessagesToStoredMessages)──► 纯数据对象
│
JSON.stringify
▼
Redis 字符串
│
JSON.parse
▼
ChatOpenAI / Agent ◄──(mapStoredMessagesToChatMessages)── 纯数据对象
- 为什么要转:HumanMessage 是类实例,原型链上挂着 _getType()、concat()、toDict() 等方法。JSON.stringify 只序列化可枚举的自有属性,方法全部丢失。直接从 Redis 读回来 JSON.parse 得到的只是 { content, role } 这样的普通对象,LangChain 内部调用 message._getType() 就会报 “not a function”。
- 映射函数干什么:把实例压平成 { type: 'human', data: {...} } 这种纯数据;反向再把纯数据 new 回对应的 HumanMessage / AIMessage / SystemMessage / ToolMessage。
- 名字是复数:mapChatMessagesToStoredMessages(messages → messages),单数形式在 v1 里不存在,写错直接 undefined is not a function。
所以 Redis 里存的就是一段 JSON 字符串,type 字段只能是 human / ai / system / tool 之一,整体必须是一个数组。手动往 Redis 塞测试数据时格式错了,读回来就会炸。
八、摘要中间件:上下文超长怎么办
Redis 存的全量历史会越来越长,最终还是顶到窗口。summarizationMiddleware 就是自动压缩器:
- trigger: { messages: 8 } —— 消息数攒到 8 条触发(键名必须是复数 messages,写成 message 会直接报 Invalid summarization middleware options)。
- keep: { messages: 4 } —— 压缩时保留最近 4 条原文,前面的用 LLM 按 summaryPrompt 压成一段摘要。
- summaryPrompt 里的 {message} 是占位符,中间件会把待压缩的对话塞进去。
压缩后的结果会作为一条摘要消息混在 result.messages 里,所以第 3 步写回 Redis 时,存进去的已经是“摘要 + 最近 4 条”的精简版。下一轮再读出来,模型看到的就是这段摘要,从而间接记住了更早的事。
这就是“短期记忆 + 自动压缩”的组合价值:Redis 负责不丢,中间件负责不爆。
九、Key 设计与 TTL 策略
推荐的 key 结构:
agent:short_memory:{sessionId}:messages → String,JSON 消息数组
agent:short_memory:{sessionId}:meta → Hash,用户信息、创建时间、轮次
agent:long_memory:{userId} → 长期记忆(实际走向量库)
设计原则:
- 前缀分层:业务:存储类型:维度:id。这样 --scan --pattern 'agent:short_memory:*' 能一次捞全某个业务的 key。
- sessionId 必须唯一且可预测(通常是 userId + 会话 id),否则多用户串话。
- TTL 按业务定:客服会话 30 分钟,编程助手可能几小时。宁可短一点,靠长期记忆兜底,也不要无限堆积。
- 多用途就别塞一个 key:元信息用 Hash,历史用 String,各管各的,读取时也省得解析整个大 JSON。
- 不要用 db 做隔离。Redis Cluster 只支持 db 0,靠 db 分环境的代码后面迁移会痛。
十、验证与排查
# 宿主机连(映射到 6380)
redis-cli -p 6380 -n 0 --scan --pattern 'agent:short_memory:*'
# 容器内连(容器里是 6379,别搞混)
docker exec -it agent_redis redis-cli -n 0
> type agent:short_memory:demo_user_001:messages # string
> get agent:short_memory:demo_user_001:messages # 看 JSON
> ttl agent:short_memory:demo_user_001:messages # 剩余秒数
排查清单:
十一、小结
四句话概括:
- Agent 无状态,记忆必须外置 —— Redis 管会话,向量库管长期。
- Redis 客户端全局单例,new 即连接、监听 error、退出前 quit。
- Store 把 GET / SET EX / TTL 封成领域方法,key 分层加唯一 sessionId。
- 消息对象和 JSON 字符串之间必须经过两个 map 函数,否则方法丢失。
再加一条工程经验:历史不会无限增长,一定要有压缩策略——trigger + keep 这套参数,就是让 Redis 里的数据始终维持在窗口可承受的范围。
本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。
I. Issues to be addressed
LLM is not in the state itself: every request is brand-new, and it doesn't remember what you said in the last round. And business often requires it “to remember what it just said and to continue talking down”. In addition, there are two realities that go beyond the past:
- context window is limited. Every round of history is stuck in Prompt, token ' s linear expansion and increasing delays.
- There are many examples of service. Nginx rotates requests to multiple machines, and if memory exists in the memory, the next sentence is assigned to another machine.
Therefore, an external, shared and automatically obsolete storage layer is required. Redis happens to meet all three.
II. WHY REDIS
Corresponds to the memory scene, in one sentence: Redis handles the current session (short term), postgres/port library interchamber (long term), and assembles the really needed information into Prompt to the model.
III. Example of Redis: from establishment to destruction
A lot of people write about Agent's memory directly everywhere, redis.get(), resulting in a connection number of explosions and a program that can't go back. The correct approach is to share a client case across the process.
3.1 Creating Examples
import Redis from 'ioredis'
const redis = new Redis({
host: 'localhost',
port: 6380, / / Note: Container 6379 Map to host 6380
db: 0, / / Select 0 Library
})
Three main points:
- new Redis() creates connection, not lazy. Once the line is implemented, the connection is already in place.
- The construction function is much more than that: password, maxRetriesPerRequest, retryStrategy, ableReadyCheck. If you're not good enough, don't password, ERR AUTH called without any password configured.
- db is the logic library, 0-15. Different db data segregation in the same Redis, SELEECT tecto. Production suggests isolation with a key prefix instead of db.
3.2 Incident listening
Redis.on
{\cHFFFFFF}{\cH00FF00}
- Error must listen. Oredis will emit error, Node 's EventEmiter will throw an abnormal termination directly at unheard events.
- Connect only means TCP is through; real use can be monitored.
- Disconnected reconnection is embedded in Oredis, you don't have to write retry logic manually.
3.3 Command call: Both Promise
await redis.set('agent:short_memory:demo_user_001:messages', payload, 'EX', 1800)
const raw = await redis.get('agent:short_memory:demo_user_001:messages')
const ttl = await redis.ttl('agent:short_memory:demo_user_001:messages')
await redis.del('agent:short_memory:demo_user_001:messages')
A few easy points to step on:
- Arguments are position parameters, not objects. Set key value EX 1800. Do not write to {ex: 1800}.
- EX units are seconds, PXs are milliseconds, NX is "none" written, XX is "nature" written.
-Ttl return value: 1 indicates that the key exists but does not expire, 2 means that the key does not exist.
- The same client's orders are lined up, and you don't have to lock yourself.
3.4 An elegant exit
Wait until the queued command is executed
/ / redis.disconnect() / / / / / / / / / / /
Failure to call will cause the Node process not to quit -- because the connection is active. This is the reason why beginner most frequently encountered “scripts run out but the end is stuck”.
Supplement: SCAN instead of KEYS*. KEYS will block the entire Redis and the production environment will be disabled; the search will be carried out using redis-cli-scan-pattern 'agent:hort memory:*.
IV. State Example: Seal Redis commands as "session storage"
Write the redis.get directly from all over the place and the key rules will be scattered and the prefix will be changed to a dozen places. So a class is drawn, only for reading and writing of the message, and not for business logic.
class RedisMessageStore {
constructor({ redis, keyPrefix, ttlSeconds }) {
This.redis=redis / / Reuse external connection instance
This.keyPrefix = keyPrefix / / / if 'agent: short memoory:'
this.ttlSeconds = ttlSeconds
}
messagesKey(sessionId) {
return `${this.keyPrefix}${sessionId}:messages`
}
async loadMessages(sessionId) {
const raw = await this.redis.get(this.messagesKey(sessionId))
if (!raw) return []
return mapStoredMessagesToChatMessages(JSON.parse(raw))
}
async saveMessage(sessionId, messages) {
const payload = JSON.stringify(mapChatMessagesToStoredMessages(messages))
await this.redis.set(this.messagesKey(sessionId), payload, 'EX', this.ttlSeconds)
}
async ttl(sessionId) {
return this.redis.ttl(this.messagesKey(sessionId))
}
}
const store = new RedisMessageStore({
Retis, / / The client created above
keyPrefix: 'agent:short_memory:',
ttlSeconds: 1800,
})
Three methods correspond to three Redis commands:
Note that SET is the whole value overlay, not add. So each round must write back the complete array of History + New Message + Model Response, and leave one missing at context.
Why is String instead of List? Because LangChain's message requires a whole sequence/reverse sequence, a JSON array is the most intuitive; using List requires LPUSH/LRANGE to manage the bottom of the label and to process the cropping. The small-volume session memory, String, is the best solution.
V. Examples of Agent and Models
const model = new ChatOpenAI({
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
{baseURL: protocol.env. OPENAI API BASE URL}, / / point to DeepSeek
temperature: 0,
})
const agent = createAgent({
model,
tools: [],
SystemPrompt: 'You're a session assistant, remember the facts mentioned by the user...',
middleware: [
summarizationMiddleware({
model,
summaryPrompt,
trigger: { messages: 8 },
keep: { messages: 4 },
})
]
})
- ChatOpen AI follows the Open AI Compatibility Agreement and can use DeepSeek by pointing baseURL to https://api.deepseek.com/v1. BaseURL is case sensitive and is written as baseurl silently lapses.
- CreateAgent returns the angent itself in non-existent condition. Every time it comes in, it only handles your messages, and forgets. Redis manual load/save - which is both a burden and a freedom (restoration only Store).
VI. COMPREHENSIVE CONTEXT OF THE TIAL
async function invokeWithMemory(agent, store, sessionId, userText) {
/ 1. Read
const result = await agent.invoke(
{messages: [...history, new Human Message]}, / 2.
{ recursionLimit: 30 }
)
/ 3. Write
/ 4.
Console.log
return result
}
The four steps are detailed:
- Read: GET has a string and JPON.parse has to go past MapStoredMessagesToChatMessages to return to the BaseMessage example.
- Spell: History before, new Human Message after. The wrong sequence model is understood to mean "user first says something new."
- Write: Saves the results.messages, not the array you're sending in. Because model responses are also in there, so are the results of the compression of the summary intermediate.
- Check: TTL resets 1800 each time, which means "active sessions never expire, idle half an hour of automatic clean-up".
VII. Two-part format conversions: why not directly save objects
This is the easiest place to be confused, remembering two boundaries:
ChatOpen AI / Agent - (mapChatMessesToStoredMessages) - pure data objects
│
JSON.stringify
▼
Redis string
│
JSON.parse
▼
ChatOpen AI/ Agent-(mapStotedMessagesToChatMessages) - Pure Data Object
- Why turn around: HumanMessage is an example of a prototype chain with methods such as getType(), concat(), toDict(). JSON.stringify only serializes an itemable number of its own properties, and all methods are lost. All you can get directly from Redis is {content, roll} a normal object that Langchain calls message. getType() will report “not a fight”.
- What does the mapping function do: apply the example to { type: 'human', data: } this pure data; reverse back to the humanmessage/AIMESSAGE/SystemMessage/ToolMesseage.
- The name is the plural: mapChatMessagesToStoredMessages. Unique forms do not exist in v1.
So Redis has a JSON string, type Z109Z can only be one of humans / ai / system / tool, and the whole must be an array. Manually test the data to Redis plug in the wrong format, and then it explodes.
VIII. summary Middle: What about context?
The full history of Redis is going to grow longer, and eventually it's going to top the window. Sommarization Middleware is the auto compressor:
-Trigger: {messages: 8} - } } } } } } } } } } } } } } } } } } }
-Keep: {messages: 4} - Keep the last 4 originals when compressing, with the LLM before pressing the file Prompt into a summary.
- The {message} in the Summary Prompt is a placeholder, and the middle will plug in the conversation to be compressed.
The result of the compression will be mixed as a summary message in a result.messages, so the 3rd step back to Redis is already contained in a simplified version of “summary + the last 4 articles”. The next round will read, and the model will see this summary, which will indirectly remember something earlier.
This is the combination value of short-term memory plus auto-compression: Redis is responsible for non-discard, and the middle is responsible for non-explosion.
Key Design and TTL Policy
Recommended key structure:
Could not close temporary folder: %s
Agent: short memoory: {sessionId}:meta → Hash, user information, creation time, rotation
angent:long memory: → long-term memory
Design principles:
- Prefix: Operations: Storage Type: Dimensions: id. This way -- scan-pattern 'ent: short memoory:* can get the key of a particular business at a time.
- SessionId must be the only and predictable (usually userId+ id) otherwise multi-user string.
- TTL, by business: 30 minutes for the guest service, maybe hours for the programming assistant. It would be better to be short, under long-term memory, than to accumulate indefinitely.
- Do not fill a key for multiple uses: Hash for meta-information, String for history, all of them, and saves the entire JSON for reading.
- Don't use db to do quarantine. Redis Cluster only supports db 0, which can hurt when moving behind the code in db.
X. VERIFICATION AND CLASSIFICATION
# Host company (map to 6380)
redis-cli -p 6380 -n 0 --scan --pattern 'agent:short_memory:*'
# Inside the container chain
docker exec -it agent_redis redis-cli -n 0
> type agent:short_memory:demo_user_001:messages # string
> get angent: short memoory: demo user 001:messages #
>tl angent: short memoory: demo user 001:messages#s left
List:
Summary
Four sentences sum up:
-Agent has no state, memory has to be external - Redis session, vector bank is long.
- Redis client single case, new is connected, bugged, quit before quit.
- Store seals the GET / SET EX / TTL into a field method, keeps layer and only session Id.
- The message object must pass two Map functions between the JSON string, otherwise the method is lost.
Add another piece of engineering experience: History will not grow indefinitely, and there will have to be a compression strategy - the trigger + keep the data in Redis - to remain within the reach of the window.
Auto-fetched and translated by AI Atlas. All rights belong to the original author.