本文重点研究 InkOS 是怎么让一个写小说的 Agent 记住前文、维护伏笔、更新角色状态,并且尽量避免越写越乱的。如果只看表面,InkOS 像是一个多 Agent 写小说工具。但深入看 inkos-core 之后会发现,它真正有意思的地方不是“让大模型写一章”,而是它围绕“长期记忆”和“状态推进”做了一套很实用的工程设计。

要解决的问题

让大模型写一章小说并不难。

难的是连续写很多章之后,还能记住:

  • 主角现在在哪里
  • 谁知道了什么,谁还不知道
  • 哪个道具已经消耗了
  • 哪条伏笔还没回收
  • 最近几章的节奏是不是重复了
  • 这一章写完之后,世界状态到底发生了什么变化

如果每次都把前面所有章节塞给大模型,成本会越来越高,效果也不稳定。模型上下文再大,也不适合当数据库用。

InkOS 的做法是:不要相信模型自己能记住一切,而是把“记忆”拆成文件、结构化数据、索引和快照。模型只负责提出变化,真正写入状态的是代码。

它把记忆分成了几层

InkOS 的记忆不是一个东西,而是几层东西配合工作。

第一层是章节正文。

每章正文保存在 chapters/ 目录里,同时有一个 chapters/index.json 记录章节号、标题、字数、审计结果等信息。这个是最硬的证据:只要章节文件存在,并且索引里也有,系统才认为这一章真的存在。

第二层是给人看的真相文件。

这些文件在 story/ 目录下,比如:

  • current_state.md:当前状态
  • pending_hooks.md:未回收伏笔
  • chapter_summaries.md:章节摘要
  • subplot_board.md:支线进展
  • emotional_arcs.md:情绪弧线
  • character_matrix.md:角色关系和信息边界

这些 Markdown 文件很好读,也方便人工修改和审查。它们像是小说项目的白板。

第三层是给程序用的结构化状态。

这些文件在 story/state/ 下:

  • manifest.json
  • current_state.json
  • hooks.json
  • chapter_summaries.json

这层才是运行时真正可靠的状态。它们会经过 Zod schema 校验,字段类型不对、伏笔状态非法、章节号倒退,都会被拦下来。

第四层是 SQLite 加速索引。

如果当前 Node 版本支持 node:sqlite,InkOS 会生成 story/memory.db,把事实、章节摘要、伏笔索引进去。这样下一章要找相关记忆时,不用每次从 Markdown 里慢慢解析。

但 SQLite 不是唯一事实源。它坏了可以删,它不可用也可以回退到 JSON 和 Markdown。这一点很重要:缓存可以丢,状态不能丢。

最关键的设计:模型不直接改状态

很多 Agent 系统容易犯一个错误:让模型直接输出一整份新的状态文件。

这样做的问题是,模型很可能把旧信息漏掉、改错、重复,甚至凭空 invent 一些状态。写短任务还可以忍,写长篇小说一定会滚雪球。

InkOS 的做法更稳。

Writer 写完正文后,Settler 不让模型输出“完整的新 current_state.md”,而是让它输出一个变化量,也就是 delta。

可以简单理解成:

{
  chapter: 12,
  currentStatePatch: {
    currentLocation: "...",
    currentGoal: "...",
    currentConflict: "..."
  },
  hookOps: {
    upsert: [...],
    mention: [...],
    resolve: [...],
    defer: [...]
  },
  chapterSummary: {...}
}

也就是说,模型只说:“这一章之后,哪些东西变了。”

真正合并状态的是代码。代码会检查章节号、检查 schema、检查是否重复摘要、检查伏笔 ID 是否冲突,然后再把这个 delta 应用到旧状态上。

这个设计非常像记账。

你不会让实习生直接重写整本账本,而是让他提交一张凭证。财务系统检查凭证合法之后,再入账。

状态机怎么推进

InkOS 的状态推进是单向的。

假设当前已经写到第 11 章,那么第 12 章的 delta 必须是 chapter: 12。如果模型输出 chapter: 10 或者又想写第 11 章,代码会拒绝。

它的基本流程可以画成这样:

