Part II · Kernel — The Context Pipeline

05 上下文管道 模型看到的,和会话里存的,从来不是一回事

已发布 两道闸 transformContext / convertToLlm 自定义 role 4 种
§1 Mental Model 一句话心智模型

先记住这一句

pi 的会话历史里有六种 role,LLM 只认三种。中间隔着两道闸:transformContext 改「有哪些消息」,convertToLlm 改「这些消息长什么样」。会话是真相,喂给模型的是它的一个投影。

这个分层解决的问题很具体:一次 !ls 的 bash 执行、一段压缩摘要、一条扩展插入的通知,在 UI 上要各自渲染成不同的东西,在会话文件里要保留各自的结构以便日后回放,但对 LLM 来说它们只能是 user/assistant/toolResult 三选一。

推论有两条,都很容易踩:

§2 Evidence 代码实证

两道闸的位置

循环在每次 LLM 调用前依次过这两道闸,顺序固定:先 transformContext(可选),再 convertToLlm(必需)。agent-loop.ts:290 agent-loop.ts:295

packages/agent/src/agent-loop.ts原文 L289–L302
let messages = context.messages;
if (config.transformContext) {
    messages = await config.transformContext(messages, signal);
}

// Convert to LLM-compatible messages (AgentMessage[] → Message[])
const llmMessages = await config.convertToLlm(messages);

// Build LLM context
const llmContext: Context = {
    systemPrompt: context.systemPrompt,
    messages: llmMessages,
    tools: context.tools,
};
两闸都不写回 context.messages——转换结果是一次性的,只用于本次请求。会话历史不受影响。

闸一:transformContext 在 pi 里等于「扩展说了算」

coding-agent 传给这道闸的实现只有一件事——把消息交给扩展系统,别的什么都不做:sdk.ts:350–354

packages/coding-agent/src/core/sdk.ts原文 L350–L354
transformContext: async (messages) => {
    const runner = extensionRunnerRef.current;
    if (!runner) return messages;
    return runner.emitContext(messages);
},
没有扩展时是恒等函数。pi 自身的裁剪 / 压缩不走这道闸——压缩是把结果写进会话历史(见 第 06 章),不是每次请求临时改写。

这个设计选择值得停一下:transformContext 是每次 LLM 调用都跑一遍的钩子,扩展在这里的任何操作都乘以 turn 数。它适合做「注入当前时间」「附上打开的文件列表」这类幂等的上下文补充,不适合做重计算。

闸二:convertToLlm 把四种自定义 role 全部压成 user

会话里有六种 role。三种原样透传,另外三种(外加 custom)一律变成 user:messages.ts:148–195

会话中的 role模型看到的怎么标识自己
user / assistant / toolResult原样—
bashExecution(用户敲的 !cmd)user序列化成命令 + 输出的文本
custom(扩展插入)user无标识,扩展自己负责写清楚
branchSummaryuser英文前后缀包裹
compactionSummaryuser英文前后缀包裹

那两段前缀是纯文本常量,不是任何协议字段:messages.ts:11 messages.ts:19

packages/coding-agent/src/core/messages.ts原文 L11 与 L19(并置)
export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from:
模型区分「摘要」和「用户真的说过的话」,全靠读懂这两句英文。这是一个 prompt 层面的约定,不是结构保证。

唯一会凭空消失的消息

bashExecution 带 excludeFromContext 标志时直接返回 undefined,最后被 filter 掉。messages.ts:154–156 这对应用户敲 !!cmd(双感叹号):命令执行、输出显示在 UI 上、写进会话文件,但不进模型上下文。

这是全书值得单独记住的一个 UX 设计:给用户一个「我自己看看,别让模型知道」的逃生口,同时会话文件仍然完整——回放和审计不丢信息。

穷尽性检查挡住了「忘了处理新 role」

switch 的 default 分支里放了 const _exhaustiveCheck: never = m;。messages.ts:190 任何人通过声明合并给 AgentMessage 加了新 role 而没在这里处理,类型检查会失败,不会静默地把消息丢掉。

对照 第 03 章看:pi 在「循环控制流」上不给扩展任何余地,但在「消息种类」上开放了声明合并——代价用一个 never 兜住。

§3 Design 设计动机与权衡

为什么是两道闸而不是一道

合成一道也能跑。分开的价值在于职责稳定性:convertToLlm 是类型驱动的、必须穷尽的、跟随 role 定义变化;transformContext 是策略驱动的、可选的、跟随部署环境变化。前者错了是类型错误,后者错了是效果问题。

体现在签名上:convertToLlm 在 AgentLoopConfig 里是必填,transformContext 是可选。agent-loop.ts:290 一个 agent 可以没有任何上下文策略,但不可能没有消息映射。

把摘要降级成普通 user 消息的代价

好处很实在:不依赖任何 provider 的特殊能力,换模型不用改;摘要能被 prompt cache 正常缓存;不需要在 pi-ai 层为「摘要」造一个跨 provider 的抽象。

代价同样实在:模型可能把摘要当成用户的话。「The conversation history before this point was compacted…」是一句请求模型配合的英文,不是一道结构约束。非英语强势的模型、或被大量后续内容淹没时,这个约定的可靠性会下降,而且失败是静默的。

仓库里未见对这一取舍的公开论述;从代码看不出有过 provider 原生机制(如 Anthropic 的 system 块分层)的尝试痕迹。

扩展能改上下文,但改不了「已经存了什么」

两道闸都不写回 context.messages。扩展可以让模型看不到某条消息,但那条消息仍在会话文件里;反过来扩展塞进去的东西,除非显式 appendEntry,否则不会留痕。

这条边界让「会话文件是可信记录」这件事成立——审计一个 run 时看会话文件即可,不必重放所有扩展。代价是「模型当时到底看到了什么」在会话文件里查不到,要靠 telemetry(见 第 14 章)。

§4 Limits 边界与失效条件

检查过但未发现问题:convertToLlm 是纯函数,不改入参数组(用 .map().filter() 产出新数组),因此不存在 第 03 章 §2 那种就地修改的隐患。

§5 Takeaway 拿走什么
§ Self-check 自测

场景用户敲了 !!git log(双感叹号)。会话 JSONL 与发给模型的请求里,分别有没有这条?

看答案

B。excludeFromContext 只在 convertToLlm 里生效,返回 undefined 后被 filter 掉。写入会话是另一条路径,不受影响。messages.ts:154

场景一次压缩之后,模型在请求里看到的那条摘要,role 是什么?

看答案

C。LLM 只认三种 role,compactionSummary 只存在于会话历史。转换时包上 COMPACTION_SUMMARY_PREFIX/SUFFIX 变成 user。messages.ts:176–183

基于 earendil-works/pi @ ac4ac9e(2026-08-07 快照)。
仓库 commit 速度约 428/月,行号引用仅对该 SHA 有效。