落地手册:配置、选型与工程边界

系列第 8 篇,共 8 篇。

前七篇讲的是模型与机制。这一篇讲把它们接进真实项目时要做的选择:存储、执行环境、模型、并发、错误边界,以及怎么用示例快速验证。

先说明版本:本文基于 @earendil-works/pi-durable@1.1.0。API 仍是实验性的,升级前请读 release notes。

一、最小启动模板

一个能跑的最小配置只需要四样东西:存储、模型、registry、执行环境。

import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { createRegistry, Harness } from "@earendil-works/pi-durable";
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
import { CodingTools } from "@earendil-works/pi-durable/tools";

const context = BACKGROUND_CONTEXT;

const models = createModels();
models.setProvider(openaiProvider()); // 读取 OPENAI_API_KEY

const registry = createRegistry();
registry.install(CodingTools); // read / write / edit / bash

const env = ({ cwd }: { cwd?: string }) =>
    new NodeExecutionEnv({ cwd: cwd ?? process.cwd() });

const harness = await Harness.open(
    await openNodeSqliteStorage("./agent.sqlite"),
    { models, registry, env },
    context,
);

const root = await harness.root(context, {
    agent: {
        model: { provider: "openai", modelId: "gpt-6.1-sol" },
        cwd: "/work/repo",
    },
});

harness.resume(); // 重启后继续未完成的 Run

启动顺序建议:

flowchart TD
    A["打开 Storage"] --> B["创建 Models 并设置 Provider"]
    B --> C["创建 Registry 并安装扩展"]
    C --> D["构建 env 函数"]
    D --> E["Harness.open(storage, options, context)"]
    E --> F["harness.resume()"]
    F --> G["harness.root(context, agent)"]
    G --> H["submit / watch"]

resume() 不是必需的:提交或等待也会启动调度器。但显式调用它,意图更清楚——「先恢复上次的工作,再接受新输入」。

二、存储与执行环境选型

flowchart TD
    A["需要持久化吗?"] -- 否 --> M["MemoryStorage"]
    A -- 是 --> B{"部署形态"}
    B -- 单机 / 桌面 --> C["SQLite 单文件"]
    B -- 需要审计 / 小规模 --> D["JSONL + fsync"]
    B -- 边缘 / 多客户端 --> E["Cloudflare Durable Object"]
    C --> F["长跑首选"]
    D --> G["内存随历史增长"]
    E --> H["提交即存储事务"]

    I["工具需要文件/进程吗?"] -- 是 --> J{"在哪里执行"}
    J -- 本机 --> K["NodeExecutionEnv"]
    J -- 远程 --> L["自定义 ExecutionEnv"]
    J -- 沙箱 --> N["每对话一个环境"]
    I -- 否 --> O["可以不传 env"]

几个判断依据:

场景建议原因
本地开发、测试Memory零配置,进程退出即失
桌面或单机长跑SQLite崩溃可恢复,内存只保留工作集
需要逐条审计JSONL + fsync: true追加写、可读性好;但内存随历史增长
边缘、多客户端Cloudflare DO提交即存储事务,天然适合附着
工具跑在别处自定义 ExecutionEnvharness 与工具可以分处两台机器

一个容易忽略的约束:同一时刻一个存储只能被一个进程打开。如果你需要多个进程服务同一批对话,正确做法是让一个进程拥有存储,其他进程通过 watch 附着,而不是各自打开同一个 SQLite 文件。

三、配置速查

3.1 HarnessOptions

字段必填作用
models是pi-ai 模型访问层
registry是本进程安装的扩展
settings否所有对话共享的运行策略,每次使用时读取
env否每次工具调用、section 渲染时构建执行环境
conversationCreated否每个新对话创建/派生时初始化自定义文档
now否自定义时钟(测试用)
onReport否接收不致命的扩展失败报告

3.2 settings 常用项

