跳转到内容

正忙时又说一句

图 3-1 正忙时又说一句:问题、插话、追问先进收件箱

图 3-1 · 正忙时又说一句:三条输入,两轮回答

这是Pi《源码精读》的第三集。正忙时又说一句。上一集讲了工具任务,模型要工具,就开一个工具任务去做,做完再问一次模型。上一集里,读文件、改文件,还跑了两次 bash,才给出最终回答。从你提问到最终回答,这几圈合起来,我们叫它一轮。这一轮还没结束,就说agent正在忙。

这次的例子,改自包里的第 19 个示例,19-inbox.ts。你先问了一个问题。这时模型还没回话,你又连着说了两句。一句是追问,“A follow-up”;另一句是插话,“A steer”。发这两句时,你分别选了追问和插话,两种方式,后面会讲区别。跑完以后,你的追问和插话一起得到了回答。

不过,这一次插话没有赶上第一轮。示例里的第一轮没有调用工具,插话没有工具后的那个时机可赶,只能和追问一起等到这一轮答完。到了 final,边界既取插话,也取追问;两条按提交顺序写进记录本,然后一起开始下一轮。示例还把 followUpMode 设成了 "all",不过这次只有一条追问,默认的 "one-at-a-time" 也会把它放下;"all" 的差别要等到同时排着多条追问才看得出来。

先打个比方。同事正在忙,你把纸条放进同事桌上的收件箱。同事不会马上停下来,而是忙到一个合适的时候,再从收件箱里拿出来看。在agent里,正忙时提交的输入,会先放进收件箱排队,不会马上写进记录本。收件箱就是对话旁边的 pi.inbox 文档,你的话放进去的那一刻就存了档。只要存储在磁盘上,程序崩了,排队的话也还在。

图 3-2 同事在忙,纸条先放收件箱

图 3-2 · 收件箱就是 pi.inbox;放进去就存档

这一轮里,排队的输入有两个正常时机被拿出来。一个是工具做完、正要再问模型的时候,叫 postTools;另一个是这一轮结束的时候,最常见的是回答完,叫 final。

图 3-3 一轮里的两个时机:postTools 取插话,final 取追问

图 3-3 · 插话等工具,追问等本轮

插话是想让agent这一轮就听到的话。工具一做完,到了 postTools,收件箱里的插话就被拿出来写进记录本,排在工具结果后面。接着再问模型,模型就能看到它。追问是等这一轮答完再说的话。到了 final,这一轮的答案先写进记录本,追问再被拿出来;然后开新的一轮。

注意,插话只是有机会赶上这一轮。要是这一轮根本不会调用工具,就没有 postTools,插话只能等这一轮答完,和追问一起在 final 被取走。示例 19 就是这种情况,收件箱里排着两句话,前面是追问,后面是插话。取的时候,默认每种只取第一条:插话一条,追问一条;steeringMode 和 followUpMode 都可以设成 "all",那样会把当下排队的同类消息一次取完。

发消息时不特别说明,就按追问处理。这句话只在正忙时成立:whenBusy 不写,和写 "followUp" 一样;写 "steer" 才是插话;正忙时写 "reject" 则直接报 ConversationBusy,不排队。空闲时 whenBusy 不起作用;另有一个 whenIdle: "reject",空闲时提交会直接报 ConversationIdle,既不进记录本,也不开始一轮。没有排队项时,消息直接进记录本并开始一轮。要是前面还排着别的输入(比如上一轮没有回答、原因记在 pi.live.runFailure 时留下的),它也会排到队尾,等一次 final 边界一起放下;也可以提交 { type: "continue" },不带新消息,把这些排队项先放下,在现有上下文上再跑一轮。continue 只在空闲时接受,正忙时同样报 ConversationBusy,从不排队。

图 3-4 whenBusy:空闲、followUp、steer、reject 分别去哪

图 3-4 · whenBusy 决定去哪;默认 followUp

为什么不马上把你的话写进记录本?比如模型已经拿着记录本去想回答了。这时写进去,他这一次看不到,可他的回答写进记录本时,会排在你这句话后面。以后翻记录本,会以为他回答过这句话,其实模型根本没看到。所以先放进收件箱,等到下一个安全时机再写进去。这样排在你这句话后面的回答,才是看过它的。

图 3-5 为什么不能马上写:写早了模型看不到

图 3-5 · 写早了模型看不到;等安全边界再写

示例 19 在空目录首次跑完以后,去掉程序自己加的说明,记录本是:你的问题、一条 app.note、模型的第一个回答、你的追问、你的插话、模型的第二个回答。那条 app.note 是只有 kind 和 data 的展示型写入;它不改变模型看到的内容,所以正忙时也不排队,当场就追加进记录本;只有前面已经排着一个 reset 时,它才会等 reset 先落地。也正因为这样,它会排在还排着队的追问和插话前面。

图 3-6 示例 19 的记录本顺序:问题、app.note、回答、追问、插话、回答

图 3-6 · 示例 19:两句话都在 final 放下

这个示例还试了 reject 和撤回:reject 直接打印 rejected: Conversation 1 is busy;已经排队的输入可以在放置前 abort(),状态变成 unanswered,原因是 aborted。这两件事不展开,只要知道它们都不会混进这一轮的记录本。

