上一篇文章《设计Agent与AI工具-总纲博客》里,我把 Agent 开发收拢成八个词——判断、授权、执行、记录、会话、隔离、决策、看见。

现在拿一个真实的、有名有姓的项目来对答案:Pi Agent Harnessearendil-works/pi,约 8.5万 star,OpenClaw 的核心运行时构建在它的 SDK 之上)。

这篇文章从 Pi 的 GitHub 仓库直接拉源码和文档,逐条核对:它是否符合这八支柱?符合到哪一层?不符合的地方,它自己的逻辑是什么?每一段结论都附源码或文档原话,不靠转述。


〇、先给结论

八支柱在 Pi 里全部存在,但被刻意分成两组:

支柱Pi 的落位证据文件
判断内核:模型是唯一建议者,且建议被做得极轻agent-loop.ts / system-prompt.ts
执行内核:四个内建工具,先 schema 校验再执行tools/index.ts / agent-loop.ts
记录内核:写意图再执行,append-only 账本harness-v2.md / session/state.ts
会话内核:树 + 车道 + 全局事实,四类状态共享一个序号harness-v2.md
看见内核:类型化 telemetry 契约 + 会话即成本账本telemetry-schema.md
授权边界:无内建策略引擎,只留一个 before_toolsecurity.md
隔离边界:真正的边界来自 OS/虚拟化,不来自进程内security.md / containerization.md
决策边界:循环内无审批弹窗,人的决策被挪到三处security.md

一句话概括 Pi 的设计立场:

五个"执行系"支柱进循环内核,三个"治理系"支柱(授权、隔离、决策)被推出循环,放到操作系统、容器、部署和一次性信任闸门上。

它和总纲框架的分野也在这里:总纲把"策略负责授权、人负责高风险决策"放在循环内(ToolGateway → PolicyEngine → 审批),Pi 认为循环内每多一个治理环节,都会放大模型要消化的东西、加长主路径、引入新的失败模式,所以选择最小内核 + 边界隔离。

下面按这个结论,一节一节用源码验证。


Part 1 · 内核五支柱

一、判断:模型是唯一建议者,而且建议被做得极轻

Pi 的工具调用循环在 agent-loop.ts。核心的 streamAssistantResponse 做一件事:把 AgentMessage[] 转成 LLM 能吃的 Message[],拼 Context,丢给 streamFunction(注入的流式调用),再逐事件把 partial 消息推回。

// packages/agent/src/agent-loop.ts
const llmMessages = await config.convertToLlm(messages);

const llmContext: Context = {
    systemPrompt: context.systemPrompt,
    messages: llmMessages,
    tools: context.tools,
};

判断权完全在模型手里,系统只负责喂什么、怎么接。这个分工和总纲"模型负责建议"完全一致。

但 Pi 把"判断"这件事做得极轻,这是它和"堆提示词"型 Agent 的分水岭:

skills 不内联进系统提示词。 system-prompt.ts 只往提示词里塞一段 <available_skills> 清单(名字 + 描述 + 路径),并叮嘱模型按需读取全文:

The following skills provide specialized instructions for specific tasks.
Read the full skill file when the task matches its description.

也就是说,系统提示词保持极小,技能不是全量灌进上下文,而是"目录化"——模型判断"这个任务匹配哪个 skill"时才去读全文。把"给模型多少判断原料"当成一项预算,而预算的默认值很小。

判断权可以跨轮迁移。 循环里每轮结束调用 prepareNextTurn,允许换模型、换 reasoning level:

// agent-loop.ts
const nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext);
if (nextTurnSnapshot) {
    config = {
        ...config,
        model: nextTurnSnapshot.model ?? config.model,
        reasoning: /* ... */,
    };
}

配合会话里的 model_change 记录(上一篇文章讲过),Pi 的判断不是绑死在单一模型上的,同一段对话中途换 provider 是受支持、可回放的。

二、执行:只有四个工具,但每个都先过 schema 校验

tools/index.ts 里内建工具只有四个,一个不多:

export {
    createBashTool,   // 跑命令
    createEditTool,   // 改代码
    createReadTool,   // 读文件
    createWriteTool,  // 写文件
} from "...";

极简主义的第一层落点:少就是多。 每个工具都是一段失败模式和一段上下文占用,砍到四个,模型要理解的东西就少。

执行不是无脑放行,agent-loop.tsprepareToolCall 里有三层闸:

