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

运行中改配置,什么时候才真正生效

把 Hook 返回值、Harness 最新配置、下一轮快照与 Session entry 四个时点分开,追踪 pendingSessionWrites 的入队、顺序 flush 和失败边界。

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

“运行中修改模型已经生效”这句话至少有四种含义:getModel() 已经返回新对象;当前 provider request 改用新模型;下一次 request 会使用新模型;Session branch 已经出现 model_change entry。v0.83.0 对这四件事给出的答案不同。上一章的快照解释了 request 边界,本章补上 Hook 与持久写入的顺序。

先给出结论:setter 在通过校验后会立即改 Harness 的最新配置。当前请求不重读这个字段;下一份 turn state 会读。若 setter 对应一条 Session entry,Harness 空闲时先追加 entry,运行中则先把 entry 形状放进 pendingSessionWrites。这支队列只是当前 AgentHarness 实例的内存状态,保存点到来时才逐项交给 Session storage。

Hook 先分成观察与改写两类

subscribe() 注册的是全事件观察者,存放在特殊的 "*" handler set 中。emitOwn()emitAny() 按注册顺序等待每个 listener;任意一个抛错,错误被规范成 Hook error,后续 listener 不再执行。它不是 fire-and-forget telemetry。

on(type, handler) 注册的是带类型的 Hook。通用 emitHook() 同样顺序等待 handlers,保存最后一个非 undefined 结果。于是 contexttool_calltool_result 等 Hook 的默认 reducer 是 last-result-wins,而不是自动合并所有对象。provider request 与 payload 另外写了链式处理:前一个 handler 改出的 stream options 或 payload 会交给下一个 handler。

createLoopConfig() 把这些 Hook 映射回低层 loop:context 可以替换 AgentMessage 列表,tool_call 可以 block,tool_result 可以改 content、details、usage、isError 或 terminate。Hook 改的是对应执行边界的输入或结果,不是直接重写 Session 文件。

这一区分决定了失败语义。message_end 到来时,Harness 先 session.appendMessage(event.message),成功后才通知 subscriber。subscriber 如果抛错,已经追加的消息不会从树上删除。setter 也是先提交自己负责的状态,再发 update event;update listener 失败会让 public Promise reject,却没有补偿性回滚。

setter 的两个时钟

setModel() 为例。它先保存 previousModel。phase 为 idle 时,函数等待 appendModelChange() 成功;phase 非 idle 时,只向 pending queue 放入 { type: "model_change", ... }。接着无论哪条路径,都把 this.model 指向新模型,并发出 model_update

setThinkingLevel()setActiveTools() 采用相同结构。setTools() 还会先校验工具名唯一、active name 均存在,再决定追加或排队 active_tools_change,然后整体替换 tool map。setResources()setStreamOptions() 不写 Session entry:它们只改变当前 Harness 实例,进程重建时需要宿主重新提供。

因此一次运行中的 await harness.setModel(second) 返回后,可以写出这张状态表:

观察位置当下看见什么原因
harness.getModel()secondsetter 已更新最新 Harness config
已发出的 provider request旧模型request 使用 active turn state
下一次 createTurnState()second保存点后从 Harness 字段重建
session.buildContext()flush 前仍是旧 branch 配置busy setter 只入 pending queue
Session storageflush 后有 model_change(second)保存点按队列顺序追加

这里所谓“下一次”,指同一 run 中工具后的下一次请求,或下一条用户 prompt 的第一次请求。若本轮没有工具、steering 或 follow-up,loop 仍会在 turn_end 后运行 prepareNextTurn(),只是重建出的状态可能没有机会发出 provider request。

pending write 为什么不能立即插队

assistant message 可能带多个 tool call。低层 loop 要先把 assistant message 和对应 tool results 形成相邻、完整的对话片段,再进入下一轮。一个 subscriber 在 message_end(assistant) 中追加 custom message,如果立刻写入 Session,就会落在 assistant 与 tool result 之间;下一次从树构建上下文时,模型 API 所需的工具配对可能被切断。

Harness 因而让运行中的 appendMessage() 只排队,idle 时才直接追加。flushPendingSessionWrites() 始终读取数组首项,根据 entry type 调用 Session 对应方法;成功后才 shift()。某项失败时,它不会越过失败项写后面的内容。这是一条简单的 FIFO,不包含重试计数、durable queue id 或跨进程恢复。

仓库的确定性测试把这条排序钉死:provider 返回 assistant okmessage_end listener 调 harness.appendMessage() 追加一个 custom message。最终 Session role 顺序是 user, assistant, custom。listener 调用发生在 assistant 事件内,但写入被延迟到 agent-emitted message 之后。

复现命令只跑这一条。它必须在已按本部导读校验 SHA256 f225b87ec3b4825dd5b94e922a8629558addca31a1b4d2c206ae598a8e2692c0 并解压的官方 pi-0.83.0-source.tar.gz 中执行;固定证据 checkout 保持只读,PI_RELEASE_SOURCE_DIR 指向一次性解压根目录:

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 'orders pending listener session writes after agent-emitted messages'
AgentHarness pending writes、save point、Session context 与 JSONL storage 的源码定位结果

这组定位把内存中的 pendingSessionWritessave_point 事件、Session context 投影和 JSONL appendEntry() 放在一起。它支持“写入接受、flush、持久化是不同阶段”的判断,但静态源码不能证明 fsync、进程崩溃恢复或外部 Hook 副作用已经完成。

官方 release source archive 已带发布时固定的 provider catalog,不再运行会读取当前上游数据的 hydrate-model-data。需要同时看 Vitest 输出中的测试名与执行数;suite 在 import 阶段失败、manifest 缺失或测试被 filter 掉,都不能算复现。

三个尚未被实现消掉的边界

第一,pending queue 不耐进程崩溃。entry shape 只存在 AgentHarness.pendingSessionWrites 数组,JSONL 与 SQLite 里没有“write accepted but not applied”的记录。public setter 在 active phase 返回时,调用方可能已经看到新配置;若进程在保存点前退出,branch 上没有相应 entry。第 24 章会专门讨论这个缺口。

第二,Harness 无法阻止宿主持有最初传入的 Session 引用。宿主如果绕过 harness.appendMessage(),直接在 run 中调用 session.appendMessage(),写入会立即落到当前 leaf,pending ordering 失效。源码文档把 Harness-scoped Session facade 标成 planned,v0.83.0 还没有这层强制边界。写嵌入程序时,Session 的写所有权必须由应用自己守住。

第三,Hook 不是数据库事务。Hook 可以在外部发 HTTP、写文件或操作业务数据库,Harness 既不知道这些副作用的提交点,也无法在 handler 后续抛错时回滚。工具与 Hook 若需要幂等,必须由宿主以稳定 id 或自己的事务协议实现。

到这里,我们一直在说“把 entry 追加到 Session”。下一章要把这个名词本身拆开:Session 不是一条只会向尾部增长的聊天数组。每条 entry 都带 parentId,另有 leaf entry 移动 active pointer;模型上下文只是从当前 leaf 向上选择并投影出来的一条路径。