Skip to content
BaiRuic
Go back

Pi Agent 如何管理上下文:从 System Prompt 构建、工具输出截断到分支经验传递

目录

在构建基于终端的编码助手(Coding Agent)时,大语言模型的上下文窗口(Context Window)始终是一个固定的硬性物理上限。而在实际工程交互中,数据量的增长往往远超预期:

  • 运行一次 npm test 或编译命令,控制台可能输出数千行测试日志与堆栈;
  • 读取一个稍大的源文件,可能直接产生数十 KB 的文本;
  • 复杂的 Monorepo 项目可能在不同层级嵌套了多份开发规约;
  • 用户在排查 Bug 时,可能在多条技术方案之间反复回退与分叉。

如果缺乏对上下文生命周期的精细治理,单次工具调用的输出就可能直接撑满模型窗口,导致请求因超出长度限制而中断;或者因无关信息过多导致模型注意力稀释(即所谓的 Context Rot 或 Lost in the Middle 现象)。

在 Prompt Engineering 中,人们通常关注如何微调单轮提问的措辞;而在自主智能体系统中,上下文工程(Context Engineering) 则是指系统在运行期间对所有输入数据(System Prompt、外部规范、工具输出、历史记忆与分支状态)进行的一整套调度、裁剪、缓存与流转机制。

Pi Agent 的 Session Tree(上):会话历史如何保存中,我们介绍了 Pi 如何通过树状拓扑和 Append-Only Log 将完整的会话历史持久化到磁盘中。本文则从模型交互的角度出发,基于开源 Agent 框架 pi(包含上层 pi-coding-agent 与底层 pi-agent-core 运行时)的架构设计,顺着一次调用的完整生命周期,探讨系统如何在构建 System Prompt裁剪工具输出清洗消息格式以及跨分支传递经验等环节全方位管理模型上下文。


一、 全景视角:上下文在交互生命周期中的流转

pi-context-lifecycle-pipeline

整个交互生命周期可划分为四个协同阶段:

  1. System Prompt 构建(规则与能力注入):通过 buildSystemPrompt 稳定前缀排布以保护 Prefix Caching,通过 loadProjectContextFiles 向上递归合并多层规范,并通过轻量清单按需懒加载 Skills;
  2. 运行时消息投影与转换:沿 Session Tree 提取当前活跃分支的线性路径,通过 convertToLlm 过滤 !! 临时排查消息并清洗为跨厂商通用的标准 Message[] 数组;
  3. 工具执行与输出裁剪(单次输出体积控制):采用不对称裁剪策略(read 采用 truncateHead 保留开头高密区,bash 采用 truncateTail 保留尾部错误堆栈),辅以 2000 行 / 50KB 双重限制及 /tmp 临时文件逃生通道;
  4. 轮次收尾与分支维护(历史会话管理):线性会话过长时触发 Compaction 压缩,多分支回退时通过 LCA 算法提取试错经验生成分支摘要。

二、 System Prompt 构建:前缀稳定与按需加载

System Prompt 随每次请求一同发送给模型,用于定义角色的行为准则、可用工具与项目规范。如何在包含组织规范与扩展能力的同时,保证前缀缓存命中率并减少初始 Token 占用?