const preparedToolCall = prepareToolCallArguments(tool, toolCall);
const validatedArgs = validateToolArguments(tool, preparedToolCall);   // ① schema 校验
if (config.beforeToolCall) {
    const beforeResult = await config.beforeToolCall(/* ... */);       // ② 钩子可拦截
    if (beforeResult?.block) {
        return { kind: "immediate",
                 result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"),
                 isError: true };                                       // ③ 拦截成 error 回执
    }
}

三层闸:先按工具的 JSON Schema 校验参数,再给 beforeToolCall 钩子一次拦截机会,拦截的结果不是静默丢弃,而是落成一条 error 回执——模型能看到"这个调用被拒了、原因是什么",然后自己改。

还有一个执行安全细节:截断消息里的工具调用全部不执行。stopReason === "length"(输出被 token 上限切断)时,流式工具调用的参数可能是不完整的 JSON,Pi 宁可把所有调用都 fail 掉,让模型重新发起:

// agent-loop.ts
const executedToolBatch =
    message.stopReason === "length"
        ? await failToolCallsFromTruncatedMessage(toolCalls, emit)
        : await executeToolCalls(currentContext, message, config, signal, emit);

工具执行支持串行/并行(工具可声明 executionMode: "sequential"),工具结果会携带 usage(工具内部如果调了 LLM,这笔账也记进结果)和 addedToolNames工具运行时可以动态注册新工具——这是"自我扩展"的机制)。

三、记录:比"回执"更硬——写意图,再执行

总纲里提过 Receipt:每个操作要有结构化回执。Pi 把它做到了一等公民的程度,harness-v2.md 里有一条文档原文的 durability rule

Before an effect: write an intent record that names what will happen and every durable id settlement will use. After an assistant/fetch effect: append the complete response entry, then its preplanned usage record.

在执行某个效果之前,先写一条意图记录,说明将要发生什么、以及所有待落定的 id 会怎么用;执行完之后,再追加完整的响应条目和预先计划好的 usage 记录。

配套机制是 provisioned ids——意图记录里预先分配好还不存在的条目 id:

/** An entry payload with its id pre-allocated. parentId, seq, and timestamp
    are assigned by storage when the entry is appended. */
type ProvisionedEntry<T extends Entry = Entry> =
  T extends Entry ? Omit<T, "parentId" | "seq" | "timestamp"> : never;

这个模式翻译成人话:先立军令状(我要执行什么、结果 id 是什么),再动手,动手之后把完整结果按预定的 id 落账。 崩溃发生在任何一刻,恢复逻辑都能从意图记录重建——这和总纲"写意图再执行、执行后落回执"的幂等思想是同一套。

配合 session/state.ts,Pi 的会话是一个 append-only 账本

  • 每条 mutation 带一个单调递增的 sequenceapplyMutation 是唯一的写路径;
  • 序号不连续 → invalid("has non-consecutive seq");id 重复 → invalid("contains duplicate id");entry 的 parent 不接在 lane 的 leaf 上 → invalid("does not chain to the lane leaf")
  • 每次写入都进 loggetLog 可以返回完整操作日志——整场会话可重放
  • Token 和成本是从 usage 记录推导出来的统计值cachedTokens / totalTokens / costTotal),账本里记的是明细,汇总只是视图。

最硬的一条来自 harness-v2 的 Goal:

No partial outcomes. A crash inside any operation — run, compaction, navigation — leaves one of two states: the operation has not happened, or recovery can complete it. Nothing in between is observable.

任何操作(run / compaction / navigation)崩溃后,只有两种可观测状态:操作还没发生,或者恢复可以完成它。中间态不可观测。

这条是"记录"支柱的极致:它把"半途而废"从状态空间里删掉了。 要么没发生,要么能被恢复完成,中间的一切都通过意图记录 + 幂等 id 变成"未发生"或"待完成"。

四、会话:树 + 车道 + 全局事实,四类状态共享一个序号

harness-v2.md 第二节直接定义了 session 的四部分持久状态:

A session has four durable parts:

  1. The tree — the conversation. Entries with parentId links… The tree is shared and passive. It belongs to no lane. It only grows; entries are never changed or deleted.
  2. Lanes — where work happens. A lane is a name plus a leaf…
  3. Lane records — the lane’s current total configuration plus what happened and what must happen…
  4. Global facts — session-scoped values where the latest write wins: the built-in session name and entry labels, plus string-keyed application facts…

四部分共享一个单调序号,图长这样:

tree (shared, append-only)          lanes
a ── b ── c ── d                    main            → d   (total config; op log: …)
      └── e ── f                    slack:171943…   → f   (total config; op log: …)

global facts: name = "Refactor auth", label(b) = "checkpoint-1"

