Agent 内存与状态持久化
Agent 内存与状态持久化
学习目标
读完本章后,你将能够:
- 理解 Agent 内置内存字段的设计动机
- 设计跨 Agent 的键值共享存储系统
- 实现内存淘汰策略防止存储膨胀
- 处理 JSON 序列化与原生 JSON 列的兼容迁移
核心概念
为什么 Agent 需要内存
Agent 的核心循环(check/receive)是无状态的函数调用——每次调用都是独立的。但很多场景需要跨次调用保留状态:
| 场景 | 需要记忆的内容 |
|---|---|
| 网站变更检测 | 上一次抓取的内容,用于对比 |
| 峰值检测 | 历史时间序列数据,用于统计分析 |
| 去重过滤 | 已处理过的事件指纹 |
| 累计汇总 | 增量计数器或累加器 |
| 条件编排 | 动态配置的目标 Agent 状态 |
内存层次结构
本地内存设计
JSON 序列化字段
Agent 的 memory 字段是一个 JSON 序列化列,存储任意键值对:
# Agent 基类中声明json_serialize :options, :memory
# 子类 Agent 中使用def receive(events) memory['data'] ||= {} memory['data']['some_key'] = value # memory 自动序列化并保存到数据库end关键设计:
- 惰性初始化:
memory['data'] ||= {}在首次访问时自动创建 - 原子保存:
memory的修改在 Agentsave时自动序列化 - 类型安全:反序列化后返回
HashWithIndifferentAccess,支持字符串和符号键
双模式兼容
迁移策略:
- 初始版本使用 TEXT 列存储 JSON 字符串
- 可选迁移到原生 JSON/JSONB 列(通过
NATIVE_JSON_COLUMNS=true环境变量) JsonSerializedField模块自动检测列类型并选择合适的序列化器- 迁移期间兼容两种格式
共享内存设计
KeyValueStoreAgent
当多个 Agent 需要共享状态时,使用专门的键值存储 Agent:
工作流程:
- 写入:Source Agent 将 KeyValueStoreAgent 设为 Controller target
- 存储:KeyValueStoreAgent 的
memory作为键值存储 - 读取:Consumer Agent 在 Liquid 模板中通过变量名访问存储数据
内存管理策略
| 配置项 | 默认值 | 作用 |
|---|---|---|
max_keys | 100 | 最大保留键数 |
| 淘汰策略 | FIFO | 超出 max_keys 时删除最旧的键值对 |
| 值序列化 | 字符串化 + as_object 过滤器 | 支持存储非字符串值 |
| 空值处理 | 值为空时删除键 | 自动清理无效数据 |
内存使用模式
模式一:时间序列存储
# PeakDetectorAgent 的模式memory['data'] ||= {}memory['data'][group] ||= []memory['data'][group] << [value, timestamp]
# 滑动窗口清理def cleanup(group) newest_time = memory['data'][group].last.last memory['data'][group].reject! { |_value, time| time <= newest_time - window_duration }end模式二:去重缓存
# DeDuplicationAgent 的模式memory['properties'] ||= []
def is_unique?(property) !memory['properties'].include?(property)end
def update_memory(property, lookback) # 超出容量时移除最旧项 if lookback != 0 && memory['properties'].length == lookback memory['properties'].shift end memory['properties'].push(property)end模式三:累计状态
# 计数器模式memory['count'] ||= 0memory['count'] += 1
# 最近状态模式memory['last_value'] = current_valuememory['last_updated_at'] = Time.now.to_i陷阱与对策
陷阱一:内存膨胀
Agent 不断向 memory 写入数据但不清理,最终导致数据库行过大。
对策:
- 设置明确的上限(
max_keys、lookback) - 定期清理过期数据(滑动窗口淘汰)
- 使用 CRC32 哈希缩短长键(
Zlib.crc32(property).to_s)
陷阱二:并发写入
多个 Worker 同时修改同一 Agent 的 memory 可能导致数据竞争。
对策:
- 每个 Agent 由单一 Worker 处理(通过
agent_id路由) - 数据库层面的行级锁(ActiveRecord
save自动加锁)
陷阱三:序列化开销
大型 memory 字段在每次 save 时都完整序列化,影响性能。
对策:
- 只保留必要数据,定期清理
- 迁移到原生 JSON 列减少序列化开销
- 使用
memory字段而非events存储聚合结果(减少行数)
设计取舍
Agent 内存 vs 独立缓存服务
| 维度 | Agent 内置内存 | 独立缓存(Redis) |
|---|---|---|
| 部署复杂度 | 零(复用数据库) | 需额外服务 |
| 一致性 | 与 Agent 状态同事务 | 需处理一致性问题 |
| 性能 | 中等(DB I/O) | 高(内存访问) |
| 查询能力 | 有限(JSON 字段查询) | 丰富(数据结构操作) |
| 适用规模 | 中小型(数千 Agent) | 大型(数万+ Agent) |
选择内置内存的原因:零额外依赖,中小规模完全够用。当 Agent 数量达到万级别时,才需要考虑引入独立缓存。
FIFO vs LRU 淘汰
| 维度 | FIFO(先进先出) | LRU(最近最少使用) |
|---|---|---|
| 实现复杂度 | 极低(数组 shift/push) | 中等(需要访问追踪) |
| 命中率 | 中 | 高 |
| 内存开销 | 无额外开销 | 需要访问时间戳 |
选择 FIFO 的原因:实现简单且对于大多数 Agent 场景足够好——最新的数据最有价值,旧数据自然被淘汰。
参考来源
- Rails serialize API — ActiveRecord 序列化机制
- Redis Data Structures — 独立缓存服务的数据结构参考