持久化与缓存:Agent的数据底座 — 没有持久化的Agent像金鱼记忆,重启就忘

先说结论

早期 self-media-agent 只有内存存储 --- 所有人设、选题、生成内容全放在进程内的 dict 里。跑得好好的,但一重启,全没了。辛苦配置的人设、攒了几十天的选题、生成的内容,全归零。

在 self-media-agent 项目里,最终演进为 三层存储 + 一层缓存 的数据底座:

复制代码
存储层(持久化)                          缓存层(提速)
┌─────────────────────────────┐       ┌──────────────────────────┐
│  Repository(数据访问层)     │       │  MemoryCache(LRU + TTL) │
│  ├── MemoryStore(内存)     │       │  热点数据 10 分钟不重复爬  │
│  └── FilePersistence(文件) │       │  选题数据 30 分钟不重复算  │
└─────────────────────────────┘       └──────────────────────────┘
        重启后数据还在                       重启后缓存清空(没关系)

同一个 Agent,配置 storage.mode: "persist" 就持久化到 YAML 文件,配置 storage.mode: "memory" 就纯内存运行。热点数据走缓存,10 分钟内不重复爬取,既省 API 调用又快。

职责 重启后 对应代码
MemoryStore 进程内 dict 存储,所有 CRUD 的实际执行者 清空 storage/memory.py
FilePersistence 内存数据 ↔ YAML/JSON 文件双向序列化 文件还在 storage/persistence.py
Repository 组合两者,启动时恢复,操作后落盘 自动恢复 storage/repository.py
MemoryCache LRU + TTL 缓存,热点数据提速 清空(无所谓) cache/__init__.py

持久化解决"重启就忘",缓存解决"重复劳动"。两者各管各的事,不能混为一谈。

一、为什么需要持久化?

金鱼记忆:重启就忘

arduino 复制代码
场景:你花了半小时配置了一个"美妆温柔干货"人设
     生成了 20 篇选题,写了 5 篇内容
     ↓
     修改了一行代码,重启服务
     ↓
     人设没了,选题没了,内容没了
     ↓
     从零开始 💀

这不是夸张。早期 self-media-agent 就是纯内存运行 --- 所有数据存在 Python 进程的 dict 里,进程一退出,dict 就被垃圾回收,数据灰飞烟灭。

没有持久化的三个致命问题

  1. 重启丢数据 --- 改代码、部署、崩溃、断电,任何一次重启都是一次"失忆"
  2. 无法多实例 --- 两个进程各存各的,数据不一致
  3. 无法审计 --- 数据在内存里,没法用 cat 查看,没法用 git 追踪变更

持久化 vs 缓存:别搞混

很多人把持久化和缓存混为一谈,但它们解决的是完全不同的问题:

arduino 复制代码
持久化(Persistence)          缓存(Cache)
├── 解决:重启后数据还在吗?     ├── 解决:能不能少算一次?
├── 数据是"事实",不能丢         ├── 数据是"副本",丢了无所谓
├── 写入时机:每次修改后         ├── 写入时机:第一次计算后
├── 生命周期:永久(直到主动删)  ├── 生命周期:TTL 过期 / LRU 淘汰
└── 例子:人设、选题、生成内容    └── 例子:热点列表、LLM 生成结果

一句话区分:持久化的数据丢了你会哭,缓存的数据丢了你会说"那就重新算一遍呗"。

在 self-media-agent 里,这两者由不同的模块负责:

复制代码
人设、选题、内容、聊天会话  →  Repository(持久化,重启不丢)
热点数据、LLM 缓存          →  MemoryCache(缓存,丢了重新爬)

二、三层存储架构

架构全景

ini 复制代码
                     ┌──────────────────────────────────────┐
                     │          Repository                   │
                     │      (数据访问层 / 门面)             │
                     │                                      │
                     │  启动时:persistence.load(store)      │
                     │  操作后:_maybe_persist()             │
                     └────────┬──────────────┬──────────────┘
                              │              │
                   ┌──────────▼──┐    ┌──────▼──────────────┐
                   │ MemoryStore  │    │  FilePersistence    │
                   │ (内存引擎)  │    │  (文件持久化)      │
                   │              │    │                     │
                   │ _personas    │    │  store.yaml         │
                   │ _topics      │◄──►│  ├── personas: []   │
                   │ _contents    │    │  ├── topics: []     │
                   │ _chat_sessions│   │  └── contents: []   │
                   └──────────────┘    └─────────────────────┘
                   快(纳秒级)          慢(毫秒级)
                   重启清空              重启还在

三层各管各的事

速度 重启后 职责
MemoryStore 纳秒级 清空 所有 CRUD 操作的执行者
FilePersistence 毫秒级 文件还在 序列化 / 反序列化
Repository --- 自动恢复 组合两者,对外提供统一接口

为什么分三层,而不是一层?

复制代码
如果只有 MemoryStore     → 重启丢数据
如果只有 FilePersistence  → 每次读写都要 IO,慢
如果合在一起             → 职责不清,改存储格式要改业务代码
分三层                   → 各司其职,换存储引擎只改一层

关键设计:MemoryStore 是"快路径"(所有读写都走内存),FilePersistence 是"慢路径"(只在落盘和恢复时走文件)。Repository 是"门面",对外屏蔽内部细节 --- 调用方不需要知道数据在内存还是在文件里。

三、MemoryStore:纯内存存储引擎

四个 dict 管所有数据

python 复制代码
# storage/memory.py
​
class MemoryStore:
    """纯内存存储引擎"""
​
    def __init__(self) -> None:
        self._personas: dict[str, PersonaConfig] = {}
        self._topics: dict[str, Topic] = {}
        self._contents: dict[str, GeneratedContent] = {}
        self._chat_sessions: dict[str, ChatSession] = {}

四类数据,四个 dict

