我在学 agent harness,也在写自己的 agent,于是想扒个真实实现看看。挑了我每天都在用的 pi,一边翻源码,一边对着自己的会话记录,看它的上下文到底是怎么拼出来的。
一条会话是一棵树
pi 把一条会话存成一个 JSONL 文件,一行一条记录。每条记录带着自己的 id 和上一条的 id,从任何一条都能一路走回开头。所以一条会话在文件里是一棵树。
我那条会话里有个分叉:有条路走到一半就放弃了,后来我从岔口换了条路重新走。两条在文件里都还在,发出去的对话只沿着其中一条走。
树上还挂着一条记录,内容是"刚才那条被放弃的路大致讲了什么",一个摘要节点。
从树到请求上下文
中间三步。
① 取当前分支
输入是整个会话文件(一棵树),加上"我现在在哪条记录上"。
从当前记录出发,沿 id 往回走,走到开头,得到一条链。树上其他分支上的记录不进这条链。
这一步顺带处理压缩:链上遇到压缩记录时,压缩点之前的记录被丢掉,只保留它标记的那一段。压缩记录本来写在文件最末尾(它就是刚刚发生的事),进链之后排到了最前面。
输出:一条记录链,开头是最近的压缩记录(下图的浅黄节点),后面是保留区一路到当前。
② 投影
输入是上一步那条链。
链上每条记录都要问两个问题:它被上下文编辑标记过吗?它本身是什么类型?被标记的不再产生消息,没被标记的按类型投影成对应的消息。
输出:一个列表,每一项是「一条记录 → 它产生的消息(可能 0 条)」。上下文编辑记录自己在这一步之后就不见了——它不产生任何东西,只改变别的记录的结果。
③ 拍平
输入是上一步那个列表。按顺序取出所有消息拼成一个数组,顺带解析出这次请求用哪个模型、思考等级多少。
输出就是真正发出去的 messages。摘要排在最前面,后面接保留的原文。
文件里:
[m1 … m50][压缩记录][m51 … m60]请求里:[摘要 ≈ m1…m34][m35 … m60]
我那条会话跨了四个小时,压过九次,请求里只留最后一次的摘要。
接着冒出个问题:系统提示词也在链上,它投影出来是什么?
系统提示词:快照与补丁
链的开头挂着一条 system 记录。它存的是一组分节的文本:开场说明、工具清单、规则、文档、技能、工作目录。发请求的时候才把这几节拼成一段。
分节是为了后面能只改一节。会话跑到一半,工具变了或者规则变了,pi 不回头改开头那条记录:它在链的末尾追加一条新记录,里面只装变化的那一节的完整新文本。
开头:
system{ 开场说明 · 工具清单 · 规则 · 文档 · 技能 · 工作目录 }中途:system{ 工具清单 ← 重写 · 规则 ← 重写 }请求里:开头那条原文 + 这条补丁,按顺序渲染
压缩的时候,压缩记录里会附带一份系统提示词的快照——把所有补丁折叠完之后的最新状态。压完新开的链读到的就是这份快照,前面那些补丁不再进链。我把手里的压缩记录都对了一遍,快照全部能和折叠结果对上。
分节、补丁、快照,做的都是同一件事:把"改系统提示词"这个动作的落点推到链的靠后位置。而系统提示词本身在链的最前面。我以前没搞明白的就是这一点——动了最前面的东西,为什么不必把缓存整段重算。
改动放在哪,决定你要花多少钱
服务端收到的请求是一个有序整体:先是所有工具定义,然后是系统提示词,最后是对话消息。缓存从头开始按字节比对,撞到第一个不一样的地方,后面的全部作废。
要缓存到哪,得请求自己标出来。标记的意思是"从这里往前的内容缓存起来"。pi 标三条:最后一条工具定义之后、系统提示词之后、最后一条消息之后。
价钱是:命中的部分按 0.1 倍读,要重算的部分按 1.25 倍写。差一个数量级,"改动落在哪一章"就是一个能换算成钱的问题。
三层那个困惑在这里有答案。系统提示词的补丁追加在链末尾,渲染出来落在第三章;第一章原文和第二章那一整段都没动,前两个标记照旧命中。改 system 提示词不必整段重算,靠的是让补丁的落点待在最后面。
压缩的动作是"把前面一段对话换成摘要",它一定重写第三章。前两章变不变,取决于上一次压缩之后系统提示词有没有被动过。我拿标本会话里两次压缩比了一下:
纪元内没动过:工具数组 16 个 → 16 个,一个字节没变;系统提示词六节全部相同;只有消息章重写(436 条 → 21 条)。 纪元内动过(删了 7 个工具、加了 1 个):压缩把补丁折进快照,工具数组 23 个 → 16 个重新排过,系统提示词里「工具」「规则」两节重写,消息章同样重写。
补丁的账会在下一次压缩时一起结掉。
工具定义只加不减
工具定义在第一章。改工具就是改第一章——在所有标记之前,最贵的那一类改动。
如果数组里删掉一个工具,它后面所有工具定义会往前挪一格,从那个位置开始的缓存全部作废。删得越靠前,废得越多。
pi 的做法是数组只加不减:它记的是"曾经声明过的所有工具",删除不进数组。删除表达在第三章——追加一条 system 消息,把 tools 那一节重写一遍;支持的模型还会在同一条消息里带上 tool_removal / tool_addition 块。
下架前
params.tools(23 个):read, bash, edit, write, web_search, …, __pi_deferred_placeholder__, compress, decompress, search_context, acp_status, acp_cache, absorb, acp_rule, recall下架后:这 23 个一字不差。
第三章多出来的那条消息:
{ "role": "system", "content": "", "sections": { "tools": "<tools>\n- read: …\n- bash: …", "rules": "…" }, "toolsAdded": [{ "name": "recall" }], "toolsRemoved": [{ "name": "compress" }, …, { "name": "acp_rule" }] }Anthropic 侧最后收到的形态:
{ "type": "tool_removal", "tool": { "type": "tool_reference", "name": "compress" } } { "type": "tool_addition", "tool": { "type": "tool_reference", "name": "recall" } }
我用同一条补丁把"上架 recall"那部分去掉重跑了一遍,两边的数组哈希完全相同。删除对第一章贡献零字节变化。
账不在这一次结。那些定义继续留在第一章,每次请求按 0.1 倍读一遍,直到下次压缩把它们清掉(前面那次压缩就把数组从 23 个收回 16 个)。所以只加不减只在删掉的工具位置靠前时才赚:同样删 7 个工具,压在数组中间的话,朴素做法要重写后面一大片;挤在末尾的话朴素做法只把数组截短,几乎不用重写,只加不减反而每轮多付那 0.1 倍。
数组里还有个占位符,叫 __pi_deferred_placeholder__。源码注释写着,没有它,第一次动态加工具会全量 miss。这类延迟工具会让服务端往提示词里加一段自己的内容,而那段内容的位置在工具定义之前,所以得从第一次请求就把它拉进缓存。后半句是我从注释推的,源码只写了结果。
模型侧和 agent 侧是怎么互相适配的
两边的诉求对着来:agent 想随时改状态,服务端要按前缀复用缓存。改一次状态,前面那一大段就白算了。
服务端这边的让步,是把"改"变成"追加"。Anthropic 专门做了 tool_addition / tool_removal 块、clear_at 参数、defer_loading 标记,这些东西存在的理由就是让 agent 不必回头改已经发出去的内容——它们的作用点都在对话末尾。
agent 这边的让步是分岔。pi 按模型能力准备了两套:支持原生中途消息的模型,补丁和工具增删都追加在对话里;不支持的模型,它把链上所有 system 记录折叠成开头一条——补丁这条路就没了,改系统提示词等于重写第二章,工具改动也只能整个数组重写。
| 中途改 system 提示词 | 中途增删工具 | |
|---|---|---|
| Anthropic | 5 个模型 | 数组不动,增删写成 tool_addition / tool_removal |
| OpenAI | 41 个里 10 个 | 只能加(additional_tools / 工具搜索),删要重写整个数组 |
| DeepSeek | v4-pro | 不支持,重写整个数组 |
| Moonshot / Kimi | 4 个全部 | kimi-k3 能加 |
| Bedrock · Google · Mistral … | 不支持 | 不支持 |
光有接口还不够。中途插入的 system 消息之所以算数,是因为模型被专门训练过区分指令层级——OpenAI 有篇论文讲这件事:模型原本倾向于把 system 提示词和用户输入的文本当成同一优先级,需要专门训练它区分。缺了这层训练,接口给了也不起作用。
剩下的是一些双方都得守的契约:中途的 system 消息要紧跟在一条 user 消息之后,不能夹在工具调用和它的返回之间;已经发出去的不要回头编辑,要改就再追加一条;内容写成"状态发生了什么变化",而不是覆盖式命令。
所以缓存命中率是三方一起决定的:pi 把改动推到后面,服务端为追加式改动开接口,训练让模型承认中途指令的效力。
还没解决的问题
上面这些机制一年之内可能全变。模型侧在加能力,也在删能力——pi 的数据里给 41 个 provider 逐个模型标了开关,这些标记跟着模型发布手写。agent 侧也在动:工具集现在来自扩展和 MCP server,随时上下线。
两边都在动的时候,"适配"这件事本身就成了要设计的东西。
我联想到 dsh 一切皆插件的理念,后面研究研究。
很有帮助!🤩