2026/10/13 19:37:22

Agent实践:从零搭建AI助手缓存系统,优化成本与响应速度

Agent实践:从零搭建AI助手缓存系统,优化成本与响应速度 这篇是Agent实践系列的第7篇。前面的阶段我把个人AI助手的骨架搭了起来LLM会话管理能跑通天气、日历、笔记、搜索这类常用工具也接上了。本以为可以舒舒服服用起来结果真实跑了两周问题全冒出来了模型API费用肉眼可见地涨一个简单问题要等两三秒才出答案同一类问题反复问、反复计费知识库检索结果一模一样却每次都要重新算。今天上午查过一次天气下午再问一次模型照样收一份钱。所以我抽出时间做了阶段3的核心工作——缓存系统搭建。这篇把整个设计和实现过程完整复盘一遍从架构选型、核心代码、参数调优到实际踩坑一次性说清楚。如果你也在自建Agent或AI助手已经跑通基本功能、接下来想优化成本与响应速度这篇会很对胃口。核心关键词先说清楚缓存系统在个人AI助手里并不是一个锦上添花的组件而是决定这个助手能不能长期低成本跑下去的关键基础设施。下面从设计思路开始一步步拆解。1. 先想清楚缓存到底解决了什么问题1.1 个人AI助手的钱和时间花在了哪里在动手写代码之前我先把助手的运行成本算了一笔账。只有把账算明白才知道缓存系统做到什么程度才算合格。假设我的助手日均处理100次LLM调用平均每次输入1500 token、输出800 token按当前主流模型API的价位输入和输出单价不同输出通常贵2到3倍估算一个月下来光API费用就是一笔不容忽视的开销。如果某些调用是重复的——比如同一个知识库问题、同一天的天气、同一只股票的行情——那么这些钱实际上是白花的。延迟也一样。一次完整的LLM调用通常要经历网络传输、排队、解码生成实测下来2到10秒都很正常。但用户也就是我自己在对话时90%的等待其实都花在了重复计算上同一个问题换个说法再问一次模型还是从零开始生成答案。我把问题归纳成三句话重复查询多天气、汇率、百科类问题答案高度稳定没必要次次调模型。单次响应慢模型生成的耗时占了整个交互延迟的大头。整体费用线性增长调用量越大费用越高没有边际递减。这三点正好是缓存系统的主战场命中缓存可以直接跳过LLM调用让响应时间从秒级降到毫秒级费用趋近于零。1.2 缓存不是可选项而是Agent稳定性的必需品很多人觉得缓存只是用来省钱的在Agent场景里它的价值远不止省钱。个人AI助手本质上是把LLM、外部工具、本地知识库拼在一起。任何一个环节抖动整个助手就不可用。我在使用中碰到过几次外部天气API限流、模型服务商网络波动这些时候如果有缓存兜底至少能保证常见问题的回答不中断。缓存在这里扮演的是降级链路的第一环请求进来 → 查缓存 → 命中直接返回 → 未命中才调用LLM或外部工具 → 拿到的结果写回缓存。有了这一层助手对上游服务的依赖从每次都要变成了通常不用体验和稳定性都上了一个台阶。1.3 盘点个人AI助手里的可缓存资产在设计缓存系统之前我先盘点了我的助手到底有哪些数据值得缓存避免一股脑全塞进去。我整理成了一张清单数据类型来源变化频率缓存建议对话历史消息本地会话记录频繁追加按session缓存用于上下文管理工具调用结果外部API天气、汇率、新闻中高频变化短TTL30秒到30分钟知识库检索结果本地向量检索低频变化长TTL或手动失效Agent中间规划结果LLM推理链依赖具体任务可按键复用缓存LLM最终回答模型生成高频相似提问语义缓存相似问题复用这个盘点过程很重要它决定了缓存系统的边界哪些数据是静态可复用的哪些是动态必须实时的。比如实时股票行情就不能缓存太久而知识库条目基本可以缓存一整天。2. 架构设计别一上来就上Redis2.1 从三层缓存策略说起个人AI助手最忌讳过度设计。我一个本地运行的助手不可能同时存在多个实例共享缓存在这种场景下引入一堆外部组件完全是给自己找麻烦。我采用的是经典的三层缓存模型但落地时只用了前两层L1 进程内缓存速度极快纳秒级访问适合高频重复读取的热数据。缺点是进程重启后数据全部丢失容量受内存限制。L2 本地磁盘缓存把缓存持久化到本地文件或SQLite进程重启后还能用容量大得多。缺点是访问速度比内存慢一两个数量级。L3 外部缓存分布式内存数据库这类组件适合多实例共享缓存。个人助手场景基本用不到我直接砍掉。这样的结构设计逻辑很简单热数据放内存冷数据落磁盘未来如果真要多实例部署再在L3层补一个外部缓存不影响现有代码。2.2 存储选型对比SQLite 为什么能打赢第二层我重点对比了几种方案最终还是选了SQLite。选型过程如下表方案优势劣势适用场景内存字典/cachetools极快实现简单重启丢数据容量受限L1热缓存SQLite单文件持久化支持并发读高并发写入略弱L2持久缓存个人场景首选JSON/JSONL文件直观可读调bug方便数据量大了读写很慢少量配置类缓存外部内存数据库功能全支持分布式多一套子系统运维成本高多实例共享项目初期不推荐我选SQLite的原因很简单一个文件搞定持久化支持事务Python自带驱动不需要额外起服务。开启WAL模式后并发读写也够用。个人AI助手的缓存量级撑死在几十万条以内SQLite完全吃得下。L1层我用的是带TTL的LRU字典python的collections.OrderedDict就能实现一个精简版后面代码部分详细说。2.3 TTL、容量与逐出策略这些参数决定命中率缓存系统有三个核心参数必须一开始就定清楚TTL过期时间、容量上限、逐出策略。TTL是缓存系统里最容易拍脑袋乱设的参数。我一开始把所有缓存都设成10分钟结果天气类缓存太短、知识库类缓存太长命中率反而不理想。后来按数据类型分别设置缓存对象建议TTL设计理由天气实况60300秒天气变化较快但用户不会频繁刷新汇率/股价300600秒这类数据实时性要求中等新闻摘要6001800秒半小时内重复读同一批新闻很常见知识库检索86400秒或手动失效本地知识变更的频率很低对话历史整个session存续期上下文管理需要连续数据语义缓存回答3600秒以上相似问题重复率高答案稳定容量策略方面L1内存缓存我限制最多2000条并在写入时检查单条大小超过50KB的大对象不放入L1只进L2防止内存被撑爆。逐出策略统一用LRULeast Recently Used因为它简单有效热点数据会自然保留下来。3. 核心实现从接口到工具的完整缓存层3.1 先设计一个不换思想也能用的缓存接口代码好不好维护接口设计是关键。我定义了一个极其精简的缓存接口只有四个方法from typing import Any, Optional class CacheProtocol: def get(self, key: str) - tuple[Any, bool]: 返回 (value, 是否命中)。命中为True未命中为False。 raise NotImplementedError def set(self, key: str, value: Any, ttl: Optional[int] None) - None: 写入缓存。ttl单位为秒None表示不自动过期。 raise NotImplementedError def delete(self, key: str) - None: 删除指定key。 raise NotImplementedError def clear(self) - None: 清空全部缓存。 raise NotImplementedError设计成元组返回(value, hit)而不是命中了返回value、没命中返回None是为了区分缓存不存在和缓存的值本身就是None这两种情况。这种细节在实际开发里很影响正确性。业务层永远只依赖这个接口底层用内存、SQLite还是别的对上层完全透明。以后想把L1换成更快的组件或者加一层分布式缓存都只需要写一个新的实现类。3.2 双层缓存实现Memory LRU SQLite 持久化先实现L1。一个带TTL和容量限制的LRU内存缓存OrderedDict是Python里做LRU最顺手的工具import time from collections import OrderedDict from dataclasses import dataclass from typing import Any, Optional dataclass class CacheEntry: value: Any expires_at: Optional[float] class MemoryLRUCache: def __init__(self, capacity: int 2000): self.capacity capacity self._store: OrderedDict[str, CacheEntry] OrderedDict() def get(self, key: str) - tuple[Any, bool]: if key not in self._store: return None, False entry self._store.pop(key) if entry.expires_at is not None and entry.expires_at time.time(): return None, False self._store[key] entry # 重新插入把它放到末尾 self._store.move_to_end(key) return entry.value, True def set(self, key: str, value: Any, ttl: Optional[int] None) - None: if key in self._store: del self._store[key] expires_at time.time() ttl if ttl else None self._store[key] CacheEntry(value, expires_at) self._store.move_to_end(key) while len(self._store) self.capacity: self._store.popitem(lastFalse) # 淘汰最久未使用的 def delete(self, key: str) - None: self._store.pop(key, None) def clear(self) - None: self._store.clear()实现逻辑很简单每次get都把命中的条目移到末尾淘汰时移除开头的条目配合TTL判断就能实现一个标准的 LRU TTL 内存缓存。然后是L2。SQLite持久层注意开启WAL模式并设置单写连接避免多线程写入报database is lockedimport sqlite3 import pickle import time from typing import Any, Optional class SQLiteCache: def __init__(self, db_path: str agent_cache.db): self._conn sqlite3.connect(db_path, check_same_threadFalse) self._conn.execute(PRAGMA journal_modeWAL) self._conn.execute( CREATE TABLE IF NOT EXISTS cache ( key TEXT PRIMARY KEY, value BLOB, expires_at REAL ) ) self._conn.execute(CREATE INDEX IF NOT EXISTS idx_expires ON cache(expires_at)) def get(self, key: str) - tuple[Any, bool]: row self._conn.execute( SELECT value, expires_at FROM cache WHERE key?, (key,) ).fetchone() if row is None: return None, False value_blob, expires_at row if expires_at is not None and expires_at time.time(): return None, False return pickle.loads(value_blob), True def set(self, key: str, value: Any, ttl: Optional[int] None) - None: expires_at time.time() ttl if ttl else None value_blob pickle.dumps(value, protocol4) self._conn.execute( INSERT OR REPLACE INTO cache(key, value, expires_at) VALUES(?,?,?), (key, value_blob, expires_at) ) self._conn.commit() def delete(self, key: str) - None: self._conn.execute(DELETE FROM cache WHERE key?, (key,)) self._conn.commit() def clear(self) - None: self._conn.execute(DELETE FROM cache) self._conn.commit()这里有两个关键决策用pickle序列化通用性最强Python对象直接序列化省去字段映射的功夫。但pickle反序列化不可信数据有安全风险我的助手只缓存本地自己生成的数据风险可控。定期清理过期数据get时惰性删除过期key另外我会写一个小脚本每小时执行一次DELETE FROM cache WHERE expires_at now()防止磁盘膨胀。最后用一个DualCache把两层串起来形成统一入口from typing import Any, Optional class DualCache: 先查内存再查SQLite。写入时两层都写。 def __init__(self, capacity: int 2000, db_path: str agent_cache.db): self.l1 MemoryLRUCache(capacitycapacity) self.l2 SQLiteCache(db_pathdb_path) def get(self, key: str) - tuple[Any, bool]: value, hit self.l1.get(key) if hit: return value, True value, hit self.l2.get(key) if hit: self.l1.set(key, value) # 回填L1下次访问更快 return value, hit def set(self, key: str, value: Any, ttl: Optional[int] None) - None: self.l1.set(key, value, ttl) self.l2.set(key, value, ttl) def delete(self, key: str) - None: self.l1.delete(key) self.l2.delete(key) def clear(self) - None: self.l1.clear() self.l2.clear()这个DualCache就是整个缓存系统的核心入口所有业务模块共用同一个实例。L2命中后回填L1热数据自然上浮到内存层这就是经典的两级缓存联动。3.3 对话历史缓存怎么做对话历史是个人AI助手里最特殊的一类缓存。它不能简单套用TTL因为会话还没结束就必须保留而且还要考虑token窗口。我的做法是session_id维度缓存一个消息列表并记录累计token数。dataclass class SessionHistory: messages: list[dict] total_tokens: int updated_at: float class DialogCache: def __init__(self, cache: DualCache): self.cache cache def get_or_create(self, session_id: str): key fdialog:{session_id} value, hit self.cache.get(key) if hit: return value history SessionHistory(messages[], total_tokens0, updated_attime.time()) self.cache.set(key, history, ttlNone) # 会话结束才删 return history def append(self, session_id: str, message: dict, token_count: int) - None: history self.get_or_create(session_id) history.messages.append(message) history.total_tokens token_count self.cache.set(fdialog:{session_id}, history, ttlNone)这里有一个很关键的细节会话历史的TTL设为None但会在会话结束时显式删除。如果直接设一个固定的TTL用户聊到一半缓存就过期了上下文断裂非常影响体验。考虑到模型窗口有限当total_tokens接近上限时我会从消息列表头部开始丢弃旧消息直到总token降到窗口的70%以下保留最近的上下文。这一层缓存虽然看起来不起眼但它避免了每次对话都要重新组织完整上下文的重复劳动。3.4 工具调用结果缓存用装饰器统一搞定工具调用是Agent成本的大头。我的助手接了好几个外部工具比如查天气、查汇率、搜新闻这些接口每次调用都有网络延迟而且有些还是收费的。给工具调用加缓存最优雅的方式是写一个装饰器把缓存逻辑与业务逻辑彻底解耦。import hashlib import inspect import time from functools import wraps from typing import Any, Callable, Optional def cached_tool(ttl: int 300, key_prefix: str tool): def decorator(func: Callable) - Callable: wraps(func) def wrapper(*args, **kwargs): # 用函数名 参数原文生成稳定key param_repr f{args}:{sorted(kwargs.items())} key f{key_prefix}:{func.__name__}:{hashlib.sha256(param_repr.encode()).hexdigest()} value, hit _global_cache.get(key) if hit: return value value func(*args, **kwargs) _global_cache.set(key, value, ttlttl) return value return wrapper return decorator使用起来非常无感原来的天气函数一行装饰即可cached_tool(ttl300, key_prefixtool) def get_weather(city: str) - dict: # 调用外部天气API ...这样改造的好处是工具函数本身不需要感知缓存的存在缓存规则全部集中在装饰器参数里。以后想调整TTL只需要改一处。这里必须强调一个原则只对只读类工具加缓存绝不缓存有副作用的操作。比如发邮件创建日历事件删除笔记这类工具调用一次就会产生一次真实效果如果被缓存会导致严重的逻辑错误。我在装饰器里加了一个白名单机制只有明确标注为只读的工具才允许使用cached_tool。3.5 LLM响应语义缓存让相似问题不再重复付钱上面的工具缓存只覆盖外部API但对LLM本身的冗余调用还无能为力。同一个知识问题换个说法问模型还是重新生成了一遍。解决这个问题的方案是语义缓存判断两个问题语义是否相似如果相似就直接复用之前的回答。我踩过直接用字符串匹配的坑——用户说上海明天天气怎么样和明天上海会下雨吗完全不一样但语义几乎相同。所以必须用embedding做相似度判断。实现思路不复杂用户每提出一个问题先计算它的embedding向量。在缓存中查找已有的向量计算余弦相似度。如果相似度超过阈值我用的0.92直接返回缓存中对应的答案。否则调用LLM生成答案并把新问题和答案缓存起来。我用一个轻量的本地向量集合来实现个人场景数据量不大线性扫描完全够用import numpy as np class SemanticCache: def __init__(self, similarity_threshold: float 0.92): self._items [] self._threshold similarity_threshold def get_similar(self, query_embedding: np.ndarray) - tuple[Any, bool]: for item in self._items: sim float(np.dot(query_embedding, item[embedding])) if sim self._threshold: return item[answer], True return None, False def add(self, query: str, query_embedding: np.ndarray, answer: str) - None: self._items.append({ query: query, embedding: query_embedding, answer: answer, created_at: time.time() })这个缓存的容量和清理策略需要额外管理我会在写入时记录时间并限制最大条目数超出后按创建时间淘汰。但语义缓存有个大坑它只能用于只读型问答。如果问题是帮我把张三的会议改到明天下午这类意图涉及实际操作绝不能复用旧答案。我专门维护了一个禁用语义缓存的意图列表包含所有工具调用类、状态修改类意图。只有那些纯信息查询的对话才会先走语义缓存。4. 参数调优与压测复盘4.1 先加埋点再谈优化缓存系统搭好了不统计命中率就等于没搭。我在DualCache里加了命中计数器每类缓存独立统计class Stats: def __init__(self): self.hits 0 self.misses 0 def hit_ratio(self) - float: total self.hits self.misses return self.hits / total if total else 0.0我是把统计逻辑直接挂在DualCache的get方法里每类key前缀dialog:、tool:、semantic:单独计数。这样能很清晰地看到哪一类缓存利用率最高哪一类几乎没命中——没命中的缓存就是在浪费内存需要重点优化或直接关闭。4.2 TTL怎么调从压测数据说起搭好之后我做了一轮压测模拟我的真实使用场景100个问题其中40个与历史问题语义相似30个是重复的天气和汇率查询30个是新问题。压测结果非常直观没有缓存平均响应时间3.2秒100次调用全部产生API费用。加了双层缓存语义缓存平均响应时间降到280毫秒其中56次命中缓存直接返回API调用次数减少了56%。只看语义缓存相似问题复现时延迟从3.2秒降到150毫秒左右。费用方面因为API调用数直接砍半当月API账单同比下降了约四成。这个数据让我坚定了一个判断缓存系统的投资回报率非常可观。TTL调参我没有公式化而是遵从一个经验法则先设置一个偏保守的TTL比如天气300秒观察一周命中率。如果命中率超过了70%说明TTL过短可以逐步放宽如果命中率过低比如低于20%说明数据变化频率高或TTL过长需要收窄。调TTL时一次只动一个参数避免多变量同时变化分不清影响。4.3 序列化与存储体积控制序列化方式直接影响缓存读写速度和磁盘占用。我在实践中对比了三种方案方案优势劣势我的选择pickle通用性强对象直存安全风险版本兼容一般L2默认用JSON可读性高跨语言大对象序列化慢数字精度有限少量配置用msgpack体积小速度快需要额外依赖体积敏感场景用最终L2我保留pickle但做了两处升级使用protocol4兼容Python 3.8。对大于10KB的value先做zlib.compress再存SQLite显著减少磁盘占用。读取时反向解压耗时增加不到1毫秒但缓存文件体积能减一半以上。别忘了给SQLite保留定期的过期数据清理。我在应用启动时注册一个后台线程每小时执行一次self._conn.execute(DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at ?, (time.time(),)) self._conn.commit()这一步不做的话SQLite文件会缓慢膨胀最终拖慢所有查询。5. 常见问题与排查技巧实录5.1 缓存穿透、雪崩、击穿在AI助手场景的具体形态缓存领域三个经典故障在AI助手场景里会有特殊表现我挨个说。缓存穿透用户的问题全是随机新问题缓存里永远没有每次请求都打到LLM上。这在个人场景里通常不是恶意攻击而是某种批量测试或爬虫行为。我的应对方案是维护一个已知无答案的短TTL缓存如果LLM返回无法回答也缓存起来只是TTL很短60秒避免反复追问同一个无解问题。缓存雪崩大量缓存设置了相同TTL同一时刻批量过期导致下一秒所有请求同时穿透到LLM。解决方式很粗暴但也有效TTL加上随机抖动。ttl base_ttl random.randint(0, 60)缓存击穿某个热点key比如每天早上8点查天气这个场景过期瞬间并发的多个请求同时发现缓存没命中一起打到LLM上。对策是互斥重建同一时间只允许一个请求去查上游其他请求阻塞等待或快速返回旧值。我用了一个简单的方式重建期间暂时返回旧的过期值避免阻塞。5.2 数据一致性工具结果变了缓存却还新鲜缓存系统最头疼的问题就是数据一致性。AI助手场景里有一个典型矛盾知识库更新了但缓存里还是旧内容。我的解决思路是主动失效机制。当某个工具执行了写操作比如修改笔记、删除日程我会主动清掉与该对象相关的所有缓存keydef invalidate_by_prefix(self, prefix: str) - None: # 删除所有以prefix开头的缓存key for key in list(self.l1._store.keys()): if key.startswith(prefix): self.l1.delete(key) self.l2._conn.execute(DELETE FROM cache WHERE key LIKE ?, (prefix %,)) self.l2._conn.commit()在知识库更新后调用cache.invalidate_by_prefix(kb:)知识库相关的检索结果和语义回答就会在下次访问时重新生成。这个机制比单纯依赖TTL要可靠得多因为它能在数据改变这一时刻立刻让旧缓存失效而不是等过期时间慢慢到来。5.3 实战踩坑记录四个让我折腾半天的坎坑一SQLite多线程写入报database is locked。我的助手偶尔会并发处理多个任务多个线程同时往SQLite写缓存就会报错。解决办法是开启WAL模式并确保上层对同一个缓存实例的写操作尽量串行化。我在写入时加了threading.Lock实测稳定了很多。坑二L1内存缓存被大对象撑爆。有一次我发现内存占用异常飙升定位下来是对话历史缓存里塞了一整段超长的对话单条几百KB。解决方案是在写入L1前判断value大小超过50KB的对象直接跳过L1只写L2。坑三语义缓存误命中。有两次用户问上海明天天气怎么样和上海明天适合跑步吗前一个是事实查询后一个是建议类问题理论上答案应该不同但相似度计算认为它们足够接近复用了旧答案导致回答牛头不对马嘴。我的对策是严苛化阈值到0.95并且只对纯知识问答类意图启用语义缓存建议类、决策类问题一概关闭。坑四重启后缓存全丢第一次响应特别慢。保存L2之前我用纯内存方案每天第一次启动助手时总要等好几秒就是因为L1缓存是空的所有请求都走LLM。加上SQLite做L2后重启后先查磁盘热数据迅速回填首次响应也不再有明显卡顿。最后再说几句实操体会这套缓存系统上线后我自己的AI助手运行成本降了一半以上日常问答基本在几百毫秒内返回。回头看缓存系统真不用一步到位我建议从工具结果缓存和对话历史缓存起步这两块实现简单、见效最快等命中率稳定后再考虑语义缓存。每次调TTL只动一个参数别贪多。缓存最大的价值是让你在不牺牲体验的情况下把成本打下来同时给系统绑上一道稳稳的安全带。