dict 存什么 key value
_personas 人设配置 persona.id PersonaConfig
_topics 选题 topic.id Topic
_contents 生成内容 content.id GeneratedContent
_chat_sessions 聊天会话 session.id ChatSession

为什么用 dict 而不是 list? --- O(1) 的按 ID 查找。如果用 list,查找要遍历,数据量大了就慢。

CRUD:简单直接

python 复制代码
    # --- Persona ---
    def save_persona(self, persona: PersonaConfig) -> PersonaConfig:
        self._personas[persona.id] = persona
        return persona
​
    def get_persona(self, persona_id: str) -> PersonaConfig | None:
        return self._personas.get(persona_id)
​
    def list_personas(self) -> list[PersonaConfig]:
        return list(self._personas.values())
​
    def delete_persona(self, persona_id: str) -> bool:
        if persona_id in self._personas:
            del self._personas[persona_id]
            return True
        return False

四个方法,对应 CRUD 的四个操作

python 复制代码
save_persona   → Create / Update(dict 赋值,有就覆盖,没有就新增)
get_persona    → Read(dict.get,不存在返回 None)
list_personas  → Read All(返回 list,不返回 dict 的视图)
delete_persona → Delete(先检查存在,再 del)

注意 save 的语义 --- 它既是 Create 也是 Update。dict 赋值 self._personas[persona.id] = persona,如果 key 已存在就覆盖,不存在就新增。这比 SQL 的 INSERT + UPDATE 分开写简单得多。

注意 list 的写法 --- list(self._personas.values()) 而不是直接返回 self._personas.values()。因为 values() 返回的是动态视图,如果调用方修改了返回值,会影响内部数据。list() 做了一层浅拷贝,隔离了外部修改。

按条件查询

rust 复制代码
    def get_topics_by_persona(self, persona_id: str) -> list[Topic]:
        return [t for t in self._topics.values() if t.persona_id == persona_id]

没有 SQL 的 WHERE,用列表推导式过滤 --- 简单场景够用了。数据量大时(比如几万条选题),遍历会慢,但当前项目数据量在百级别,不是问题。

聊天会话的特殊查询

python 复制代码
    def get_chat_session_by_content(self, content_id: str) -> ChatSession | None:
        """获取某篇内容的聊天会话(一篇内容最多一个会话)"""
        for s in self._chat_sessions.values():
            if s.content_id == content_id:
                return s
        return None

一篇内容最多一个聊天会话 --- 不是按 ID 查,而是按 content_id 反查。遍历 _chat_sessions 找到第一个匹配的。如果数据量大,应该建一个 content_id → session_id 的反向索引,但当前规模不需要。

统计

python 复制代码
    def stats(self) -> dict[str, int]:
        return {
            "personas": len(self._personas),
            "topics": len(self._topics),
            "contents": len(self._contents),
        }

len(dict) 是 O(1) --- Python 的 dict 内部维护了 size 计数器,不需要遍历。所以统计接口几乎零开销。

MemoryStore 的本质:一个纯 Python dict 的 CRUD 封装,零外部依赖,零配置,纳秒级读写。它是整个存储层的"快路径",所有数据操作最终都落在它身上。

四、FilePersistence:文件持久化

save:内存 → 文件

python 复制代码
# storage/persistence.py

class FilePersistence:
    """可选文件持久化(YAML/JSON)"""

    def __init__(self, store_dir: str = "data/store", format: str = "yaml") -> None:
        self.store_dir = Path(store_dir)
        self.format = format
        self.store_dir.mkdir(parents=True, exist_ok=True)

    def save(self, store: MemoryStore) -> None:
        """将内存数据落盘"""
        data = {
            "personas": [p.model_dump(mode="json") for p in store.list_personas()],
            "topics": [t.model_dump(mode="json") for t in store._topics.values()],
            "contents": [c.model_dump(mode="json") for c in store.list_contents()],
        }

        if self.format == "yaml":
            file_path = self.store_dir / "store.yaml"
            with open(file_path, "w", encoding="utf-8") as f:
                yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
        else:
            file_path = self.store_dir / "store.json"
            with open(file_path, "w", encoding="utf-8") as f:
                json.dump(data, f, ensure_ascii=False, indent=2, cls=_DateTimeEncoder)

save 做了三件事

ini 复制代码
① 序列化:Pydantic model → dict(model_dump(mode="json"))
② 组织:三个列表放进一个 dict
③ 写文件:yaml.dump 或 json.dump

为什么用 model_dump(mode="json") 而不是 model_dump()

javascript 复制代码
# model_dump() 返回的 dict 里,datetime 还是 datetime 对象
>>> persona.model_dump()
{'created_at': datetime(2026, 7, 17, 16, 38, 53), ...}

# model_dump(mode="json") 返回的 dict 里,datetime 变成了 ISO 字符串
>>> persona.model_dump(mode="json")
{'created_at': '2026-07-17T16:38:53.828809', ...}

YAML 不能直接序列化 datetime 对象 (会报错或产生不兼容的格式),mode="json" 把所有类型转成 JSON 兼容的类型(datetime → 字符串、Path → 字符串、Enum → 值),这样 yaml.dump 就能直接处理。

注意 sort_keys=False --- 保持插入顺序,不按 key 字母序排列。这样人读 YAML 文件时,字段顺序和代码里定义的一致,不是乱序的。

落盘后的文件长什么样

yaml 复制代码
# data/store/store.yaml(截取)

personas:
- id: 760b08d8f2d9
  name: 美妆温柔干货
  niche: beauty
  persona_type: gentle_expert
  writing_style: casual
  content_format: xiaohongshu
  opening_template: 姐妹们~今天来聊聊{topic}
  closing_template: 希望对你们有帮助呀~💕
  emoji_frequency: medium
  temperature: 0.8
  style_profile: ''
  style_preferences: []
  created_at: '2026-07-17T16:38:53.828809'
  updated_at: '2026-07-20T15:25:11.930723'

