文章

Pi 系列 08|Context 构建:发给模型的输入怎么拼出来

Pi 系列 08|Context 构建:发给模型的输入怎么拼出来

前面几篇可以先看:

01 事件流

02 Agent loop 与 turn

03 Provider 抽象

04 Tool 系统(上)

05 Tool 系统(下)

06 实战:给应用加一个 tool

07 Session 系统

本文源码主要在 core/system-prompt.tscore/messages.tsextensions/runner.tsagent.tsagent-loop.ts,以及 agent-session.tscompaction/compaction.tscore/sdk.ts 和 provider 实现。

上一篇讲 session:对话存成一棵树,buildSessionContext 从当前 leaf 投影出一串 messages。

接下来引入一个问题:

这串 messages 是否就是发给模型的全部输入?

答案:不是

用一个普通协作场景类比:只给别人聊天记录,通常还不够。对方还需要知道任务边界、项目约定、可用工具和已有流程。模型也是一样。

pi 里,agent loop 使用的请求上下文是一个三件套:

1
2
3
4
5
AgentContext = {
  systemPrompt: string,        // 系统提示:角色、规矩、工具清单、项目约定
  messages: AgentMessage[],    // 对话:session 投影出来的那串
  tools: Tool[],               // 工具定义:模型能调用的工具
}

createContextSnapshot() 就是把这三样打包成一次请求。Context 构建,就是把 session 投影出的对话,补齐为一次模型调用所需上下文的过程。

所以咱们按这条线来看一下:systemPrompt 怎么拼出来、messages 发出前还要过哪几个步骤、为什么说”不只是 messages”。

发给模型的三件套:systemPrompt + messages + tools

一、systemPrompt 怎么拼出来

systemPrompt 由 buildSystemPrompt 拼装而成。它分两个分支:给了自定义 prompt 走 custom 分支,否则走默认分支。默认分支拼的块,顺序固定:

1
2
3
4
5
6
7
8
9
10
You are an expert coding assistant operating inside pi…   ← 角色定义(硬编码开头)
Available tools:                                          ← ① 工具清单
  - read: <一行说明>   - bash: <一行说明>
Guidelines:                                               ← ② 准则(按工具动态生成)
  - <条目>
Pi documentation (read only when…):                      ← ③ pi 文档路径
  + appendSystemPrompt                                   ← ④ 追加文本
  + <project_context>…</project_context>                 ← ⑤ AGENTS.md / CLAUDE.md 这类项目文件
  + skills 清单                                          ← ⑥(仅当有 read 工具)
  + Current date / Current working directory             ← ⑦ 日期、cwd(动态)

这里有两个条件分支需要注意,因为它们说明 systemPrompt 会随环境变化,并非常量

  • 工具清单只列”有一行说明的”visibleTools = tools.filter(name => 有 snippet)。给了工具但没有配置 snippet,它就不出现在这份清单里(不影响能不能用,只是没在提示里写出来)。
  • 准则随工具动态变:有 bash 没 grep/find/ls,加一条”用 bash 做文件操作”;有 grep/find/ls,加一条”优先用它们,比 bash 快、尊重 .gitignore”。你开了哪些工具,systemPrompt 的内容就跟着变。

其中第 ⑤ 块,是项目约定进入模型的方式。AGENTS.md / CLAUDE.md 这类文件被包进标签拼进去:

1
2
3
4
5
<project_context>
<project_instructions path="<文件路径>">
<文件内容>
</project_instructions>
</project_context>

第 ⑥ 块 skills,只在有 read 工具时才拼——因为一份 skill 在 systemPrompt 里只放名字、描述、文件位置,正文要靠模型用 read 工具按需去读。没有 read 工具,给了也用不上。

systemPrompt 的拼装顺序:角色 + 工具清单 + 准则 + 项目文件 + skills + 日期

二、systemPrompt 什么时候重建

工具清单和准则是按工具生成的,所以工具一变,这份 prompt 就要重建。

这里涉及两个字段:

字段是什么谁改
_baseSystemPrompt基线,重建后存这里_rebuildSystemPrompt 的结果
agent.state.systemPrompt这一轮实际发给模型的通常等于基线;每轮可被 extension 临时覆盖

基线的重建有三个触发点

触发入口为什么
① 开局初始化时 setActiveToolsByName(初始工具集) 顺带触发开局的 prompt 搭在”设置初始工具”这个动作上,没有单独的初始化步骤
② 工具集变化setActiveToolsByName(改了工具后重拼,下一轮生效)工具清单/准则按工具生成,工具变要重拼
③ extension 追加资源extendResourcesFromExtensionsextension 带来新 skills / prompts / themes,ResourceLoader 更新后重建

重建时(_rebuildSystemPrompt),它从 ResourceLoader 当前已加载的资源重新组装——skills、项目文件、自定义 prompt、追加文本,再按当前工具组装,交给 buildSystemPrompt。这一步使用当前已加载的资源;重新读磁盘发生在 resourceLoader.reload() 或 extension 扩展资源时。

