Day 08 / 共 20 天 · 第 2 周 Agent 大脑

Agent 执行循环

昨天讲了模型挂了怎么自动改道。今天回到正常路径:一条消息怎么变成 LLM 调用 → 工具调用 → 回复?ReAct 循环在 Pi 里,但 OpenClaw 负责"准备输入 + 观察输出"。今天走完从入站到回复的完整链路,为明天讲"喂给模型的提示词到底长什么样"铺路。

📍 你在整门课的位置(第 2 周 · Agent 大脑)
W1 全景· Day6 Pi 内核 Day7 故障转移 Day8 执行循环 Day9 提示词 Day10 记忆· W3 技能/渠道
L01

ReAct 在哪

🤔 痛点:大模型只会"说",怎么让它"做事"? 你问"北京天气?",模型脑子里没有实时天气——它只会生成文字。要它真去查、真去办事,就得让它能"喊工具"、拿到结果后再接着想。这个"想一步、动手、看结果、再想"的循环,谁来跑?
💡 本质:ReAct 循环 = 带一个能干的实习生 你交代任务给实习生:他想一步(我得查天气)→ 动手(调 weather 工具)→ 看结果(晴 25°C)→ 再想(够了,可以答了)→ 交活。这个循环由 Pi 发动机跑(Day 06),OpenClaw 只管两端:进循环前把资料备齐(提示词/工具/历史),循环里旁听实习生每一步动作。今天全程用"实习生干活"这个类比。

ReAct 循环(LLM 输出 → 有工具调用就执行 → 结果回灌 → 再调 LLM → 直到没有工具调用)在 Pi 库内部,由 session.prompt() 驱动。OpenClaw 不重写它,只"喂输入、看输出"。

回顾 ReAct(Day 06 讲过发动机在 Pi) ReAct = Reasoning + Acting,模型"边想边做":想一下 → 要调工具就调 → 看结果再想 → …… → 觉得够了给最终答复。这个循环是 Pi 发动机的活儿。OpenClaw 的活儿在循环的"两端":进循环前准备好模型/工具/提示词/历史,循环跑起来后订阅它吐出的每个事件(文字增量、工具调用、用量)转成自己的回调。理解"OpenClaw 管两端、Pi 管中间",这一天就通了。
L02

完整链路

  1. 入站消息经渠道层 → auto-reply → agent-runner-execution.ts 组装运行参数。
  2. runWithModelFallback(外层,Day 07)→ runEmbeddedPiAgentrun.ts:256,内层 + 会话排队)。
  3. runEmbeddedAttemptattempt.ts:1369)——单次尝试:修复会话 → 建系统提示 → createAgentSession + 挂 streamFn → session.prompt() 触发循环。
  4. subscribeEmbeddedPiSessionattempt.ts:2187)订阅事件流,转成 onPartialReply/onToolResult 等回调。
  5. usage 累加回 run.tsUsageAccumulator)。
读法:Day 07 的两层 failover 在最外面,本课的 attempt/prompt/subscribe 是"一次尝试"内部的事。一次尝试失败 → Day 07 的循环换 profile/模型再来一次 attempt。层层嵌套但职责清晰。
俄罗斯套娃:一层套一层,最里面才是"想+做" runWithModelFallback(Day 07 · 挂了换模型/profile) runEmbeddedPiAgent(enqueueSession 会话串行排队 · run.ts) runEmbeddedAttempt(体检+修复会话/建提示 · attempt.ts:1369) session.prompt() ← Pi 的 ReAct 循环(想→调工具→看结果→再想) attempt.ts:2460 · OpenClaw 只"喂输入" subscribeEmbeddedPiSession 在旁"看输出"(订阅事件流)
图注:failover 在最外层,最里层的 session.prompt() 才是真正的"想+做";OpenClaw 管最内层的两端(喂输入 / 看输出)。
📝 举个例子:一条"北京天气?"怎么穿过这几层 入站 → 组装参数(agent-runner-execution.ts)→ 进 runWithModelFallback(先用 gpt-5,挂了才换)→ 进 runEmbeddedPiAgent(若你同时又发一条,它排队等这条跑完)→ 进 runEmbeddedAttempt(打开会话、拼系统提示、修好脏历史)→ 执行 session.prompt("北京天气?"):Pi 里模型想"要查天气"→调 weather 工具→拿到"晴 25°C"→再想"够了"→出文字。全程 subscribe 把"文字增量/工具结果/token 用量"转成回调推给渠道。输出 "北京今天晴,25°C"
L03

会话串行排队

runEmbeddedPiAgentenqueueSession/enqueueGlobalrun.ts:275-276)把运行串行化。

为什么要排队? 同一个会话的状态(消息历史)存在文件里。如果用户连发两条消息、两个 Agent 运行同时去读写同一个会话文件,就会写坏(竞态)。解决办法:同一会话的运行排成一队,一个跑完再跑下一个(enqueueSession);某些全局资源还有全局队列(enqueueGlobal)。这和 Day 07 SuperAGI 的"状态存 DB"、LangGraph 的"检查点"一样,都是在解决"Agent 状态并发安全"这个共性问题——只是 OpenClaw 用"会话级串行队列"来解。

