拆解 pi(DeepSeek 开源终端 Coding Agent)v0.83.0 的 turn snapshot 机制:一次请求的输入必须是稳定的一整份,而不是从字段里零散读取。源码依据:pi-internals 20 章。

长任务里最常见的撕裂
Agent 跑一个长任务时,模型在变、工具在变、资源在变、思考级别在变。这些变化不是坏事------长任务本来就要在轮次之间调整。坏的是:一个请求发出去了,读到的输入却是新旧混合的。
比如系统提示来自旧版资源、调用文本却来自新版资源;或者请求开始时模型是 A,发到一半配置被改成 B,请求却按 B 的期望返回。这种撕裂是最难排查的 bug 之一:不是逻辑错,是状态错。
pi 的答案是:每个请求开始前,先拍一张完整的快照。
turn 的边界
先明确 turn 是什么。它不是用户在界面上看到的整条 prompt,也不是整个 runAgentLoop()。它更接近「一次 assistant response,加上它要求的完整工具批次」。
这个单元结束后,Harness 才把这一轮攒下的 pending writes 排到 transcript 尾部,再为下一次 provider 请求重建状态。
所以「快照」天然与 turn 绑定:一个 turn 一份快照,turn 之间状态可以随便变,turn 内部必须稳定。
快照里装着什么
createTurnState() 做的事很具体:先调用 session.buildContext() 读取当前 active branch 的消息和派生配置,再读资源、session metadata、tool context。工具注册表被展开,active tool name 被映射成具体工具。系统提示如果是回调,也在这里执行一次,得到本轮真正使用的字符串。
返回值包含 messages、resources、toolContext、streamOptions、sessionId、systemPrompt、model、thinkingLevel、全部工具和 active tools。
createContext() 再对 messages 做一次顶层浅拷贝,把 active tool 绑定到本轮解析出的 toolContext。一个 provider 请求需要的可变输入,从此不再从 Harness 字段里零散读取------它是一份自洽的整体。
快照不是深拷贝
这里有个容易被误读的细节:快照不等于冻结整个对象图。resources 的容器和 stream options 会被复制,但 skill、prompt template、headers 里更深的对象仍然共享引用,tools 也是具体对象。
这个实现依赖调用方遵守不可变更新的习惯------用 setResources() 换一组值,而不是悄悄改写旧的 skill 对象。源码保证的是 Harness 自己不会在同一 turn 内重新选择这些对象,不是 JavaScript 世界里的深冻结。
这个取舍是诚实的:深拷贝一个对象图很贵,而且对「快照稳定性」的收益有限。真正需要保证的是「这一轮不会读到被改了一半的配置」,而不是「这一轮读到的对象永远不会变」。
三个入口共用同一份快照
prompt()、skill()、promptFromTemplate() 三种用户发起的运行,都先调用 createTurnState()。skill 和 template 也从这份 state 的 resources 里找。
为什么要强调这一点?因为如果 skill 入口自己另读一份 resources,就可能出现「调用文本来自新版资源、系统提示却来自旧版资源」的撕裂。统一入口 + 统一快照,把这个可能性直接关掉。
保存点在工具结果之后
低层 runLoop() 的时序给出了 turn 的完整边界:先 stream assistant response;如果响应含工具调用,就执行完整工具批次并把结果追加到 context;然后发出 turn_end。
Harness 在 turn_end 里规定更细的时序:先等待普通 subscriber;记录 flush 前是否存在 pending mutation;按接收顺序 flush;若 subscriber 失败再抛出错误;最后发出 save_point。
保存点之后,prepareNextTurn() 保证 pending writes 已清空,调用 createTurnState(),把新鲜的 context、model、thinking level 交回低层 loop------下一轮开始。
拆完这章,我的感受是:Agent 框架的可靠性,很大一部分藏在「输入的稳定性」里。模型调用本身是不可靠的,但框架可以保证它收到的输入是一份自洽的、属于当前时刻的快照------把「状态变了」和「请求发出去了」这两件事严格分开。做到这一点,很多诡异的中途 bug 根本不会出现。

想看完整拆解(turn_end/save_point/prepareNextTurn 源码与时序实验),这里有 pi-internals 小册子。
源码依据:packages/agent/src/harness/agent-harness.ts L354-L397、L538-L565、L442-L497、L658-L704;packages/agent/src/agent-loop.ts L192-L245,pi v0.83.0,commit 845d6ff1。