OpenClaw 源码解读(17)从日志追踪 Embedded Agent Runner 执行全链路
一条 Matrix 消息从「路由入队」到「LLM 流式返回」中间到底发生了什么本文以 AgentTeams 场景下的一段真实运行日志为线索逐行定位到 OpenClaw 源码还原 Embedded Agent Runner 的完整执行链路。一、背景某天晚上我给 ClawForge 的 Code Analyst Worker 发了一条消息。Worker 很快回复了。见《openclaw源码解读15》log2.log日志内容《源码解读15》中的日志log.log的续篇日志来自 AgentTeams 多 Agent 框架ClawForge里的一个 Workercode-analyst运行时是 OpenClaw日志分两段log.log消息从 Matrix 通道进来 → 路由 → 入队 → 触发 hooks《源码解读15》、《源码解读16》的日志内容log2.logEmbedded Agent Runner 真正启动组装 prompt、调 LLM、流式返回《源码解读17》的日志内容log2.log 是 log.log 的续篇——log.log 停在「路由 消息入队 reply_dispatch 钩子」log2.log 从这里接着往下走进入Embedded Agent Runner 真正调 LLM 的执行链路对应你四课体系里的第三、四课。时间上 log.log 最后是04:39:16.5log2.log 从04:39:18.7开始中间那 ~2 秒就是 prompt 组装阶段。二、全景一条消息的生命周期Matrix 消息到达 │ ▼ resolve-route.ts 路由解析把消息路由到具体 agentbuildAgentSessionKey 等 │ ▼ diagnostic.ts 诊断事件message received → queued → session stateprocessing │ ▼ hooks.ts reply_dispatch 钩子面向切面runClaimingHook │ ▼ runs.ts 【Admission 并发控制】登记 active runreasonrun_started │ ▼ attempt-prompt-assembly.ts 组装 prompt 修复孤儿消息 │ ▼ preemptive-compaction.ts 上下文溢出预检fits / compact / truncate │ ▼ openai-completions-transport.ts 钳制 max_tokens │ ▼ provider-transport-fetch.ts POST 请求 → SSE 流式返回 │ ▼ AgentTeamsmc mirror 会话状态落盘 → mirror 回 MinIO三、逐行拆解从日志到源码1. 日志行1. 注入 extraParams把配置塞进请求[agent/embedded] applying extraParams to agent streamFn for agentteams-gateway/qwen3.7-plus源码src/agents/embedded-agent-runner/extra-params.ts:819function applyPrePluginStreamWrappers(ctx: ApplyExtraParamsContext): void { //... const wrappedStreamFn createStreamFnWithExtraParams( //... ctx.agent.streamFn, streamParams, ctx.provider, ctx.model, ); if (wrappedStreamFn) { log.debug(applying extraParams to agent streamFn for ${ctx.provider}/${ctx.modelId}); ctx.agent.streamFn wrappedStreamFn; } }逻辑把配置里给 provider/model/agent 三层设的采样参数temperature、topP、maxTokens、thinking 等通过createStreamFnWithExtraParams()包一层在真正调模型时作为请求options的默认值展开注入{...streamParams, ...options}请求级 options 可再覆盖。优先级请求级 agent 级 model 级 provider 默认。注意区分这里是options 展开不改 payload 对象真正改 payload 的是另一类streamWithPayloadPatchwrapperextra_body注入、store删除等在applyPostPluginStreamWrappers里。亮点日志里的provideragentteams-gateway/qwen3.7-plus说明——OpenClaw 不是直连大模型厂商而是把请求发给了AgentTeams 的 AI 网关由网关再代理到 qwen。这是多 Agent 框架常见的「网关统一鉴权/限流/路由」设计。2. 日志行2-3登记 active runAdmission 并发控制的落地2026-08-11T04:39:18.71200:00 [diagnostic] session state: sessionId... sessionKeyagent:main:main prevprocessing newprocessing reasonrun_started queueDepth1 2026-08-11T04:39:18.71200:00 [diagnostic] run registered: sessionId... totalActive1源码src/agents/embedded-agent-runner/runs.ts:832-862export function setActiveEmbeddedRun(sessionId, handle, sessionKey?, sessionFile?) { const previousHandle ACTIVE_EMBEDDED_RUNS.get(sessionId); const wasActive previousHandle ! undefined; // ... ACTIVE_EMBEDDED_RUNS.set(sessionId, handle); // ... logSessionStateChange({ sessionId, sessionKey, sessionFile, state: processing, reason: wasActive ? run_replaced : run_started, }); // ... diag.debug(run registered: sessionId${sessionId} totalActive${ACTIVE_EMBEDDED_RUNS.size}); }逻辑ACTIVE_EMBEDDED_RUNS是一个按sessionId索引的 Map每个 session 同一时刻最多一个 active run。首次登记reasonrun_started若已有 run 在跑则是run_replaced。totalActive并发 embedded run 数 Admission 并发控制的落地一个 session 同一时刻只一个 run。亮点这就是 OpenClaw 的Admission 并发控制——防止同一个 session 的多个消息并发触发多个 run 导致上下文串扰。totalActive1是全局并发 run 数。3. 日志行4组装 promptembedded run prompt start[agent/embedded] embedded run prompt start: runId... sessionId... provideragentteams-gateway apiopenai-completions endpointcustom routeproxy-like policynone源码src/agents/embedded-agent-runner/run/attempt-prompt-assembly.ts:218const routingSummary describeProviderRequestRoutingSummary({ provider: attempt.provider, api: attempt.model.api, baseUrl: attempt.model.baseUrl, capability: llm, transport: stream, }); log.debug(embedded run prompt start: runId${attempt.runId} sessionId${attempt.sessionId} ${routingSummary});逻辑进入 prompt 组装阶段。做的事情包括拼 system prompt含 model identity 行、缓存边界、启动 prompt cache 观测最后打出路由摘要。在代码L76-216行详情见《源码解读18》亮点routeproxy-like是关键——它告诉下游传输层「这是个自定义代理端点」会触发后面第 8 节 max_tokens 的特殊钳制逻辑。4. 日志行5修复孤儿消息避免连续 user turn[agent/embedded] Removed already-queued orphaned user message to prevent consecutive user turns. ... triggeruser源码src/agents/embedded-agent-runner/run/attempt-prompt-assembly.ts:232const leafEntry input.orphanRepair?.messageEntry; if (leafEntry input.orphanRepair) { const messageMergeStrategy input.orphanRepair.strategy; const orphanPromptMerge messageMergeStrategy.mergeOrphanedTrailingUserPrompt({ prompt: effectivePrompt, trigger: attempt.trigger, leafMessage: leafEntry.message, }); // ... const action input.orphanRepair.removeLeaf ? orphanPromptMerge.merged ? Merged and removed : Removed already-queued : Preserved; }逻辑当一条新 user 消息进来但会话叶子节点已经有一条「trailing 的 user 消息」时直接把它合并/移除。背景很多 LLM 的 chat 协议要求user/assistant严格交替出现两条连续的 user turn 会被拒绝。所以 OpenClaw 在组装阶段就「修复」这种结构。5. 日志行6上下文诊断[context-diag] pre-prompt[agent/embedded] [context-diag] pre-prompt: ... messages6 roleCountsassistant:3,toolResult:1,user:2 historyTextChars1539 maxMessageTextChars644 systemPromptChars26492 promptChars1711 ...源码src/agents/embedded-agent-runner/run/attempt-prompt-observability.ts:169const sessionSummary summarizeSessionContext(input.sessionMessages); // ... log.debug( [context-diag] pre-prompt: sessionKey... messages${input.sessionMessages.length} roleCounts${sessionSummary.roleCounts} historyTextChars${sessionSummary.totalTextChars} ..., );逻辑summarizeSessionContext()汇总会话上下文消息数、角色分布、文本字符数、图片块数发出context.assembled诊断事件 debug 日志。亮点几个数字很有意思——字段值解读systemPromptChars26492~26KB系统 prompt 巨长agent 的 system 配置 skillspromptChars1711—真正要发的用户 prompt 很短sessionFilesqlite:.../code-analyst/.openclaw/.../sessions.json—会话状态持久化到 worker 的本地文件系统6. 日志行7上下文溢出预检编译期预算检查[agent/embedded] [context-overflow-precheck] ... routefits estimatedPromptTokens10209 promptBudgetBeforeReserve130000 overflowTokens0 toolResultReducibleChars0 reserveTokens20000 effectiveReserveTokens20000 contextTokenBudget150000 messages6 ...源码src/agents/embedded-agent-runner/run/preemptive-compaction.ts:409let route: PreemptiveCompactionRoute fits; if (overflowTokens 0) { if (toolResultReducibleChars 0) { route compact_only; } else if (toolResultReducibleChars truncateOnlyThresholdChars) { route truncate_tool_results_only; } else { route compact_then_truncate; } }逻辑这是「编译期」的预算检查在真正发请求前估算输入 token决定要不要压缩上下文。决策公式contextTokenBudget 150000 模型上下文上限 reserveTokens 20000 预留给输出 promptBudgetBeforeReserve 150000 - 20000 130000 estimatedPromptTokens 10209 10209 130000 → overflowTokens 0 → route fits不压缩亮点这是 OpenClaw 防上下文溢出OOM的第一道防线。如果超了有三条降级路线compact_only直接压缩历史truncate_tool_results_only只截断 tool 结果因为 tool result 通常最占空间、最可压缩compact_then_truncate先压历史再截 tool result7. 日志行8生命周期事件agent start[matrix] embedded run agent start: runId...源码src/agents/embedded-agent-subscribe.handlers.lifecycle.ts:40export function handleAgentStart(ctx: EmbeddedAgentSubscribeContext) { ctx.log.debug(embedded run agent start: runId${ctx.params.runId}); emitAgentEvent({ ... stream: lifecycle, data: { phase: start, startedAt: Date.now() } }); }逻辑发出一个lifecycle流的phasestart事件标记 agent 生命周期开始。日志前缀[matrix]说明这条是从 Matrix 通道订阅链路进来的。8. 日志行9钳制 max_tokensclamp_max_tokens[openai-transport] [completions] clamp_max_tokens provideragentteams-gateway apiopenai-completions modelqwen3.7-plus requested128000 output126533 effectiveContext150000 estimatedInput23466源码src/agents/openai-completions-transport.ts:1804if (compatDetection.capabilities.usesExplicitProxyLikeEndpoint clampedMaxTokens ! undefined effectiveContextTokens ! undefined) { const estimatedInputTokens estimateOpenAICompletionsInputTokens(params); const remainingBudget Math.max(1, effectiveContextTokens - estimatedInputTokens - 1); if (clampedMaxTokens remainingBudget) { clampedMaxTokens remainingBudget; // 打出 clamp_max_tokens 日志 } }逻辑这是第二个钳制分支专门针对proxy-like端点第 3 节里的routeproxy-likeremainingBudget 150000 - 23466 - 1 126533 requested(128000) remainingBudget(126533) → 钳到 126533亮点注意estimatedInput23466比第 6 节的预检值10209大了不少——因为这里是发送前的最终估算已经包含了 tools 定义、system prompt 等所有实际会塞进请求的内容。9. 日志行10-11真正发请求[model-fetch][provider-transport-fetch] [model-fetch] start provideragentteams-gateway apiopenai-completions modelqwen3.7-plus methodPOST urlhttp://agentteams-controller:8080/v1/chat/completions ... [provider-transport-fetch] [model-fetch] response provideragentteams-gateway apiopenai-completions modelqwen3.7-plus status200 elapsedMs1247 contentTypetext/event-stream; charsetutf-8源码src/agents/provider-transport-fetch.ts:866 / 894emitModelTransportDebug(log, [model-fetch] start provider${model.provider} api${model.api} model${model.id} method${...} url${formatModelTransportDebugUrl(rawUrl)} ...); // ... fetch ... emitModelTransportDebug(log, [model-fetch] response ... status${response.status} elapsedMs... contentType${response.headers.get(content-type) ?? });逻辑真正发 HTTP 请求的地方。1.2 秒后收到status200contentTypetext/event-streamSSE 流式返回。四、核心知识点上下文预算的三次估算整条链路里最值得记住的是上下文预算的「三次估算」层层递进阶段位置估算值作用预检preemptive-compaction.tsestimatedPromptTokens10209决定要不要压缩fits/compact/truncate发送前openai-completions-transport.tsestimatedInput23466含 tools/system用于钳 max_tokens钳制openai-completions-transport.tsremainingBudget126533保证「输入 输出」不超上下文这三个数字关系预检最粗只算消息发送前最细算全量钳制是兜底强制不超过预算。这种「粗筛 → 精算 → 兜底」的防御式设计是工程上非常成熟的防 OOM 思路。五、日志行12-25番外AgentTeams 的 MinIO 数据流log2.log的尾巴有一段不是 OpenClaw 的日志而是MinIO 客户端mc的mirror输出/root/agentteams-fs/agents/code-analyst/HEARTBEAT.md - agentteams/agentteams- storage/agents/code-analyst/HEARTBEAT.md /root/agentteams-fs/agents/code-analyst/.openclaw/agents/main/agent/openclaw-agent.sqlite- wal - agentteams/agentteams-storage/...源码agentteams-controller/internal/oss/minio.go:137func (c *MinIOClient) Mirror(ctx context.Context, src, dst string, opts MirrorOptions) error { // ... args : []string{mirror, src, dst} if opts.Overwrite { args append(args, --overwrite) } _, err : c.runMC(ctx, args...) return err }逻辑AgentTeams 的 Controller 在 reconcile 时把 worker 的本地文件系统/root/agentteams-fs/agents/worker/镜像回 MinIO 的agentteams/agentteams-storage/agents/worker/。设计意图OpenClaw 的会话/记忆状态SQLite WAL落在 worker 本地文件系统AgentTeams 再把它 mirror 到 MinIO——这样 worker 是「无状态」的挂了之后可以从 MinIO 恢复重建。这是多 Agent 框架里经典的「本地状态 对象存储持久化」架构。六、总结本文从一段真实日志出发还原了 OpenClaw Embedded Agent Runner 的执行链路。核心结论入站到 run 启动是严格串行的一条dispatchInboundMessage流水线贯穿始终日志里的函数大多是「诊断探针」被主流程直接调用模块间用 hooks观察者模式解耦——函数之间没有调用关系 ≠ 多线程并行lane队列和 SQLite 锁正是串行化的证据。Admission 并发控制ACTIVE_EMBEDDED_RUNS按 sessionId 去重一个 session 同一时刻只一个 run。防御式上下文管理三次预算估算预检 → 精算 → 兜底钳制 孤儿消息修复保证请求结构合法、不超上下文。网关透明代理routeproxy-like表明 OpenClaw 可把请求发往自定义 AI 网关AgentTeams Controller而非直连厂商。状态持久化分离OpenClaw 管执行AgentTeams 管状态MinIO mirror。对做多 Agent 框架的同学这条链路里最有参考价值的是串行化设计lane 队列 Admission 控制和上下文预算的三次估算——两者都是「单 Agent 执行引擎」的精华可以直接平移复用到多 Agent 编排层。本文是《OpenClaw 源码解读》系列的一篇。ClawForge是由 AgentTeams OpenClaw 搭建的日志前缀[matrix]、[oss]、[watcher]是 AgentTeams 的那套 Go 代码ClawForge 里 Code Analyst 那个定位根因的 Agent本质干的就是这件事从日志/报错反查源码找根因。正在规划《OpenClaw源码解读》书籍欢迎出版社编辑交流