topics:
- id: a1b2c3d4
  persona_id: 760b08d8f2d9
  title: 夏季防晒推荐
  status: used
  created_at: '2026-07-17T16:40:29.000000'

contents:
- id: e5f6g7h8
  persona_id: 760b08d8f2d9
  topic_id: a1b2c3d4
  title: 夏季防晒怎么做?10个干货帮你轻松搞定
  body: |
    姐妹们~今天来聊聊夏季防晒...
  created_at: '2026-07-17T16:40:35.000000'

人能读,Git 能 diff --- 这是 YAML 相比 JSON 的优势。JSON 没有注释、不能多行字符串,改起来不直观。YAML 可以直接用编辑器打开修改,改完重启就生效。

load:文件 → 内存

python 复制代码
    def load(self, store: MemoryStore) -> None:
        """从文件恢复到内存"""
        yaml_path = self.store_dir / "store.yaml"
        json_path = self.store_dir / "store.json"

        # 确定加载哪个文件
        file_path: Optional[Path] = None
        if self.format == "yaml" and yaml_path.exists():
            file_path = yaml_path
        elif self.format == "json" and json_path.exists():
            file_path = json_path
        elif yaml_path.exists():
            file_path = yaml_path
        elif json_path.exists():
            file_path = json_path

        if file_path is None:
            logger.debug("未找到持久化文件,跳过加载")
            return

文件查找的优先级

复制代码
① 配置说 yaml + yaml 文件存在  → 读 yaml
② 配置说 json + json 文件存在  → 读 json
③ yaml 文件存在(不管配置)    → 读 yaml(兼容旧配置)
④ json 文件存在(不管配置)    → 读 json(兼容旧配置)
⑤ 都不存在                    → 跳过,纯内存启动

③④ 是容错 --- 比如之前用 YAML 存的,后来配置改成 JSON,但还没迁移。这时优先读已存在的文件,不因为配置不匹配就丢数据。

反序列化:容错是关键

python 复制代码
        # 恢复人设
        from ..persona.schema import PersonaConfig
        from ..topic.schema import Topic
        from ..content.schema import GeneratedContent

        for p_data in data.get("personas", []):
            try:
                persona = PersonaConfig.model_validate(p_data)
                store.save_persona(persona)
            except Exception as e:
                logger.warning(f"恢复人设失败: {e}")

        for t_data in data.get("topics", []):
            try:
                topic = Topic.model_validate(t_data)
                store.save_topic(topic)
            except Exception as e:
                logger.warning(f"恢复选题失败: {e}")

每条数据单独 try-except --- 一条恢复失败,不影响其他条。

python 复制代码
场景:YAML 文件里有一条人设的字段格式不对
     ↓
     ❌ 如果整体 try-except:全部人设恢复失败,数据全丢
     ✅ 逐条 try-except:  只丢这一条,其他正常恢复

model_validate 做反序列化 + 校验 --- 把 dict 转成 Pydantic model,同时校验类型和值域。如果 YAML 文件被手动改坏了(比如 temperature: "abc"),model_validate 会抛 ValidationError,被 try-except 捕获,跳过这一条。

为什么 import 写在函数内部而不是文件顶部? --- 避免循环导入。persistence.py 导入 memory.py,如果再在顶部导入 persona/schema.py,而 persona/schema.py 又间接依赖 storage/,就会循环。放在函数内部,只在 load 被调用时才导入,此时所有模块已加载完毕。

JSON 格式的特殊处理

python 复制代码
class _DateTimeEncoder(json.JSONEncoder):
    """JSON 编码器,处理 datetime"""

    def default(self, obj: object) -> str:
        if isinstance(obj, datetime):
            return obj.isoformat()
        return super().default(obj)

YAML 能直接序列化 datetime,JSON 不行 --- Python 的 json.dump 遇到 datetime 会报错 TypeError: Object of type datetime is not JSON serializable。所以需要自定义编码器,把 datetime 转成 ISO 字符串。

不过用了 model_dump(mode="json") 后,datetime 已经变成字符串了,这个编码器其实是双保险 --- 万一某个嵌套字段漏了 mode="json",编码器还能兜底。

五、Repository:数据访问层

组合 MemoryStore + FilePersistence

python 复制代码
# storage/repository.py

class Repository:
    """数据访问层 --- 统一接口,屏蔽存储实现"""

    def __init__(
        self,
        persist_dir: Optional[str] = None,
        persist_format: str = "yaml",
    ) -> None:
        self.store = MemoryStore()
        self.persistence: Optional[FilePersistence] = None

        # 启动时从文件恢复
        if persist_dir:
            self.persistence = FilePersistence(
                store_dir=persist_dir,
                format=persist_format,
            )
            self.persistence.load(self.store)
            logger.info(f"Repository 初始化(持久化模式: {persist_dir})")
        else:
            logger.info("Repository 初始化(纯内存模式)")

Repository 做了两件事

lua 复制代码
① 创建 MemoryStore(必有)
② 如果传了 persist_dir:
     创建 FilePersistence
     启动时立即 load(文件 → 内存)
   如果没传:
     纯内存模式,不落盘

两种模式,一个开关

ini 复制代码
# 持久化模式(配置 storage.mode: "persist")
repo = Repository(persist_dir="data/store", persist_format="yaml")
# → 启动时从 store.yaml 恢复,操作后落盘

# 纯内存模式(配置 storage.mode: "memory")
repo = Repository(persist_dir=None)
# → 启动时空数据,操作后不落盘,重启丢数据

配置驱动 --- 在 app.py 里根据配置决定用哪种模式:

python 复制代码
# api/app.py

