青雲的博客
深入浅出 Pi 第四部:会话、快照与 Harness 第 20 章

一轮请求为什么需要快照与保存点

从 createTurnState、turn_end 与 prepareNextTurn 的实际时序解释一轮模型请求看见什么,配置变更又怎样在下一次请求前跨过保存点。

源码版本
v0.83.0
验证日期
Commit
845d6ff1f6643aba440341cce877ce1c43ebbc39

上一章确认了 AgentHarness 自己驱动 loop。随之而来的问题是:应用可以在 Agent 运行时调用 setModel()setResources()setActiveTools(),低层 loop 又可能因为工具调用连续发出多次模型请求。第二次请求该用旧配置还是新配置?如果直接读取 Harness 上的可变字段,第一次请求尚未结束时,工具与模型就可能在半轮中途换掉;如果从 prompt 开始把一切冻结到结束,同一个 run 又无法响应运行中的配置调整。

Pi 的答案分成两个词:turn snapshot 与 save point。这里的 turn 不是用户在界面里看到的整条 prompt,也不是整个 runAgentLoop()。它更接近“一次 assistant response,加上它要求的完整工具批次”。这个单元结束后,Harness 才把 pending writes 排到 transcript 尾部,再为下一次 provider request 重建状态。

快照里装的不是几项标量

createTurnState() 先调用 session.buildContext(),读取当前 active branch 经 compaction 处理后的消息与派生配置;再读取资源、session metadata 和 tool context。工具注册表被展开,active tool name 被映射为具体工具。系统提示若是 callback,也在这里执行一次,并得到本轮真正使用的字符串。

返回值包含 messages、resources、toolContext、streamOptions、sessionId、systemPrompt、model、thinkingLevel、全部工具与 active tools。createContext() 又对 messages 做一次顶层浅拷贝,并把 active tool 绑定到本次解析出的 toolContext。一个 provider request 需要的可变输入因此不再从 Harness 字段零散读取。

“快照”不等于深拷贝整个对象图。resources 的容器与 stream options 会被复制,但 skill、prompt template、headers 中更深的对象仍可能共享引用;tools 也是具体对象。这个实现依赖调用方遵守不可变更新习惯,例如用 setResources() 换一组值,而不是悄悄改写旧 skill 对象。源码保证的是 Harness 自己不会在同一 turn 内重新选择这些对象,不是 JavaScript 世界里的冻结。

prompt()skill()promptFromTemplate() 都先调用 createTurnState()。后两者也从这份 state 的 resources 中找 skill 或 template,避免“调用文本来自新版资源、系统提示却来自旧版资源”的撕裂。

保存点位于工具结果之后

低层 runLoop() 的顺序给出了 turn 的边界。它先 stream assistant response;若响应包含工具调用,就执行完整工具批次并把结果追加到 current context;随后发出 turn_end。只有 turn_end 的事件处理完成后,loop 才调用 config.prepareNextTurn(),用返回值替换下一轮的 context、model 与 reasoning。

Harness 在 handleAgentEvent(turn_end) 内又规定了更细的时序:先等待普通 turn_end subscriber;记录 flush 前是否存在 pending mutation;按接收顺序 flush;若 subscriber 失败,再抛出该错误;最后发出 save_point。而 createLoopConfig.prepareNextTurn 会再次保证 pending writes 已清空,随后调用 createTurnState() 并把 fresh context、model 与 thinking level返回低层 loop。

完整时序可以写成:

sequenceDiagram
  participant App as Application / Hook
  participant H as AgentHarness
  participant L as runLoop
  participant P as Provider
  participant S as Session

  H->>S: buildContext()
  H->>H: createTurnState #1
  H->>L: context + config snapshot
  L->>P: provider request #1
  P-->>L: assistant + tool calls
  L->>L: execute complete tool batch
  L->>H: turn_end
  App->>H: setModel / appendMessage
  H->>S: flush pending writes in order
  H-->>App: save_point
  L->>H: prepareNextTurn()
  H->>S: buildContext()
  H->>H: createTurnState #2
  H-->>L: fresh context + model
  L->>P: provider request #2

图中 setModel / appendMessage 画在 turn_end listener 位置,只是一个可观察例子。变更也可能更早发生在 tool_execution_start、provider hook 或外部 UI 回调中。只要结构操作仍处在 turn phase,会话类写入就进入 pending queue;Harness 上的最新配置则立即更新。两者在保存点重新汇合。

一条 run 可以使用多份快照

测试 refreshes model, thinking level, resources, system prompt, and active tools at save points 把这个合同写得很具体。第一个 faux provider response 请求 calculate 工具;订阅者在工具开始时把 model 改成 second、thinking level 改成 high,替换资源和 active tool。第一次请求捕获的仍是 first/off/first prompt/calculate,工具完成后的第二次请求才看到 second/high/second prompt/get_current_time

这条测试不要在固定证据 checkout 中运行。先按本部导读校验官方 pi-0.83.0-source.tar.gz 的 SHA256 f225b87ec3b4825dd5b94e922a8629558addca31a1b4d2c206ae598a8e2692c0,解压到一次性目录并安装 lockfile 依赖,再让 PI_RELEASE_SOURCE_DIR 指向解压根目录。仓库把 Harness 测试单列配置,命令要使用对应 script;直接跑默认 package test 可能根本不包含它。

repo="${PI_RELEASE_SOURCE_DIR:?先按第四部导读校验并解压官方 release source archive}"
cd "$repo"
test -f packages/ai/src/providers/data/.manifest.json

npm run test:harness --workspace @earendil-works/pi-agent-core -- \
  test/harness/agent-harness.test.ts \
  -t 'refreshes model, thinking level, resources, system prompt, and active tools at save points'

官方 release source archive 已包含发布时固定的 provider catalog,不需要也不应在复现时重新运行 live hydrate-model-data。若 suite 在导入阶段失败、manifest 不存在或显示 0 test,不能把命令退出前的日志当作通过证据。测试通过只证明 faux provider 下的保存点刷新和捕获序列;它不证明任意第三方工具都遵守不可变对象约定。

save_point 不是数据库术语

事件类型只有 type: "save_point"hadPendingMutations。它告诉观察者:本轮 agent 消息已经处理,pending mutation 已按顺序 flush。它没有 transaction id、fsync receipt 或跨后端的 durability level。内存 Session 的 flush 只是写进数组;JSONL 后端调用的是宿主提供的 appendFile();SQLite 后端还有自己的事务实现。把这个事件翻译成“所有副作用都已永久提交”会越过 storage contract。

它也不是“用户 prompt 已完成”。若 assistant 调用了工具,保存点后会立即进入下一次 provider request;若 steering queue 有输入,同一 run 也会继续。agent_endsettled 才接近 Harness 运行结束,而 prompt() 的 Promise 还要等 finally 中的清理完成。

快照解决“当前请求读哪套配置”,保存点解决“运行中产生的写入排在哪里”。但它们还没有回答写入 API 的细节:为什么 setModel() 会立即改变 getter,却可能暂时没有对应 Session entry?Hook 抛错时,已经落盘的状态是否回滚?第 21 章沿 pendingSessionWrites 与 Hook settlement 把这段时序继续拆开。