我在学 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 提示词中途增删工具
Anthropic5 个模型数组不动,增删写成 tool_addition / tool_removal
OpenAI41 个里 10 个只能加(additional_tools / 工具搜索),删要重写整个数组
DeepSeekv4-pro不支持,重写整个数组
Moonshot / Kimi4 个全部kimi-k3 能加
Bedrock · Google · Mistral …不支持不支持

光有接口还不够。中途插入的 system 消息之所以算数,是因为模型被专门训练过区分指令层级——OpenAI 有篇论文讲这件事:模型原本倾向于把 system 提示词和用户输入的文本当成同一优先级,需要专门训练它区分。缺了这层训练,接口给了也不起作用。

剩下的是一些双方都得守的契约:中途的 system 消息要紧跟在一条 user 消息之后,不能夹在工具调用和它的返回之间;已经发出去的不要回头编辑,要改就再追加一条;内容写成"状态发生了什么变化",而不是覆盖式命令。

所以缓存命中率是三方一起决定的:pi 把改动推到后面,服务端为追加式改动开接口,训练让模型承认中途指令的效力。

还没解决的问题

上面这些机制一年之内可能全变。模型侧在加能力,也在删能力——pi 的数据里给 41 个 provider 逐个模型标了开关,这些标记跟着模型发布手写。agent 侧也在动:工具集现在来自扩展和 MCP server,随时上下线。

两边都在动的时候,"适配"这件事本身就成了要设计的东西。

我联想到 dsh 一切皆插件的理念,后面研究研究。