class AppState:
    def __init__(self, config: AppConfig | None = None) -> None:
        self.config = config or load_config()
        self.repo = Repository(
            persist_dir=self.config.storage.persist_dir
                if self.config.storage.mode == "persist"
                else None,
            persist_format=self.config.storage.persist_format,
        )

一行配置切换模式 --- storage.mode: "persist" 持久化,storage.mode: "memory" 纯内存。不用改代码,不用重新部署。

_maybe_persist:操作后可选落盘

python 复制代码
    def _maybe_persist(self) -> None:
        """操作后可选落盘"""
        if self.persistence:
            self.persistence.save(self.store)

就这 3 行 --- 如果有持久化层,就把内存数据全量写入文件;如果没有(纯内存模式),什么都不做。

调用方式 --- 在路由或业务代码里,每次修改数据后调一次:

php 复制代码
# api/routes/persona.py
state.repo.store.delete_persona(persona_id)  # ① 改内存
state.repo._maybe_persist()                  # ② 落盘

# pipeline/runner.py
self.repo.store.save_content(content)         # ① 改内存
self.repo._maybe_persist()                    # ② 落盘

# topic/pool.py
result = self.repo.store.save_topic(topic)    # ① 改内存
self.repo._maybe_persist()                    # ② 落盘

两步走的模式 :先操作 repo.store(快,纳秒级),再调 _maybe_persist()(慢,毫秒级)。如果不需要持久化,第二步就是空操作,零开销。

为什么 Repository 没有封装 save_persona 等方法? --- 早期版本可能封装过,但后来发现每个方法都要写 self.store.save_xxx() + self._maybe_persist(),样板代码太多。不如让调用方显式两步走,反而更清晰 --- 你能清楚地看到"这里改了数据"和"这里落了盘"。

六、持久化时机:什么时候保存?

当前方案:每次修改后全量保存

bash 复制代码
# 每次修改数据后,立即全量落盘
state.repo.store.save_topic(topic)
state.repo._maybe_persist()  # → 把所有数据写入 store.yaml

"全量"的意思 --- 不是只写改了的那一条,而是把内存里的所有人设、选题、内容全部重新写入文件。

scss 复制代码
# persistence.py 的 save 方法
def save(self, store: MemoryStore) -> None:
    data = {
        "personas": [p.model_dump(mode="json") for p in store.list_personas()],   # 全部人设
        "topics": [t.model_dump(mode="json") for t in store._topics.values()],    # 全部选题
        "contents": [c.model_dump(mode="json") for c in store.list_contents()],   # 全部内容
    }
    yaml.dump(data, f, ...)

三种持久化时机对比

方案 实现 优点 缺点
每次修改后(当前) 改一条 → 全量写文件 最简单,数据最安全 频繁写 IO,数据量大时慢
定时保存 每 N 秒写一次 IO 次数少 最多丢 N 秒的数据
退出时保存 atexit 钩子 IO 最少 崩溃时丢所有未保存数据

当前选"每次修改后"的原因

  1. 数据量小 --- 百级别的人设和选题,全量写 YAML 只需几十毫秒,用户无感
  2. 数据珍贵 --- 人设配置花了几十分钟,丢一次用户就不信任了
  3. 实现最简 --- 不需要定时器、不需要退出钩子、不需要脏标记

什么时候该换方案?

yaml 复制代码
数据量 < 1000 条    → 每次修改后全量保存(当前方案)
数据量 1000~10000   → 定时保存(每 30 秒)+ 退出时保存
数据量 > 10000      → 增量保存(只写改了的那条)或换数据库

为什么不用增量保存?

javascript 复制代码
增量保存:只写改了的那一条
  → 需要记录"哪条改了"(脏标记)
  → 需要读旧文件、改对应条目、写回
  → YAML 不支持"改一个字段",必须全量重写
  → 要增量保存,必须换格式(SQLite、JSONL)

YAML 的局限 --- YAML 是全量读、全量写的格式,不支持局部更新。要增量保存,得换存储格式。但当前数据量下,全量写的性能完全够用,换格式是过度设计。

七、缓存策略:LRU + TTL

为什么需要缓存?

erlang 复制代码
场景:用户打开 Web 界面,查看"美妆"赛道的当前热点
     ↓
     第一次:调用热点爬虫,爬小红书+抖音+微博 → 耗时 3 秒
     第二次:又查看"美妆"热点 → 再爬一遍 → 又 3 秒
     第三次:又查看 → 又 3 秒...
     ↓
     热点数据 10 分钟内不会变,为什么要重复爬?

缓存的核心思想:相同输入的计算结果,存起来,下次直接用。

复制代码
有缓存:第一次 3 秒,之后 0.01 秒(命中缓存)
没缓存:每次都 3 秒

LRU + TTL:两种淘汰策略

css 复制代码
LRU(Least Recently Used)最近最少使用
  ├── 容量满时,淘汰最久没访问的
  └── 解决:容量有限,优先保留"热门"数据

TTL(Time To Live)存活时间
  ├── 每个条目设置过期时间,到期自动淘汰
  └── 解决:数据会过期,热点 10 分钟前的数据不新鲜了

两者配合

复制代码
缓存满了       → LRU 淘汰最久没用的(腾空间)
数据过期了     → TTL 淘汰过期的(保新鲜)
两者同时满足   → 既不溢出,又不过期

MemoryCache 实现

python 复制代码
# cache/__init__.py

@dataclass
class CacheEntry:
    """缓存条目"""

    value: Any
    expires_at: float  # 过期时间戳,0 表示永不过期
    created_at: float = field(default_factory=time.time)
    access_count: int = 0

    @property
    def is_expired(self) -> bool:
        return self.expires_at > 0 and time.time() > self.expires_at

CacheEntry 存了四个字段

字段 作用
value 实际的缓存值
expires_at 过期时间戳(绝对时间),0 表示永不过期
created_at 创建时间,用于统计
access_count 访问次数,用于统计热度

