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)的取文件顺序:
agentDir(默认~/.pi/agent)里的那份,永远排第一;- 从 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而不是看启动参数。