本文重点研究 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.jsoncurrent_state.jsonhooks.jsonchapter_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 加速索引 │
└──────────────────────────┘
拆开说,就是:
- 先根据章节文件和
index.json判断现在真正写到第几章 - 读取
story/state/*.json - 如果结构化状态不存在,就从 Markdown 旧文件里迁移出来
- 规划下一章
- 选择本章需要的上下文
- 写正文
- 从正文里结算状态变化
- 对 delta 做仲裁和校验
- 应用 delta 得到新状态
- 渲染出新的 Markdown 真相文件
- 保存章节、状态、索引和快照
这里有一个细节很值得学: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 章,系统会:
- 恢复第 19 章快照
- 删除第 20 章之后的章节文件
- 删除对应的 runtime 产物
- 删除后续快照
- 删除
memory.db这类可重建索引 - 重新写第 20 章
这比“直接改第 20 章正文”可靠得多,因为小说的后续状态都是从前文推出来的。前面一章变了,后面的记忆也必须跟着回滚。
伏笔为什么要单独治理
长篇小说最容易乱的就是伏笔。
模型很喜欢开新坑,但不一定记得填坑。InkOS 对伏笔做了比较细的治理。
一个伏笔大概包含这些字段:
hookId:伏笔 IDstartChapter:从哪章开始status:open、progressing、deferred、resolvedlastAdvancedChapter:最近哪章推进过expectedPayoff:预期怎么回收payoffTiming:近期、中程、慢烧、终局等dependsOn:依赖哪些其他伏笔
模型如果想新增伏笔,不能随便加。代码会先判断:
- 有没有类型
- 有没有回收信号
- 是否和已有伏笔太像
- 如果太像,是纯重复,还是有新信息
如果是纯重复,就只记一次 mention。如果有新信息,就合并到旧伏笔。如果真的是新伏笔,才创建新的 hookId。
这套逻辑的意义很明显:控制“坑”的数量,避免每一章都开一堆新线。
记忆检索不是全量塞上下文
写下一章前,InkOS 会先生成本章目标。然后根据目标去找相关记忆。
比如本章目标是“把注意力拉回师徒矛盾”,系统会从目标、大纲节点、必须保留的信息里提取关键词,再去找:
- 最近几章摘要
- 命中关键词的旧摘要
- 相关伏笔
- 当前状态里最重要的事实
- 长时间没推进的伏笔债务
最后 Composer 会把这些材料编译成 context.json、rule-stack.yaml、trace.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。
如果正文写出来了,但状态结算失败,或者新旧真相文件对不上,系统不会硬把坏状态写进去。
它会:
- 保存章节正文
- 把章节标成
state-degraded - truth files 回退到旧状态
- 在章节索引里记录问题
- 要求后续先修复状态
这很实用。
因为正文有时是有价值的,但状态一旦污染,后面每一章都会带着错误继续写。InkOS 的选择是:正文可以先留着,长期记忆不能随便污染。
可以复刻成什么样
如果我要在自己的 Agent 项目里复刻这套设计,最小版本会这样做。
首先,建立这样的目录:
project/
items/
index.json
memory/
state.json
summaries.json
hooks.json
snapshots/
runtime/
task-0001.context.json
task-0001.trace.json
然后规定几条铁律:
- 模型不能直接覆盖完整状态
- 模型只能提交 delta
- delta 必须过 schema
- 状态推进必须单调
- 每次成功推进都做快照
- 进度只相信真实产物,不相信模型文本
- 缓存索引可以重建,不能当唯一事实源
- 状态校验失败时,宁愿降级,也不要污染记忆
如果是小说系统,就保留 current_state、hooks、chapter_summaries。
如果是代码 Agent,可以换成:
- 当前任务状态
- 文件修改摘要
- 待解决问题
- 已做决策
- 外部依赖和约束
思想是一样的。
总结
InkOS 的记忆系统最值得学的地方,不是用了 SQLite,也不是用了多少个 Agent。
真正值得学的是它的边界感:
- 大模型负责生成和判断
- 代码负责入账和校验
- Markdown 负责让人看懂
- JSON 负责让程序可靠运行
- SQLite 负责加速,但不负责兜底
- 快照负责让系统敢于重写和回滚
这套设计把“写作”变成了一个可以持续推进、可以审计、可以回滚的过程。
对任何需要长周期运行的 Agent 来说,这个思路都很有参考价值:不要把记忆托付给模型本身,要把记忆设计成一个能被检查、能被回放、能被修复的系统。