AI图鉴

AI图鉴首页 › AI 资讯 › Redis 2:Redis 在怎么处理harness记忆

Redis 2:Redis 在怎么处理harness记忆

来源:掘金 · 发布时间:11-04 · 由 AI图鉴每小时自动聚合

一、要解决的问题 大模型本身是无状态的:每次请求都是全新的,它不记得上一轮你说过什么。而业务往往要求它“记住刚才的话、接着往下聊”。此外还有两个绕不过去的现实: 上下文窗口有限。把全部历史每轮都塞进

一、要解决的问题

大模型本身是无状态的:每次请求都是全新的,它不记得上一轮你说过什么。而业务往往要求它“记住刚才的话、接着往下聊”。此外还有两个绕不过去的现实:

- 上下文窗口有限。把全部历史每轮都塞进 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图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。

相关 AI 工具

相关免费羊毛

同源资讯

阅读原文(juejin.cn)

标题、摘要与正文均来自该信源的公开页面,版权归原作者所有;本站自动聚合与翻译,仅作出处跳转。

← 返回 AI 资讯列表AI工具图鉴首页

返回 AI图鉴首页 · 提交工具 · 商务合作 · 关于本站 · 609 款 AI 工具分类导航 · 每日 AI 热门项目与动态