几个值得注意的架构决策:

  • 树是共享的、被动增长的,只存对话;车道配置、编排状态、指针一律不进树。删掉所有操作日志,剩下的仍是一段完整有效的对话。
  • 车道(lane)像 git 分支,文档原话:“It resembles a git branch in its own worktree: new work advances it, and navigation moves it to any existing entry without rewriting history.” 每条车道同一时刻最多一个进行中的操作,多开就是 corruption。
  • 车道之间可并行,但整个 session 保持单一写入者(single writer),由 serving 层保证。多进程并发写一个 session 被明确列为 non-goal。
  • 上下文只允许尾增长,这是一条关于 KV cache 的硬不变量:

Across the requests of a lane, provider context only grows at the tail. An insertion before the previous request’s tail invalidates the provider’s KV cache from that point on and multiplies token cost.

所以回合中途的写入(deferred writes)会推迟到 checkpoint 再落地——保证追加都在尾巴上。compaction 是唯一一次刻意的缓存失效,用一次失效换一个更小的上下文。

  • 车道生命周期是显式的状态机Idle → Running → Cancelling → Idle,崩溃恢复后进入 Suspendedresume() 续跑,abort() 走取消对账。resume 不用程序计数器:“Resume continues, but never starts, an operation and uses no persisted program counter.”——恢复是重放记录、落到第一个未完成转移,而不是"从第 N 步继续"。

五、看见:类型化 telemetry 契约 + 会话即成本账本

Pi 的可观测性分成两层,harness-v2.md 一句话划清了职责:

Events observe execution and cannot change it. Hooks intercept execution and can change it.

事件只观察,钩子才能干预。 事件流本身在 events.ts,harness 层最简形态是 run_start / run_end(带 lanerunIdoutcome: completed|aborted|failedleafId)。

真正成体系的是 telemetry-schema.md——一套生成式的、类型化的 span 契约,等价于给整条执行链路定义了 OpenTelemetry 协议。摘几个关键的:

Span含义
pi.harness.run一次被接受的 run 调用,带 pi.operation.idpi.operation.recovery、outcome
pi.harness.turn一次 assistant 响应 + 它的整个工具批次
pi.harness.step一次持久化重试尝试(assistant/compaction/branch_summary),带 attempt 序号,outcome 含 retry/deferred/overflow
pi.harness.tool一次工具执行,带 pi.tool.replaynever/safe)、pi.tool.recovery
pi.harness.checkpoint一次 run 检查点(normal/failure_drain/abort_reconcile
pi.ai.request一次 provider 请求,带全套 usage token、pi.ai.usage.costtime_to_first_chunk_ms
pi.session.write一次已提交的 session mutation,带 pi.session.seq

注意几个细节:pi.harness.tool 的 start 属性里就有 replay 字段——工具自己声明"我这个操作能不能安全重放",这直接服务于恢复逻辑;pi.ai.requesttime_to_first_chunk_ms,TTFB 是 telemetry 的一等字段。而且这些是契约 + 参考适配器 + 一致性测试(pi-telemetry 的定位),各个后端可以按契约自己接。

所以"看见"在 Pi 里有两条腿:一条是面向外部的类型化 trace,一条是会话本身就是完整账本——每一分钱、每一个 token 都长在 session 里,/session 直接显示 message count / tokens / cost。


Part 2 · 边界三支柱

六、授权:无内建策略引擎,只留一个缝 + 一次性信任闸门

这是 Pi 和总纲框架差异最大的一支柱。总纲把 PolicyEngine(用户是谁 / 有没有权限 / 高不高危 / 要不要审批 / Schema / 限额)放在 ToolGateway 里,作为硬门卫。Pi 明确不做这个。

循环里留了一条授权缝——before_tool 钩子可以拦截任意工具调用,官方示例原样:

const off = harness.hooks.on("before_tool", async (event) => {
  if (event.toolName === "bash") return { block: { reason: "not allowed" } };
});

也就是说,授权能力是存在的(钩子可以 block),但默认不内置任何策略。安全立场写在 security.md 开头:

Pi is a local coding agent. It runs with the permissions of the user account that starts it, and it treats files writable by that user as inside the same local trust boundary.

Pi 是一个本地编码 Agent。它以启动它的用户账户的权限运行,并把该用户可写的文件视为同一本地信任边界内。

Pi 唯一的"内建授权"是 project trust——一个一次性闸门,只决定"要不要加载项目本地资源(.pi/settings.json、扩展、skills 等)",默认值 "ask",决策存 ~/.pi/agent/trust.json。文档对它的定位写得很清醒:

Project trust is only an input-loading guard. It prevents a repository from silently changing pi’s settings or extensions before you approve it. It does not make untrusted code, untrusted prompts, or untrusted model output safe.

翻译:trust 只防"仓库悄悄改你的配置",它不构成任何操作级授权。 模型真正执行什么,默认全凭 OS 用户权限。

七、隔离:真正的边界来自 OS/虚拟化,不来自进程内

security.md 有一段值得整段背诵的话:

Pi does not include a built-in sandbox. Built-in tools can read files, write files, edit files, and run shell commands with the permissions of the pi process.

A partial in-process sandbox would be easy to misunderstand as a security boundary while still depending on the host shell, filesystem, package managers, credentials, and extension code. Real isolation needs to come from the operating system or a virtualization/container boundary.

一个进程内的半吊子沙箱很容易被误解成安全边界,但它仍然依赖宿主 shell、文件系统、包管理器、凭证和扩展代码。真正的隔离必须来自操作系统或虚拟化/容器边界。

这是 Pi 对"隔离"最硬核的判断:在进程内做一个看起来像沙箱的东西,比没有更危险——因为它给你虚假的安全感。 模型能操纵 shell、文件系统、包管理器、读取凭证,这些全都在进程边界之内,一个纯 JS 层的"沙箱"挡不住任何一个。

于是隔离被整体推到了进程外,containerization.md 给出三种模式:

模式隔离什么适用
Gondolin内建工具 + ! 命令路由进本地 Linux 微 VM,认证留在宿主想要工具级隔离又不想把 API key 带进容器
Plain Docker整个 pi 进程装进容器最简单的本地容器边界(API key 会进容器)
OpenShell整个 pi 进程进策略沙箱,含文件/进程/网络/凭证/推理控制本地或远程的托管沙箱

对不可信输入的官方建议,security.md 也写死了:跑在容器/VM/远程沙箱里,只挂载必要路径、传最小凭证、限制网络、结果拷回前先 review diff

八、决策:循环内没有审批弹窗,人的决策被挪到三处

总纲里"人负责高风险决策"对应的是审批工作流(生成凭证 → 用户确认 → 放行执行)。Pi 默认 YOLO 模式,循环里没有任何审批弹窗。

但"人"没有消失,人的决策被挪到了三个更靠前/更靠后的位置:

  1. 进入工作目录时的一次性信任选择ask / always / never)——你决定"这个项目能不能改我的配置";
  2. 部署时选择要不要容器化、用哪种隔离——你决定"这个任务的信任边界画在哪";
  3. 事后 review diff——你决定"结果要不要拷回可信系统"。

