跳转到内容

模型每次看到的是什么

这是 Pi《源码精读》的第六集,模型每次看到的是什么?模型是真正回答问题的大模型。agent 每次把要它看的东西发过去,等它回话,这就叫发一次请求。这次的例子,改自包里的第八个示例。视频里为了讲清楚,用的是五条的简化版;示例 8 有九条,下面按当前运行结果讲。九条是:一句带错字的话,一条被打断的半截回复,一次带两个工具调用的回复,两条前后颠倒的工具结果,一条系统提示词,一次正常回复,一条修改,还有一条只给界面看的备注。

结果,到第一次读上下文时,记录本里躺着九条,模型只拿到六条:一条系统提示词,加上改正后的那句话、一次带两个调用的回复、两条结果,还有最后一次回复。模型拿到的这一份,叫做上下文。它不是从记录本里原样搬过去的,而是每次发请求之前,从记录本现拼出来的。

图 6-1 九条记录,只有六条进入这次请求

图 6-1 · 九条记录里,只有六条进入这次请求

看代码。每条记录先看有没有修改:有修改就按修改来,可以换掉,也可以省略;没有修改,就用它自己的 model。被打断、出错、被服务商推迟的回复,一律过滤掉。

源码对照 · packages/durable/src/harness/context.ts:10, 283

const EXCLUDED_STOP_REASONS: ReadonlySet<AssistantMessage["stopReason"]> = new Set(["aborted", "error", "deferred"]);
// …
function contribute(entry: EntryRecord, edit: ContextEdit | undefined): readonly Message[] {
if (edit?.action === "omit") return Object.freeze([]);
const contributed = edit?.action === "replace" ? edit.messages : (entry.model ?? []);
return Object.freeze(
contributed.filter((message) => message.role !== "assistant" || !EXCLUDED_STOP_REASONS.has(message.stopReason)),
);
}

contribute() 做的就是上面那一步:omit 直接给空,replace 用新消息,其余用原条目的 model;最后那步把 aborted、error、deferred 三种回复剔掉。

所以九条记录拼出六条消息。带错字的原话没被删,它还在记录本里,只是被后面那条修改盖住了。记录本只会往后加,条目写进去就不会改,也不删。想改某一句,就在后面再添一条修改;修改本身也是新条目,用 edits 指回旧条目。代码里把条目定义成不可变的。

图 6-2 修改作为新条目指回旧条目,原话仍留在记录本里

图 6-2 · 修改作为新条目指回旧条目,原话仍留在记录本里

存储实况 · 示例 8 的 SQLite(本次运行)

示例 8(packages/durable/test/examples/08-context.ts)用的是 SQLite,数据库落在当前目录的 pi-durable-context-data/SQLite/session.sqlite。第一次读上下文时,记录本里有九条,id 从 8 到 16;同一个库再次运行会继续往后编号,要复现干净结果就换一个空目录运行。从 entries 表查出来是这样:

SELECT id, json_extract(record, '$.kind') AS kind,
json_extract(record, '$.model[0].role') AS role,
CASE WHEN json_type(record, '$.model[0].content') = 'text'
THEN json_extract(record, '$.model[0].content')
ELSE json_extract(record, '$.model[0].content[0].text') END AS text,
json_extract(record, '$.model[0].sections') AS sections,
json_extract(record, '$.data') AS data
FROM entries
WHERE commit_seq <= 10
ORDER BY id;

在空库上,这九条写入的 commit_seq 是 2 到 10;把 record 里的字段摊开,就是下面这张表(ID 是这次运行的值):

id kind 条目里有什么 进上下文?
8 message user:read a and b 被 15 号替换
9 message assistant:I crashed(aborted) 跳过
10 message assistant:reading + call(a) call(b) 整条进
11 message toolResult:file b(call b) 排到 a 后面
12 pi.system system,sections 是 {"cwd":"<cwd>/repo</cwd>"} 进,移到最前
13 message toolResult:file a(call a) 排到 b 前面
14 message assistant:a and b look fine 进
15 edit data 是 user fixed a typo;edits 指回 8,replace 成 read files a and b 只提供替换内容
16 note data 是 display only 不进

同一个 view.messages(这里的 view 来自 context(),是 ContextView,不是用于 watch 的 ConversationView),示例自带的 show() 打出来是六条:

["system:{\"cwd\":\"<cwd>/repo</cwd>\"}",
"user: read files a and b",
"assistant: reading call(a) call(b)",
"result(a)",
"result(b)",
"assistant: a and b look fine"]
图 6-3 两条结果倒着写入,发送时按 call 顺序排好