字段默认作用
extensions全部已安装默认选中的扩展集
stream.timeoutMs未设置单次模型请求超时
stream.maxRetries未设置SDK 级单请求重试
retry.maxRetries未设置generation 任务级持久重试
compaction.reserveTokens16384为回答预留的上下文
compaction.keepRecentTokens20000摘要后逐字保留的近期上下文
compaction.backgroundTokens32768提前后台压缩的距离;0 关闭
progress.partialIntervalMs100流式回答的最小提交间隔
progress.outputIntervalMs100工具输出的最小提交间隔
toolExecution"parallel"同轮多工具的执行方式
steeringMode"one-at-a-time"忙碌时 steer 的放置策略
followUpMode"one-at-a-time"follow-up 的放置策略
contextRetentionMs未设置空闲对话在内存中保留上下文的时长

3.3 会话级 AgentChange

字段作用
model{ provider, modelId }
thinkingLevel"off" 到 "max" 的思考档位
extensions数组 = 精确选择;{ add, remove } = 增删默认
tools数组 = 精确启用;{ remove } = 从默认去掉
modelTools其中只把哪些提供给模型
instructions会话级指令,渲染在 system prompt 最后
cwd会话工作目录,传给 env

传 null 表示清除回宿主默认;不传表示不动。

四、常见工程边界

4.1 哪些操作不能放进 commit 回调

commit 回调可能回滚,因此不能有不可撤销的外部副作用:

操作放哪里
扣款、退款任务阶段 + 幂等键
发邮件、发 Slack任务阶段 + 幂等键
写对象存储任务阶段;必要时先写临时对象
修改本地文件工具 + ExecutionEnv
读数据库commit 回调可以读,但不要依赖读后写

4.2 重试的三层

层配置语义
单请求stream.maxRetriesSDK 级,同一次请求内
generation 任务retry.maxRetries持久重试,跨重启
提交requestId客户端重试不重复执行

三层不能互相替代。最常见的错误是只配了 SDK 重试,却以为进程崩溃后也会自动重试。

4.3 取消的边界

操作影响范围
submission.abort()撤回排队中的提交
root.abort()停止整个对话:撤回排队输入、abort 当前所有前台任务
root.abort({ background: true })连同后台任务一起停止
abort 一个任务自底向上,先子后父

4.4 错误的三条规则

  1. 存储错误 → 终止 Harness,所有等待以 SessionFailed reject,宿主重开;
  2. 非法读取 → 只让该次读取失败(StorageRequestError);
  3. 业务代码 → 只让当前单元失败:task faulted、tool 错误结果、hook 跳过、commit 回滚。

记住一句话:存储不可信时不要假装还能继续;业务代码的错误必须被隔离。

五、32 个示例怎么用

示例不是教程,而是「可执行真相」。按问题找:

你想做的事看这个
最小可跑的对话14-chat
一次工具轮次的完整解剖17-coding-tools
崩溃不重复、不丢12-tasks + 13-recovery
持久任务的状态机12-tasks
subagent(前台,答案回传给模型)22-subagent-foreground
subagent(后台,能活过一轮对话)23-subagent-background
多步子任务 + 失败回滚 + 取消24-child-tasks
长对话不爆炸25-compaction
一个能用的编码智能体26-coding-agent
模式切换(只读 / 可写)27-plan-mode
多角色(主 + 审查者)28-reviewer
每个对话一个沙箱29-sandbox-per-conversation
换掉内置工具 / 装饰工具30-tool-override
热重载31-reload-and-restart
多人协作 / 中途加入21-late-join + 05-watches
机器可读输出(CLI / 桥接)18-print + 19-json

运行方式:

