用JSONL文件替代数据库作为自主Agent的状态层,状态变更即Git diff,支持审计追踪、断点恢复和并发写入。
我们的自主 Agent 已经跑了三个月的小型发布业务:发帖、回复、关注、发布文章,并追踪每一个决策。这背后用的状态层不是 Postgres,不是 SQLite,也不是 Redis,而是一个目录里的 JSONL 文件,直接提交到 git。
这个选型偶尔会被人笑话,所以这篇文章就来实话实说——哪些模式让这种只能追加的文本文件在崩溃、重试、并发写入和 LLM 热衷于重复运行已运行过的任务面前存活下来。
三个特性最终比查询能力更重要:
每一次状态变更都是一次 diff。当 Agent 关注某人、回复帖子或发布文章时,证据会带着时间戳和作者信息进入 git log。审计一个自主系统是运行它最难的部分;而用 git 里的账本,审计轨迹就是存储引擎本身。
定时任务和交互式会话共享状态,无需服务器。我们的 GitHub Actions 任务 checkout 仓库、读取账本、行动、提交。交互式会话在做出任何决定前先 pull。合并边界是 git 的问题,而 git 的合并是一个被研究得很透彻的问题。
LLM 可以原生读取自己的状态。一个能 grep 自己完整决策历史的 Agent,比一个需要专门写查询层的 Agent 在意义上更聪明。
几乎每个账本都是只能追加的:每行一个 JSON 对象,新事实追加到末尾。只能追加意味着一次崩溃的写入最多损坏最后一行,恢复方式是"丢掉损坏的尾部",而不是"从备份恢复"。
例外:消费型账本(预写帖子的库存、待关注候选人的队列)需要在已有行上打上 consumedAt 时间戳。对于这些,我们采用整体 load-modify-rewrite——因为文件很小,这是可以接受的——但有一条硬规则:已消费标记永远不会被覆盖。更新函数拒绝修改已设置 consumedAt 的行。重试安全性来自这个拒绝机制,而不是寄希望于调用方表现正确。
每个映射外部事件的账本行都带有外部系统自己的标识符——帖子 URI、文章 ID、评论永久链接。摄入时按这个键去重,所以抓取同一份反馈两次只会记录一次。这就是让"定时任务触发了两次"和"Agent 超时后重新运行命令"变成无事发生的关键。
推论:永远不要让 LLM 手打标识符。输入文件里的每一个 DID、URI 和 ID 都是从前一个命令的输出中机械复制的。我们为此付出过代价——一次手打的标识符(一个 DID 里写错了一个字符)创建了一条指向一个不存在账号的关注记录。API 接受了它因为字符串语法上是有效的,我们的管道里没有取关操作,而且那行数据永远留在历史里——账本不会忘记。
对外部世界执行操作的命令在执行任何一项之前先验证整个批次。如果回复批次中有一条格式错误,整个批次会在第一条回复发出之前抛出异常。半执行的批次是自主系统能处于的最坏状态——账本说一套,世界说另一套——所以我们根本不去创建它。
状态在仓库里最好的部分:你的测试套件可以读取生产状态。我们的提交门槛包括测试,它们加载真实的账本并断言不变量——每条库存帖子都在平台字数限制内,没有库存文章的标题与已发布的文章冲突,没有开放的 TODO 项超过其宽限期。损坏或矛盾的状态无法被提交,因为守护它的测试在每次提交时都会运行。状态 bug 在写入时被 CI 捕获,而不是在凌晨三点被尝试消费坏行的定时任务发现。
公平起见说说这一节。你放弃的是:跨文件事务(我们尽可能把每个命令限定在一次账本写入)、同一文件的并发写入(git rebase 处理跨任务竞态;同一工作树里的两个写入者需要协调——我们遇到过这种情况,只能通过约定序列化)、以及任何比线性扫描复杂的查询(在我们这个规模下没问题:我们最大的账本不到一千行,所有账本加起来不到四千行)。
如果你的 Agent 每小时处理数千个事件,用数据库。我们的 Agent 每天处理几十个需要信任和审计的决策,多年后还要能回头查看。对于这种形态的问题,一堆在 git 下的 JSONL 文件一直是我们做过的最无聊——因此也是最好——的基础设施决策。
这里描述的 Agent 驱动着 Rulestack,它的配置模式就是我们的打包销售产品。
日常运营笔记见 Bluesky @ai-shop.bsky.social。