还有第四处,只改”这一轮实际发的”、不动基线:每次发 prompt 前的 emitBeforeAgentStart,让 extension 有机会临时改这一轮的 systemPrompt:

1
2
if (result?.systemPrompt) agent.state.systemPrompt = result.systemPrompt; // extension 改了 → 用改后的
else                      agent.state.systemPrompt = _baseSystemPrompt;    // 没改 → 还原成基线

这也是两个字段分开的原因:extension 可以只改”这一轮”,下一轮如果没再改,else 分支把它还原回基线。_baseSystemPrompt 保存不随单轮变化的基线,state.systemPrompt 保存这一轮实际发出去的内容。

systemPrompt 的基线与每轮覆盖

三、messages 发出前的两个步骤

messages 从 session 投影出来后,还要经过两个步骤才会发出去。其中 transformContext 是本文所说”裁剪”的入口。

看 agent loop 里发请求前的这段:

1
2
3
4
5
6
7
8
9
10
11
let messages = context.messages;
if (config.transformContext) {
  messages = await config.transformContext(messages, signal);   // 步骤①
}
const llmMessages = await config.convertToLlm(messages);        // 步骤②

const llmContext = {              // provider 会基于它组装 API payload
  systemPrompt: context.systemPrompt,
  messages: llmMessages,
  tools: context.tools,
};

调用顺序是:先 transformContext,再 convertToLlm,然后和 systemPrompt、tools 合并发出。而且这段在每个 turn 都会走一遍。

步骤①:transformContext —— 留给 extension 改写的钩子

transformContext 是 core 埋的一个可选钩子,签名是 AgentMessage[] → AgentMessage[]——同类型进出,意味着它能对整组对话做任意改写:增、删、改。core 只规定”发请求前调一次、可选”,改什么由上层填。 这和上一批文章里 beforeToolCall 是同一个设计:core 提供调用点,具体策略由上层实现。

coding-agent 把这个钩子接给了 extension(runner.emitContext):

1
2
3
4
5
6
7
8
9
let currentMessages = structuredClone(messages);   // 深拷贝,不动 session 树里的原始数据
for (const ext of extensions) {
  const handlers = ext.handlers.get("context");     // 找注册了 "context" 的 extension
  for (const handler of handlers) {
    const result = await handler({ type:"context", messages: currentMessages }, ctx);
    if (result?.messages) currentMessages = result.messages;  // 用返回值替换,链式
  }
}
return currentMessages;

几个要点:

  • 链式:多个 extension 依次处理,前一个的输出是后一个的输入。
  • 深拷贝:改的是副本,不污染 session 树。
  • 容错:某个 handler 抛错被捕获记录,不打断其它 extension。

这是 README 说的”裁剪”的入口——但要说清:是否裁剪、如何裁剪,由 extension 决定。 core 和 coding-agent 只提供这个”发模型前改写整组对话”的位置。

这里需要区分一件事:transformContext 不做压缩。压缩是另一套机制(上一篇讲过,往 session 树里加节点)。这里纯粹是 extension 的改写钩子。

步骤②:convertToLlm —— 把 pi 的消息翻成模型认的格式

pi 内部的消息角色比较多,但模型 API 只认三种:

  • user(用户方)
  • assistant(模型方)
  • toolResult(工具返回)

模型靠这个角色区分每句话是谁说的。convertToLlm 就是把 pi 的多种内部类型,映射到这三种角色。那些模型不认识的特殊类型(各种摘要、bash 输出等),统一翻成 user,也就是当作用户方提供的内容读:

pi 内部角色翻成处理
user / assistant / toolResult原样直接返回
bashExecutionuser转成文本;带 !! 前缀的(标记为不进上下文)直接丢弃,不发模型
custom(extension 注入的)user内容转成 user 消息
branchSummaryuser包上”这是回退分支的摘要…”前后缀
compactionSummaryuser包上”此前对话已压缩成以下摘要…”前后缀

这里有个和上一篇的衔接点:session 篇产出的压缩摘要、分支摘要,是抽象的 entry;到这一步才被翻成模型能读的 user 文本,前面加一句说明让模型知道”这是摘要内容”。也就是说,session 里的摘要 entry,在这里变成模型可读的文本。

(coding-agent 实际用的是一层包装 convertToLlmWithBlockImages:先做上面的翻译,再按配置过滤图片内容。)

请求发出前:before_provider_request —— 作用于 payload

convertToLlm 之后,agent loop 得到 LLM Context { systemPrompt, LLM messages, tools }。这里的 LLM messagesconvertToLlm 产出的 Message[],也就是模型 API 能识别的消息列表;下文用它和 session 投影出的 AgentMessage[] 区分。provider 再把这个 context 转成各家 API 的 payload。payload 发出前,会触发 before_provider_request

