pi 的一次 LLM 请求:12,672 字符提示词、28 份工具 schema 与 318,136 字符历史

pi 0.85.1(be26e32)。本文的行号都指这份源码。

请求正文由 before_provider_request 扩展钩子落盘:provider adapter 已完成序列化,HTTP 还没发送,不是 tcpdump 一类网络抓包。字符统计来自这份 payload,token usage 来自 provider 响应;Anthropic 对照、目录继承顺序等数据来自离线 adapter 和独立实验。

下面是第 73 次调用的请求体。长文本只保留开头,... 是本文加的省略标记,不是请求字段。

{
  "model": "qwen3.8-flash",
  "messages": [
    {
      "role": "system",
      "content": "You are an expert coding assistant operating inside pi..."
    },
    {
      "role": "user",
      "content": [
        {
          "type": "text",
          "text": "TASK.md是任务指引,现在我要分析PI的全部上下文是如何组装的..."
        }
      ]
    },
    {
      "role": "assistant",
      "content": null,
      "reasoning_content": "The user wants to analyze how PI assembles...",
      "tool_calls": [
        {
          "id": "call_07df3d3633bf4e54a2eb530c",
          "type": "function",
          "function": {
            "name": "bash",
            "arguments": "{\"command\":\"cd /home/yangsen/wordpress/agent && ls -la...\"}"
          }
        }
      ]
    },
    {
      "role": "tool",
      "content": "total 64\ndrwxrwxr-x 6 yangsen yangsen...",
      "tool_call_id": "call_07df3d3633bf4e54a2eb530c"
    },
    "... messages[4..165];messages[1..165] 都是对话历史 ..."
  ],
  "stream": true,
  "stream_options": {
    "include_usage": true
  },
  "store": false,
  "max_completion_tokens": 131072,
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "read",
        "description": "Read the contents of a file...",
        "parameters": {
          "type": "object",
          "required": ["path"],
          "properties": {
            "path": { "type": "string", "description": "Path to the file..." },
            "offset": { "type": "number", "description": "Line number to start..." },
            "limit": { "type": "number", "description": "Maximum number of lines..." }
          }
        },
        "strict": false
      }
    },
    {
      "type": "function",
      "function": {
        "name": "bash",
        "description": "Execute a bash command in the current working directory...",
        "parameters": {
          "type": "object",
          "required": ["command"],
          "properties": {
            "command": { "type": "string", "description": "Shell command to execute" },
            "timeout": { "type": "number", "description": "Timeout in seconds..." }
          }
        },
        "strict": false
      }
    },
    "... tools[2..27]: edit, write, look, subagent, bg_wait, plan_mode_question, plan_mode_complete, web_search, source_check, fetch_content, get_search_content, lsp_diagnostics, lsp_hover, lsp_definition, lsp_references, lsp_symbols, lsp_rename, lsp_code_actions, lsp_completions, code_overview, ast_search, code_rewrite, mcpScript, mcp, mcp__playwright, subagent_supervisor ..."
  ],
  "enable_thinking": true,
  "reasoning_effort": "max"
}

现场是本会话的中间状态:cwd ~/wordpress/agent,模型 token-plan/qwen3.8-flash,协议 openai-completions,第 73 次 LLM 调用前。这一次请求的实体:

块 位置 字符 占比
系统提示词 messages[0],role: system 12,672 3.3%
工具定义 tools[],28 条 56,013 14.5%
对话历史 messages[1..],165 条 318,136 82.2%

请求体

实际序列化出来的顶层字段,按 buildParams() 的赋值顺序:

model                "qwen3.8-flash"
messages             [ system, user, assistant, tool, ... ]   166 条
stream               true
stream_options       { "include_usage": true }
store                false
max_completion_tokens 131072
tools                [ { type: "function", function: {...} }, ... ]  28 条
enable_thinking      true
reasoning_effort     "max"

max_completion_tokens 是 clampMaxTokensToContext(model, context, options?.maxTokens ?? model.maxTokens) 的结果(packages/ai/src/api/simple-options.ts:15-34),这里等于模型自己声明的 131072。enable_thinking 与 reasoning_effort 来自 compat.thinkingFormat === "qwen" 那条分支(packages/ai/src/api/openai-completions.ts:879-886),下发值先过 model.thinkingLevelMap。

