markdown 即数据库:为 AI 会话设计一个"文件协议"工作流

我平时的干活方式是:同时开两到四个 AI 编码会话,让它们并行推进不同的事。很快我就撞上一个所有"人 + 多 agent"团队都会撞上的问题------这些会话互相不知道对方在干什么。两个会话认领了同一个任务、一个会话崩了之后任务永远卡在"进行中"、状态改来改去最后没人知道哪个是对的。

第一反应是上数据库、起个服务、加把分布式锁。第二反应是:一个单人工作流,搞这些是不是有病?最后我用一个文件夹的 markdown 文件 解决了,顺手做成了一个开源小工具 huntbook(v0.1 已发 npm)。这篇文章讲它的协议设计------不聊产品,聊"文件系统当数据库"用在这个场景下,每一个决定是怎么做的。

一、先把问题摆正:AI 会话是没有共享记忆的同事

AI 编码会话(Claude Code、Cursor 之类)的协作模型有个特点:每个会话都是无状态 的------上下文关了就全忘。人类团队的解法是"大家看同一个看板",但 AI 会话不会自己去打开网页,它只认两样东西:你发给它的文本 ,和它在磁盘上读到的文件

所以"给 AI 会话一个工作流"本质上是设计一个协议:状态放在哪、怎么读、怎么改、怎么防止两个写者打架。而协议的载体必须是文件------因为文件是 AI 会话唯一可靠的共享记忆。

这也是为什么我不想用数据库。不是嫌重(虽然确实重),是因为数据库对这个协议的读者不友好 :AI 会话读 PostgreSQL 里的状态,还得经过一层查询工具;读 markdown 文件,cat 一下就完了,而且它天然擅长读 markdown------训练语料里全是。

二、.huntbook/:整个工作台就是一个文件夹

huntbook init 之后,磁盘上多出来的东西就是全部状态:

yaml 复制代码
.huntbook/
├── board.md          # 看板(派生物,可随时重建)
└── cases/
    ├── 2026-08-28-acme-com-login-rate-limit-bypass.md
    └── ...

每个任务(case)是一个 md 文件:frontmatter 存字段,正文存笔记:

yaml 复制代码
---
id: 2026-08-28-acme-com-login-rate-limit-bypass
target: acme.com
title: Login rate limit bypass
severity: high
grade: A
status: doing
claimed_at: 2026-08-28T09:12:00.000Z
---


## Notes


(自由格式的过程记录)

这个结构的直接红利:git init 它,你就同时拥有了备份、同步、历史审计和多人协作 。换台机器,git clone 一下,整个作战现场原样恢复。没有迁移、没有 dump/restore、没有"数据库连接串"。哪天不用了,删文件夹就是卸载。

有同行会问:SQLite 不也是单文件吗?是,但它对 git 不友好(二进制 diff 灾难),对 AI 会话不友好(要过查询层),对人类不友好(打开看不见内容)。三个读者里 markdown 全赢,才敢不引数据库。

三、防打架:90 分钟认领窗口

核心问题来了:两个会话(或者你 + 一个会话)怎么不抢同一个任务?

数据库方案是 SELECT ... FOR UPDATE。文件方案是把这个锁降级成一个时间戳 :认领任务时,把 status 改成 doing 并写入 claimed_at。规则只有一条------doing 状态的任务,90 分钟内不进候选队列:

arduino 复制代码
/** Two agent sessions must not fight over the same target inside this window. */
export const REENTRY_MINUTES = 90;

huntbook next(领取下一个任务)的核心判定逻辑:

ini 复制代码
export async function nextCase(root: string, now = new Date()): Promise<NextPick | null> {
  const cases = await listCases(root);


  // 1. 有没过期的认领 → 吸附回去,别开新目标
  const freshDoing = cases.find(
    (theCase) =>
      theCase.status === "doing" &&
      theCase.claimedAt !== null &&
      now.getTime() - Date.parse(theCase.claimedAt) < REENTRY_MINUTES * 60_000,
  );
  if (freshDoing) return { doc: freshDoing, resumed: true };


  // 2. 候选 = todo + 已过期回收的 doing
  const eligible = cases.filter(
    (theCase) =>
      theCase.status === "todo" ||
      (theCase.status === "doing" &&
        (theCase.claimedAt === null ||
          now.getTime() - Date.parse(theCase.claimedAt) >= REENTRY_MINUTES * 60_000)),
  );
  // 3. 领取 = 写回文件(status + claimed_at)
  const pick = eligible[0];
  if (!pick) return null;
  pick.status = "doing";
  pick.claimedAt = now.toISOString();
  await writeFile(casePath(root, pick.id), caseToMarkdown(pick));
  return { doc: pick, resumed: false };
}

