runEmbeddedPiAgent 逐行走读

这是 OpenClaw 源码解析系列第三篇。上一篇我们俯瞰了 Agents 模块的四层架构,这一篇聚焦**唯一入口函数 runEmbeddedPiAgent**,逐行走读它的每一条关键代码路径。


第一步:调用链

用户在 Telegram 发消息 "帮我总结"


auto-reply/reply/agent-runner-execution.ts(第 1025 行)

result = await runEmbeddedPiAgent({
sessionId, ← 会话 ID
sessionKey, ← 会话标识
provider,"openai"
model,"gpt-4o"
prompt, ← 用户消息文本
trigger: "user",
...
});

也可以是 cron 定时触发:

cron job 到期


cron/isolated-agent/run-executor.ts(第 160 行)

result = await runEmbeddedPiAgent({
trigger: "cron",
...
});

两条路径最终汇入同一个函数。


函数签名

// pi-embedded-runner/run.ts:207
export async function runEmbeddedPiAgent(
params: RunEmbeddedPiAgentParams,
): Promise<EmbeddedPiRunResult> {

关键参数:

参数 来源 含义
sessionId 配置 / 自动生成 一眼能看懂的会话名字
sessionKey 配置 / 自动生成 内部唯一标识
provider 配置 "openai" / "anthropic" / "ollama"
model 配置 "gpt-4o" / "claude-opus-4-6"
prompt 用户的消息 要发给 LLM 的文本
trigger 调用者 "user" / "cron" / "heartbeat"
images 用户附件 图片文件路径列表
abortSignal 调用者 用于取消执行
lane 调用者 走哪个 global lane(默认 main

六个阶段总览

┌─────────────────────────────────────────────────────────────┐
│ 时 序 图 │
│ │
│ t0 接收参数,解析 lane │
│ t1 排队:sessionLane → globalLane │
│ t2 准备:workspace, plugins, provider, model, auth │
│ t3 ┌─ 主循环 while(true) ──────────────────────────┐ │
│ │ t4 构建 prompt + 检查是否需要 compaction │ │
│ │ t5 调 LLM API → 流式响应 → 订阅处理 │ │
│ │ ├── 如果是文本:流式输出,写入 transcript │ │
│ │ └── 如果是 tool call:执行工具,返回结果 │ │
│ │ t6 如果异常:failover 重试(换 key / 换模型) │ │
│ │ t7 如果 tool call 了 → 回到 t5 │ │
│ │ t8 如果 done → 退出循环 │ │
│ └──────────────────────────────────────────────┘ │
│ t9 返回结果给调用者 │
└─────────────────────────────────────────────────────────────┘

阶段 t0–t1:lane 排队(20 行代码,精妙设计)

// run.ts:221-257
const sessionLane = resolveSessionLane(params.sessionKey); // "session:abc123"
const globalLane = resolveGlobalLane(params.lane); // "main" / "cron" / "nested"

const enqueueSession = (task) => enqueueCommandInLane(sessionLane, task);
const enqueueGlobal = (task) => enqueueCommandInLane(globalLane, task);

throwIfAborted(); // 先检查是否已经被取消

return enqueueSession(() => { // ← 第一道门:同一 session 串行
throwIfAborted();
return enqueueGlobal(async () => { // ← 第二道门:全局并发控制
throwIfAborted();
// ... 真正的执行从这里开始 ← 只有两层排队都拿到了"令牌"
});
});

为什么要双层排队?

sessionLane ("session:abc123")            globalLane ("main")
┌─────────────────────────────┐ ┌─────────────────────┐
│ 只有 1 个并发 │ │ 只有 4 个并发 │
│ 同一个人发 3 条消息 → 必须串行│ ──→ │ 4 个人同时聊天 → │
│ 保证第一条回复完才处理第二条 │ │ 最多 4 个同时处理 │
└─────────────────────────────┘ └─────────────────────┘

排队的不是 "WebUI 请求""health check" ——
排队的只是 "runEmbeddedPiAgent 的真正执行代码"

类比:session lane = 你家的门,只能一个人进;global lane = 小区大门,最多 4 个人同时在小区里。


阶段 t2:准备(约 200 行)

// 260–294 行:workspace + plugins + model 初始化

// 1. 解析 workspace 目录
const resolvedWorkspace = resolveRunWorkspaceDir({...});

// 2. 加载插件运行时
ensureRuntimePluginsLoaded({ config, workspaceDir });

// 3. 确保模型清单文件存在
await ensureOpenClawModelsJson(config, agentDir);

// 4. 让 hooks 有机会修改 provider/model
const hookSelection = await resolveHookModelSelection({...});
provider = hookSelection.provider; // hooks 可能重定向到另一个模型
modelId = hookSelection.modelId;
// 322–383 行:真正解析模型 + 构建认证

// 5. 解析模型
// → 查插件注册表:"openai" → 找到 openai extension
// → 构建 API client:endpoint URL, headers, transport
const { model, authStorage, modelRegistry } = await resolveModelAsync(
provider, modelId, agentDir, config
);

// 6. 准备 auth profile 列表(API key 轮换)
// → 如果有多个 key,按顺序排好
// → 第一个 key 失败了自动换下一个
const profileCandidates = resolveAuthProfileOrder({...});

阶段 t3–t8:主循环 while(true)

// 第 633 行
while (true) {

// === 防护 ===
if (runLoopIterations >= MAX_RUN_LOOP_ITERATIONS) {
// 超过最大重试次数 → 报错退出
return handleRetryLimitExhaustion({...});
}
runLoopIterations += 1;

// === t4: 构建 prompt ===
// 把重试指令拼进去(比如"上次你只调用了 tool 没有回复文本,请给出完整回复")
const prompt = [basePrompt, planningOnlyRetryInstruction, ...].join("\n\n");

// === t5: 执行一次尝试 ===
const attempt = await runEmbeddedAttemptWithBackend({...});
// 这一步里面:
// 1. 构建系统提示词
// 2. 加载会话历史(从 transcript 文件)
// 3. 加载工具清单(经过 policy pipeline 过滤)
// 4. 调用 LLM API
// 5. subscribeEmbeddedPiSession() → 处理流式响应

// === t6: 处理结果 ===
if (attempt 异常) {
→ failover 逻辑(换 key → 重试 / 换模型 → 重试 / 退避等待)
continue; // 回到 while 顶部
}

if (attempt 需要 compaction) {
compact() → 生成摘要 → 替换历史
continue; // 回到 while 顶部
}

if (attempt 只有 tool calls 没有文本) {
→ 设置 "请给出文字回复" 指令
continue; // 回到 while 顶部,重试
}

// === t8: 成功 ===
→ 写入 transcript
→ 调用 hooks (after-agent-start)
return 结果;
}

关键概念速查

概念 代码位置 一句话
双层排队 run.ts:221–257 session lane 串行 + global lane 并发
Hook 模型重定向 run.ts:310–318 resolveHookModelSelection 可以中途换模型
Auth profile 轮换 run.ts:348–383 多个 API key 排队,失败换下一个
主循环 while(true) run.ts:633 每次迭代 = 一次 LLM 调用 + 结果处理
attempt run.ts:692 实际调 LLM API 的那一步,包含构建 prompt / tools / 历史
failover 循环后半 任何异常都有一套重试逻辑(换 key / 换模型 / compaction)

六个阶段的本质

把复杂代码拆开看,runEmbeddedPiAgent 做的无非六件事:

  1. 排队 — 保证不互相踩脚(session 串行 + global 并发)
  2. 准备 — 找对 workspace、插件、模型、API key
  3. 循环 — 允许失败重试,这是 Agent 健壮性的关键
  4. 构建 — 每次重试可能带不同的 prompt 和参数
  5. 调用 — 实际对话 LLM,处理流式响应和工具调用
  6. 恢复 — 任何异常都有一套兜底策略

理解了这六个阶段,run.ts 的几百行代码就不再神秘。


下一步

这一篇我们走完了 runEmbeddedPiAgent 的主流程。但还有两个关键环节没展开:

  • runEmbeddedAttemptWithBackend 里面发生了什么? —— 系统提示词怎么构建、工具清单怎么加载、LLM API 怎么调用
  • subscribe 管道怎么处理 LLM 流式响应? —— 事件驱动的 pipeline 如何把 token 逐个推送到 WebUI

下一篇将从这两个方向中选一个深入。敬请期待。


本系列文章基于 OpenClaw v2026.4.23 源码分析,代码引用以实际文件行号为准。