这个 map 是模型能力表,不是改名表。models.json 里 qwen3.8-flash 写的是 off/minimal/low/medium/xhigh 全为 null,只有 high 与 max 有值。请求发出前还有一道钳位:clampThinkingLevel(model, thinkingLevel)(packages/coding-agent/src/core/sdk.ts:253,会话内改档位走 agent-session.ts:1889-1891 同一个函数),不在可用清单里的档位会被抬到模型支持的那一档。同一台模型的观察:档位请求 low 时,enable_thinking 仍为 true,reasoning_effort 下来的是 high;档位为 max 时下来的是 max。结论是 low/medium/xhigh 在这台模型上等于没写,而且看不到任何提示,openai-completions.ts:882 那行 ?? 兜底拿到的已经是钳位后的值。

换成 Anthropic 协议,同一个 context 过 buildParams() 会得到不同的骨架:

anthropic-messages:  model, messages, max_tokens, stream, system, tools
openai-completions:  model, messages, stream, ..., tools, ...
内容 anthropic-messages openai-completions
系统提示词 顶层 system[] 文本块,带 cache_control messages[0],role: system 或 developer
工具定义 tools[].input_schema tools[].function.parameters 加 strict
工具结果 user 消息里的 tool_result block 独立 role: "tool" 消息,带 tool_call_id
缓存 cache_control: ephemeral,打在 system 块与最后一个 tool 上 prompt_cache_key(sessionId 截断)

上面两列分别是 anthropic-messages.ts:1021-1130 与 openai-completions.ts:792-900 的 buildParams() 输出。pi-ai 里还有 pi-messages、openai-responses、bedrock-converse-stream 等 adapter(api/pi-messages.ts:385 同样在发送前留了 onPayload),字段布局各有各的写法。

system 字段在 Anthropic 请求体里的键序排在 messages 之后。客户端只决定字段内容,模型阅读顺序由服务端模板决定,所以讨论”顺序”时得区分这两件事:提示词在 OpenAI 协议下确实排在历史前面,在 Anthropic 协议下它跟历史不在同一条序列里。

系统提示词的七段结构

buildSystemPrompt()(packages/coding-agent/src/core/system-prompt.ts:127-167)返回一个字符串,段序固定,实测偏移:

偏移 段 字符 占提示词
0 identity 171 1.3%
171 Available tools: 2,313 18.3%
2,484 Guidelines: 2,398 18.9%
4,882 Pi documentation 1,231 9.7%
6,113 <project_context> 1,117 8.8%
7,230 <available_skills> 5,386 42.5%
12,616 Current working directory: 56 0.4%

identity 加 tools 加 guidelines 加 pi-docs 是 system-prompt.ts:127-144 那个模板字符串,中间只有三处变量:toolsList、guidelines、三个文档路径。

Available tools 不是工具定义

这一段每行 - 工具名: 一行摘要,摘要来自工具定义的 promptSnippet 字段。装配处在 agent-session.ts:1065-1098:先按当前启用工具名过滤,再从 _toolPromptSnippets(agent-session.ts:2730-2737,由 definition.promptSnippet 归一化而来)取摘要,只有给了摘要的工具会进这一段(system-prompt.ts:82 的 visibleTools)。

本会话这段列了 21 个工具,tools[] 有 28 条。差的 7 个(look、bg_wait、plan_mode_question、plan_mode_complete、ast_search、code_rewrite、subagent_supervisor)来自没写 promptSnippet 的扩展工具。两边顺序也不一样:数组按注册顺序 read bash edit write look subagent bg_wait plan_mode_question plan_mode_complete web_search ...,提示词段按 tools 白名单顺序。

Guidelines 由启用工具决定

addGuideline 去重后按插入顺序拼(system-prompt.ts:87-125)。来源三种:工具自带的 promptGuidelines(跟 promptSnippet 同一个 Map 装配循环里收集)、调用方传入的 promptGuidelines、以及硬编码兜底的两条 Be concise in your responses 与 Show file paths clearly when working with files。文件探索那条建议在 hasBash/hasGrep/hasFind/hasLs 的组合判断里出现与否(system-prompt.ts:104-118),也就是说换掉工具集,这段文字会变。

project_context:一个目录只取一个文件

候选名顺序写死在 resource-loader.ts:72:

AGENTS.override.md, AGENTS.md, AGENTS.MD, CLAUDE.md, CLAUDE.MD

loadProjectContextFiles()(resource-loader.ts:119-156)的取文件顺序:

  1. agentDir(默认 ~/.pi/agent)里的那份,永远排第一;
  2. 从 cwd 往上走到文件系统根,每层最多取一个命中,边收集边 unshift,所以最终顺序是从根到 cwd。