三个设计点值得展开:

认领窗口是"惰性过期"的。 注意整个库里没有任何定时器、没有守护进程、没有 cron。一个卡死的 doing 任务不会被谁"释放",它只是在你(或下一个会话)下次调 next 时,被那行 >= REENTRY_MINUTES * 60_000 判定为过期,自动回到队列。状态机的推进永远搭在下一次事务上,而不是搭在后台进程上------这是文件协议最重要的一课:你的数据躺着不动,逻辑跟着读写走。

窗口内是"吸附"语义,不是"拒绝"语义。 第二个会话在窗口内调 next,拿到的不是报错,而是那个正在进行的任务本身,输出前缀是 still on it (claimed ...)。这是有意的:单人 + 多会话的规模下,正确的协作姿态是"一个时间窗内全团队聚焦一件事",而不是各自漂移去开新战线。会话读到这个前缀,就知道该接着当前目标干,而不是换人。

为什么是乐观窗口而不是真锁? 因为写者的真实规模是"1 个人 + 2~4 个会话,写冲突的概率本来就近乎零"。锁要解决的问题(高频并发抢占)在这个规模下不存在;这里真正要防的是会话崩溃后的死锁人类忘记释放------这两个恰好是时间戳最擅长的。90 分钟是"一次专注审计"的时长上限,超过它,默认出事了,回收。为不存在的并发上分布式锁,是这类工具最容易犯的过度设计。

四、board.md 是缓存,不是事实源

看板文件 board.md 每次任何写操作之后全量重建

typescript 复制代码
export async function regenerateBoard(root: string): Promise<string> {
  const markdown = renderBoard(await listCases(root));
  await writeFile(join(huntbookRoot(root), BOARD_FILE), markdown);
  return markdown;
}

规则很简单:cases/*.md 是唯一事实源,board.md 永远可以从事实源完整推导出来------所以它可以被删、可以被并发写坏、可以被手贱编辑,毫无关系,下次任何操作都会把它纠正回来。

这就是"幂等"在文件协议里的具体形态:把派生物和事实源物理分开,派生物允许脏。对比一下很多工具的做法------状态和视图存在同一个地方,坏一处就全坏。给 AI 会话用的系统尤其要这样:会话是有概率把文件改出花的,你要保证的是"改坏了也能自愈",而不是"指望它永远不改坏"。

五、故意不用 YAML

frontmatter 长得像 YAML,但解析器是我手写的,26 行:

typescript 复制代码
/**
 * Flat `key: value` frontmatter --- the only format huntbook needs. Deliberately
 * NOT yaml: case files are machine-written, values are flat strings or null,
 * and a hand-rolled parser keeps the zero-dependency promise.
 */
export function parseFrontmatter(markdown: string): {
  fields: Record<string, string | null>;
  body: string;
} {
  const match = /^---\n([\S\s]*?)\n---\n?/.exec(markdown);
  if (!match) return { fields: {}, body: markdown };
  const fields: Record<string, string | null> = {};
  for (const line of match[1].split("\n")) {
    const colon = line.indexOf(":");
    if (colon === -1) continue;
    const key = line.slice(0, colon).trim();
    const raw = line.slice(colon + 1).trim();
    fields[key] = raw === "" || raw === "null" ? null : raw;
  }
  return { fields, body: markdown.slice(match[0].length) };
}

不是不能引 yaml 依赖,是这个文件的根本属性被搞反了 :YAML 是"人写、机器读"的格式,宽容多义、语法 sugar 层出不穷;而 case 文件是机器写、人和机器都读 的------写入方是我自己的 serializeFrontmatter,值全是平面字符串或 null。机器写文件的格式,应该选机器最不会写错、写错了最容易修 的格式,而不是表达力最强的格式。平面 key: value 就是那个答案:正则一行截取,坏行跳过,整份文件没有一种写法能让解析器炸掉。

顺手引出的另一个原则:零依赖是安全属性,不只是洁癖。你的状态文件格式只被你自己的一段 26 行代码定义,就不会因为上游依赖的一个 breaking change 而变得读不出来。

