模型每次看到的是什么
这是 Pi《源码精读》的第六集,模型每次看到的是什么?模型是真正回答问题的大模型。agent 每次把要它看的东西发过去,等它回话,这就叫发一次请求。这次的例子,改自包里的第八个示例。视频里为了讲清楚,用的是五条的简化版;示例 8 有九条,下面按当前运行结果讲。九条是:一句带错字的话,一条被打断的半截回复,一次带两个工具调用的回复,两条前后颠倒的工具结果,一条系统提示词,一次正常回复,一条修改,还有一条只给界面看的备注。
结果,到第一次读上下文时,记录本里躺着九条,模型只拿到六条:一条系统提示词,加上改正后的那句话、一次带两个调用的回复、两条结果,还有最后一次回复。模型拿到的这一份,叫做上下文。它不是从记录本里原样搬过去的,而是每次发请求之前,从记录本现拼出来的。
图 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 · 修改作为新条目指回旧条目,原话仍留在记录本里
存储实况 · 示例 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 dataFROM entriesWHERE commit_seq <= 10ORDER 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(a)、call(b) 的顺序排好
两条结果倒着写进记录本,拼的时候被挪到各自调用后面,按 a、b 的顺序排好。如果结果还没写,上下文会给调用补一条 isError: true 的结果,正文是 Tool result unavailable: history ends before this call completed.——示例从工具调用处分叉,拿到的就是两条这样的结果。
图 6-4 · 工具结果缺失时,上下文为每个调用补一条 isError 结果
上下文最前面,通常还有系统提示词。第十四个示例(packages/durable/test/examples/14-system-prompt.ts)里,主对话的系统提示词有三段:开场白、工作目录、一条规矩。这三段由扩展用 section() 声明,发请求前依次渲染。第一次请求前,它们合成一条 pi.system,整份写进记录本。这条 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 sectionsFROM entriesWHERE 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 里就只写这一段
所以第二次的上下文里,旧的系统提示词还在最前面,新的工作目录排在这次提问后面。前面那一大段和上次一模一样,服务商通常能复用上次算过的前缀,更快也更省钱。
图 6-7 · 第二次请求里,旧系统提示词移到最前,新 cwd 留在提问之后
能用哪些工具也是这样,通常只记变化。工具变了,会搭在系统条目上一起写。
另外,记录本里留着每一次变化,事后翻开它,就能看出模型在每个时刻看到的是什么。这一集记住一句话:记录本里的东西都留着,模型看到的上下文是每次发请求前现拼出来的。系统提示词平时也一样,只在变了的时候添新条目;段序变了会整份重排。
有一个例外:压缩或重置之后,记录本会写一条带 head 的新条目,指定新上下文从哪儿开始。下一次请求前,如果 head 后面还没有新的系统条目,就补一份完整的系统提示词基线;保留范围里如果还有旧系统条目,才用 edits 标成 omit。旧条目还在,只是不参与这段上下文。那是下一集的事。
下一集我们来看,对话越聊越多,快要装不下的时候,Agent 怎么办?假如你和 Agent 聊了一整天,讨论了几十个问题,记录本越来越厚,可是模型一次能读的东西有限,读不下的部分,它根本看不到。记录本一直在变厚,模型能读的量却不会变多。
总有一天会装不下。