is_expired 的逻辑 --- expires_at > 0 排除"永不过期"的情况(expires_at = 0),time.time() > expires_at 判断是否已过期。

get:读缓存

python 复制代码
class MemoryCache:
    """进程内存缓存引擎 --- LRU + TTL"""

    def __init__(
        self,
        max_size: int = 2000,
        default_ttl: float = 300.0,
        cleanup_interval: float = 60.0,
    ) -> None:
        self.max_size = max_size
        self.default_ttl = default_ttl
        self._store: OrderedDict[str, CacheEntry] = OrderedDict()
        self._lock = asyncio.Lock()
        self._hits = 0
        self._misses = 0

    async def get(self, key: str, default: Any = None) -> Any:
        async with self._lock:
            entry = self._store.get(key)

            if entry is None or entry.is_expired:
                self._misses += 1
                if entry is not None and entry.is_expired:
                    del self._store[key]  # 过期了,顺手删掉
                return default

            # LRU: 移到末尾(最近访问)
            self._store.move_to_end(key)
            entry.access_count += 1
            self._hits += 1
            return entry.value

get 做了四件事

vbnet 复制代码
① 查找:self._store.get(key)
② 判断:不存在 or 已过期 → miss,返回 default
③ LRU 更新:move_to_end(key),标记为"最近访问"
④ 统计:hits + 1,access_count + 1

OrderedDict 是 LRU 的关键 --- Python 的 OrderedDict 有一个特性:move_to_end(key) 可以把某个 key 移到末尾。这样末尾是最近访问的,头部是最久没访问的 。容量满时,popitem(last=False) 弹出头部,就是 LRU 淘汰。

less 复制代码
OrderedDict 的顺序(LRU 的核心):

  头部(最久没访问)              尾部(最近访问)
  ┌────┬────┬────┬────┬────┐
  │ A  │ B  │ C  │ D  │ E  │
  └────┴────┴────┴────┴────┘

  访问 C → move_to_end("C")
  ┌────┬────┬────┬────┬────┐
  │ A  │ B  │ D  │ E  │ C  │  ← C 移到末尾
  └────┴────┴────┴────┴────┘

  容量满,淘汰 → popitem(last=False)
  ┌────┬────┬────┬────┐
  │ B  │ D  │ E  │ C  │  ← A 被淘汰(最久没访问)
  └────┴────┴────┴────┘

set:写缓存

python 复制代码
    async def set(
        self,
        key: str,
        value: Any,
        ttl: Optional[float] = None,
    ) -> None:
        if ttl is None:
            ttl = self.default_ttl

        expires_at = 0.0 if ttl <= 0 else time.time() + ttl

        async with self._lock:
            # 如果 key 已存在,先删除(保证 move_to_end 正确)
            if key in self._store:
                del self._store[key]

            # LRU 淘汰:容量满时删除最旧的
            while len(self._store) >= self.max_size:
                oldest_key, _ = self._store.popitem(last=False)
                logger.debug(f"LRU 淘汰: {oldest_key}")

            self._store[key] = CacheEntry(value=value, expires_at=expires_at)

set 做了四件事

python 复制代码
① 计算 TTL:None → 用默认 TTL,0 → 永不过期
② 删旧值:如果 key 已存在,先删(保证新值在末尾)
③ LRU 淘汰:容量满时,while 循环淘汰最旧的(可能淘汰多个)
④ 写入:新 CacheEntry 放入 OrderedDict(自动在末尾)

while 而不是 if --- 理论上一次只淘汰一个就够了(因为每次 set 只加一个)。但用 while 更安全 --- 如果 max_size 被改小了,可能需要淘汰多个才能腾出空间。

TTL 的三种情况

csharp 复制代码
# ttl=None → 用默认 TTL(300 秒)
await cache.set("key", value)

# ttl=600 → 600 秒后过期
await cache.set("key", value, ttl=600)

# ttl=0 → 永不过期
await cache.set("key", value, ttl=0)

命名空间:隔离不同业务

python 复制代码
    async def clear_namespace(self, namespace: str) -> int:
        """清空指定命名空间的所有缓存"""
        prefix = f"{namespace}:"
        async with self._lock:
            keys_to_delete = [k for k in self._store if k.startswith(prefix)]
            for k in keys_to_delete:
                del self._store[k]
            return len(keys_to_delete)

命名空间用 key 前缀实现

python 复制代码
cache_key = f"hotspot:{niche or 'all'}"   → "hotspot:beauty"
cache_key = f"topic:{persona_id}"          → "topic:760b08d8f2d9"

clear_namespace("hotspot")  → 删除所有 "hotspot:*" 的缓存

好处 --- 热点数据更新了,可以一键清空所有热点缓存,不用逐条删。不同业务的数据互不干扰。

get_or_set:缓存未命中时自动生成

python 复制代码
    async def get_or_set(
        self,
        key: str,
        factory: Any,  # Callable[[], Awaitable[Any]]
        ttl: Optional[float] = None,
    ) -> Any:
        """获取缓存,未命中时调用 factory 生成并缓存"""
        value = await self.get(key)
        if value is not None:
            return value

        # 未命中,调用工厂函数
        value = await factory()
        await self.set(key, value, ttl=ttl)
        return value

get_or_set 是缓存的"惯用写法" --- 先查缓存,命中就返回;未命中就调 factory() 生成值,写入缓存,再返回。

python 复制代码
# 不用 get_or_set:手动三步
cached = await cache.get("hotspot:beauty")
if cached is not None:
    return cached
data = await crawl_hotspots("beauty")
await cache.set("hotspot:beauty", data, ttl=600)
return data

# 用 get_or_set:一行
return await cache.get_or_set(
    "hotspot:beauty",
    lambda: crawl_hotspots("beauty"),
    ttl=600,
)

