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 上交流。

相关推荐
全栈弄潮儿3 小时前
《小项目实战 1:用 AI 从零搭一个 API 服务》
aigc·openai·ai编程
金字塔頂の蝸牛7 小时前
每周GitCode开源项目推荐
开源·软件工程·ai编程
李航19837 小时前
给自己的图形引擎,配上了AI渲染,做设计真是太方便了
人工智能·python·计算机视觉·ai·ai编程
tingke10 小时前
AI Native 团队完整开发落地手册
ai编程
四六的六10 小时前
Agent 长会话设计实战:从对话上下文到持久状态,把记忆写进检查清单
人工智能·个人开发·ai编程·ai产品·长上下文·ai代码生成·ai会话
ServBay11 小时前
基于Jev的浏览器Agent插件狂揽 21k star,3分钟教你解放双手
后端·aigc·ai编程
9i编程11 小时前
15. 把 DDD 开源脚手架化为自己的:第四次联调(一)——刚加载瘦身的 CLAUDE.md,问题就排着队来
人工智能·openai·ai编程
HelloWorld00111 小时前
告别大模型废话!给 Agent 装上 Jev“小脑”:70ms 决策实战与成本暴降 90% 的秘密
ai编程
TTc_12 小时前
Agent长上下文压缩为什么会反复失败
ai·ai编程
深蓝AI12 小时前
旗舰被小弟反超:Claude Sonnet 5.5 智能体编码凭什么压过 Opus 5.5
agent·ai编程