落地手册:配置、选型与工程边界
系列第 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 | 提交即存储事务,天然适合附着 |
| 工具跑在别处 | 自定义 ExecutionEnv | harness 与工具可以分处两台机器 |
一个容易忽略的约束:同一时刻一个存储只能被一个进程打开。如果你需要多个进程服务同一批对话,正确做法是让一个进程拥有存储,其他进程通过 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.reserveTokens | 16384 | 为回答预留的上下文 |
compaction.keepRecentTokens | 20000 | 摘要后逐字保留的近期上下文 |
compaction.backgroundTokens | 32768 | 提前后台压缩的距离;0 关闭 |
progress.partialIntervalMs | 100 | 流式回答的最小提交间隔 |
progress.outputIntervalMs | 100 | 工具输出的最小提交间隔 |
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.maxRetries | SDK 级,同一次请求内 |
| generation 任务 | retry.maxRetries | 持久重试,跨重启 |
| 提交 | requestId | 客户端重试不重复执行 |
三层不能互相替代。最常见的错误是只配了 SDK 重试,却以为进程崩溃后也会自动重试。
4.3 取消的边界
| 操作 | 影响范围 |
|---|---|
submission.abort() | 撤回排队中的提交 |
root.abort() | 停止整个对话:撤回排队输入、abort 当前所有前台任务 |
root.abort({ background: true }) | 连同后台任务一起停止 |
| abort 一个任务 | 自底向上,先子后父 |
4.4 错误的三条规则
- 存储错误 → 终止 Harness,所有等待以
SessionFailedreject,宿主重开; - 非法读取 → 只让该次读取失败(
StorageRequestError); - 业务代码 → 只让当前单元失败: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()。
六、踩坑清单
- 把 commit 当保存按钮。 任何绕过
commit()的写入都会破坏恢复语义。 - 在 commit 回调里发外部请求。 回调可能回滚,副作用必须进任务。
- 忘了
replay: "safe"。 只读工具不声明,崩溃后会被报告为interrupted,而不是重跑。 - 以为 memo 可以先做副作用再写。 顺序必须反过来,否则重跑会重复可见效果。
- 用内存闭包保存应用状态。 应该用
defineDoc(),与 transcript 同一次落盘。 - 以为 reset 会删除历史。 它只移动模型可见起点;旧记录仍可查。
- 以为 fork 会复制历史。 它引用前缀,不复制;父子后续写入互不影响。
- 在客户端维护工具输出。 应该读
pi.live,否则刷新后状态对不上。 - 多个进程打开同一个 SQLite。 没有跨进程锁;只能一个进程拥有,其他通过 watch 附着。
- 用 JSONL 跑长对话。 它把全部历史放在内存里,长跑应换 SQLite。
- 只配 SDK 重试,忽略任务级重试。 进程崩溃不在 SDK 的重试范围内。
- 认为
modelTools会禁用工具。 它只缩小模型可见的工具,嵌套调用仍可调用。 - 在 section 里返回当前时间。 每次内容不同会破坏 prompt cache。
- 把后台任务当成前台任务等待。 后台任务不让对话 busy,
waitForTask要显式调用。 - 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 的决策可以收成四句话:
- 存储决定故障模型。 Memory 只适合测试;长跑用 SQLite;边缘用 Durable Object。
- 任务决定恢复粒度。 每个有副作用的步骤都要 checkpoint + 幂等键。
- 所有权决定取消边界。 前台属于当前工作,后台用
background: true显式划出。 - 提交决定观察一致性。 客户端只读已提交状态,不维护第二份真相。
系列到这里结束。回到第 1 篇的五条不变量,你会发现后面七篇只是把它们展开成可执行的工程约束:先落盘、同事务、可恢复、幂等、可中止。