当前项目的热点路由没有用 get_or_set,而是手动展开 --- 因为想在"未命中"时加一些额外逻辑(比如日志、配置检查),手动写更灵活。但 get_or_set 在简单场景下更简洁。

后台清理:自动淘汰过期数据

python 复制代码
    async def start(self) -> None:
        """启动后台过期清理任务"""
        if self._cleanup_task is None:
            self._cleanup_task = asyncio.create_task(self._cleanup_loop())

    async def _cleanup_loop(self) -> None:
        """后台定期清理过期条目"""
        while True:
            try:
                await asyncio.sleep(self.cleanup_interval)  # 默认 60 秒
                await self._cleanup_expired()
            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.error(f"缓存清理异常: {e}")

    async def _cleanup_expired(self) -> None:
        """清理所有过期条目"""
        async with self._lock:
            expired_keys = [k for k, v in self._store.items() if v.is_expired]
            for k in expired_keys:
                del self._store[k]

为什么需要后台清理?

scss 复制代码
没有后台清理:
  → 过期数据不会被主动删,只在被 get() 访问到时才删
  → 如果某些 key 过期了但再也没人访问,它们会一直占着内存
  → 内存只增不减,最终可能 OOM

有后台清理:
  → 每 60 秒扫一遍,主动删除所有过期条目
  → 内存占用稳定

asyncio.create_task --- 在事件循环里起一个后台协程,和主逻辑并行。start() 在应用启动时调用(lifespan 里),stop() 在应用关闭时取消任务。

统计:命中率

python 复制代码
    async def stats(self) -> dict[str, Any]:
        async with self._lock:
            total = self._hits + self._misses
            hit_rate = self._hits / total if total > 0 else 0.0

            now = time.time()
            expiring_soon = sum(
                1 for e in self._store.values()
                if 0 < e.expires_at < now + 60  # 60秒内过期
            )

            return {
                "size": len(self._store),
                "max_size": self.max_size,
                "hits": self._hits,
                "misses": self._misses,
                "hit_rate": hit_rate,
                "expiring_soon": expiring_soon,
                "default_ttl": self.default_ttl,
            }

命中率 = hits / (hits + misses) --- 命中率越高,缓存越有效。如果命中率低于 50%,说明缓存策略有问题(TTL 太短、或者 key 设计不合理)。

expiring_soon --- 60 秒内即将过期的条目数。用于监控预警 --- 如果大量条目同时过期,可能会在下一秒产生缓存雪崩(大量请求同时 miss,同时回源)。

异步安全:asyncio.Lock

python 复制代码
self._lock = asyncio.Lock()

async def get(self, key: str, ...):
    async with self._lock:    # ← 加锁
        entry = self._store.get(key)
        ...

为什么需要锁? --- FastAPI 是异步框架,多个请求可能并发访问缓存。OrderedDict 不是线程安全的,并发读写可能导致数据错乱。

asyncio.Lock 而不是 threading.Lock --- 因为 FastAPI 是单线程异步(事件循环),不是多线程。asyncio.Lock 是协程级别的锁,不会阻塞事件循环;threading.Lock 会阻塞整个线程,在异步框架里是禁忌。

全局单例:懒初始化

python 复制代码
# ========== 全局缓存实例 ==========

_global_cache: Optional[MemoryCache] = None

def get_cache() -> MemoryCache:
    """获取全局缓存实例(懒初始化)"""
    global _global_cache
    if _global_cache is None:
        _global_cache = MemoryCache()
    return _global_cache

def init_cache(
    max_size: int = 2000,
    default_ttl: float = 300.0,
) -> MemoryCache:
    """初始化全局缓存实例"""
    global _global_cache
    _global_cache = MemoryCache(max_size=max_size, default_ttl=default_ttl)
    return _global_cache

两个函数,两种用法

scss 复制代码
init_cache(max_size=2000, default_ttl=300)  → 启动时用配置初始化
get_cache()                                  → 运行时获取已初始化的实例

懒初始化 --- get_cache() 第一次调用时才创建实例。这样即使 init_cache() 没被调用,get_cache() 也能用默认参数创建一个,不会报错。

为什么用全局单例? --- 缓存需要在整个应用中共享。如果每个模块各创建一个 MemoryCache,缓存就不互通了。全局单例保证所有人用的是同一个缓存实例。

八、缓存实战:热点数据缓存

热点路由的缓存模式

python 复制代码
# api/routes/hotspot.py

@router.get("", response_model=APIResponse)
async def get_hotspots(niche: str | None = None) -> APIResponse:
    """获取当前热点(优先从缓存读取)"""
    state = get_state()

    if not state.config.hotspot.enabled:
        return APIResponse(success=False, message="热点抓取未启用")

    from ...hotspot.crawler import HotspotCrawler

    cache_key = f"hotspot:{niche or 'all'}"
    cached = await state.cache.get(cache_key)
    if cached is not None:
        return APIResponse(data=cached)

    # 未缓存,实时抓取
    crawler = HotspotCrawler()
    platforms = state.config.hotspot.platforms
    items = await crawler.crawl(niche=niche or "beauty", platforms=platforms)
    data = [i.model_dump(mode="json") for i in items]

    # 写入缓存
    await state.cache.set(cache_key, data, ttl=state.config.hotspot.cache_ttl)

    return APIResponse(data=data)

完整的缓存流程

ini 复制代码
用户请求 GET /api/hotspots?niche=beauty
  ↓
① 构造 cache_key = "hotspot:beauty"
  ↓
② cache.get("hotspot:beauty")
  ├── 命中(未过期)→ 直接返回缓存数据(0.01 秒)
  └── 未命中(不存在 or 已过期)→ 继续
  ↓