它和 transformContext 都是 extension 钩子,但改的层不同:

 transformContextbefore_provider_request
时机messages 翻译前provider payload 发出前
改的对象AgentMessage[],pi 内部的对话payload,即将发给各家 API 的请求参数
能改什么增删改对话消息修改 provider payload 里的任意字段

一个作用在对话层,一个作用在 provider payload 层。pi 自带一个 provider payload example extension 演示它,下面是简化版:

1
2
3
4
5
6
7
8
9
10
11
12
pi.on("before_provider_request", (event) => {
  // 例一:记录每次实际发出的 payload,用于调试或审计
  logPayload(event.payload);

  // 例二:payload 是对象时,返回一个新 payload 替换当前 payload
  if (typeof event.payload === "object" && event.payload !== null) {
    return {
      ...(event.payload as Record<string, unknown>),
      temperature: 0,
    };
  }
});

处理方式和 transformContext 一样:

  • 多个 extension 链式处理。
  • handler 返回新值就替换当前 payload;返回 undefined 就保留当前 payload。
  • 某个 handler 抛错时,runner 记录错误并继续执行后续 handler,当前 payload 保持抛错前的值。

messages 加工与 provider payload hook:transformContext、convertToLlm、before_provider_request

四、压缩压的是哪一块

接着看 compaction 实际修改哪一部分:我们常说的”压缩 context”,压的是三件套里的哪个?

compaction 的修改对象是 messages; systemPrompt 和 tools 保持不变。两处源码可以确认:估算和动手都只针对 messages——prepareCompaction 产出的是 messagesToSummarize,压缩代码里没有一句改 systemPrompt 或 tools。

为什么只压 messages,个人理解:systemPrompt 是身份和规则,tools 是能力清单,每轮都必须完整在场,而且大小基本恒定。如果压缩这些内容,模型会丢失角色、约束或工具信息。messages 是唯一随对话不断增长的部分,token 增长主要来自这里。所以压缩主要处理这一块。

这里需要区分”度量”和”修改对象”:“要不要压缩”的判断使用更大的度量口径。 正常一轮回复后,pi 用的是 provider 返回的真实 usage(calculateContextTokens(usage))。这个 usage 的输入侧计数,按 API 定义覆盖的是整次请求的输入——system prompt、tools、messages 全在内;calculateContextTokens 再按 totalTokensinput + output + cacheRead + cacheWrite 得到总量。

只有本轮出错、拿不到本轮 usage 时,才退回 estimateContextTokens(messages) 作兜底。这个函数的入参是 messages,但如果 messages 里有历史 assistant usage,它会先用历史 provider usage 做锚点,再加上之后新增消息的估算;完全没有历史 usage 时,才全部按字符数粗估。

因此更准确的说法是:度量看的是真实请求用量(含 system+tools),动手改的只有 messages。

源码还处理了重复触发问题。压缩之后,旧 assistant 上的 usage 仍然反映压缩前那个更大的 context。pi 会先跳过早于最新 compaction boundary 的 assistant;在错误兜底路里,如果 estimateContextTokens 找到的 usage 锚点也是压缩前的旧 assistant,也会直接不触发。这样可以避免刚压缩完,又被旧 usage 误判成还需要再压一次。

压缩压的是 messages 这一块,systemPrompt 和 tools 保持不变

完整流水线

把所有步骤放到一起,从 session 投影到发出请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
buildSessionContext()              // 上一篇:树 → messages
        ↓
createContextSnapshot()            // 打包 { systemPrompt, messages, tools }
        ↓  systemPrompt 来自 buildSystemPrompt(角色/工具/准则/项目文件/skills/日期)
        ↓  tools 来自 registry
        ↓
transformContext(messages)         // 步骤①:extension 改写整组对话
        ↓
convertToLlm(messages)             // 步骤②:AgentMessage → LLM messages(摘要翻成文本)
        ↓
LLM Context { systemPrompt, LLM messages, tools }
        ↓
provider 组装各家 API 请求体
        ↓
before_provider_request(payload)   // extension 可查看或替换最终 payload
        ↓
provider 发出请求(Provider 篇)

回到开头的问题——为什么 messages 之外还需要 systemPrompt 和 tools? 因为模型要正常工作,只有对话不够:得知道自己是谁(systemPrompt)、项目有什么规矩(项目文件)、手上有哪些工具(tools)、能调哪些技能(skills);对话里的特殊消息(bash 输出、压缩摘要)还得翻成它读得懂的格式(convertToLlm)。

messages 是三件套里的一项。Context 构建做的,是把这一块对话,补齐成一份模型可以据此工作的完整输入。

Context 构建完整流水线:投影 → 打包三件套 → 两个步骤 → 组装请求体 → 发出

本文由作者按照 CC BY 4.0 进行授权