先说结论
早期 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 就被垃圾回收,数据灰飞烟灭。
没有持久化的三个致命问题:
- 重启丢数据 --- 改代码、部署、崩溃、断电,任何一次重启都是一次"失忆"
- 无法多实例 --- 两个进程各存各的,数据不一致
- 无法审计 --- 数据在内存里,没法用
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 最少 | 崩溃时丢所有未保存数据 |
当前选"每次修改后"的原因:
- 数据量小 --- 百级别的人设和选题,全量写 YAML 只需几十毫秒,用户无感
- 数据珍贵 --- 人设配置花了几十分钟,丢一次用户就不信任了
- 实现最简 --- 不需要定时器、不需要退出钩子、不需要脏标记
什么时候该换方案?
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
- 三层存储各司其职 --- MemoryStore 负责快读写(纳秒级 dict 操作),FilePersistence 负责序列化(YAML/JSON 双向转换),Repository 负责组合与编排(启动时恢复 + 操作后落盘)。换存储引擎只改一层,业务代码不用动。配置
storage.mode一行切换内存/持久化模式。 - 持久化时机选"每次修改后全量保存" --- 在数据量小(百级别)的场景下,全量写 YAML 只需几十毫秒,用户无感,且数据最安全。当数据量增长到千级别以上时,再考虑定时保存或增量保存。不要过早优化,但要在 YAML 文件超过 1MB 时开始规划。
- 缓存用 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 个真实教训,每个都是根因 → 修复 → 教训三段式。真实项目的价值不在光鲜的架构图,而在这些踩过的坑。