③ 实时爬取:crawler.crawl(niche="beauty", platforms=["xiaohongshu", "douyin", "weibo"])
  → 耗时 2-5 秒
  ↓
④ cache.set("hotspot:beauty", data, ttl=600)
  → 写入缓存,600 秒后过期
  ↓
⑤ 返回数据

cache_key 的设计 --- f"hotspot:{niche or 'all'}"。用 niche 作为 key 的一部分,不同赛道的热点分开缓存。niche 为空时用 "all",避免 key 是 "hotspot:"(末尾冒号看着别扭)。

刷新缓存

python 复制代码
@router.post("/refresh", response_model=APIResponse)
async def refresh_hotspots(req: HotspotRefreshRequest) -> APIResponse:
    """刷新热点缓存"""
    state = get_state()

    from ...hotspot.crawler import HotspotCrawler

    crawler = HotspotCrawler()
    platforms = req.platforms or state.config.hotspot.platforms
    items = await crawler.crawl(niche=req.niche or "beauty", platforms=platforms)
    data = [i.model_dump(mode="json") for i in items]

    # 更新缓存
    cache_key = f"hotspot:{req.niche or 'all'}"
    await state.cache.set(cache_key, data, ttl=state.config.hotspot.cache_ttl)

    return APIResponse(data=data, message="热点已刷新")

刷新 = 强制重新爬取 + 覆盖缓存 --- 不检查缓存是否存在,直接爬取并 cache.set 覆盖旧值。用户觉得热点不新鲜了,点"刷新",立即拿到最新数据。

缓存配置

yaml 复制代码
# config/default.yaml

cache:
  enabled: true
  max_size: 2000          # 最多缓存 2000 条
  default_ttl: 300        # 默认 5 分钟过期
  hotspot_ttl: 600        # 热点 10 分钟
  topic_ttl: 1800         # 选题 30 分钟
  content_ttl: 0          # 内容永不过期

不同数据,不同 TTL

数据类型 TTL 原因
热点 600 秒(10 分钟) 热点更新快,但不能太频繁爬
选题 1800 秒(30 分钟) 选题变化慢,缓存久一点
内容 0(永不过期) 生成的内容不会变,可以一直缓存

TTL 的权衡

ini 复制代码
TTL 太短 → 缓存命中率低,频繁回源,省不了多少
TTL 太长 → 数据不新鲜,用户看到的是过时数据
TTL = 0  → 永不过期,适合不变的数据(生成内容)

九、数据恢复:启动时从文件恢复

启动流程

ini 复制代码
应用启动
  ↓
AppState.__init__()
  ↓
Repository(persist_dir="data/store")
  ↓
FilePersistence(store_dir="data/store")
  ↓
persistence.load(store)
  ├── 读取 store.yaml
  ├── 逐条 model_validate → save_persona / save_topic / save_content
  └── 恢复完成,日志打印数量
  ↓
MemoryStore 里有数据了
  ↓
服务就绪,可以处理请求

启动时的日志

csharp 复制代码
[INFO] 从持久化文件恢复数据: data/store/store.yaml
[INFO] 数据恢复完成: 3 人设, 45 选题, 28 内容
[INFO] Repository 初始化(持久化模式: data/store)
[INFO] Self-Media-Agent V2 Web 服务启动: http://0.0.0.0:8088

恢复失败的处理 --- 前面讲过,逐条 try-except,单条失败不影响整体:

less 复制代码
[WARNING] 恢复人设失败: 1 validation error for PersonaConfig
  temperature: Input should be a valid float [type=float_type, ...]
[INFO] 数据恢复完成: 2 人设, 45 选题, 28 内容
         ↑ 本来 3 个,1 个恢复失败,只恢复了 2 个

恢复 vs 首次启动

lua 复制代码
首次启动(没有 store.yaml):
  → FilePersistence.load() 找不到文件
  → logger.debug("未找到持久化文件,跳过加载")
  → MemoryStore 是空的
  → 正常运行,操作后开始落盘

后续启动(有 store.yaml):
  → FilePersistence.load() 读取文件
  → 逐条恢复到 MemoryStore
  → MemoryStore 有数据了
  → 正常运行,操作后覆盖落盘

无缝切换 --- 用户不需要关心是首次启动还是恢复启动,代码自动处理。这是 load 方法里 if file_path is None: return 的作用 --- 没有文件就跳过,不报错。

踩坑记录

坑1:早期只有内存存储,重启丢数据

ini 复制代码
早期版本:
  personas = {}   # 全局 dict
  topics = {}
  contents = {}

  → 跑了一下午,生成 20 篇内容
  → 改了一行代码,重启
  → 全没了 💀
  → 重新生成又要花一小时 + LLM API 费用

根因:纯内存存储,进程退出数据就没了。

修复 :加了 FilePersistence,每次修改后落盘到 YAML 文件,启动时恢复。

复制代码
修复后:
  → 跑了一下午,生成 20 篇内容
  → 改代码,重启
  → 启动时从 store.yaml 恢复,20 篇内容还在 ✅

教训:任何需要跨会话保留的数据,都必须有持久化。纯内存存储只适合临时数据(缓存)或演示用。持久化应该在项目第一天就加上,不要等"丢数据了"才补。

坑2:每次修改都全量保存,YAML 大文件性能差

ini 复制代码
场景:积累了 100 篇内容,每篇 2000 字
  → store.yaml 约 2MB
  → 每次生成一篇新内容 → _maybe_persist()
  → 把 100+1 篇内容全量写入 YAML
  → 每次写盘 200ms+
  → 批量生成 10 篇 → 10 次 × 200ms = 2 秒纯 IO

根因:YAML 是全量写格式,每次保存都要把所有数据重新序列化。数据量大了,IO 成为瓶颈。

当前缓解

makefile 复制代码
# config/default.yaml
storage:
  mode: "persist"
  persist_format: "yaml"   # 也可以用 json,性能略好

