跳转到内容

这些东西存在哪里

正文按当前源码 1.5.0(commit 3e0eec29f)校正。

这是Pi《源码精读》的第五集。这些东西存在哪里?这次的例子,改自包里的第0个和第1个示例(00-conversation.ts、01-documents.ts):第0个建会话,第1个写两次存档,每次往记录本里记一条,再改一下文档。同一段代码,换了三种存法来跑。内存,一个SQLite文件,一个JSONL目录,三次跑出来的结果,一模一样。包里的这两个示例本身用的是内存;SQLite 和 JSONL 是同一段写入换后端各跑一遍,下面的实况数据来自这次探针,不是示例自带的输出。包里还有一个IndexedDB存储(入口 @earendil-works/pi-durable/storage/indexeddb),给浏览器里的页面用;另有一个 StoreStorage(入口 @earendil-works/pi-durable/storage/store),把 Durable 的存储接到 Chord v2 的 store 上。这一集还是按前面这三种讲。

然后我们故意做了两件坏事。一次是存档的代码自己抛了错,攒下的改动一样都没交出去。另一次是模拟写文件,最后一次的提交标记只写了一半。结果那次存档整个不算,上一次完整地留着。先看三种存法。第一种是内存,什么都不写到磁盘上,程序一关就没了。前面的例子,为了方便,大多用的都是它。

第二种是SQLite,所有东西放在一个数据库文件里。第四集用的就是它。第三种叫做JSONL,放在一个目录里,一行一条记录,分几个文件放。Durable,自己不关心存在哪。它和存储之间有一份约定。写明存储要做到哪几件事。三种存法都照着约定来,所以换着用都能跑。

图 5-1 换三种存法,结果一样

图 5-1 · 同一段写入,内存不落盘,SQLite 一个文件,JSONL 一个目录,结果相同

一次存档里写的东西,不是写一样,存一样。他们先攒着,等这次存档的代码正常跑完,再打包成一批,一起交给存储。几笔存档同时到,会合成一次 commit(batches, receive, context) 调用;每一笔仍是单独的一批,各有各的 seq。要是中途出了错,攒着的东西一样都不交出去。看代码,出错就丢掉,正常才提交。整批交给存储。那份约定里也写着,交给存储的这一批,要么全部存下,要么一样都不存。三种存法都要做到这一条。我们那个失败探针里,存档的代码先记了一条,又改了文档,最后出了错。结果这次存档在记录本里一行都没有,文档也停在原来的值。

图 5-2 先攒着,跑完才整批交出

图 5-2 · 一次存档先攒着,正常跑完才整批提交,中途抛错就全部丢弃

这里说的“出错”,是存档那段代码自己抛的错,攒下的改动直接丢掉。要是存储本身报错,Session会整个失败,之后的调用都拿到SessionFailed,只能关掉重开;commit() 返回 { stored, refused? },只要 refused 出现,Session 也会失败,前面的批次已经落盘。只有读请求不合法(StorageRequestError)才只废掉那一次读,commit()、mintId() 抛的这个错照样算存储坏了。

图 5-3 错在哪层,就废到哪层

图 5-3 · 存档代码抛错只丢这一批,存储报错会让 Session 整个失败,读请求不合法只废一次读

SQLite是一个数据库,数据库自己就会这一招。Durable把一次 commit() 调用里的所有批次,都包在数据库的一组操作里;每批仍是单独的一笔存档,各有各的 seq。看代码,先说开始,全部写完才说确认;中途出错就撤销,把这一组改动全部作废。只是前一批被 Durable 自己的检查拒绝时,前面的批次照存,后面的不存。所以程序崩了,确认过的存档都还在,没确认的一样都不留。

普通存档会立刻交给存储;流式回答和工具运行中的输出例外,它们的提交先留在内存里给 watch 看,等下一次读 pi.live、close() 或最多 settings.progress.persistWithinMs(默认 1000 毫秒)才落盘;崩溃最多丢约一秒的这类内容。

源码对照 · packages/durable/src/storage/sqlite/storage.ts:218

async commit(
batches: readonly (readonly StorageWrite[])[],
receive: (index: number, seq: Seq) => void,
_context: Context,
): Promise<CommitResult> {
// …
await this.db.transaction(async (transaction) => {
const metadata = await this.readMetadata(transaction);
let next = metadata.next_seq;
for (const writes of batches) {
const documentActions = this.prepareDocumentActions(writes);
// … 检查这一批;被拒绝就记下 refused 并 break …
const seq = seqFromNumber(next++);
await this.applyBatch(transaction, writes, documentActions, seq);
seqs.push(seq);
}
// … writeMetadata() …
});
for (const [index, seq] of seqs.entries()) receive(index, seq);
return refused === undefined ? { stored: seqs.length } : { stored: seqs.length, refused: refused.error };
}

这里的db.transaction,在Node的适配器里就是先BEGIN IMMEDIATE,全部写完才COMMIT;中途抛错就ROLLBACK(packages/chord/src/sqlite/node.ts:159,164,169)。记录、文档和存档序号在同一个回调里写;数据库出错就一起作废,前一批被拒绝时,前面的批次照存。

