跳转到内容

对话太长了怎么办

这是Pi《源码精读》的第七集,对话太长了怎么办?这次的例子,改自包里的第24个示例。我们用一个假模型,把它的窗口故意设成 3000 token,然后连问五个旅行问题。每问完一次,就数两样东西:现在拼出来的上下文有几条消息,记录本里存了几条。

图 7-1 记录本持续变厚,模型窗口始终只有 3000 token

图 7-1 · 记录本持续变厚,模型窗口始终只有 3000 token;当前上下文只放摘要和最近原话。

把五次打印抄下来,是这样:

问完 上下文消息 记录本条目
第一问 2 2
第二问 4 4
第三问 6 6
第四问 4 9
第五问 7 12

前三问两边一样多。第四问开始,记录本的涨法变了:每问一轮本来只多两条原话;碰到后台压缩的那一轮,会多出一条摘要,下一轮再补一条新写的系统提示词;碰到阻塞压缩,这一轮压完就补上新的系统提示词,再发请求。这次是第四问越过后台线,压缩在后台做完;第五问补上新的系统提示词,没有再越线。上下文却没有跟着问题数一路涨,而是在 4 到 7 条之间打转。最近的一段原话原样留着,只有更早的那些被缩成一条摘要。这件事就叫压缩。

图 7-2 五问之后,上下文在 4 到 7 条之间打转,记录本涨到 12 条

图 7-2 · 五问之后,上下文在 4 到 7 条之间打转,记录本涨到 12 条。

压缩不是等装不下才做。每次发请求之前,Agent都会先估一估这次要发的东西有多大,再拿它和两条线比。下面一条是后台线,越过去就开一个后台压缩任务,聊天照常继续;上面一条是阻塞线,越过去就说明马上装不下了,这一轮先停下来,压完再发。两条线都由 contextWindow 算出来:contextWindow - reserveTokens 是阻塞线,再减去 backgroundTokens 是后台线;backgroundTokens 设成 0,后台压缩就关掉。最近大约保留多少原话,由 keepRecentTokens 管。这些设置都在 settings.compaction 里,contextWindow 来自模型定义;enabled 只管自动压缩,手动压缩不受它限制。如果某个模型的窗口特别小,还可以用 modelOverrides,按 "provider/modelId" 单独给它一套。例子里,第四问越过后台线,压缩在后台做完,聊天没停;示例最后一段让提供方直接报 overflow,阻塞压缩完成后这一轮重试成功。

图 7-3 两条阈值线:先过后台线,再过阻塞线

图 7-3 · 越过后台线先做后台压缩,越过阻塞线先停下,压完再发。

压缩任务叫 pi.compaction。它先选出要压的那一段,再把这段对话拍成纯文本,交给模型写摘要。写摘要用的是一条专门的系统提示:You are a context summarization assistant,下面紧接着 Do NOT continue the conversation 和 ONLY output the structured summary。提示还规定了格式,要写目标、约束、进度、做过的决定、下一步,以及继续干活需要的关键上下文。如果前面已经有一份摘要,就把它一起并进新的摘要,而不是从头再写。摘要失败可重试时,会退避后再试;重试用尽或任务反复被打断,压缩就以失败结束,状态写进 pi.live.compactionFailure,里面的 trigger 记着这次是哪种压缩。任务不会挂在那里等下一次重试;阻塞压缩失败时,这一轮以 pi.live.runFailure 结束。

图 7-4 压缩分 select、summarize、placeSummary 三步

图 7-4 · 压缩分 select、summarize、placeSummary 三步;可重试的失败会退避重试,重试用尽则以失败结束。

压缩完成、写进记录本时,记录本里会多一条 pi.compaction。看这段代码:

源码对照 · packages/durable/src/harness/compaction.ts:449

const text = `${SUMMARY_PREFIX}${summary}${SUMMARY_SUFFIX}`;
const entry = {
kind: CompactionEntry.kind,
head: firstKept,
model: [{ role: "user", content: [{ type: "text", text }], timestamp: runtime.now() }],
data: { reason: current.input.reason },
} satisfies EntryDraft;

它记的不是一个叫 summary 的字段,也不是 firstKept:摘要正文放在 model 里,是一条带 <summary> 包装的 user 消息;第一个保留条目的 id 放在 head 里;data.reason 记着这次是 manual、threshold 还是 overflow。源码内部把第一个保留条目叫 firstKept,写进记录本时,字段名是 head。跑完示例,一共留下三条摘要条目:

存储实况 · SQLite(示例 24 · 24-compaction.ts 本次运行,跑完后)

SELECT id, head, json_extract(record,'$.data.reason') AS reason
FROM entries
WHERE json_extract(record,'$.kind') = 'pi.compaction'
ORDER BY id
LIMIT 3;

数据库在 packages/durable/pi-durable-compaction-data/session.sqlite;示例每次运行会重建它。

id head reason
25 18 threshold
37 30 manual
48 42 overflow

