LLM 层与 Pi 内核
第 1 周你看清了消息怎么流转、配置怎么塑造助理。第 2 周进入 Agent 大脑——就是消息旅程第④站里那个"想+做"。第一课要打破一个错觉:OpenClaw 自己并不写 LLM 调用循环——它内嵌了第三方 Agent 框架 Pi,自己做"编排外壳"。理解这层关系,本周后面(故障转移、执行循环、提示词、记忆)全通。
一个反直觉的事实
你可能以为 OpenClaw 里有一大段"调 OpenAI/Anthropic API + 解析工具调用 + 循环"的代码。其实没有。真正的 LLM 调用和 ReAct 循环在第三方库 Pi(@mariozechner/pi-agent-core / pi-ai / pi-coding-agent,见 package.json:352-355)里。
Pi 是什么
Pi 是一个 TypeScript 的编码 Agent 框架,分三个包:
pi-ai:底层 LLM 传输(streamSimple真正发请求)。pi-agent-core:Agent 会话、消息类型、StreamFn接口。pi-coding-agent:编码工具(read/write/edit/grep…)+ ReAct 循环session.prompt()。
OpenClaw 通过 src/agents/pi-embedded-runner/("内嵌 Pi 运行器")来驱动它。入口 createAgentSession(attempt.ts:1824):
import { streamSimple } from "@mariozechner/pi-ai";
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
({ session } = await createAgentSession({
cwd: resolvedWorkspace, agentDir,
authStorage, modelRegistry, model: params.model,
tools: builtInTools, customTools: allCustomTools, // ← OpenClaw 注入的工具
sessionManager, settingsManager, resourceLoader,
}));
createAgentSession,Pi 负责跑循环。OpenClaw 的活儿是"准备好这些输入 + 观察输出"。streamFn 抽象
Pi 用一个 StreamFn 函数接口抽象"怎么和模型对话"。OpenClaw 把不同的传输实现挂到 session.agent.streamFn 上(attempt.ts:1879-1905)。
streamFn——像一个标准插座。OpenClaw 按当前用的是哪个 provider,往这个插座里插不同的"插头"(OpenAI WebSocket 插头、Ollama 插头、默认插头)。这就是我们反复见到的接口/实现分离:Pi 定义接口,OpenClaw 提供实现。function talk(msg){ return callOpenAI(msg) }——只能用 OpenAI,加个日志/修个参数就得改这个函数。真实版把它拆成"选插头 + 套装饰器":换 provider 只改选插头那步,加能力只加一层装饰器壳,核心 talk 永不动。多出来的每一层,都是为了"不改核心也能扩展"。Provider 分流
if (params.model.api === "ollama") {
activeSession.agent.streamFn = ollamaStreamFn; // 本地 Ollama 原生 API
} else if (params.model.api === "openai-responses" && params.provider === "openai") {
activeSession.agent.streamFn = createOpenAIWebSocketStreamFn(wsApiKey, ...); // WebSocket
} else {
activeSession.agent.streamFn = streamSimple; // Pi 默认(覆盖大多数 provider)
}
streamSimple。大多数 provider(Anthropic、Google…)都由 streamSimple 统一处理,只有需要特殊传输的才单独分支。model.api = "ollama"(本地跑模型)→ 插 ollamaStreamFn(走本机原生 API)。• 配 OpenAI 的
openai-responses → 插 createOpenAIWebSocketStreamFn(WebSocket 传输)。• 配 Anthropic Claude / Google Gemini → 都落到
else 分支,插 Pi 自带的 streamSimple(一个插头覆盖大多数家)。装饰器中间件
选好 streamFn 后,OpenClaw 用一串"包裹函数"层层增强它(attempt.ts:1925-2059):
// 每一层都接收上一层的 streamFn、返回一个增强版 streamFn
streamFn = cacheTrace.wrapStreamFn(streamFn); // 缓存命中跟踪
streamFn = wrapStreamFnTrimToolCallNames(streamFn); // 修剪工具名
streamFn = wrapStreamFnRepairMalformedToolCallArguments(streamFn); // 修复畸形工具参数
// …… Ollama num_ctx 修正、xAI 参数解码、Anthropic payload 日志
streamFn 加一个能力(修工具参数、记日志、跟踪缓存),最外层还是一个普通 streamFn,Pi 循环照常调用、浑然不觉。这是函数式编程里极常见的"中间件/装饰器"手法——把横切关注点(日志、修复、缓存)和核心逻辑解耦。模型选择
用哪个模型?入口 runEmbeddedPiAgent(run.ts:256)在 run.ts:299-300 定 provider/model(默认值 src/agents/defaults.ts)。插件钩子可在解析前改写:
// before_model_resolve 钩子可返回 { providerOverride, modelOverride }
// resolveModel(provider, modelId, agentDir, config) → { model, authStorage, modelRegistry }
resolveModel(model.ts)把"provider 名 + 模型 id"解析成真正的模型对象 + 鉴权存储 + 模型注册表。插件可以通过 before_model_resolve 钩子拦截并换模型(Day 12 讲扩展)。模型目录在 models-config.*.ts / model-catalog.ts。插件式 Provider
Provider 既有内建(anthropic/openai/google…),也能由扩展动态注入。比如 extensions/ollama/index.ts 的 register 只做一件事:
register(api) {
api.registerProvider({ id, label, auth, discovery, wizard, onModelSelected });
}
api.registerProvider 即可。由 src/plugins/providers.ts + provider-discovery.ts 汇入模型配置。这是"开闭原则":对扩展开放、对修改关闭。Day 12 详讲扩展系统。今日小结 + 动手
🧠 今天你应该能回答
- OpenClaw 自己写 LLM 循环吗?真正的循环在哪?
- Pi 的三个包各管什么?
- streamFn 是什么抽象?为什么要分 provider 挂不同实现?
- 装饰器中间件怎么增强 streamFn?举两个例子。
- 怎么加一个新的模型 provider(不改核心代码)?
✋ 动手
cd /Users/bitmart/work/codes/github/openclaw
grep -n 'pi-agent-core\|pi-ai\|pi-coding-agent' package.json
sed -n '1870,1910p' src/agents/pi-embedded-runner/run/attempt.ts # streamFn 分流
grep -n 'wrapStreamFn' src/agents/pi-embedded-runner/run/attempt.ts | head