图 5-4 SQLite:一组事务,全有或全无

图 5-4 · SQLite 把记录、文档修订和序号更新包进同一组事务,一起提交或一起回滚

存储实况 · SQLite(按示例 0、1 的写法,同一段写入换 SQLite 后端,本次运行)

库里有九张表:会话、记录、任务、提交、文档、文档修订,加上元数据、编号和迁移版本;迁移版本那张表里最新一行是2。记录本和文档修订里的行是这些:

SELECT id, conversation_id, commit_seq, json_extract(record, '$.data') AS data
FROM entries ORDER BY id;
id conversation_id commit_seq data
3 2 2 hello
6 2 3 goodbye
SELECT document_id, seq, kind, content FROM document_revisions ORDER BY seq;
document_id seq kind content
4 2 base {"text":"after hello"}
4 3 delta [["s",["text"],"after goodbye"]]

ID是这次运行的值,不是固定编号;换一个空库重跑,分配从头开始,复用旧库则会接着往后排。两笔存档各留下一条记录、一条文档修订;第二次是在第一次的基础上改的,所以文档修订是先base、再delta。

JSONL没有数据库,也就没人替它确认,它用的办法很像记账。目录里有一个main.jsonl,记这次存档改了哪些东西;文档内容放在doc-4.jsonl这样的sidecar里,还没结束的任务也有自己的task-*.jsonl。每次存档,先往sidecar里追加这次的内容,最后才往main.jsonl末尾追加一行提交标记;几笔存档一起提交时,会先把各笔的sidecar都写完,再把它们的标记一次追加到main.jsonl,仍是一笔一行。这一行就像记完一笔账,签个名。

源码对照 · packages/durable/src/storage/jsonl/storage.ts:349

for (const sidecar of sidecars) {
const result = await this.fs.appendFile(sidecar.path, sidecar.content, context);
if (!result.ok) throw this.poison(errorFromFile(`append to ${sidecar.file}`, result.error));
}
// … fsync …
const markers = commits.length === 1 ? commits[0]!.marker : commits.map((commit) => commit.marker).join("");
const marker = await this.fs.appendFile(this.mainPath, markers, context);
if (!marker.ok) throw this.poison(errorFromFile(`append to ${MAIN_FILE}`, marker.error));

先写sidecar,再往main.jsonl写提交标记;sidecar写失败就直接抛错,走不到写标记那一步。顺序反过来也不行:签名先到,正文还没到,重新打开时这笔存档就对不上,会报损坏。

图 5-5 JSONL:先写正文 sidecar,最后写标记

图 5-5 · JSONL 先写 sidecar 正文,最后往 main.jsonl 追加提交标记

重新打开时,只认签了名的存档。sidecar里的记录,靠标记里的seq和ordinal对上;对不上的部分都不算。要是最后一次没签上,或者只签了一半,就当它没发生过。已经写完整、但标记没提到的sidecar末尾行,也会被截掉。

存储实况 · main.jsonl 第 3 行与 doc-4.jsonl 第 2 行(同一段写入换 JSONL 后端,本次运行)

{"format":1,"type":"commit","seq":3,"writes":[{"type":"entry","value":{"kind":"note","data":"goodbye","id":6,"conversationId":2}},{"type":"document.change","id":4,"ordinal":0}]}
{"format":1,"type":"record","seq":3,"ordinal":0,"payload":{"type":"document","id":4,"content":{"version":1,"kind":"delta","ops":[["s",["text"],"after goodbye"]]}}}

main.jsonl第1行建会话,第2行是第一次存档,第3行是第二次。第3行里,document.change只说“第4号文档、第0条”,正文在doc-4.jsonl里;两边seq都是3,ordinal都是0。把main.jsonl的最后一行截成半行再打开,第二次存档整个没了:记录本里只剩那条hello,文档还是after hello,第一次完整地留着。

图 5-6 标记只写一半,这次存档不算

图 5-6 · 提交标记只写一半,重开时截掉,第二次存档不算,第一次完整保留

为什么一定要全部存,要么全部不存?回想第二集,模型说要读文件的那句话,和新建的工具任务装在同一次存档里。代码里也是这样:助手那条回复和这一轮要跑的工具任务,是一起写进去的(packages/durable/src/harness/generation.ts:1028)。假如只存下了模型那句话,没存下工具任务会怎样?重新启动后,记录本里写着要读文件,却没人去读,整个对话就可能停在那里。接不下去。这一集,记住一句话,一次存档里的东西,要么全部存,要么全部不存,三种存法都是这样。本集用到的几处代码都列在这里,下一集我们来看看,模型每次看到的到底是什么。

图 5-7 回复和任务,必须一起存

图 5-7 · 助手回复和工具任务在同一次存档里,只存一半,重启后对话就接不下去

假如你跟agent聊天,忽然发现第一句话里有个错字,想把它改掉。又或者,模型回答到一半,被你打断了。那下一次提问时,模型看到的是哪一句?那半截回答,他还看得到吗?

正在加载交互演示…