实测在一个三层目录(根、a/、a/b/、a/b/c/ 各放 AGENTS.md,a/b/ 同时放 CLAUDE.md,a/b/c/ 同时放 AGENTS.md)里跑出来的结果:

0: ~/.pi/agent/AGENTS.md
1: /tmp/pi-ctx-demo/AGENTS.md
2: /tmp/pi-ctx-demo/a/AGENTS.md
3: /tmp/pi-ctx-demo/a/b/AGENTS.md          同目录的 CLAUDE.md 被跳过
4: /tmp/pi-ctx-demo/a/b/c/AGENTS.override.md

两条推论:想让某个目录的指令生效,CLAUDE.md 在同目录已有 AGENTS.md 时完全不进上下文;越靠近 cwd 的文件在提示词里越靠后,但没有任何”后面覆盖前面”的标记,优先级纯粹靠位置。

每份文件包成 <project_instructions path="...">,整组外面套 <project_context> 加一句 Project-specific instructions and guidelines:(system-prompt.ts:150-158)。本会话只有一份全局 ~/.pi/agent/AGENTS.md,1,030 字符,因为项目目录里没放上下文文件。

available_skills 只放元数据

formatSkillsForPrompt()(packages/coding-agent/src/core/skills.ts:355-383)为每个 skill 输出三行:<name>、<description>、<location>。description 是 SKILL.md frontmatter 的原文,全量内联;正文不进上下文,靠模型自己去读 location。

这一段 5,386 字符,占整个提示词的 42.5%,比 project_context 与 guidelines 加起来还大。两个控制点:frontmatter 里 disableModelInvocation 为真的 skill 被 visibleSkills 过滤掉,整段消失;如果启用的工具里没有 read 也没有 bash(skillFileReadTool 为空),这段一个字都不写(system-prompt.ts:161-163),因为模型读不了文件,列出来只是浪费窗口。

覆盖与追加是两条路

appendSystemPrompt 插在 pi-docs 与 project_context 之间(system-prompt.ts:146-148),来源可以是 --append-system-prompt 命令行参数、--system-prompt 的配套,或者扩展与包里配置的追加。

customPrompt 走另一条分支(system-prompt.ts:48-73):identity、tools、guidelines、pi-docs 四段全部不生成,只剩追加段、project_context、skills、cwd。也就是说自定义系统提示词会连带丢掉工具摘要段和 guidelines,这两段不是”追加”能补回来的。

tools 数组:14.5% 的请求是工具说明书

数据链路:工具定义进 agent.state.tools(agent-session.ts:980),每轮 Agent.state 快照成 context(packages/agent/src/agent.ts:439-441,{ systemPrompt, messages, tools }),到 streamAssistantResponse 组 llmContext(packages/agent/src/agent-loop.ts:296-299),最后由 adapter 转成 provider 格式。

OpenAI 侧转换在 openai-completions.ts:1471-1509(由 buildParams 的 params.tools = convertTools(activeTools, compat) 触发,openai-completions.ts:842),每条输出 { type:"function", function:{ name, description, parameters, strict } }。pi 内部的 AgentTool 还有 label、executionMode、execute、prepareArguments 这些字段,adapter 一个都不读,JSON.stringify 也带不走函数,所以实现代码永远不会进请求。

28 条的体积分布,按 desc 加 schema:

subagent                 20,737   37.0%
web_search                4,754    8.5%
bg_wait                   4,209    7.5%
mcp                       3,037    5.4%
fetch_content             2,672    4.8%
source_check              2,024    3.6%
get_search_content        1,433    2.6%
plan_mode_question        1,348    2.4%
lsp_completions           1,247    2.2%
edit                      1,102    2.0%
mcpScript                 1,089    1.9%
... 剩下 17 条合计       12,361   22.1%
read                        607    bash                    466
write                       352    look                    307

subagent 一个工具的 schema 就占 16,225 字符,是整个系统提示词的 1.28 倍;tools[] 总量是提示词的 4.4 倍。内置四件套 read/bash/edit/write 合计 2,527 字符,只占数组的 4.5%。固定前缀 68,685 字符里,想省窗口的着力点很清楚是扩展工具,不是提示词措辞。

splitDeferredTools()(packages/ai/src/utils/deferred-tools.ts:7-38)负责编把工具摆成两组,它只在 compat.supportsToolReferences 打开时生效(Anthropic 侧调用在 anthropic-messages.ts:1030-1036)。判据不是“没被用过”,而是 toolResult 消息上的 addedToolNames:某工具是运行中途才加入上下文的,而且历史里还没有 assistant 调用过它,才会被归到 deferred,排到 tools[] 末尾且不单独打缓存点(anthropic-messages.ts:1101-1120);immediate 组的最后一个才带 cache_control。同一件事在 OpenAI 侧只在 compat.deferredToolsMode === "kimi" 时做,kimi 会把 deferred 工具包成一条带 tools 字段、没有 content 的 role: system 消息(openai-completions.ts:839-841、1450-1458)。