1. 提示词排布顺序与前缀缓存机制(buildSystemPrompt

现代主流模型服务商(如 Anthropic、OpenAI)均支持前缀缓存(Prompt Caching / Prefix Caching):当多次请求开头的 Prompt 文本完全一致时,服务端会复用已计算好的 KV 缓存状态。这不仅能降低多轮交互中的响应延迟(Time to First Token, TTFT),还能大幅降低长会话的 Token 计费成本。

然而,前缀缓存对字节级的一致性要求极为严格。如果在 Prompt 开头插入了会频繁变动的动态变量(例如精确到秒的时间戳或临时随机会话 ID),后续所有静态规则与工具说明的缓存就会完全失效。

pi-coding-agent 的系统提示词构建(buildSystemPrompt)中,拼接顺序体现了对前缀稳定性的考量:

pi-context-system-prompt-structure

系统将静态的全局角色设定、工具定义、通用指南、项目规范与 Skills 清单放在最前面,而将动态的当前日期(Current date)和工作目录(Current working directory)移至提示词的最末尾。这样可以确保在同一个工作目录中,无论交互进行到第几轮,Prompt 开头数千 Token 的静态前缀都能保持不变,从而最大化利用服务商的前缀缓存。

2. 向上递归查找项目规范(loadProjectContextFiles

在大型团队或 Monorepo 仓库中,不同目录往往有不同的工程规范(例如根目录要求通用 TypeScript 规范,子目录要求特定的测试框架)。

pi-coding-agent 的资源加载模块中,loadProjectContextFiles 实现了多层规约的收集逻辑:

// 规范收集逻辑实现(loadProjectContextFiles)
export function loadProjectContextFiles(options: { cwd: string; agentDir: string }) {
  const contextFiles: Array<{ path: string; content: string }> = [];
  const seenPaths = new Set<string>();

  // 1. 加载用户全局规范 (~/.pi/CLAUDE.md)
  const globalContext = loadContextFileFromDir(options.agentDir);
  if (globalContext) {
    contextFiles.push(globalContext);
    seenPaths.add(globalContext.path);
  }

  // 2. 从当前目录 (cwd) 沿文件树向上递归查找
  const ancestorContextFiles: Array<{ path: string; content: string }> = [];
  let currentDir = options.cwd;

  while (true) {
    const contextFile = loadContextFileFromDir(currentDir);
    if (contextFile && !seenPaths.has(contextFile.path)) {
      // 插入到头部,保证最外层祖先目录排在前面
      ancestorContextFiles.unshift(contextFile);
      seenPaths.add(contextFile.path);
    }
    const parentDir = dirname(currentDir);
    if (parentDir === currentDir) break;
    currentDir = parentDir;
  }

  return [...contextFiles, ...ancestorContextFiles];
}

这里包含两个重要的工程细节:

  • 从外到内的阅读顺序:通过 ancestorContextFiles.unshift(contextFile),系统将全局规约排在最前,从根目录到当前子目录的规范逐层向后追加。这使得模型接收到的规范顺序符合“先总则、后细则”的认知层级。
  • XML 标签隔离与来源感知:每个规范文件使用 <project_instructions path="..."> 包裹。明确的闭合标签既防止了注入混淆,又让模型清楚每条规范来自哪个具体文件。
  • Linked Worktree 去重(findShadowedContextFile:在 Git 工作区场景下,避免重复加载主仓库与工作区中内容相同的规约文件。

3. Skills 懒加载:从全量预注入到按需读取(formatSkillsForPrompt

在 Agent 架构中,扩展技能(Skills)通常包含针对特定任务(如部署、迁移、E2E 测试)的详尽操作手册,单个 Skill 的 SKILL.md 可能长达数千字。

如果采用传统的全量预加载(Eager Loading),将系统中所有 Skills 的正文全部塞入 System Prompt,10 个 Skills 就会消耗约 30K~50K Token。不仅成本高昂,还会引入大量无关信息,分散模型的注意力。

Pi 采用了 Agent Skills 标准的按需懒加载(Lazy Loading)。在 pi-coding-agent 的技能模块中,formatSkillsForPrompt 仅向 System Prompt 中输出轻量级的清单:

<available_skills>
  <skill>
    <name>test-setup</name>
    <description>How to run and debug tests in this repository</description>
    <location>/home/user/project/.pi/skills/test-setup/SKILL.md</location>
  </skill>
</available_skills>

并在清单上方附带指令:“当任务与描述匹配时,使用 read 工具读取该 Skill 文件”

策略Token 开销信息利用率机制本质
全量预加载(Eager Loading)极高(每次请求预先在 System Prompt 中注入所有 Skills 正文)低(当前任务通常只涉及 0~1 个 Skill)将所有专业知识全部作为静态指令
按需懒加载(Lazy Loading)极低(清单仅占用几百 Token)高(仅在命中任务时由模型主动读取全文)将 Tool Call 作为动态加载上下文的载体

这种设计将“工具调用能力”转变为了“动态上下文加载器”,使系统的可扩展能力不再受限于基础 Prompt 的体积。


三、 工具输出裁剪:行数与字节数的双重边界

在编码任务中,单次工具执行的结果具有很强的不确定性:运行测试可能产生上万行报错,读取构建产物可能遇到数 MB 的压缩文本。如果不对工具输出做防御性裁剪,单次交互就能直接耗尽上下文。

1. 为什么朴素的截断算法会失效?

简单的“按前 N 个字符截断”或“按前 N 行截断”在实际开发中会遇到三个问题:

  1. 单行极端体积:如果仅限制行数,遇到未换行的 Minified JS、CSS 或单行日志时,只需 2~3 行就能撑爆 100KB 字节;
  2. 截断位置与语义错位:如果统一从头部截断,Bash 的错误日志通常在最末尾,模型只能看到无关紧要的启动输出;如果统一从末尾截断,文件读取的前部 import 和接口定义就会丢失;
  3. 多字节字符截断损坏:直接按字节切割字符串,极易将 UTF-8 编码中的多字节字符(如中文或 Emoji 代理对)从中间切断,导致后续解码产生非法字符 “。

2. 双重限制与方向选择(truncateHeadtruncateTail

pi-coding-agent 的工具截断模块中,系统建立了双重限制规则:

  • 行数上限:DEFAULT_MAX_LINES = 2000
  • 字节数上限:DEFAULT_MAX_BYTES = 50 * 1024(50KB)

算法在遍历时同时累加行数与字节数,行数或字节数任意一项先达到上限便停止收集。行数保证展示的可读性,字节数守住硬性的数据体积。

同时,系统根据工具的信号特征采用不对称的截断方向:

pi-context-truncation-strategies
  • truncateHead(用于 read:从前向后收集。源文件的头部通常包含依赖引用、类型定义与核心接口,这些元信息对于模型理解代码结构至关重要。
  • truncateTail(用于 bash:从后向前收集。命令行执行的早期日志通常是构建进度等低信息密度内容,真正的错误原因、调用堆栈与汇总统计均在输出的最下方。

3. 边界安全与单行限长

针对特殊边界情况,truncate.ts 补充了以下防御机制:

  • UTF-8 字符边界对齐(truncateStringToBytesFromEnd:在从尾部截取字节时,算法会向前跳过 UTF-8 后续字节(0x80 掩码),确保截断点始终落在完整字符的边界上。
  • 首行超限保护:当出现单行内容直接超过 50KB 的极端情况时,truncateTail 截取该行末尾的 50KB 内容并设置 lastLinePartial = true,避免因找不到换行符而返回空内容。
  • grep 单行限长(truncateLinegrep 工具使用 truncateLine 将单行匹配长度限制在 500 字符以内(GREP_MAX_LINE_LENGTH = 500),超长部分替换为 ... [truncated],防止单行超长搜索结果打乱上下文。

4. 临时文件与逃生通道(OutputAccumulator

截断机制虽然保护了上下文安全,但不可避免地带来了信息损失。如果模型需要查看被截断的内容,应该如何处理?

pi-coding-agent 的流式输出收集器(OutputAccumulator)中,系统负责流式接收子进程的标准输出。当输出超过阈值时,它会将完整的原始日志流式写入磁盘临时文件(如 /tmp/pi-bash-*.log)。

在工具返回给模型的输出末尾,会附带一条元信息提示:

[Showing lines 6501-8500 of 8500. Full output: /tmp/pi-bash-a1b2c3d4.log]

这行提示构成了截断机制的逃生通道(Escape Hatch):它进入了模型上下文,使模型意识到输出发生了截断。如果模型在分析完尾部错误堆栈后发现需要排查前面的编译警告,它可以主动调用 read 工具并传入行号参数去读取该临时文件。


四、 消息格式转换与分支上下文维护

Pi Agent 的 Session Tree(上):会话历史如何保存中,我们讨论了 Session Tree 如何使用 Append-Only 方式保存包含分支和配置变更的树状日志。然而,大模型的推理接口只接受线性的消息数组。

在将树状结构转换为模型上下文的过程中,系统需要完成格式转换,并在用户切换分支时维护不同尝试之间的经验关联。

1. 运行时自定义消息的清洗与排除(convertToLlm

pi-coding-agent 的消息转换逻辑中,convertToLlm 负责将内部富状态消息(AgentMessage)投影为标准模型消息(Message):

pi-context-convert-to-llm-flow

消息投影过程中针对不同角色与语义执行细粒度的清洗与分流:

  • 上下文排除语法(!!:在终端交互中,用户有时需要执行纯本地排查命令(例如查看机器端口占用 !!netstat -nlp),这些命令的结果无需让模型知晓。带有 !! 前缀的命令会被标记为 excludeFromContext: true,在 convertToLlm 阶段被直接过滤丢弃,不进入模型上下文;
  • 标准协议消息透传:标准的 userassistant 以及 toolResult 消息直接保留为标准 LLM 输入格式;
  • 自定义执行块转换:包含退出码与终端输出的 BashExecutionMessage 被转换为易于模型理解的标准文本格式;
  • 结构化事件包装:压缩摘要(CompactionSummaryMessage)和分支摘要(BranchSummaryMessage)被包装为带有结构化 <summary> XML 标签的 user 角色消息,为模型提供干净的前置背景。

2. 分支切换与探索经验摘要(BranchSummarization

使用树状会话结构时,一个常见的工程痛点是分支遗忘

例如:用户让 Agent 尝试“使用方案 A 重构数据库访问层”,Agent 经过 8 轮交互、读取了多个文件并运行测试后,发现方案 A 存在严重的兼容性死锁,不得不放弃。此时用户执行回退,切换回分叉点开启“方案 B”。

       Root (分叉点)
      /            \
[方案 A 分支]     [方案 B 分支 (当前活跃)]
(经过 8 轮探索发现
 方案 A 会导致死锁)

如果系统简单地沿着 Root 到方案 B 的叶子节点构建上下文,方案 B 将完全看不到方案 A 的执行历史。模型很可能会在方案 B 的探索过程中再次尝试已被证明行不通的方案 A。但如果将方案 A 的所有原始交互全部拼接到方案 B 中,又会带来巨大的 Token 开销。

pi-coding-agent(以及底层 pi-agent-core 运行时)通过分支摘要机制解决这一问题:

  1. 计算最近公共祖先(LCA)collectEntriesForBranchSummary 对比被放弃分支与当前分支的路径,找出两条路径的分叉节点,提取出被放弃分支上独有的所有交互记录;
  2. 生成轻量结构化摘要:调用轻量模型生成结构化总结,包含 Goal(目标)、Constraints(限制条件)、Progress(进展)、Key Decisions(核心结论)和 Next Steps(后续建议)5 个小节,并将输出限制在 2048 Token 以内;
  3. 注入前言并挂载到新分支:摘要带有专用的前言说明(BRANCH_SUMMARY_PREAMBLE):
    The user explored a different conversation branch before returning here.
    Summary of that exploration:
    ...
    该节点作为 BranchSummaryMessage 挂载到新分支的起点。模型在进入方案 B 时,能够清晰知晓“之前曾探索过方案 A 并因为死锁而放弃”,避免重复踩坑,同时仅付出极小的 Token 代价。

3. 长对话压缩的宏观职责(Compaction

当单条分支上的对话持续增长,总 Token 数接近 contextWindow - reserveTokens 安全水位时,系统会触发长对话压缩(Compaction)。

buildContextEntries 会在路径上插入压缩摘要节点,替换掉切分点之前的旧消息,仅保留切分点之后的近期上下文。这确保了线性长会话能够无限持续进行。

(注:关于 Compaction 的切分点定位算法 findCutPoint、6 小节模板设计与增量更新逻辑,将在本系列的下一篇专文中详细拆解。)


五、 交互全流程追踪:一次调用的上下文流转

将上述机制串联起来,一次完整的 Agent 交互轮次在上下文处理上的调用时序如下:

[1] 用户在终端提交 Prompt


[2] 构建 System Prompt (buildSystemPrompt)
    ├─ 放置通用角色、工具列表与指南文档 (固定前缀保持缓存稳定)
    ├─ 向上递归加载 CLAUDE.md / AGENTS.md (loadProjectContextFiles)
    ├─ 注入 Skills 清单 (formatSkillsForPrompt)
    └─ 末尾放置当前日期与工作目录


[3] 提取会话路径与清洗格式
    ├─ buildSessionPath: 回溯当前 leafId 提取活跃分支节点
    ├─ buildContextEntries: 处理 Compaction 节点,过滤已被压缩的旧历史
    └─ convertToLlm: 过滤 excludeFromContext (!!) 临时消息,清洗为标准 Message[]


[4] 发起模型推理,模型返回 toolCall


[5] 执行对应工具并进行防御性裁剪
    ├─ read 工具 -> truncateHead (保留开头 2000 行 / 50KB)
    └─ bash 工具 -> truncateTail (保留末尾 2000 行 / 50KB,全量日志落盘 /tmp)


[6] 工具结果作为 ToolResultMessage 追加进 Session Tree (包含临时文件路径)


[7] 轮次收尾检查与分支状态维护
    ├─ 检查 shouldCompact(): 若 Token 超限则生成压缩摘要
    └─ 若用户执行分支切换: 触发 collectEntriesForBranchSummary 生成分支摘要

六、 总结

上下文工程并不是一个单一的功能点,而是分布在 System Prompt 构建、工具运行时、消息协议转换与会话树维护中的一系列协同控制策略:

  1. Prompt 前缀顺序排布:将静态指令前置、动态参数后置,保护模型服务商的前缀缓存(Prefix Caching)命中率;
  2. Skills 按需懒加载:使用轻量 Skills 清单配合 read 工具,按需加载具体规则,避免基础 Prompt 膨胀;
  3. 双重限制与双向截断:结合行数、字节数与业务语义对工具输出进行双向裁剪,并为完整日志提供磁盘临时文件作为逃生通道;
  4. 基于 LCA 的分支摘要:在树状历史切换时,通过 LCA 算法提取被放弃分支的关键结论,实现低成本的跨分支经验传递。

这些机制在各自的生命周期节点上对送入模型的数据进行过滤、裁剪与组织,使得 Coding Agent 既能具备丰富的外部规范与工具能力,又能将整体数据量安全约束在模型的上下文窗口之内。