当前示例 19 的第一轮没有工具调用,空目录首次运行时,记录本顺序以上面列出的“问题、app.note、第一个回答、追问、插话、第二个回答”为准。

示例 19 本身用 SQLite,数据库文件落在当前目录的 pi-durable-inbox-data/SQLite/session.sqlite。同一个数据库再次运行会复用旧对话,记录会累加;要复现干净结果,换一个空目录运行。

再看这一轮真正调用工具时,两个时机怎么分工。我用同一套机制跑了一个小探针:先让模型调用一个会等的 hold 工具,工具还没做完时,提交一条插话和一条默认的追问。

存储实况 · SQLite(探针,本次运行,三个时刻)

SELECT id, status, json_extract(record, '$.type') AS type,
json_extract(record, '$.entry') AS entry
FROM submissions ORDER BY id;
id 是什么 工具运行中 工具做完 本轮答完
8 第一个问题 placed placed done
13 插话(steer) queued placed,entry 16 done
14 追问(follow-up) queued queued done,entry 19

排队时,pi.inbox 的 items 是 [{"id":13,"mode":"steer","content":"A steer"},{"id":14,"mode":"followUp","content":"A follow-up"}];工具做完,只剩 [{"id":14,"mode":"followUp","content":"A follow-up"}];答完是 []。这三条输入提交的状态也跟着走:排队时是 queued,被放下时是 placed,答完变成 done。

记录本里的顺序,也能在 SQLite 里查到:

SELECT id, json_extract(record, '$.kind') AS kind
FROM entries
WHERE json_extract(record, '$.kind') <> 'pi.system'
ORDER BY id;
id kind 对应
7 pi.user 第一个问题
11 pi.assistant 要 hold 工具
15 pi.tool-result 工具结果
16 pi.user 插话
18 pi.assistant 看过插话后的回答
19 pi.user 追问
21 pi.assistant 追问的回答

插话排在工具结果后面,和回答在同一条运行里;追问等这条回答写完,才排到最后。两句话都待在该待的位置上。

这一集主要看 inbox.ts,再看一眼提交时的 submissions.ts。提交既可以在事务外调用 submit(submission, context),也可以在事务里调用 submit(tx, submission);后者走同一个 admitSubmission,但要求事务此前没有写过表,否则抛 ReadAfterWrite。先看一个边界怎么挑人。

源码对照 · packages/durable/src/harness/inbox.ts:85

const reset = hasQueuedReset(items);
const final = at === "final" || reset;
const pick = (mode: "steer" | "followUp", queueMode: QueueMode): number[] => {
const indexes = items.flatMap((item, index) => (item.mode === mode ? [index] : []));
return queueMode === "all" ? indexes : indexes.slice(0, 1);
};
const writes = items.flatMap((item, index) => (item.mode === "write" ? [index] : []));
const users = [...pick("steer", boundary.steeringMode), ...(final ? pick("followUp", boundary.followUpMode) : [])];
users.sort((a, b) => a - b);

final 在 at === "final" 或队列里有 reset 时为真;也就是说,队列里有一个重置(reset)时,工具后的这个边界也会提前按 final 处理。pick() 默认只取第一条,只有模式是 "all" 才全取。到了 final,追问也会被加进来;users 拼好后用 users.sort 按收件箱索引排序,所以插话和追问混在一起时,也严格按提交先后放置。写操作单独放在 writes 里,先于用户消息放置。

图 3-7 inbox.ts:final、pick、users.sort 的边界逻辑

图 3-7 · 边界怎么挑人:先 writes,再按收件箱索引放 users

当前源码中 const final 在 inbox.ts:86,const users 在 inbox.ts:92,下一行 users.sort 在 inbox.ts:93。

再看提交的时候,默认值是从哪来的。

源码对照 · packages/durable/src/harness/submissions.ts:287

const items = (boundary?.inbox ?? (await tx.doc(InboxDoc, conversationId))).items;
if (draft.type === "write") items.push({ id, mode: "write", entry: value as JsonObject });
else {
const mode = draft.whenBusy === "steer" ? "steer" : "followUp";
const content = value as JsonRepresentation<UserInput>;
const entry = draft.entry === undefined ? {} : { entry: pickInputEntry(draft.entry) };
items.push({ id, mode, content, ...entry });
}

只有明确写 whenBusy: "steer" 才是插话;不写、或者写 "followUp",都落到 followUp。write 走另一条分支,放进收件箱时 mode 是 "write";没有排着 reset 时,只有 kind 和 data 的展示型写入已经在前面直接追加,不会走到这里;如果前面排着 reset,它也会作为 write 排队,等 reset 先落地。这段之前已经以 status: "queued" 建好提交;输入被边界选中后改成 placed,write 被选中后直接 done。

这一集记住一句话:agent正忙时,你的输入先进收件箱;插话等工具做完,追问等这一轮答完,到了边界再写进记录本。能赶上就赶上,赶不上就下一轮。

图 3-8 插话赶这一轮,追问等下一轮

图 3-8 · 能赶上就赶上,赶不上就下一轮

能赶上的话,插话就进这一轮。追问等这一轮答完,再开新的一轮。下一集,我们来看一个更要命的场景。agent做到一半,程序突然崩了,会怎样?agent正在帮你往文件里写一行字,程序突然被杀掉了。重新启动以后,他还记得做到哪一步了吗?

已经做了一半的事,会不会再做一遍?

正在加载交互演示…