cd packages/durable
node --conditions=source --experimental-strip-types test/examples/14-chat.ts
  • --conditions=source:@earendil-works/* 解析到源码而非构建产物;
  • --experimental-strip-types:Node 直接跑 .ts;
  • 需要真实模型的示例读 OPENAI_API_KEY,没有则回退到 fauxProvider()。

六、踩坑清单

  1. 把 commit 当保存按钮。 任何绕过 commit() 的写入都会破坏恢复语义。
  2. 在 commit 回调里发外部请求。 回调可能回滚,副作用必须进任务。
  3. 忘了 replay: "safe"。 只读工具不声明,崩溃后会被报告为 interrupted,而不是重跑。
  4. 以为 memo 可以先做副作用再写。 顺序必须反过来,否则重跑会重复可见效果。
  5. 用内存闭包保存应用状态。 应该用 defineDoc(),与 transcript 同一次落盘。
  6. 以为 reset 会删除历史。 它只移动模型可见起点;旧记录仍可查。
  7. 以为 fork 会复制历史。 它引用前缀,不复制;父子后续写入互不影响。
  8. 在客户端维护工具输出。 应该读 pi.live,否则刷新后状态对不上。
  9. 多个进程打开同一个 SQLite。 没有跨进程锁;只能一个进程拥有,其他通过 watch 附着。
  10. 用 JSONL 跑长对话。 它把全部历史放在内存里,长跑应换 SQLite。
  11. 只配 SDK 重试,忽略任务级重试。 进程崩溃不在 SDK 的重试范围内。
  12. 认为 modelTools 会禁用工具。 它只缩小模型可见的工具,嵌套调用仍可调用。
  13. 在 section 里返回当前时间。 每次内容不同会破坏 prompt cache。
  14. 把后台任务当成前台任务等待。 后台任务不让对话 busy,waitForTask 要显式调用。
  15. abort 后立刻假设副作用已清理。 要等 waitForTask 或对话 idle,确保自底向上的清理完成。

七、上线检查表

存储

  • 选定的后端能挺过目标故障(进程崩溃 / 断电 / 重新部署)
  • 只有一个进程拥有存储
  • 自定义后端跑过 registerStorageConformance()
  • 远程部署调大了 progress 间隔

恢复

  • 所有非只读工具都没有声明 replay: "safe"
  • 所有会产生外部副作用的步骤都有幂等键
  • 客户端重试带 requestId
  • 有 harness.closed 的重开逻辑
  • 验收过「执行后、提交前」的崩溃窗口

对话

  • 长对话配置了 compaction 阈值
  • 忙碌时的输入策略(steer / follow-up / reject)符合产品语义
  • pi.inbox 在 Run 失败后的保留行为符合预期
  • 需要交接的场景使用 reset(note) 或 control.handoff

扩展与工具

  • 工具参数用 TypeBox schema 声明
  • structuredOutputSchema 与实际返回一致
  • hooks 的决策写入 memo
  • 热重载验证过「运行中的调用用旧代码跑完」

观察与协作

  • 首屏用 viewState(),增量用 watch()
  • 断线重连重新取快照
  • 多人协作只提交意图,不在客户端维护工作状态
  • 慢消费者接受 100 帧合并策略

八、什么时候不该用 Pi Durable

Pi Durable 不是所有 Agent 场景的默认答案。以下情况应慎重:

  • 单轮、短生命周期的调用:直接调模型 API 更简单,持久化没有收益;
  • 极低延迟的交互:每次提交都是存储写,频繁进度提交会增加开销;
  • 团队没有持久化运维能力:SQLite 单文件很轻,但「一个进程拥有存储」仍然是一条需要遵守的部署约束;
  • 需要跨进程共享同一存储:当前设计不支持,必须通过一个拥有者进程 + watch 附着。

它的优势场景很明确:长时间运行、可能崩溃、需要恢复、需要多人观察的 Agent。

小结

落地 Pi Durable 的决策可以收成四句话:

  1. 存储决定故障模型。 Memory 只适合测试;长跑用 SQLite;边缘用 Durable Object。
  2. 任务决定恢复粒度。 每个有副作用的步骤都要 checkpoint + 幂等键。
  3. 所有权决定取消边界。 前台属于当前工作,后台用 background: true 显式划出。
  4. 提交决定观察一致性。 客户端只读已提交状态,不维护第二份真相。

系列到这里结束。回到第 1 篇的五条不变量,你会发现后面七篇只是把它们展开成可执行的工程约束:先落盘、同事务、可恢复、幂等、可中止。