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


一、要解决的问题

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

  1. 上下文窗口有限。把全部历史每轮都塞进 Prompt,token 成本线性膨胀,延迟也越来越高。
  2. 服务是多实例的。Nginx 把请求轮流分给多台机器,如果记忆存在进程内存里,用户下一句话被分到另一台机器就"失忆"了。

所以需要一层外部、共享、能自动过期的存储。Redis 正好三样都满足。

二、为什么是 Redis

需求 Redis 的能力
高频读写、低延迟 数据在内存,亚毫秒级
会话过期自动清理 TTL / EX,不用自己写定时任务
多实例共享 所有 Agent 服务连同一个 Redis
结构灵活 String/Hash/List/Set/ZSet 按场景选
可持久化 AOF/RDB,重启不丢

对应到记忆场景,就一句话:Redis 管当前会话(短期),postgres/向量库管跨会话(长期),把真正需要的信息组装进 Prompt 交给模型。

三、Redis 实例:从建连到销毁

很多人写 Agent 记忆时直接到处 redis.get(),结果连接数爆炸、程序退不出去。正确做法是全程序共用一个客户端实例。

3.1 创建实例

js 复制代码
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 事件监听

js 复制代码
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

js 复制代码
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 优雅退出

js 复制代码
await redis.quit()     // 发 QUIT 命令,等已排队的命令执行完再断
// redis.disconnect()  // 立刻断,不等

不调用就会导致 Node 进程不退出------因为连接是活跃句柄。这是新手最常遇到的"脚本跑完了但终端卡住"的原因。

补充:SCAN 代替 KEYS *。KEYS 会阻塞整个 Redis,生产环境禁用;排查用 redis-cli --scan --pattern 'agent:short_memory:*'。

四、Store 实例:把 Redis 命令封装成"会话仓储"

直接在各处写 redis.get 会让 key 规则散落一地,改前缀要改十几处。所以抽一个类,只负责消息的读写,不掺业务逻辑。

js 复制代码
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 命令:

方法 命令 语义
loadMessages GET key 不存在返回 null → 转成空数组
saveMessage SET ... EX 覆盖写,同时续期 1800 秒
ttl TTL 看还剩多少秒

注意 SET 是整值覆盖 ,不是追加。所以每轮必须把"历史 + 新消息 + 模型回复"的完整数组写回去,漏一条就丢上下文。

为什么用 String 而不是 List? 因为 LangChain 的消息需要整体序列化/反序列化,一个 JSON 数组最直观;用 List 就得 LPUSH/LRANGE 管理下标,还要处理裁剪,反而更复杂。数据量小的会话记忆,String 是最优解。

五、Agent 实例与模型实例

js 复制代码
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)。

六、一轮对话的完整闭环

js 复制代码
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
}

四步各有细节:

  1. 读 :GET 拿到的是字符串,JSON.parse 后还要过 mapStoredMessagesToChatMessages 才能变回带方法的 BaseMessage 实例。
  2. 拼 :历史在前,新的 HumanMessage 在后。顺序错了模型会理解成"用户先说了新话"。
  3. 写 :存的是 result.messages,不是你传进去的数组。因为模型回复也在里面,摘要中间件压缩后的结果也在里面。
  4. 查 :TTL 每次写都会重置回 1800,这就是"活跃会话永不过期,闲置半小时自动清理"。

七、两处格式转换:为什么不能直接存对象

这是最容易糊涂的地方,记住两个边界:

javascript 复制代码
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 结构:

vbnet 复制代码
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 分环境的代码后面迁移会痛。

十、验证与排查

bash 复制代码
# 宿主机连(映射到 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   # 剩余秒数

排查清单:

现象 常见原因
WRONGTYPE 拿 String 的命令操作 Hash/List 等 key
TTL 返回 -2 key 已过期或从没写成功
读回来报 _getType is not a function 少了 mapStoredMessagesToChatMessages
摘要不生效 trigger 写成了 message(单数)
脚本跑完不退 忘了 redis.quit()
数据莫名其妙没了 检查是不是设了很短的 EX,或写了 FLUSHDB

十一、小结

四句话概括:

  1. Agent 无状态,记忆必须外置 ------ Redis 管会话,向量库管长期。
  2. Redis 客户端全局单例 ,new 即连接、监听 error、退出前 quit。
  3. Store 把 GET / SET EX / TTL 封成领域方法,key 分层加唯一 sessionId。
  4. 消息对象和 JSON 字符串之间必须经过两个 map 函数,否则方法丢失。

再加一条工程经验:历史不会无限增长,一定要有压缩策略 ------trigger + keep 这套参数,就是让 Redis 里的数据始终维持在窗口可承受的范围。

相关推荐
Patrick_Wilson1 小时前
Go 变量不赋值时,为什么也有值
后端·go·编程语言
Patrick_Wilson1 小时前
读 Go 项目里的命令:run、build、test 分别做了什么
后端·go·编程语言
程序猿乐锅2 小时前
万字详解Single-flight(单飞模式)
java·开发语言·分布式·后端·学习·single-flight
code2cat3 小时前
【随笔】连续批处理:长短请求怎样在迭代之间补位
java·人工智能·后端·大语言模型·vllm
掘金者阿豪4 小时前
SQL Server数据迁移完成后,凌晨的数据库告警该怎么查?
前端·后端
鶴哥只手遮天4 小时前
AFSIM 2.9.0 深度解析·进阶卷第 1 章 插件源码精读:16 个 WSF 插件的架构与实现
后端
愚农搬码4 小时前
低代码表单与在线问卷不是一回事:产品定位和技术边界
后端
武子康4 小时前
固定时间步已经开了,为什么游戏回放还会走出不同位置?
人工智能·后端·agent
70后写java的老码农4 小时前
换模型只改三行配置:LangChain4j + Spring Boot 3.5 实战,一次分别接入 9 家模型服务
后端·ai编程