外加 --approve / --no-approve 对单次运行做覆盖。也就是说:Pi 把"人决策"从"操作中实时审批"改成了"进入前选择信任模式 + 边界部署 + 事后审查"。 对本地编码场景,这个取舍很顺滑;但对"多租户 SaaS 收到不可信输入"的场景,这条路走不通(见第四节)。


Part 3 · 为什么这么设计

九、极简主义的三笔账

Pi 把三个治理支柱推出循环,背后是三道算得过来的账:

账一:每个内建能力都在吃上下文。 上下文只能尾增长、KV cache 只能一次失效(compaction),所以系统提示词里每多一段工具描述、每多一条规则,都是在跟用户的真实内容抢窗口。内建权限弹窗 = 模型必须理解"什么操作要弹窗" = 多一套上下文和一套失败模式。Pi 的选择:把这些从循环里拿走,让系统提示词保持在极小量级。

账二:每个 in-loop 治理环节都是新的失败模式。 一旦你承诺"no partial outcomes"(任何崩溃都没有中间态),那么循环里的每一个会打断流程的环节——审批等待、权限弹窗——都需要一整套对应的持久化恢复逻辑。审批弹窗意味着"run 被挂起等人工",而这个"挂起"状态必须在崩溃后还能恢复。功能越少,持久化状态机越简单,越敢承诺 no partial outcomes。 Pi 用删功能换来了一个可以严格验证的恢复模型。

账三:半吊子安全比没有更糟。 security.md 那句"process-internal sandbox 会被误解为安全边界"是最锋利的观察。安全边界的价值取决于它的完整性,一个 JS 层的"沙箱"既挡不住 shell,又让你放松警惕。把边界挪到 OS/容器,是承认"只有完整边界才算边界"。

十、对照总纲:八支柱的两种摆法