未来方案

css 复制代码
方案A:定时保存(每 30 秒一次,而不是每次修改)
  → IO 次数从 N 次降为 1 次/30 秒
  → 但最多丢 30 秒数据

方案B:增量保存(只写改了的那条)
  → 需要换格式:JSONL(每行一条 JSON)或 SQLite
  → YAML 不支持局部更新

方案C:分文件存储(每个 persona 一个文件)
  → 单文件不会太大
  → 但跨人设查询需要读多个文件

教训:全量保存在小数据量下没问题,但要提前规划 --- 当数据量增长到什么程度时需要换方案?不要等用户抱怨"越来越慢"才想起来。

坑3:缓存和持久化搞混

复制代码
早期想法:把热点数据也持久化到 store.yaml
  → 热点数据 10 分钟就过期了
  → 持久化到文件,重启后还是过期的数据
  → 用户看到 2 小时前的热点,还以为是最新的

根因:没区分"持久化"和"缓存"。热点数据是"会过期的临时数据",不是"需要永久保存的事实"。

修复 :热点数据走 MemoryCache(TTL 600 秒),不走 FilePersistence。重启后缓存清空,重新爬取最新热点。

复制代码
持久化(store.yaml):人设、选题、内容、聊天会话
  → 重启后恢复,因为这些是用户的数据

缓存(MemoryCache):热点数据
  → 重启后清空,因为旧热点没意义了

教训 :持久化和缓存解决不同问题,不要混在一起。判断标准:这数据丢了我会不会心疼? 心疼 → 持久化;不心疼 → 缓存。

坑4:恢复时单条失败导致整体失败

ini 复制代码
早期 load 方法:
  data = yaml.safe_load(f)
  personas = [PersonaConfig.model_validate(p) for p in data["personas"]]
  # → 如果第 3 条数据格式不对,model_validate 抛异常
  # → 列表推导式中断
  # → 所有 persona 都没恢复,不只是第 3 条

根因:用列表推导式一次性转换,一条失败全盘皆输。

修复:改成 for 循环 + 逐条 try-except:

kotlin 复制代码
for p_data in data.get("personas", []):
    try:
        persona = PersonaConfig.model_validate(p_data)
        store.save_persona(persona)
    except Exception as e:
        logger.warning(f"恢复人设失败: {e}")

修复后:第 3 条失败,只跳过第 3 条,第 1、2、4、5... 条正常恢复。

教训:批量操作要考虑"部分失败"的情况。特别是数据恢复这种场景 --- 能恢复多少是多少,不要因为一条坏数据把全部数据都丢了。逐条 try-except 是数据恢复的基本素养。

关键 Takeaway

  1. 三层存储各司其职 --- MemoryStore 负责快读写(纳秒级 dict 操作),FilePersistence 负责序列化(YAML/JSON 双向转换),Repository 负责组合与编排(启动时恢复 + 操作后落盘)。换存储引擎只改一层,业务代码不用动。配置 storage.mode 一行切换内存/持久化模式。
  2. 持久化时机选"每次修改后全量保存" --- 在数据量小(百级别)的场景下,全量写 YAML 只需几十毫秒,用户无感,且数据最安全。当数据量增长到千级别以上时,再考虑定时保存或增量保存。不要过早优化,但要在 YAML 文件超过 1MB 时开始规划。
  3. 缓存用 LRU + TTL,和持久化分开 --- LRU 解决容量溢出(淘汰最久没访问的),TTL 解决数据过期(淘汰超时的)。缓存存"丢了无所谓的数据"(热点),持久化存"丢了会心疼的数据"(人设、内容)。OrderedDict.move_to_end 是 LRU 的核心,asyncio.Lock 保证异步安全,后台清理任务防止过期数据占内存。

下篇预告

下一篇:《我踩过的坑:从0到1搭建Agent的12个教训

本文讲了持久化与缓存的数据底座 --- 三层存储架构怎么保证重启不丢数据,LRU + TTL 缓存怎么避免重复劳动。但回顾整个项目,踩过的坑远不止存储这一块。

css 复制代码
热点爬虫 fallback 写死 beauty → 劳务派遣抓到美妆热点
赛道硬编码在 JS → 加新赛道要改 3 个文件
SPA 单文件膨胀 → 800 行 HTML 在一个 Python 函数里
修改后原文丢失 → 没有保存 RevisionRecord
风格画像太长 → token 超限
LLM 返回 JSON 格式不稳定 → 解析失败
...

下一篇不讲"做对了什么",讲"踩了什么坑" --- 12 个真实教训,每个都是根因 → 修复 → 教训三段式。真实项目的价值不在光鲜的架构图,而在这些踩过的坑。

相关推荐
多学一分钟2 小时前
RAG:原理、完整链路、各模块,以及优缺点
agent
左青2 小时前
markdown 即数据库:为 AI 会话设计一个"文件协议"工作流
ai编程
小四的小六2 小时前
AI写测试翻车实录:测试全绿,上线还是崩了——一个format函数暴露的盲区
aigc·openai·ai编程
半糖程序员2 小时前
从零构建 Agent(2):选择并调用不同模型服务
agent
码途AI工坊2 小时前
大模型时代必修课:懂 Token 的人,用 1 块钱跑出别人 100 块钱的效果。
ai编程
武子康2 小时前
同一份长文问两次,SGLang 怎样少算一遍
人工智能·llm·agent
Jul1en_3 小时前
Matt 与 Uncle Bob 的播客访谈有感
开发语言·经验分享·笔记·ai·开源·github·ai编程
王解3 小时前
AG-33_Grok Build vs Claude Code:两种 Code Agent 路线对比
agent
是2的10次方啊3 小时前
AI 能写代码了,还要学设计模式、Spring 源码和 JVM 吗?
ai编程