本会话的模型 compat 只有 thinkingFormat 与 supportsDeveloperRole 两项,这条分流没生效:28 条工具完全按注册顺序下发,一条 read bash edit write look subagent bg_wait plan_mode_question plan_mode_complete web_search source_check fetch_content get_search_content lsp_diagnostics lsp_hover lsp_definition lsp_references lsp_symbols lsp_rename lsp_code_actions lsp_completions code_overview ast_search code_rewrite mcpScript mcp mcp__playwright subagent_supervisor。

对话历史:165 条,占 82.2%

从磁盘开始

会话不是内存对象,是 JSONL。启动时 agent.state.messages = existingSession.messages(packages/coding-agent/src/core/sdk.ts:376),每写一条走 SessionManager.appendMessage()(session-manager.ts:1071),落盘是一行 {"type":"message","id":...,"parentId":...,"message":{...}}(session-manager.ts:1035、1054)。父指针让会话可分叉,发给 LLM 时只取当前链。

当前轮输入没有专用槽位

runAgentLoop() 第一件事就是 currentContext = { ...context, messages: [...context.messages, ...prompts] }(packages/agent/src/agent-loop.ts:103-107)。用户这轮说的话、中途插进来的 steering 消息,都是往数组尾部追加普通消息,然后整个数组重新发一遍。所谓”当前轮的新输入”在请求体里不是一个特殊位置。

内部角色到 LLM 角色的收敛

convertToLlm()(packages/coding-agent/src/core/messages.ts:148-195)是唯一的出口映射:

pi 内部 发给 provider
user / assistant / toolResult 原样
custom(扩展注入) role: user
bashExecution(! 前缀手敲命令) role: user,文本化
bashExecution 带 !! 前缀 丢弃,不进请求
branchSummary / compactionSummary role: user 加固定前后缀

到这一步,五六种内部类型全部收敛成三种 LLM 角色。压缩和分叉总结之所以能被模型”看见”,靠的就是这个压平动作,模型分不清哪条是摘要。

本会话历史的真实构成

wire 角色 条数 字符 占历史
assistant 带 tool_calls 67 167,158 52.5%
tool 95 146,056 45.9%
assistant 纯文本 1 4,474 1.4%
user 2 448 0.1%

拆细 assistant 那 167,158 字符:reasoning_content 101,804,工具调用参数 65,354,可见回复文本 0(这些消息 content 是 null)。工具结果按工具归类:bash 81 次 144,636 字符,write 8 次 753 字符,edit 6 次 667 字符。

数字很直白:整个上下文里属于”用户说的话”的部分是 448 字符,占请求体的 0.1%。窗口被三类东西吃掉:模型自己的 thinking(101,804)、模型自己写的工具参数(65,354)、工具吐回来的结果(146,056)。

thinking 的回放值得单看。它不是明文续写,ThinkingContent.thinkingSignature 记录着流式阶段拿到的字段名(openai-completions.ts:280 定义了 reasoning、reasoning_content、reasoning_text 三种),转换时按签名把 thinking 原文塞回 assistant 消息的同名字段(openai-completions.ts:1312-1318):

