上个月我打开本地部署的AI Web界面,想找回两周前调prompt时记下的一段系统提示词,结果会话列表已经滚了几百条,翻了三分钟才找到。顺手看了一眼数据目录,好家伙——5.8GB。也就是从那天起,我意识到“本地AI会话越积越多”不是矫情的问题,而是一个真实存在、被我无视了很久的工程负债。
这段经历让我决定认真做一次“真正可控的清理”:不是把整个数据目录一锅端,而是先看清有哪些会话、各自多大、什么时候产生的,再决定留什么、删什么、怎么备份、怎么恢复。于是我用一个下午写了个专门处理本地AI会话数据的小工具,并把设计思路、实现细节和踩坑过程完整记录下来。这篇文章适合所有在本机跑过本地大模型、本地AI应用或AI智能体实验的人,尤其是会话记录越堆越多但不敢乱删的朋友。
1. 当“保存一切”变成负担:本地AI会话为什么越堆越多
1.1 一次“翻遍三百条会话”的崩溃经历
事情是这样的:我本地跑了一套带Web界面的AI对话服务,主要用来做prompt调试、文档问答和一些Agent流程实验。最初我很享受“所有历史都在”的安全感,觉得每次实验的上下文和数据都是资产。但慢慢地,这个“资产”变成了一个不断膨胀的黑洞。
那天我要找一条很有意思的系统提示词,我记得大概是在某次Agent多轮协作调试中写的,标题是“工具调用边界约束 v3”。结果打开会话列表,当前显示的会话已经堆到380多条,还有大量未命名会话和自动生成的测试会话。我忍不住按标题翻,按时间翻,甚至按正文搜索,最后发现那条提示词被我写进了一个标题为“Copy of 未命名 12”的会话里。那一刻我意识到:会话数据多到一定程度,就不再是“方便回顾”,而是“找不到东西的负担”。
也正是这次经历,让我产生了写一个清理工具的想法。但前提是,这个工具绝不能是“哗啦一下全部清空”,因为里面确实藏着很多有价值的调试记录。
1.2 解析会话存储结构才会看到的堆积原因
很多人觉得会话多就是“聊得频繁”,但实际原因比这复杂得多。我把自己常用那套本地AI应用的数据目录完整翻了一遍,发现堆积主要来自四个源头:
第一个源头是自动保存机制。绝大多数本地AI Web界面都会在每次对话后自动写入会话和消息记录,这是“聊天应用”的基本逻辑,但几乎没有产品会主动提示你“已保存了300个会话”。而这些记录不只是文字,还包括了Markdown附件、上传的图片、文件引用、参数配置,甚至调试用的JSON片段。一个带附件的会话动辄几MB到几十MB,几百条累计下来非常可观。
第二个源头是Agent实验的爆炸式增长。我在跑智能体任务时,经常一个脚本案例能拆出几十个独立子会话:函数调用失败、重试、多Agent协作、工具返回结果,每一步都可能被记录成独立会话。这种实验型数据有很多是临时性的,当时的我认为“先留着再说”,结果一留就是几个季度。
第三个源头是向量库和索引文件。很多本地AI应用为了让“历史消息”支持语义搜索,会把每条消息切成chunk后写入向量存储。也就是说,你删除一个会话的文字记录还不够,相关的向量chunk如果不同步清理,SQLite数据库或者本地向量索引文件照样占着空间。
第四个源头是备份文件自身。部分AI应用在更新时会自动备份数据库,或者我自己在做实验时手动复制了data目录。这类备份文件命名通常是webui.db.backup、data_20241208.tar.gz,散落在数据目录里,非常容易被遗忘。
当我把这些源头列出来之后,再去想“清理”这件事,答案就清晰了:我不能靠手动去删,更不能只删表面的会话列表,我需要的是一套能识别“会话存储结构”的小工具,从数据层面做一次可观测、可回退、可重复的清理。
2. 手动删和SQL删都不可控,我需要一个“懂会话”的工具
2.1 为什么不能直接进数据库删记录
一开始我试过两个很自然的笨办法:一是直接打开数据库管理工具手动删除;二是干脆把整个data目录压缩备份后,删掉里面的大文件。
先说直接删数据库。我用的这套本地AI应用,会话数据存在SQLite里,表结构至少涉及session、message、file三个维度。我尝试手动执行一条DELETE FROM message WHERE session_id = xxx,结果界面确实能显示会话数量变少了,但检索时开始出现残留的语义片段。原因很简单:我只删了文字记录,没有同步清理消息关联的向量chunk和附件引用。更危险的是,有些表之间存在外键关联,如果先删了session,后面某些残留的message记录就成了“孤儿数据”,可能导致界面报错、统计不准,甚至某些页面直接崩溃。
再说直接删大文件。看着data目录里几个几百MB的文件很碍眼,直接删除虽然“爽”,但风险极大:你根本不知道哪个文件被当前运行中的服务占用着,也不知道哪个文件其实是模型加载缓存,删完可能连启动都启动不了。
经历过这两次“不可控”之后,我的结论很明确:清理工具必须“懂会话结构”,知道session、message、附件、向量chunk之间的关系,能识别哪些是纯历史会话、哪些是要保留的白名单,并且所有删除都要经过备份和预览。
2.2 可控性被我拆成了四个维度
为了把“可控”落到操作层面,我把需求拆成了四个维度,后来这也成为小工具的核心功能设计:
按时间清理:比如保留最近30天会话,清理更早且未标记保留的记录。这个维度最适合日常维护,因为它符合“旧数据价值随时间递减”的直觉。
按占用清理:统计每个会话占用的存储大小,优先清理占用超过指定阈值(例如50MB)的会话。这个维度最适合磁盘快满时的紧急释放。
按内容筛选:通过会话标题匹配正则表达式或关键词,找出明显是测试、临时、混乱命名的会话,例如“未命名”“test”“Copy of”等。
按白名单保留:有些会话虽然旧且大,但它是核心业务方案或重要实验记录,必须无条件保留。工具必须支持手动标记、标签识别和“已导出备份”标记,让这些会话绕过所有自动清理规则。
这四个维度组合起来之后,“清理”就从一个高风险动作变成了可精细控制的操作。你完全可以先用“时间+大小”筛出一批候选会话,再通过“白名单+关键词排除”把不想删的挑出来,最后看预览列表确认无误再执行删除。这才是真正意义上的“可控清理”。
3. 把思路写成脚本:自适应扫描与全量备份
3.1 第一步:识别当前AI应用的数据目录
写这个工具之前,我先把常用的本地AI应用数据存放规律摸了一遍。不同的应用差异很大:
- 带Web界面的主流本地AI服务(类似Open WebUI)通常把数据集中在一个data目录下,内部有SQLite数据库文件,以及存放上传文件、向量索引的子目录。
- 桌面类的AI工具(例如LM Studio)则偏好把每个会话独立保存为JSON文件,路径一般在用户目录下的工作文件夹中,结构相对简单。
- 文档问答类的应用(类似AnythingLLM)则混合了关系型数据库和本地storage目录,向量库可能单独存在一个文件夹里。
我的做法是写一个“存储适配器”配置层,通过读取环境变量或配置文件,告诉工具“当前要处理的是哪类AI应用、数据目录在哪、会话表叫什么名字、附件目录在哪”。这样工具本身不耦合任何具体产品,换个应用只需要新增一个适配器配置。
识别目录之后,工具会先打印一份扫描报告,包括数据库文件大小、附件目录大小、会话总数、消息总数。这一步非常重要,因为你只有先看清“盘子”里有什么,才有资格决定“倒掉”什么。
3.2 第二步:先备份,再谈清理
在整个工具的逻辑里,我最坚持的一点就是:不备份就不清理。哪怕只是删一个测试会话,我都要确认备份完成,因为这个世界里“手滑”是真实存在的。
备份我分两层做:
第一层是轻量快照备份。对于SQLite数据库,我不建议直接cp整个db文件,因为服务还在运行的话,复制出来的文件可能是不一致的。正确做法是用数据库自带的备份机制,或者先执行一条规范化命令。比如SQLite场景下可以用备份API,或者执行VACUUM INTO指定路径,这样能拿到一个一致性好、体积也相对小的快照文件。如果服务已经停止,直接复制db文件也没问题,但我会额外校验文件完整性。
第二层是附件和向量目录的增量备份。附件目录可能非常大,每次清理都全量复制太蠢。我的工具使用了硬链接或增量复制的方式:首次备份全量;后续备份只复制新增或变动的文件,历史文件通过硬链接复用,避免重复占空间。这一层备份主要为了应对“清理后用着用着发现某张图片还需要”的后悔场景。
备份文件统一存放在一个带日期的目录里,例如backup/20250215_0300/webui.db,并按周滚动保留最近2个轮次。这样既不会把磁盘空间从“被会话撑爆”变成“被备份撑爆”,又能保证足够长的后悔期。
3.3 核心清理流程的代码骨架
工具的主体逻辑不复杂,但流程编排要清晰。我贴一个简化版的扫描核心,方便你理解我做了什么:
import sqlite3 from pathlib import Path DATA_DIR = Path("/path/to/your/ai/data") DB_PATH = DATA_DIR / "webui.db" conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row cur = conn.cursor() # 统计每个会话的消息数、最后活动时间、对应附件大小 cur.execute(""" SELECT s.id AS session_id, s.title, MAX(m.created_at) AS last_active, COUNT(m.id) AS message_count FROM session s LEFT JOIN message m ON m.session_id = s.id GROUP BY s.id ORDER BY last_active DESC """) sessions = [] for row in cur.fetchall(): session_id = row["session_id"] att_dir = DATA_DIR / "attachments" / session_id att_size = sum(f.stat().st_size for f in att_dir.rglob("*") if f.is_file()) sessions.append({ "session_id": session_id, "title": row["title"], "message_count": row["message_count"], "last_active": row["last_active"], "att_size": att_size, }) for s in sessions: print(f"{s['session_id']}\t{s['last_active']}\t{s['att_size']}B\t{s['title']}")这个阶段我只做“读”操作,不写任何数据。扫描结果会生成一张预览清单,作为后续所有过滤条件的输入。有了这张清单,我就可以任意组合时间、大小、标题正则、白名单,输出“即将清理的会话列表”,也可以生成统计报表。
4. 真正的“可控”操作:dry-run预览、软删除、恢复闭环
4.1 dry-run:先给你一张“即将失去清单”
判断一个清理工具是否“可控”,第一个标准就是:能不能在真正删除之前,明确告诉你到底将要清理什么。我的实现方式是提供一个dry-run模式,执行时不修改任何数据,只把筛选结果输出为表格或只读HTML报告。
我在报告里列出会话ID、标题、最后活动时间、消息数、附件大小,并按大小降序排列。同时还会汇总“将释放空间”“将清理会话数”“白名单内保留会话数”三个指标。这类报告我看无数遍都不嫌多,因为数字比直觉可靠得多。
执行举例(命令行方式)是这样的:
convo-cleaner scan --config ai-webui.yaml --older-than 30d --min-size 10MB --dry-run输出结果中,所有会话前面会带有[DELETE]或[KEEP]标记。如果发现有不想删的会话,下一步就是把它加入白名单,而不是关掉工具手动去数据库改。
4.2 软删除:动手前留出后悔期
真正执行删除时,我不建议直接对数据库执行DELETE。更好的方法是“软删除”策略:把待清理会话的session记录标记为deleted,并移动到内部的回收站表;关联的附件文件则统一移动到回收站目录;向量chunk数据也会被移除或标记无效。
软删除的好处很多。第一,它不会破坏数据库事务的原子性,即使中途出错,数据也不会处于“删了一半”的脏状态。第二,它在应用层是“不可见的”——会话列表不再显示这些记录,但底层数据仍然保留,万一发现误删,可以随时恢复。第三,它可以批量执行,性能上只需要执行一次UPDATE和INSERT操作,不需要逐条DELETE。
我给软删除过程设计了两条原则:一是删除前必须看到dry-run预览清单;二是删除过程中必须记录“操作日志”,包括删除时间、执行参数、清理的会话数量、回收站路径。日志本身就是安全网,能让你知道自己到底干了什么。
4.3 恢复命令:让误删变成小概率事故
软删除的下一步是恢复功能。刚开始实现这个功能时,我遇到一个很现实的坑:SQLite的自增主键在删除后可能被复用,如果直接“原路插回”会导致新数据和旧数据冲突。所以我的恢复逻辑不是简单的INSERT INTO原来的表,而是先把待恢复数据从回收站表读出来,检查目标ID是否存在,如果被占用就重新分配一个新ID,然后通过应用自身的API触发一次索引重建。
恢复操作我设计成一条命令:
convo-cleaner restore --recycle-bin-id 17 --output-to-original执行后,对应会话会从回收站表回到正常会话表,附件文件也会从回收站目录移动回原附件位置。整个过程同样会产生日志,方便审计。
有了dry-run预览、软删除和恢复闭环之后,我会很放心地定期跑清理任务。因为我心里清楚:最坏的结果不过是从回收站里捞回来,而不是“数据永久消失”。
5. 把清理做成日常:定时任务、留痕审计与配额保护
5.1 定时清理的推荐节奏与杀器参数
工具写出来后,我先手动跑了几次,确认流程稳定后,才考虑把清理变成日常自动化。自动化需要解决一个矛盾:清理太频繁会让备份任务很重;清理太少又会回到膨胀老路。
我目前的节奏是每周日凌晨3点执行一次“时间维度”的软清理,保留最近45天会话;每月1号执行一次更彻底的“大小维度”清理,把超过100MB且未在30天内活跃的会话清理掉。这种节奏既覆盖了日常温和维护,又处理了那些“巨型僵尸会话”。
如果你是第一次接手一个已经膨胀很久的目录,建议不要上来就跑自动清理。先手动执行dry-run,生成报告,确认一个比较干净的阈值,比如“保留最近90天,清理更早的未标记会话”。等跑过几次,你对数据分布有了感觉,再决定是否让定时任务接管。
5.2 清理日志:每一次删除都有据可查
自动化最忌讳的是“黑箱”。一个人什么时候清理、清理了什么、释放了多少空间,都应该有记录。我的工具会在每次执行后生成一份CSV日志,字段包含:清理任务ID、执行时间、过滤条件、清理会话数、释放空间、备份路径、回收站路径。
这份日志非常重要。有一次我发现某个部门用户反馈“之前的某次对话找不到了”,就是靠日志定位到是自动清理任务把它当作未命名测试会话清掉了。还好数据还在回收站,一条恢复命令就捞了回来。如果没有日志,这种问题根本没法排查。
5.3 配额检查:防止自动化把家底清空
自动化最大的风险不是“多删了一点”,而是“某天删光了不该删的东西”。比如你在白名单里遗漏了一批重要会话,或者某次正则写得太宽,把所有标题含“v3”的会话都匹配进去了,而“v3”恰好是你核心实验的通用后缀。
所以我给工具加了一个硬性保护机制:每次自动清理前,都会先做一次“配额检查”。如果待清理会话数量超过总会话数的40%,或者待释放空间占总空间的比例超过50%,工具会中止并发送提醒,要求人工确认。这个阈值看起来保守,但经历过一次“误伤”之后,你会明白保守是有意义的。
另外一个细节是,自动清理和手动执行要使用不同的配置入口。我手动执行时允许更激进的参数,比如“强制清理所有大小超过200MB的会话”;但定时任务只允许使用白名单明确、范围可预测的参数组合。这样即使我某天写错配置,定时任务也会被安全网拦下来。
6. 实测效果与五个踩坑记录
6.1 一次真实清理:释放了2.4GB,冷启动快了很多
工具跑通后的第一次实测,是在我那套已经膨胀到5.8GB的本地AI服务上进行的。我先执行dry-run,生成报告,结果有点出乎意料:440个会话里,可以按“超过60天未活跃+非白名单”的条件清理掉130个会话,释放空间约1.6GB;再按“超过30天未活跃+附件大于10MB”补充清理,又能释放约0.8GB。
我保留了近30天活跃会话、所有白名单会话和所有含“重要”标签的会话,实际清理了约180个会话,释放2.4GB。清理后我第一次感受到一个明显变化:Web界面的冷启动时间从大概8秒降到了2.3秒左右。原因是会话列表加载、未读计数统计、语义索引初始化都因为数据量减少而大幅提速。
不过我也注意到,释放的2.4GB里有很大一部分来自附件文件和向量chunk,而不是单纯的文字消息。这验证了我前面反复强调的观点:清理文字记录只是表面功夫,真正占空间的往往是那些“影子数据”。
6.2 值得写进避坑清单的五个经验
第一,扫描时必须关闭数据库连接后再做备份。我第一次写的版本是边扫描边备份,导致SQLite数据库被自己锁住,备份出来的文件每次都缺少最后几条记录。后来改成“先关闭读写连接,再快照备份”,问题消失。
第二,向量chunk不能只靠删除会话表记录来清理。很多本地AI应用会把消息chunk写入独立的集合,你必须通过应用自己的API或者专用的清理接口来删除chunk,或者精确到集合级删除,否则UI里会出现“搜得到文字但打开会话空白”的诡异现象。
第三,附件目录未必按会话ID命名。有的应用用哈希命名附件,有的直接用UUID。如果你扫描工具里只用“session_id作为附件目录名”这一个假设,会在某些应用上翻车。所以我最终把“附件定位”也做成了适配器配置,而不是硬编码规则。
第四,统计“最后活跃时间”不要依赖UPDATE的时间戳。有些会话虽然最近没有新消息,但被用户星标或收藏过,应用会更新这个字段,导致这类会话不会进入清理条件。这其实是好事,但我一开始误以为这类会话“最近活跃过”,差点漏掉了真正该清理的目标。
第五,删除操作尽量在服务停止时执行。虽然软删除设计成了事务性操作,但如果你在服务运行中执行,界面正在缓存一些会话状态,可能出现清理后界面短暂显示异常。稳妥做法是写一个前置检测脚本,检测到服务进程存在就停下来等两分钟,或者干脆提示用户手动停止服务。
最后聊一点点个人体会。这套清理工具真正改变我的,不是“释放了多少GB空间”,而是每次执行之前,我都会被迫思考一个问题:这些会话里,到底哪些是资产,哪些是垃圾?以前我没这个意识,什么东西都留着;现在我会定期在清理报告上打勾,把重要的实验记录导出成独立文档,再让那些早该离开的临时会话体面地退场。这个习惯,比工具本身值钱得多。