图 6-3 · 两条结果倒着写入,发送时按 call(a)、call(b) 的顺序排好

两条结果倒着写进记录本,拼的时候被挪到各自调用后面,按 a、b 的顺序排好。如果结果还没写,上下文会给调用补一条 isError: true 的结果,正文是 Tool result unavailable: history ends before this call completed.——示例从工具调用处分叉,拿到的就是两条这样的结果。

图 6-4 工具结果缺失时,为每个调用补一条 isError 结果

图 6-4 · 工具结果缺失时,上下文为每个调用补一条 isError 结果

上下文最前面,通常还有系统提示词。第十四个示例(packages/durable/test/examples/14-system-prompt.ts)里,主对话的系统提示词有三段:开场白、工作目录、一条规矩。这三段由扩展用 section() 声明,发请求前依次渲染。第一次请求前,它们合成一条 pi.system,整份写进记录本。这条 pi.system 在记录本里排在用户输入后面,拼上下文时才移到最前。

图 6-5 两个扩展各声明 section,合成一条三段式 pi.system

图 6-5 · 两个扩展各声明自己的 section,合成一条三段式 pi.system

后来我们把工作目录改成 /repo/packages,再提问一次。系统提示词重新算出来还是三段,但只有工作目录那段变了。系统条目只多了一条 pi.system,里面只有新的工作目录。

存储实况 · 示例 14 的两条 pi.system(SQLite,本次运行)

示例 14 也用 SQLite,数据库在 pi-durable-system-prompt-data/SQLite/session.sqlite。root 对话(conversation_id = 1)里查 pi.system,只有两条;同一个库再次运行会复用旧对话,上一次的两条系统条目还在,第一次打印直接就是两条;下面的查询结果不变。要复现第一次打印的一条,就换一个空目录运行:

SELECT id, json_extract(record, '$.model[0].sections') AS sections
FROM entries
WHERE conversation_id = 1 AND json_extract(record, '$.kind') = 'pi.system'
ORDER BY id;

查出来是 id 16 和 26 两条,sections 分别是:

[
{
"preamble": "You are a coding agent. Be terse.",
"cwd": "<cwd>\n/repo\n</cwd>",
"agents_md": "<agents_md>\nRun npm run check after changes.\n</agents_md>"
},
{ "cwd": "<cwd>\n/repo/packages\n</cwd>" }
]

第一条的 sections 有三个 key,第二条只有 cwd。数据里的标签和 Be terse. 只是每段文字的包装,段数还是三段。

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

const patch: Record<string, string | null> = {};
for (const [key, value] of shown) {
const next = desired.get(key);
if (next !== value) patch[key] = next ?? null;
}
for (const [key, value] of desired) if (!shown.has(key)) patch[key] = value;
return Object.keys(patch).length === 0 ? [] : [patch];

shown 是模型已经看到的段,desired 是这次刚算出来的段。段序不变时,值变了才写进 patch,新加的段也写进去,删掉一段则记 null;都没变,就返回空,不添条目。

图 6-6 只有 cwd 变了,新 pi.system 里就只写 cwd

图 6-6 · 只有 cwd 变了,新 pi.system 里就只写这一段

所以第二次的上下文里,旧的系统提示词还在最前面,新的工作目录排在这次提问后面。前面那一大段和上次一模一样,服务商通常能复用上次算过的前缀,更快也更省钱。

图 6-7 第二次请求:旧系统提示词在最前,新 cwd 留在提问之后

图 6-7 · 第二次请求里,旧系统提示词移到最前,新 cwd 留在提问之后

能用哪些工具也是这样,通常只记变化。工具变了,会搭在系统条目上一起写。

另外,记录本里留着每一次变化,事后翻开它,就能看出模型在每个时刻看到的是什么。这一集记住一句话:记录本里的东西都留着,模型看到的上下文是每次发请求前现拼出来的。系统提示词平时也一样,只在变了的时候添新条目;段序变了会整份重排。

有一个例外:压缩或重置之后,记录本会写一条带 head 的新条目,指定新上下文从哪儿开始。下一次请求前,如果 head 后面还没有新的系统条目,就补一份完整的系统提示词基线;保留范围里如果还有旧系统条目,才用 edits 标成 omit。旧条目还在,只是不参与这段上下文。那是下一集的事。

下一集我们来看,对话越聊越多,快要装不下的时候,Agent 怎么办?假如你和 Agent 聊了一整天,讨论了几十个问题,记录本越来越厚,可是模型一次能读的东西有限,读不下的部分,它根本看不到。记录本一直在变厚,模型能读的量却不会变多。

总有一天会装不下。

正在加载交互演示…