25 是第四问的后台摘要,37 是手动摘要,48 是最后一段 overflow 的阻塞摘要。threshold 只说明它是过线触发的;到底是后台还是阻塞,要看任务的 background,或 pi.live.compactions 里的 blocking,摘要条目本身不记这个区别。手动压缩那条的完整记录,只留和这段叙事有关的字段,是这样:

{
"id": 37,
"kind": "pi.compaction",
"head": 30,
"data": { "reason": "manual" },
"model": [{
"role": "user",
"content": [{ "type": "text", "text": "The conversation history before this point was compacted into the following summary:\n\n<summary>\n## Goal\nPlan a week in Lisbon (summary #2).\n</summary>" }]
}]
}
图 7-5 摘要条目没有 summary 字段,正文在 model 里

图 7-5 · 摘要条目没有 summary 字段,正文在 model 里,head 指向第一个保留条目。

head 是 30。这次运行里,id 30 正好是 “Nightlife?” 的那条回答,所以保留段从一条助手原话开始,不一定从用户问题开始。下次拼上下文,Agent先找最新的 head 标记,也就是这条摘要条目;从它 head 指向的 id 30 开始往后扫,再把摘要本身放在前面。head 不是备注,它直接决定下一次从哪里开始拼。更早的条目根本没有进这次上下文,但它们还在记录本里,一条都没删。

图 7-6 摘要只追加,旧原话一条不删

图 7-6 · 摘要只追加,旧原话一条不删,head 改变模型看到的范围。

手动压缩可以带一句话,告诉它最想留下什么。示例里我们趁模型还在写回答,调用 compact("Keep the hotel shortlist");这句话会作为 Additional focus 附在摘要提示后面。压缩做好时对话还忙着,摘要不会硬塞进去,而是作为一条 write 提交进 pi.inbox 排队。下面是当时抓到的真实快照:

存储实况 · pi.live 与 pi.inbox(手动压缩进行中)

文档 当时的内容(节选)
pi.live.compactions [{"taskId":34,"trigger":"manual","blocking":false,"attempt":1}]
pi.inbox.items [{"id":35,"mode":"write","entry":{"kind":"pi.compaction","head":30,…}}]

表里 pi.live.compactions 用的是 trigger;摘要条目里的同一条信息记在 data.reason。

blocking 是 false,因为这是手动压缩,不是某一轮生成自己的阻塞压缩。阻塞压缩是那一轮 generation 自己的任务,摘要由它直接追加,不经过收件箱;后台和手动压缩在对话忙时才走 pi.inbox,空闲时也直接追加。等回答结束、到了下一个边界,排队的写入才被放进记录本。控制台先打印 manual compaction finished; its summary is queued,回答结束后再打印 after the answer, the summary is done。压缩任务结束时,这条状态就从 pi.live.compactions 里移除;摘要可能还在 pi.inbox 里等边界。这就是图 7-7 里那条队。

图 7-7 手动压缩的摘要先进 pi.inbox 排队

图 7-7 · 对话忙时摘要作为 write 进入 pi.inbox,等下一个边界;阻塞压缩直接追加。

还有一件容易漏掉的事:压缩以后,下一次准备请求时,系统提示词会重新写一份完整的 baseline。看代码。这里的 view 是 ContextView,不是用于 watch 的 ConversationView。

源码对照 · packages/durable/src/harness/prompt.ts:72

const head = view.head;
if (head !== undefined && !view.entries.some((entry) => SystemEntry.is(entry) && entry.id > head.id)) {
const edits: ContextEdit[] = view.entries
.filter((entry) => SystemEntry.is(entry))
.map((entry) => ({ target: entry.id, action: "omit" }));
const baseline = systemEntry(
Object.fromEntries(desired),
{ toolsRemoved: [], toolsAdded: tools.map(toToolDeclaration) },
timestamp,
);
return [edits.length === 0 ? baseline : { ...baseline, edits }];
}

只要最新的 head 标记后面还没有新的系统条目,就写一条完整的 baseline,把当前所有段落和工具重新列一遍;保留范围里如果还有旧系统条目,就用 edits 标成 omit。它们没有被删除,只是不进上下文。在这个例子里,旧系统条目都在 head 之前,所以这次写出的 baseline 不带 edits。这条 baseline 排在最前面,摘要跟在它后面,不跟在新输入后面。压缩后如果已经有新的系统条目,就只补差异,不重写整条。

图 7-8 head 后没有新系统时重写完整 baseline

图 7-8 · head 后没有新系统条目时重写完整 baseline,保留范围内的旧 system 用 omit。

为什么不干脆把旧的删掉?不删有一个好处,记录本始终是完整的历史,原话随时能翻回去看。模型看的那一份可以缩,记录本里的原话却一条都不删。压缩还能提前在后台做,那时聊天不用停下来等它。这一集记住一句话:对话太长时,把前面压缩成一条摘要,模型看的是摘要和最近的原话;记录本里,什么都不删。下一集我们来看,新功能是怎么接进 Agent 里来的。假如你想给Agent加一个新本事,比如让它能读文件,你写好了一个读文件的工具,那这段代码该放在哪里?它要不要也存进存储?代码改了程序,重启了又会怎样?

正在加载交互演示…