let signature = nonEmptyThinkingBlocks[0].thinkingSignature;
if (signature && isOpenAICompletionsReasoningField(signature)) {
    assistantMsg[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n");
}

签名不是字段名就丢掉,所以换一个 provider 续跑同一个会话,历史里的 thinking 可能整体消失。compat.requiresReasoningContentOnAssistantMessages 为真时还会给没有 thinking 的 assistant 消息补一个空 reasoning_content(openai-completions.ts:1355-1362)。

tool 消息在 openai-completions.ts:1390-1400 生成,content 是工具结果文本,tool_call_id 对应 assistant 里的调用 id。id 会先经过 normalizeToolCallId(openai-completions.ts:1186-1210):管道分隔的 {call_id}|{item_id} 形式(Responses API 与 copilot 类 provider 产生)会被拆词、过滤非法字符、超长时截断前缀并拼上 8 位短哈希,因为 Chat Completions 要求 id 不超过 40 字符且互不相同。同一份历史换协议,这些 id 会变。

空 assistant 消息会被跳过(openai-completions.ts:1366-1373):content 空且没有 tool_calls 的直接丢,处理的是被中断的那一轮。反过来,某些 provider 不允许 tool 消息后面直接跟 user,pi 会插一条 assistant 内容写 I have processed the tool results.(openai-completions.ts:1226-1231,条件 compat.requiresAssistantAfterToolResult)。历史里凭空多出来的这句套话就是这么来的。

图片在非视觉模型上会被 transformMessages() 换成占位文本 (image omitted: model does not support images)(packages/ai/src/api/transform-messages.ts:13-30),连续占位会去重成一条。

组装顺序的完整链条

把上面串起来,一次请求的构造路径:

SessionManager 读 JSONL                    sdk.ts:376
  -> agent.state.messages
AgentContext { systemPrompt, messages, tools }   agent.ts:439-441
  -> runAgentLoop 追加本轮输入              agent-loop.ts:103-107
  -> config.transformContext(可选)        agent-loop.ts:287-289
  -> config.convertToLlm                    messages.ts:148-195
  -> llmContext                             agent-loop.ts:296-299
  -> adapter transformMessages(图片/签名)  transform-messages.ts
  -> adapter convertMessages(角色与字段)    openai-completions.ts:1178 起
  -> buildParams(拼 model/messages/tools)   openai-completions.ts:792-900
  -> onPayload(扩展 before_provider_request)sdk.ts:343
  -> HTTP body

系统提示词在另一个时间轴上:它不每轮重新拼装,重建的触发点是工具启用集合变化、扩展改动工具集、/reload(agent-session.ts:983、2516)。但每轮开跑前 prepareNextTurnWithContext()(agent-session.ts:561-581)会重新取一次当前 systemPrompt 与 state.tools 快照,所以提示词字符串本身稳定,引用它的时机是每轮。推论:改一句 AGENTS.md 不会立刻进到当前轮,得等到下一次提示词重建。

缓存事实

前缀稳定性直接反映在 usage 上:

第 1 次调用   input 10,920   cacheRead  7,168   context 18,088
第 73 次调用  input  1,968   cacheRead 118,144  context 120,112

固定前缀 68,685 字符约等于首轮那 18k tokens(约 3.8 字符每 token)。此后每轮 input 只有 1k 到 3k,其余全部命中缓存读,说明提示词与工具数组逐字节稳定;每轮新增的 context 就是上一轮的 thinking、工具参数和工具结果。

Anthropic 协议下 pi 靠 cache_control 断点显式做这件事(打在 system 文本块与最后一个 immediate tool 上,anthropic-messages.ts:1063-1120),OpenAI 协议下靠 prompt_cache_key(openai-completions.ts:810-818,只有 baseUrl 含 api.openai.com 或开了 long retention 才下发)。本会话的 baseUrl 是 token plan 域名且 retention 为 short,所以这个字段不存在,缓存完全由 provider 自己按前缀匹配。两条协议路径共同的一点:每轮都是整段重发,pi 不做增量请求,能省的只有前缀。

几条能直接用的结论

  • 工具 schema 是占比最高的固定成本,56,013 字符对 12,672 字符提示词是 4.4 倍。禁用不需要的扩展比打磨提示词措辞有效得多,subagent 一个工具就是 20,737 字符。
  • skills 段占提示词 42.5%,因为它把每个 SKILL.md 的 description 全文内联。想让某个 skill 不常驻上下文,改 frontmatter 的 disableModelInvocation,不是删文件。
  • 同一个目录里的上下文文件只取一个,顺序 AGENTS.override.md、AGENTS.md、AGENTS.MD、CLAUDE.md、CLAUDE.MD。想让 CLAUDE.md 生效,得先确认同目录没有 AGENTS.md。
  • 多层 AGENTS.md 是”全局、根、…、cwd”的顺序平铺在系统提示词里,不在消息里,也没有后写覆盖先写的机制。
  • 用户输入在上下文里的占比可以低到 0.1%。做压缩时删用户发言或删回复文本收益很小,真正的大头是 thinking 回放(101,804 字符)和工具输出(146,056 字符)。
  • 历史发给模型前会被压平成 user、assistant、tool 三种角色,且 id 归一化、图片降级、空消息剔除、合成 assistant 补位都发生在协议层。同一份会话换 provider,请求体不一定等价。
  • 档位能力表会静默钳位:这台 qwen 把 low/medium/xhigh 标为不可用,请求这些档位时实际下发 high,而模型侧看到的只是 reasoning_effort: "high"。排查“为什么开了 thinking 没效果”时,得先对 thinkingLevelMap 而不是看启动参数。