👶 小白:那我连发三条消息,助理岂不是要等半天才理我?

👨‍🏫 老师:排队只针对同一个会话——你和助理这一条对话线里的消息按顺序处理,保证它不会把你第 2 句的答复写进第 1 句的历史里(写坏存档)。但不同会话之间是并行的:你在 Telegram 私聊 和 你在某个群里,是两条独立的队,互不阻塞。就像银行柜台:同一个人的业务得一笔一笔办(防止账目错乱),但多个柜台可以同时服务不同的人。

L04

一次尝试 attempt

runEmbeddedAttemptattempt.ts:1369)在真正调模型前做一堆"准备+修复":

  • SessionManager.openattempt.ts:1731)打开会话,修复畸形的 tool_use/tool_result 配对(sanitizeToolUseResultPairing)。
  • buildEmbeddedSystemPromptattempt.ts:1642)建系统提示(Day 09)。
  • createAgentSession + 挂 streamFn(Day 06)。
  • 修复尾部孤立 user 消息以免连续 user turn(attempt.ts:2364-2378)。
  • 加载 prompt 里引用的图片(detectAndLoadPromptImagesattempt.ts:2390)。
读法:大模型 API 对消息格式很挑剔(tool_use 必须配 tool_result、不能连续两条 user)。attempt 在调用前"体检并修复"会话,避免因历史脏数据直接报错。这些防御性修复是长期运行的 Agent 必备——历史越长越容易出现边角脏数据。
L05

prompt() 触发点

// attempt.ts:2460 —— 这一行进入 Pi 的 ReAct 循环
await abortable(activeSession.prompt(effectivePrompt, { images }));
// 无图则 attempt.ts:2462: await abortable(activeSession.prompt(effectivePrompt));
读法:整个"想+做"循环就浓缩在 session.prompt(...) 这一次调用里——它内部反复调 LLM、执行工具、直到收敛。abortable(...) 包裹让它可被超时/用户中止打断;yield 式中止被当成"干净停止"(attempt.ts:2468-2473)。这一行是 OpenClaw"喂输入"与"看输出"的交界。
L06

订阅式观察

循环跑的同时,subscribeEmbeddedPiSessionpi-embedded-subscribe.ts:34)订阅 Pi 会话事件流,把内部事件转成 OpenClaw 的回调:

// assistant 文本增量 → onPartialReply(流式打字)
// 完整消息块 → onBlockReply
// 工具调用/结果 → onToolResult
// 推理过程 → onReasoningStream
// 事件处理器分文件:pi-embedded-subscribe.handlers.{messages,tools,lifecycle,compaction}.ts
"观察者模式"——不打断循环,只旁听 OpenClaw 不去改 Pi 循环内部,而是在旁边"订阅/旁听"它发生的每件事:模型吐了一段文字 → 转成流式回复推给渠道;调了个工具 → 记录工具结果;用了多少 token → 累加计费。这就是观察者模式:循环是"广播电台",OpenClaw 是"听众",听到什么就做相应的事,但不干涉广播本身。这样 OpenClaw 既能利用 Pi 的循环,又能把结果接到自己的渠道/计费/日志体系。
L07

溢出自动压缩

对话越来越长,迟早超出模型上下文窗口。run.ts 在尝试失败后检测溢出并自动压缩重试:

// MAX_OVERFLOW_COMPACTION_ATTEMPTS = 3(run.ts:740)
// 检测到 isLikelyContextOverflowError(Day 07 的错误分类)→ 触发 auto-compaction 重试
//   (run.ts:1003-1188),压缩逻辑在 pi-embedded-runner/compact.ts
// 另有:thinking 级别回退(pickFallbackThinkingLevel)、超大工具结果截断
//       (truncateOversizedToolResultsInSession, run.ts:69)
读法:"上下文超长"不换 key(Day 07),而是把历史压缩(总结旧消息、截断超大工具结果)再重试,最多 3 次。还有 thinking 降级(高强度思考失败就降一档重试)。这些自动恢复让长对话不会因为"聊太多"而卡死——又是"长期运行 Agent"的必备能力。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • ReAct 循环在哪?OpenClaw 管哪两端?
  • 从入站到回复的完整链路 5 步?
  • 为什么要会话串行排队?
  • attempt 在调模型前为什么要"修复会话"?
  • 订阅式观察是什么模式?溢出压缩怎么触发?

✋ 动手

cd /Users/bitmart/work/codes/github/openclaw
grep -n 'activeSession.prompt' src/agents/pi-embedded-runner/run/attempt.ts
grep -n 'enqueueSession\|enqueueGlobal' src/agents/pi-embedded-runner/run.ts
grep -n 'MAX_OVERFLOW_COMPACTION_ATTEMPTS' src/agents/pi-embedded-runner/run.ts
ls src/agents/pi-embedded-subscribe.handlers.*.ts
明天预告 · Day 09提示词构造——模型到底看到什么?buildAgentSystemPrompt 怎么把技能目录、记忆召回、身份、工具清单、项目上下文一段段拼成系统提示;PromptMode 三档(full/minimal/none)。
← Day 07 故障转移 Day 09 · 提示词构造 →