六、输出是给"下一个命令"的,不是给人的

huntbook 的每个命令都遵守同一条输出纪律:结构化 markdown、无 TTY 依赖、连续两次运行结果幂等

ruby 复制代码
$ huntbook next
picked next case:
2026-08-28-acme-com-login-rate-limit-bypass --- Login rate limit bypass [high · A]
$ huntbook next
still on it (claimed 2026-08-28T09:12:00.000Z):
2026-08-28-acme-com-login-rate-limit-bypass --- Login rate limit bypass [high · A]

这套纪律是为 AI 会话定的:会话把上一条命令的 stdout 直接粘进下一段 prompt,所以输出必须机器可判读 ------picked 还是 still on it,一个前缀就能让会话分叉逻辑;exit code 认真给(0 干成了 / 1 没任务 / 2 用错了),CI 和会话都能判断成败。

还有个小设计藏在 CLI 里:--now 参数可以注入时间。

lua 复制代码
huntbook next --now 2026-08-28T10:00:00Z

它本来是为了让 vitest 不用 mock 时钟(红测试从第一行开始写的,时间注入让"过期回收"这种时间相关的逻辑测试起来一目了然),但它同时给了 AI 会话一种有趣的能力:重放和推演------"如果两小时后我再领任务,队列会是什么样"。协议设计里凡是和时间有关的逻辑,留一个时钟注入口,测试和使用都会感谢你。

七、边界:什么时候这个模式会失效

诚实一点,"markdown 即数据库"不是银弹,三类需求出现时就该老实换方案:

  1. 真·高频并发写------多机、多进程每秒级抢写。文件 + 乐观窗口撑不住,需要真锁。
  2. 复杂查询------"列出所有 grade ≥ B 且三个月内没动过的 case"。目录扫描 + 内存过滤在几百个文件内毫无问题,上千个就该有索引了。
  3. 跨设备实时同步------git 是分钟级快照,不是实时通道。

huntbook 的赌注是:单人工作流终身都活在第一类的射程之外、第二类的几百个文件之内。目前它在我自己的真实产线上验证了几个月,状态机本身从更早的纯手工 md 流程提炼而来------先有实践,后有协议。

写在最后

回看这几个决定,其实共享同一个母题:AI 会话是最好的"哑终端" ------它不装你的 SDK,不连你的数据库,但它读文件、执行命令、认 stdout。为它设计系统,就是在为"一个只认识 cat 和 exit code 的同事"设计系统。而你会发现,能通过这种最苛刻客户端考验的接口,人类用起来反而格外舒服。

文件系统是被低估的 IPC。下次你的多会话工作流开始打架,先别急着起服务------看看是不是一个文件夹就能解决。


链接

  • huntbook:GitHub · npx huntbook init 即可开箱
  • 文中源码:src/pipeline.ts / src/frontmatter.ts / src/types.ts / src/cli.ts(全部源码 441 行,运行时依赖只有参数解析器)

我正在系统性地给 Vue 生态提 PR,同时维护两个小工具:distguard(构建产物凭证泄露扫描)和 huntbook。欢迎来 GitHub 上交流。

相关推荐
小四的小六1 小时前
AI写测试翻车实录:测试全绿,上线还是崩了——一个format函数暴露的盲区
aigc·openai·ai编程
码途AI工坊2 小时前
大模型时代必修课:懂 Token 的人,用 1 块钱跑出别人 100 块钱的效果。
ai编程
Jul1en_2 小时前
Matt 与 Uncle Bob 的播客访谈有感
开发语言·经验分享·笔记·ai·开源·github·ai编程
是2的10次方啊2 小时前
AI 能写代码了,还要学设计模式、Spring 源码和 JVM 吗?
ai编程
全栈弄潮儿11 小时前
不要先问“用哪个 AI”,先盘点你的开发工作流
aigc·openai·ai编程
杨杨杨大侠12 小时前
大模型的权重到底怎么用?拆开一个 token 的生成过程
aigc·openai·ai编程
吴佳浩 Alben12 小时前
Agent 怎么做自动化评测?构建端到端的 Agent Evaluation 体系
人工智能·语言模型·架构·自动化·ai编程
小虎AI生活13 小时前
从四大模型一周连发看企业 AI 落地,为什么 95% 的试点不赚钱
ai编程
孟健13 小时前
Gemini 3.8 Flash 没涨价,干活却贵了 40%?
ai编程