支柱总纲的摆法(循环内治理)Pi 的摆法(边界治理)
判断模型建议,Context Compiler 管喂什么同,但系统提示词极简、skill 按需读取
授权ToolGateway → PolicyEngine → 审批before_tool 钩子留缝,默认交给 OS 权限
执行UseCase → Domain,风险分级四工具 + Schema 校验 + length 截断全拒
记录Receipt + AgentTrace + Replay写意图再执行 + append-only 账本 + no partial outcomes
会话AgentRun / Checkpoint / Resume树 + lane + 全局事实 + 版本化 JSONL
隔离内建(文件级/工作树)容器/微 VM/策略沙箱,进程外
决策高风险操作审批,人在环中进入前信任选择 + 边界部署 + 事后 review
看见Trace/Span/Metric + 成本落库类型化 telemetry 契约 + 会话即账本

两种摆法都能自洽,分歧在信任边界画在哪。 总纲假设"agent 要面对多用户、多租户、可能不可信的输入",所以治理必须内建、必须结构上不可绕过;Pi 假设"agent 在你自己信任的目录里、用你自己的凭证、替你写你自己的代码",所以治理可以外包给操作系统和部署层。

对照着看,最有价值的收获其实是一条判断准则:

信任边界在哪,治理就放哪。 如果 agent 的运行环境本身可信(本地、单用户、自管凭证),把授权/隔离/决策推到边界是省力且正确的;如果运行环境不可信(多租户、收外部输入、凭证不是你一个人的),治理必须进循环、且必须结构上拦得住。

十一、什么时候不能照抄 Pi

Pi 的"无内建权限 + 无内建沙箱 + 无审批弹窗"建立在一个前提下:agent 在可信边界内工作,模型能触碰的都是"这个用户本来就写得动"的东西。 有三个场景照抄会出事:

  1. 多租户 SaaS:用户的 prompt 是外部输入,工具副作用跨租户边界。这里必须有 PolicyEngine 级别的授权、按租户隔离的数据面、以及高风险操作的结构化审批——正是总纲第三节那套,Pi 的做法不适用。
  2. 处理不可信代码/文档的自动化:security.md 自己承认,“Prompt injection from repository files, comments, documentation, context files, or build output is expected local-agent risk and cannot be reliably prevented by pi.” 如果 agent 的输入里混着不可信内容、且会自动执行,你需要的是容器级隔离 + 只读挂载 + 结果人工闸门(Pi 的 containerization 文档给了这套),而不是把 agent 直接放出来跑。
  3. 强一致 / 可追责场景:Pi 把"多进程写一个 session"明确列为 non-goal,single writer 由 serving 层保证。你需要横向扩展会话、或者对每一次工具副作用做精确审计时,得自己补这层。

反过来,如果你在做本地开发工具、个人助手、单租户内部系统,Pi 的摆法值得抄:内核只留执行系五支柱,治理全推到边界,复杂度会低一个数量级。


收束

回到开头的提问:Pi 符合八支柱吗?

符合,但它把八根柱子摆在了两个不同的平面上。 判断、执行、记录、会话、看见做进循环内核,用最小的系统提示词、四个工具、写意图再执行的账本、树形会话和类型化 telemetry 撑起一个可以严格验证、崩溃可恢复的执行系统;授权、隔离、决策被清醒地推出循环,交给操作系统和部署层,并且文档里把每一步取舍的理由都写明白了——尤其是那句"半吊子沙箱比没有更危险"。

它和总纲框架的分歧,最后收敛成一个关于信任边界的判断题,而这道题没有标准答案,只有适用条件。

一个 Agent 的治理密度,应当等于它的信任边界倒数的平方——边界越不可信,治理越要进循环;边界越可信,越可以把治理推出循环,换一个极简的内核。


源码与文档出处(均取自 earendil-works/pi 仓库 main 分支):

  • packages/agent/src/agent-loop.ts — 工具调用循环、beforeToolCall 拦截、length 截断全拒
  • packages/agent/src/harness/system-prompt.ts — skills 不内联
  • packages/agent/src/harness/tools/index.ts — 内建四工具
  • packages/agent/src/harness/session/state.ts — append-only 账本、seq 校验
  • packages/agent/docs/harness-v2.md — Durable AgentHarness 设计(durability rule、no partial outcomes、lanes、resume、append-only context、hooks 目录)
  • packages/agent/docs/telemetry-schema.mdpi.harness.* / pi.ai.request / pi.session.write span 契约
  • packages/agent/src/harness/events.ts — run_start / run_end 事件
  • packages/coding-agent/docs/security.md — 安全立场、project trust、无内建沙箱
  • packages/coding-agent/docs/containerization.md — Gondolin / Docker / OpenShell 三种隔离模式
  • packages/coding-agent/docs/session-format.mdsessions.mdcompaction.md — 会话树、版本化 JSONL、compaction 检查点
Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