┌──────────────────────────┐
│ 1. 读取硬证据             │
│ chapters/*.md + index.json│
└─────────────┬────────────┘
              │ 判断真正写到第几章
              v
┌──────────────────────────┐
│ 2. 读取结构化状态         │
│ story/state/*.json        │
└─────────────┬────────────┘
              │ 如果没有,就从 Markdown 迁移
              v
┌──────────────────────────┐
│ 3. Planner 规划下一章     │
│ 产出 intent / memo        │
└─────────────┬────────────┘
              │
              v
┌──────────────────────────┐
│ 4. Composer 选上下文      │
│ 产出 context / rule-stack │
└─────────────┬────────────┘
              │
              v
┌──────────────────────────┐
│ 5. Writer 写正文          │
│ 产出章节文本              │
└─────────────┬────────────┘
              │
              v
┌──────────────────────────┐
│ 6. Settler 结算变化       │
│ 只产出 delta,不改账本    │
└─────────────┬────────────┘
              │
              v
┌──────────────────────────┐
│ 7. 代码层检查 delta       │
│ schema / 章节号 / 伏笔    │
└─────────────┬────────────┘
              │
              v
┌──────────────────────────┐
│ 8. 应用 delta 得到新状态  │
│ applyRuntimeStateDelta    │
└─────────────┬────────────┘
              │
              v
┌──────────────────────────┐
│ 9. 渲染 Markdown 投影     │
│ 给人看的 truth files      │
└─────────────┬────────────┘
              │
              v
┌──────────────────────────┐
│ 10. 保存章节、状态、快照  │
│ 同步 memory.db 加速索引   │
└──────────────────────────┘

拆开说,就是:

  1. 先根据章节文件和 index.json 判断现在真正写到第几章
  2. 读取 story/state/*.json
  3. 如果结构化状态不存在,就从 Markdown 旧文件里迁移出来
  4. 规划下一章
  5. 选择本章需要的上下文
  6. 写正文
  7. 从正文里结算状态变化
  8. 对 delta 做仲裁和校验
  9. 应用 delta 得到新状态
  10. 渲染出新的 Markdown 真相文件
  11. 保存章节、状态、索引和快照

这里有一个细节很值得学:InkOS 不相信 current_state.md 里写的“当前章节”。

因为模型可能在正文或状态里写出一个奇怪数字,比如年份 1988,被错误解析成章节号。InkOS 判断进度时,只相信实际存在的章节文件和 chapters/index.json。这叫“硬证据优先”。

记忆更新流程

写完一章之后,InkOS 的重点不是“把新章节保存下来”这么简单,而是要把这一章造成的影响整理进长期记忆里。

这个过程更像一次数据库事务:

                ┌────────────────────┐
                │ 新章节正文          │
                └─────────┬──────────┘
                          │
                          v
                ┌────────────────────┐
                │ Settler 阅读正文    │
                │ 总结状态变化        │
                └─────────┬──────────┘
                          │ 只输出 delta
                          v
┌────────────────────────────────────────────────┐
│ RuntimeStateDelta                               │
│ - 当前状态改了什么                              │
│ - 哪些伏笔推进/提及/回收/延后                   │
│ - 本章摘要是什么                                │
└──────────────────────┬─────────────────────────┘
                       │
                       v
┌────────────────────────────────────────────────┐
│ HookArbiter 伏笔仲裁                            │
│ 新坑是否真的新?是否只是旧坑换了个说法?        │
└──────────────────────┬─────────────────────────┘
                       │
                       v
┌────────────────────────────────────────────────┐
│ applyRuntimeStateDelta                          │
│ 把 delta 合并进旧状态                           │
└──────────────────────┬─────────────────────────┘
                       │
                       v
┌────────────────────────────────────────────────┐
│ validateRuntimeState                            │
│ 检查重复 ID、章节倒退、字段非法                 │
└───────────────┬────────────────────────────────┘
                │
        ┌───────┴────────┐
        │                │
        v                v
┌────────────────┐   ┌──────────────────────┐
│ 校验通过        │   │ 校验失败              │
│ 写入新状态      │   │ 标记 state-degraded   │
└───────┬────────┘   │ 保留正文,不污染记忆  │
        │            └──────────────────────┘
        v
┌────────────────────────────────────────────────┐
│ 渲染 Markdown truth files                       │
│ current_state / pending_hooks / summaries       │
└──────────────────────┬─────────────────────────┘
                       │
                       v
┌────────────────────────────────────────────────┐
│ 保存快照 + 重建/同步 memory.db                  │
└────────────────────────────────────────────────┘

这里有几个关键点。

第一,delta 是模型和代码之间的分界线。

模型可以说“主角现在到了黑市”“H12 这个伏笔被推进了”“本章摘要是这些”。但模型不能直接覆盖整份状态。这样可以避免模型一激动把旧伏笔删了,或者把已经解决的问题又写回 open。

第二,伏笔要先过仲裁。

如果模型说“新增一个神秘黑衣人的伏笔”,代码会检查已有伏笔里是不是已经有类似的“神秘来客”“黑衣人线索”。如果只是重复,就不会新建;如果确实有新信息,就合并进旧伏笔;只有真的不同,才开新伏笔。

第三,状态校验失败时,系统会宁愿降级。

比如正文写得还行,但是状态结算把章节号弄错了,或者把同一个伏笔写出了两个 ID。InkOS 不会硬写坏状态,而是把章节标成 state-degraded。这等于告诉用户:正文先留着,但这章的记忆账还没记好,别急着往后滚。

快照和回滚

每写完一章,InkOS 会做一次快照。

快照里不只保存 Markdown 真相文件,也保存 story/state/*.json。这样如果后面要重写某一章,就可以回到那一章之前的状态。

比如你要重写第 20 章,系统会:

  1. 恢复第 19 章快照
  2. 删除第 20 章之后的章节文件
  3. 删除对应的 runtime 产物
  4. 删除后续快照
  5. 删除 memory.db 这类可重建索引
  6. 重新写第 20 章

这比“直接改第 20 章正文”可靠得多,因为小说的后续状态都是从前文推出来的。前面一章变了,后面的记忆也必须跟着回滚。

伏笔为什么要单独治理

长篇小说最容易乱的就是伏笔。

模型很喜欢开新坑,但不一定记得填坑。InkOS 对伏笔做了比较细的治理。

一个伏笔大概包含这些字段:

  • hookId:伏笔 ID
  • startChapter:从哪章开始
  • status:open、progressing、deferred、resolved
  • lastAdvancedChapter:最近哪章推进过
  • expectedPayoff:预期怎么回收
  • payoffTiming:近期、中程、慢烧、终局等
  • dependsOn:依赖哪些其他伏笔

模型如果想新增伏笔,不能随便加。代码会先判断:

  • 有没有类型
  • 有没有回收信号
  • 是否和已有伏笔太像
  • 如果太像,是纯重复,还是有新信息

如果是纯重复,就只记一次 mention。如果有新信息,就合并到旧伏笔。如果真的是新伏笔,才创建新的 hookId。

这套逻辑的意义很明显:控制“坑”的数量,避免每一章都开一堆新线。

记忆检索不是全量塞上下文

写下一章前,InkOS 会先生成本章目标。然后根据目标去找相关记忆。

比如本章目标是“把注意力拉回师徒矛盾”,系统会从目标、大纲节点、必须保留的信息里提取关键词,再去找:

  • 最近几章摘要
  • 命中关键词的旧摘要
  • 相关伏笔
  • 当前状态里最重要的事实
  • 长时间没推进的伏笔债务

最后 Composer 会把这些材料编译成 context.jsonrule-stack.yamltrace.json

这样写手 Agent 拿到的不是一大坨历史文本,而是一份比较干净的“本章需要知道什么”。这也是它能写长篇的关键。

记忆检索流程

检索流程可以理解成:先问“这一章想干什么”,再去记忆库里找“跟这件事有关的东西”。

┌────────────────────────────┐
│ 1. 本章任务                 │
│ goal / outline / mustKeep   │
└──────────────┬─────────────┘
               │ 提取关键词
               v
┌────────────────────────────┐
│ 2. 生成查询线索             │
│ 人名、地点、冲突、伏笔 ID   │
└──────────────┬─────────────┘
               │
               v
┌──────────────────────────────────────────────┐
│ 3. 读取记忆源                                 │
│ 优先 memory.db                                │
│ 不可用就回退 story/state/*.json               │
│ 再不行就解析 Markdown truth files             │
└──────────────┬───────────────────────────────┘
               │
               v
┌──────────────────────────────────────────────┐
│ 4. 分别筛选                                   │
│ - 章节摘要:最近 + 命中关键词                 │
│ - 当前事实:位置、目标、冲突优先              │
│ - 伏笔:相关 + 近期活动 + 陈旧债务            │
│ - 卷摘要:命中长期主线                         │
└──────────────┬───────────────────────────────┘
               │
               v
┌──────────────────────────────────────────────┐
│ 5. 组装本章上下文包                           │
│ context.json                                  │
└──────────────┬───────────────────────────────┘
               │
               v
┌──────────────────────────────────────────────┐
│ 6. 组装规则栈                                 │
│ rule-stack.yaml                               │
│ 硬规则、软约束、诊断规则分层                   │
└──────────────┬───────────────────────────────┘
               │
               v
┌──────────────────────────────────────────────┐
│ 7. 交给 Writer                                │
│ 只带本章真正需要的记忆                         │
└──────────────────────────────────────────────┘

这个流程里,最重要的是第 4 步。

章节摘要不是简单取最近 N 章。InkOS 会给摘要打分:最近的更重要,命中本章关键词的更重要。比如本章要写师徒矛盾,那么十章前某个师徒决裂的摘要,也可能比上一章打怪更重要。

当前事实也不是全塞进去。它会优先选那些对下一章最有用的东西,比如当前位置、当前目标、当前冲突、当前限制。这些是写错了会立刻穿帮的信息。

伏笔筛选更特殊。它不只找“相关伏笔”,还会找“很久没动的伏笔”。因为长篇最怕有些坑开了十几章没人管。InkOS 会把这种叙事债务交给 Planner,让 Planner 在本章决定:推进、回收、延后,还是继续保持。

所以它的检索不是搜索引擎式的“找到相似文本”那么简单,而是在问:

  • 本章写作一定不能忘什么?
  • 哪些旧事和本章目标有关?
  • 哪些伏笔已经欠太久了?
  • 哪些事实如果写错会立刻破坏连续性?
  • 哪些规则在本章要压过默认大纲?

最终输出的 context.json 不是给人看的长文,而是一包有来源、有理由、有摘录的材料。这样后续如果写崩了,还能回头查:这一章到底给 Writer 喂了哪些记忆。

state-degraded:宁愿标记坏状态,也不污染长期记忆

InkOS 还有一个保守机制,叫 state-degraded

如果正文写出来了,但状态结算失败,或者新旧真相文件对不上,系统不会硬把坏状态写进去。

它会:

  1. 保存章节正文
  2. 把章节标成 state-degraded
  3. truth files 回退到旧状态
  4. 在章节索引里记录问题
  5. 要求后续先修复状态

这很实用。

因为正文有时是有价值的,但状态一旦污染,后面每一章都会带着错误继续写。InkOS 的选择是:正文可以先留着,长期记忆不能随便污染。

可以复刻成什么样

如果我要在自己的 Agent 项目里复刻这套设计,最小版本会这样做。

首先,建立这样的目录:

project/
  items/
    index.json
  memory/
    state.json
    summaries.json
    hooks.json
    snapshots/
  runtime/
    task-0001.context.json
    task-0001.trace.json

然后规定几条铁律:

  1. 模型不能直接覆盖完整状态
  2. 模型只能提交 delta
  3. delta 必须过 schema
  4. 状态推进必须单调
  5. 每次成功推进都做快照
  6. 进度只相信真实产物,不相信模型文本
  7. 缓存索引可以重建,不能当唯一事实源
  8. 状态校验失败时,宁愿降级,也不要污染记忆

如果是小说系统,就保留 current_statehookschapter_summaries

如果是代码 Agent,可以换成:

  • 当前任务状态
  • 文件修改摘要
  • 待解决问题
  • 已做决策
  • 外部依赖和约束

思想是一样的。

总结

InkOS 的记忆系统最值得学的地方,不是用了 SQLite,也不是用了多少个 Agent。

真正值得学的是它的边界感:

  • 大模型负责生成和判断
  • 代码负责入账和校验
  • Markdown 负责让人看懂
  • JSON 负责让程序可靠运行
  • SQLite 负责加速,但不负责兜底
  • 快照负责让系统敢于重写和回滚

这套设计把“写作”变成了一个可以持续推进、可以审计、可以回滚的过程。

对任何需要长周期运行的 Agent 来说,这个思路都很有参考价值:不要把记忆托付给模型本身,要把记忆设计成一个能被检查、能被回放、能被修复的系统。