从零开始拆解Pi系列——(11)配置系统

一、引言:配置系统解决什么问题

不同用户有不同的需求------有人用 OpenAI、有人用 Anthropic、有人用自建代理;有人需要 compaction、有人不需要;有人装了 10 个扩展、有人只用内置工具;有人终端支持图片、有人不支持。配置系统让同一个 pi 二进制在不同用户/项目下表现出不同行为,不需要重新编译。

三个组件各管一件事:

组件 文件 行数 管什么
SettingsManager settings-manager.ts 1091 ~40 个配置项(模型/行为/资源路径/终端)+ 三层 merge
ModelRegistry model-registry.ts 1033 模型列表 + provider 注册 + getAvailable()
ResourceLoader resource-loader.ts 927 extensions/skills/prompts/themes 加载 + npm/git 包

启动顺序是 SettingsManager → ModelRegistry → ResourceLoader------先读配置,再用配置初始化模型注册表,最后按配置加载资源。第 5 章展开完整启动流程。

二、SettingsManager

SettingsManager 管理 pi 的全部配置项。它解决的问题是:同一个 pi 二进制,在不同用户(全局配置)、不同项目(项目级配置)、不同启动方式(CLI 参数)下,应该表现出什么行为。

1. 配置项分类

Settings 接口(settings-manager.ts:77-116)定义了 ~40 个配置项,按功能分 7 类:

模型与 Provider

字段 类型 默认值 说明
defaultProvider string - 默认 provider(如 "openai")
defaultModel string - 默认模型 ID
defaultThinkingLevel "off"/"minimal"/"low"/"medium"/"high"/"xhigh" - 默认推理等级
transport "sse"/"websocket"/"websocket-cached"/"auto" "auto" 传输方式
enabledModels string\[\] - 模型筛选(Ctrl+P 循环范围)

行为

字段 类型 默认值 说明
steeringMode "all"/"one-at-a-time" "one-at-a-time" steering 队列模式(文章 5 讲过)
followUpMode "all"/"one-at-a-time" "all" followUp 队列模式
compaction.enabled boolean true 是否开启 compaction
compaction.reserveTokens number 16384 给模型回复预留的空间
compaction.keepRecentTokens number 20000 保留最近多少 token 不压缩
retry.enabled boolean true 是否开启重试
retry.maxRetries number 3 最大重试次数
retry.baseDelayMs number 2000 指数退避基础延迟
retry.provider.timeoutMs number - provider 请求超时
retry.provider.maxRetries number - provider SDK 重试次数
branchSummary.reserveTokens number 16384 branch summary 预留空间
branchSummary.skipPrompt boolean false 跳过"是否摘要"提示

资源路径

字段 类型 默认值 说明
extensions string\[\] - 本地扩展文件/目录路径
skills string\[\] - 本地 skill 文件/目录路径
prompts string\[\] - 本地 prompt template 路径
themes string\[\] - 本地主题文件路径
packages PackageSource\[\] - npm/git 包源(含过滤)
enableSkillCommands boolean true skill 是否注册为 /skill:name 命令

终端与 UI

字段 类型 默认值 说明
terminal.showImages boolean true 是否显示图片
terminal.imageWidthCells number 60 图片宽度(终端列数)
terminal.clearOnShrink boolean false 内容缩小时清空空行
terminal.showTerminalProgress boolean false OSC 9;4 进度条
images.autoResize boolean true 图片缩放到 2000x2000
images.blockImages boolean false 阻止图片发给 LLM
editorPaddingX number 0 输入编辑器水平 padding
autocompleteMaxVisible number 5 autocomplete 最大可见项
showHardwareCursor boolean - 显示终端光标
markdown.codeBlockIndent string " " 代码块缩进
hideThinkingBlock boolean - 隐藏 thinking 块
theme string - 主题名

会话

字段 类型 默认值 说明
sessionDir string - 自定义 session 存储目录
doubleEscapeAction "fork"/"tree"/"none" "tree" 双 Esc 空编辑器时的动作
treeFilterMode "default"/"no-tools"/"user-only"/"labeled-only"/"all" "default" /tree 默认过滤模式

Shell 与命令

字段 类型 默认值 说明
shellPath string - 自定义 shell 路径
shellCommandPrefix string - bash 命令前缀
npmCommand string\[\] - npm 命令(如 "mise","exec","node@20","--","npm"

系统

字段 类型 默认值 说明
quietStartup boolean - 静默启动
collapseChangelog boolean - 折叠 changelog
enableInstallTelemetry boolean true 安装遥测
httpIdleTimeoutMs number - HTTP 空闲超时
websocketConnectTimeoutMs number - WebSocket 连接超时
thinkingBudgets ThinkingBudgetsSettings - thinking 各等级 token 预算
warnings.anthropicExtraUsage boolean true Anthropic 额外用量警告

2. 三层配置

SettingsManager 从三个来源读配置,按优先级合并(settings-manager.ts:178-179):

typescript 复制代码
this.globalSettingsPath = join(resolvedAgentDir, "settings.json");           // 全局
this.projectSettingsPath = join(resolvedCwd, CONFIG_DIR_NAME, "settings.json"); // 项目级
层级 路径 作用域 优先级
全局 ~/.pi/agent/settings.json 所有项目 最低
项目级 .pi/settings.json 当前项目
CLI 参数 pi --model xxx --steering-mode all 本次运行 最高

典型用法:

  • 全局defaultProvider / defaultModel / theme------用户级别的偏好,所有项目共享
  • 项目级extensions / skills / compaction------项目特定的资源和行为
  • CLI 参数--model / --steering-mode------临时覆盖,只影响本次运行

3. deep merge

三层配置不是简单覆盖------嵌套对象递归合并(settings-manager.ts:118-139):

typescript 复制代码
function deepMergeSettings(base: Settings, overrides: Settings): Settings {
  const result: Settings = { ...base };

  for (const key of Object.keys(overrides) as (keyof Settings)[]) {
    const overrideValue = overrides[key];
    const baseValue = base[key];

    if (overrideValue === undefined) continue;

    // 嵌套对象递归合并
    if (
      typeof overrideValue === "object" && overrideValue !== null && !Array.isArray(overrideValue) &&
      typeof baseValue === "object" && baseValue !== null && !Array.isArray(baseValue)
    ) {
      (result as Record<string, unknown>)[key] = { ...baseValue, ...overrideValue };
    } else {
      // 非嵌套对象直接覆盖
      (result as Record<string, unknown>)[key] = overrideValue;
    }
  }

  return result;
}

关键规则:

  • 嵌套对象递归合并compaction 是嵌套对象------全局设了 reserveTokens: 16384,项目级设了 keepRecentTokens: 30000,合并后两者都保留。不是"项目级覆盖整个 compaction 对象"。
  • 数组直接覆盖extensions 是数组------项目级的 extensions 完全覆盖全局的,不是追加。
  • 标量直接覆盖defaultModel 是字符串------项目级的覆盖全局的。

这让用户能在全局设基础配置(如 compaction.enabled: true),项目级只覆盖需要改的字段(如 compaction.keepRecentTokens: 30000),不用重复整个配置。

4. reload

SettingsManager 支持 reload()------重新读两层配置文件,重新 merge。/reload 命令触发(文章 7 讲过热重载)。修改 settings.json 后不需要重启 pi------/reload 即可生效。

5. transport------传输方式选择

transport 配置项控制 pi 和 LLM provider 之间的通信协议。四种值:

方式 协议 连接 特点
SSE HTTP + Server-Sent Events 每次请求新建 HTTP 连接 通用------所有 provider 都支持。但每次发完整 context,延迟高。
WebSocket WebSocket 持久连接,复用 低延迟------连接保持,每次请求不用重新握手。但只支持 OpenAI Codex Responses API。
WebSocket-cached WebSocket + context 缓存 持久连接 + 服务端缓存 最低延迟------不只复用连接,还复用 context。用 previous_response_id 告诉服务端"上次那个 context 我还要,只追加新消息"。请求体从"完整 context"缩成"增量消息"。
auto 自动选择 - 默认------优先 WebSocket-cached,失败降级 SSE

auto 模式的选择逻辑

transport: "auto"(默认)时,pi 的逻辑(openai-codex-responses.ts:244-299):

typescript 复制代码
const transport = options?.transport || "auto";
const websocketDisabledForSession = transport !== "sse" && isWebSocketSseFallbackActive(sessionId);

if (transport !== "sse" && !websocketDisabledForSession) {
    // 尝试 WebSocket
    try {
        await processWebSocketStream(...);
        return;  // 成功
    } catch (error) {
        if (aborted || isCodexNonTransportError(error)) throw error;
        // WebSocket 失败------降级到 SSE
        recordWebSocketSseFallback(sessionId);
    }
}

// SSE 降级路径

三步:

  1. 不是 "sse" 且没被 fallback 标记 → 尝试 WebSocket
  2. WebSocket 失败(连接错误、超时)→ 标记该 session 为 fallback,降级到 SSE
  3. 后续请求 → 检查 isWebSocketSseFallbackActive(sessionId),被标记的直接走 SSE,不再尝试 WebSocket

WebSocket-cached 的增量请求

"auto" 时默认启用 cached context(openai-codex-responses.ts:1315):

typescript 复制代码
const useCachedContext = options?.transport === "websocket-cached" || options?.transport === "auto";
const requestBody = useCachedContext && entry
    ? buildCachedWebSocketRequestBody(entry, fullBody)  // 增量请求
    : fullBody;                                          // 完整请求

如果 WebSocket 连接有缓存(entry 存在),用增量请求体------只发新消息,不发完整 context。没有缓存就用完整请求体。这让 "auto" 在支持 WebSocket 的 provider 上自动获得最低延迟。

fallback 机制

一旦 WebSocket 连接失败,整个 session 后续请求都走 SSE------不会每次都试 WebSocket 再失败:

typescript 复制代码
function recordWebSocketFailure(sessionId, error) {
    websocketSseFallbackSessions.add(sessionId);  // 标记这个 session
    stats.websocketFailures++;
    stats.lastWebSocketError = formatThrownValue(error);
    stats.websocketFallbackActive = true;
}

这是 session 级别的 fallback 标记,重启 session 后重试 WebSocket。

各模式对比

模式 行为 适用场景
"auto" 优先 WebSocket-cached → 失败降级 SSE → 后续不再试 默认,适合大部分场景
"websocket" 强制 WebSocket,不降级 确认 provider 支持 WebSocket,不需要降级
"websocket-cached" 强制 WebSocket + context 缓存 确认 provider 支持 cached context
"sse" 强制 SSE,不试 WebSocket 网络环境不支持 WebSocket(如某些代理)

三、ModelRegistry

ModelRegistry 管理模型列表------pi 启动时"有哪些模型可用、每个模型的 provider / baseUrl / cost / contextWindow 是什么、认证状态如何"。它从多个来源收集模型,合并成统一列表。

模型来源汇总图

flowchart TD A[models.generated.ts<br/>446KB 自动生成<br/>内置模型清单] -->|getModels| D[loadBuiltInModels] B[models.json<br/>用户自定义模型 + override] -->|loadCustomModels| E[parseModels] C[pi.registerProvider<br/>Extension API 运行时注册] -->|registerProvider| F[applyProviderConfig] D --> G[mergeCustomModels<br/>内置 + 自定义合并] E --> G G --> H[OAuth provider 修正<br/>modifyModels] F --> H H --> I[this.models<br/>最终模型列表] I --> J[getAvailable<br/>过滤有认证的模型]

1. 内置模型------models.generated.ts

pi 内置的模型清单不是手动维护的------是自动生成的。models.generated.ts(446KB、16871 行)由 scripts/generate-models.ts(2156 行)在构建时生成。

generate-models.ts 做的事:

  1. 从多个 API 抓取模型元数据

    • models.devhttps://models.dev/api.json)------主要来源,提供 Anthropic / Google / OpenAI / Groq / Cerebras 等的模型数据
    • NVIDIA NIM API------NVIDIA 模型
    • OpenRouter API------OpenRouter 的模型列表
    • Vercel AI Gateway API------Vercel 网关的模型
  2. 统一成 pi 的 Model<TApi> 格式------每个模型有 id / name / api / provider / baseUrl / reasoning / input / cost / contextWindow / maxTokens 等字段

  3. 手动修正------代码里有"Fix known mismatches"、"Fix incorrect cache pricing"等逻辑,修正上游数据和实际行为不一致的地方

  4. 生成 MODELS 常量 ------按 provider 分组的对象,getModels(provider) 从这里查

生成结果是一个巨大的常量------pi 不需要运行时从网络抓取模型列表,全部打包在二进制里。更新模型列表只需要重新跑 npm run generate-models 重新构建。

2. 用户自定义------models.json

用户可以在 ~/.pi/agent/models.json 里定义自定义模型或覆盖内置模型:

typescript 复制代码
// model-registry.ts:529-573(简化)
private loadCustomModels(modelsJsonPath: string): CustomModelsResult {
  if (!existsSync(modelsJsonPath)) return emptyCustomModelsResult();

  const content = readFileSync(modelsJsonPath, "utf-8");
  const config = JSON.parse(stripJsonComments(content)) as ModelsConfig;

  // 解析 providers 配置------baseUrl / apiKey / headers / compat override
  for (const [providerName, providerConfig] of Object.entries(config.providers)) {
    if (providerConfig.baseUrl || providerConfig.compat) {
      overrides.set(providerName, { baseUrl: providerConfig.baseUrl, compat: providerConfig.compat });
    }
    // 解析自定义模型
    for (const modelDef of providerConfig.models ?? []) {
      models.push({
        id: modelDef.id,
        name: modelDef.name ?? modelDef.id,
        api: modelDef.api ?? providerConfig.api ?? builtInDefaults?.api,
        provider: providerName,
        baseUrl: modelDef.baseUrl ?? providerConfig.baseUrl ?? builtInDefaults?.baseUrl,
        // ...
      });
    }
  }

  return { models, overrides, modelOverrides, error: undefined };
}

models.json 做两件事:

  • 添加自定义模型:定义新 provider + 新模型(如公司内部的 LLM 代理)
  • 覆盖内置模型 :用 modelOverrides 修改内置模型的某些字段(如改 baseUrl 转发到代理、改 cost 修正价格)

3. 合并------mergeCustomModels

内置模型和自定义模型合并(model-registry.ts:516-527):

typescript 复制代码
private mergeCustomModels(builtInModels: Model<Api>[], customModels: Model<Api>[]): Model<Api>[] {
  const merged = [...builtInModels];
  for (const customModel of customModels) {
    const existingIndex = merged.findIndex(
      (m) => m.provider === customModel.provider && m.id === customModel.id
    );
    if (existingIndex >= 0) {
      merged[existingIndex] = customModel;   // 同 provider + 同 id → 覆盖
    } else {
      merged.push(customModel);              // 新模型 → 追加
    }
  }
  return merged;
}

规则:provider + id 相同就覆盖,不同就追加。这让用户能用 models.json 修正内置模型的价格、baseUrl 等字段,也能加全新模型。

合并后还要经过 OAuth provider 的修正(model-registry.ts:472-477)------如 OAuth 登录的 provider 可能需要更新 baseUrl 指向 token 对应的 endpoint。

4. Extension 运行时注册------registerProvider

Extension API 的 pi.registerProvider(文章 7 讲过)在运行时动态注册 provider(model-registry.ts:870-875):

typescript 复制代码
registerProvider(providerName: string, config: ProviderConfigInput): void {
  const migratedConfig = migrateLegacyRegisterProviderConfigValues(providerName, config);
  this.validateProviderConfig(providerName, migratedConfig);
  this.applyProviderConfig(providerName, migratedConfig);   // 应用配置到模型列表
  this.upsertRegisteredProvider(providerName, migratedConfig); // 存入 registeredProviders
}

applyProviderConfig 把新 provider 的模型加到 this.models 里。upsertRegisteredProvider 存配置------refresh() 时重新应用(如 /reload 后)。

校验逻辑(model-registry.ts:911-925):

typescript 复制代码
private validateProviderConfig(providerName: string, config: ProviderConfigInput): void {
  if (config.streamSimple && !config.api) {
    throw new Error(`Provider ${providerName}: "api" is required when registering streamSimple.`);
  }
  if (!config.models || config.models.length === 0) return;
  if (!config.baseUrl) {
    throw new Error(`Provider ${providerName}: "baseUrl" is required when defining models.`);
  }
  if (!config.apiKey && !config.oauth) {
    throw new Error(`Provider ${providerName}: "apiKey" or "oauth" is required when defining models.`);
  }
}

注册时必须有 baseUrl + apiKey(或 oauth)+ models。不满足就抛异常------Extension 注册失败但不影响其他 provider。

5. getAvailable------过滤有认证的模型

getAvailable()model-registry.ts:699-701)返回有认证配置的模型:

typescript 复制代码
getAvailable(): Model<Api>[] {
  return this.models.filter((m) => this.hasConfiguredAuth(m));
}

private hasConfiguredAuth(model: Model<Api>): boolean {
  const providerApiKey = this.providerRequestConfigs.get(model.provider)?.apiKey;
  return (
    this.authStorage.hasAuth(model.provider) ||
    (providerApiKey !== undefined && isConfigValueConfigured(providerApiKey))
  );
}

内置 200+ 模型,但用户只配了 OpenAI 的 API key------getAvailable() 只返回 OpenAI 的模型。没配 API key 的 provider 的模型被过滤掉,不出现在 /model 选择器里。

6. loadModels 完整流程

loadModelsmodel-registry.ts:454-480)把以上步骤串起来:

markdown 复制代码
loadModels:
  1. loadCustomModels(models.json)     → 自定义模型 + override
  2. loadBuiltInModels(overrides)      → 内置模型 + 应用 override
  3. mergeCustomModels(内置, 自定义)    → 合并
  4. OAuth provider modifyModels        → 修正 OAuth provider 的模型
  5. this.models = 合并结果

refresh()model-registry.ts:431-445)重新执行整个 loadModels + 重新应用所有 registeredProviders------/reload 时调用。

四、ResourceLoader

ResourceLoader 按 settings 配置加载 extensions / skills / prompts / themes。单个资源的加载逻辑在前面文章讲过------文章 6 讲了 loadSkills、文章 7 讲了 loadExtensions。本章不讲单个资源怎么加载,讲 ResourceLoader 怎么编排:从 settings 读路径、合并多层来源、控制加载顺序、支持 reload。

1. 路径来源

ResourceLoader 从四个来源收集资源路径:

来源 settings 字段 例子
全局配置 settings.extensions / settings.skills / settings.prompts / settings.themes ["~/.pi/agent/extensions/my-tool.ts"]
项目级配置 同上(项目级 settings.json 覆盖) [".pi/extensions/deploy.ts"]
npm/git 包 settings.packages [{ source: "@my-org/pi-extensions", extensions: ["deploy"] }]
CLI 参数 --extensions / --skills / --prompts pi --extensions ./temp-ext.ts

四种路径合并成一个数组,传给对应的加载函数。全局和项目级的合并走 SettingsManager 的 deep merge(数组直接覆盖)。CLI 参数临时追加。npm/git 包通过 PackageManager 解析。

2. 加载顺序

reload()resource-loader.ts:321-460)是 ResourceLoader 的核心方法------按固定顺序加载四类资源:

markdown 复制代码
reload():
  1. settingsManager.reload()                     → 重新读配置
  2. packageManager.resolve()                     → 解析 npm/git 包路径
  3. 合并所有路径来源(全局 + 项目 + 包 + CLI)
  4. loadExtensions(extensionPaths)               → 加载扩展(文章 7 讲过)
  5. updateSkillsFromPaths(skillPaths)            → 加载 skills(文章 6 讲过)
  6. updatePromptsFromPaths(promptPaths)           → 加载 prompt templates
  7. updateThemesFromPaths(themePaths)            → 加载主题
  8. 检测冲突(同名工具/命令/flag)
  9. 存入 this.extensions / this.skills / this.prompts / this.themes

顺序有意义------extensions 先加载,因为 extension 可能注册新工具/命令/skill 路径。skills 在 extensions 之后加载,确保 extension 注册的 skill 路径能被发现。

3. reload

reload()/reload 命令的底层实现。它重新执行整个加载流程------从读 settings 开始,到加载所有资源结束。这让修改 settings.json、增删 .pi/extensions/ 文件后不需要重启 pi。

typescript 复制代码
// resource-loader.ts:321-322
async reload(): Promise<void> {
  await this.settingsManager.reload();     // 先重新读配置
  const resolvedPaths = await this.packageManager.resolve();  // 重新解析包
  // ... 重新加载所有资源
}

reload 是全量替换------不是增量更新。旧资源全部丢弃,新资源重新加载。这简化了实现------不用追踪"哪些资源变了",直接全部重载。

4. 冲突检测

加载完 extensions 后,ResourceLoader 检测同名冲突(resource-loader.ts:405-408):

typescript 复制代码
const conflicts = this.detectExtensionConflicts(extensionsResult.extensions);
for (const conflict of conflicts) {
  extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
}

如果两个 extension 注册了同名工具、同名命令、同名 flag------记冲突诊断。冲突不阻止加载------两个都保留,按加载顺序决定优先级(先加载的优先)。

5. 统一暴露

ResourceLoader 加载完所有资源后,通过 getter 方法暴露:

typescript 复制代码
getExtensions(): LoadExtensionsResult { return this.extensionsResult; }
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] } {
  return { skills: this.skills, diagnostics: this.skillDiagnostics };
}
getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] } { ... }
getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } { ... }
getSystemPrompt(): string | undefined { return this.systemPrompt; }
getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> } { ... }

每个 getter 返回资源列表 + diagnostics(加载时的 warning / error)。调用方(如 AgentSession)从这些 getter 拿资源,分发给各子系统------extensions 给 ExtensionRunner、skills 给 formatSkillsForPrompt、prompts 给 slash 命令系统、themes 给 TUI。

6. override------测试和 SDK 支持

ResourceLoader 支持 override 回调(resource-loader.ts:228-234):

typescript 复制代码
private extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult;
private skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => { ... };
private promptsOverride?: (base: ...) => { ... };
private themesOverride?: (base: ...) => { ... };

如果设置了 override,加载完真实资源后调 override 函数,用返回值替换。这让测试可以 mock 资源(如注入假 extension),SDK 可以定制资源加载逻辑(如只加载特定 skill)。

五、启动流程

三个组件怎么串联?pi 启动时按什么顺序初始化?这靠 createAgentSessionsdk.ts:166-397)------pi coding-agent 的入口函数。

流程图

flowchart TD A[createAgentSession] --> B[SettingsManager.create<br/>读全局 + 项目级 settings.json] A --> C[ModelRegistry.create<br/>加载 models.json + 内置模型] A --> D[SessionManager.create<br/>初始化 session 文件] B --> E[ResourceLoader<br/>new DefaultResourceLoader] E --> F[resourceLoader.reload<br/>加载 extensions + skills + prompts + themes] C --> G[findInitialModel<br/>从 settings 的 defaultModel + 认证状态选初始模型] B --> G G --> H[new Agent<br/>用 settings 配置 steeringMode / transport / thinkingBudgets 等] H --> I[new AgentSession<br/>绑定 agent + session + resource + extension] I --> J[_buildRuntime<br/>创建 ExtensionRunner + 注册工具 + 安装 hook] J --> K[session_start 事件<br/>agent 就绪]

1. 创建三个基础组件

typescript 复制代码
// sdk.ts:166-184(简化)
export async function createAgentSession(options) {
  const cwd = options.cwd ?? process.cwd();
  const agentDir = getDefaultAgentDir();

  // 1. AuthStorage------认证存储
  const authStorage = AuthStorage.create(join(agentDir, "auth.json"));

  // 2. ModelRegistry------模型注册表
  const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json"));

  // 3. SettingsManager------配置管理器
  const settingsManager = SettingsManager.create(cwd, agentDir);

  // 4. SessionManager------session 管理
  const sessionManager = SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir));

  // 5. ResourceLoader------资源加载器
  const resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
  await resourceLoader.reload();   // 立即加载所有资源
}

三个组件的创建顺序:AuthStorage → ModelRegistry → SettingsManager → SessionManager → ResourceLoader。

  • AuthStorage 先创建------ModelRegistry 依赖它判断哪些 provider 有认证
  • ModelRegistry 在 AuthStorage 之后------加载模型时需要检查认证状态
  • SettingsManager 独立创建------只读文件,不依赖其他组件
  • ResourceLoader 最后创建------依赖 SettingsManager(从 settings 读资源路径)

2. 选择初始模型

typescript 复制代码
// sdk.ts:191-221(简化)
let model = options.model;

// 如果 session 有历史数据,从 session 恢复模型
if (!model && hasExistingSession && existingSession.model) {
  const restoredModel = modelRegistry.find(existingSession.model.provider, existingSession.model.modelId);
  if (restoredModel && modelRegistry.hasConfiguredAuth(restoredModel)) {
    model = restoredModel;      // 恢复上次的模型
  }
}

// 如果还没模型,从 settings 的 defaultProvider / defaultModel 找
if (!model) {
  const result = await findInitialModel({
    defaultProvider: settingsManager.getDefaultProvider(),
    defaultModelId: settingsManager.getDefaultModel(),
    modelRegistry,
  });
  model = result.model;
}

两层查找:

  1. 从 session 恢复 :如果 session 有历史数据(用户之前用过),从 session 的 model_change entry 恢复上次的模型
  2. 从 settings 找 :没有 session 历史时,用 settings.defaultProvider + settings.defaultModel + modelRegistry.getAvailable() 找第一个有认证的模型

3. 创建 Agent

typescript 复制代码
// sdk.ts:293-359(简化)
agent = new Agent({
  initialState: {
    systemPrompt: "",
    model,
    thinkingLevel,
    tools: [],
  },
  convertToLlm: convertToLlmWithBlockImages,    // settings.images.blockImages 过滤
  streamFn: async (model, context, options) => {
    // 从 ModelRegistry 拿 API key
    const auth = await modelRegistry.getApiKeyAndHeaders(model);
    // 从 SettingsManager 拿 retry / timeout / transport 配置
    const providerRetrySettings = settingsManager.getProviderRetrySettings();
    return streamSimple(model, context, {
      ...options,
      apiKey: auth.apiKey,
      timeoutMs: providerRetrySettings.timeoutMs,
      maxRetries: providerRetrySettings.maxRetries,
      headers: auth.headers,
    });
  },
  onPayload: (payload) => extensionRunnerRef.current?.emitBeforeProviderRequest(payload),
  onResponse: (response) => extensionRunnerRef.current?.emitAfterProviderResponse(response),
  steeringMode: settingsManager.getSteeringMode(),
  followUpMode: settingsManager.getFollowUpMode(),
  transport: settingsManager.getTransport(),
  thinkingBudgets: settingsManager.getThinkingBudgets(),
});

Agent 的创建是配置系统的"消费点"------SettingsManager 的配置项在这里被读取并传入 Agent。Agent 不直接读 settings------它从构造函数参数拿配置。这让 Agent 和 SettingsManager 解耦。

streamFn 是关键------它把 ModelRegistry(拿 API key)和 SettingsManager(拿 retry / timeout 配置)结合起来,包装成 Agent 需要的 stream 函数。每次 Agent 调 LLM 时走这个函数。

4. 创建 AgentSession + _buildRuntime

typescript 复制代码
// sdk.ts:375-389
const session = new AgentSession({
  agent,
  sessionManager,
  settingsManager,
  cwd,
  resourceLoader,
  modelRegistry,
  initialActiveToolNames,
  extensionRunnerRef,
});

AgentSession 构造函数(agent-session.ts:325-350)里调 _buildRuntime

typescript 复制代码
// agent-session.ts:2375-2427(简化)
private _buildRuntime(options) {
  // 1. 从 settings 读工具配置
  const autoResizeImages = this.settingsManager.getImageAutoResize();
  const shellCommandPrefix = this.settingsManager.getShellCommandPrefix();

  // 2. 创建内置工具定义
  const baseToolDefinitions = createAllToolDefinitions(this._cwd, {
    read: { autoResizeImages },
    bash: { commandPrefix: shellCommandPrefix, shellPath },
  });

  // 3. 从 ResourceLoader 拿 extensions
  const extensionsResult = this._resourceLoader.getExtensions();

  // 4. 创建 ExtensionRunner
  this._extensionRunner = new ExtensionRunner(
    extensionsResult.extensions,
    extensionsResult.runtime,
    this._cwd,
    this.sessionManager,
    this._modelRegistry,
  );

  // 5. 绑定 extension core + UI
  this._bindExtensionCore(this._extensionRunner);
  this._applyExtensionBindings(this._extensionRunner);

  // 6. 刷新工具注册表(内置 + extension 工具)
  this._refreshToolRegistry({
    activeToolNames: options.activeToolNames,
    includeAllExtensionTools: options.includeAllExtensionTools,
  });
}

_buildRuntime 是三个组件的"汇合点":

  • SettingsManager 提供 autoResizeImages / shellCommandPrefix → 影响内置工具的创建参数
  • ResourceLoader 提供 extensionsResult → 创建 ExtensionRunner
  • ModelRegistry 传给 ExtensionRunner → 让 extension 能调 pi.setModel

5. 启动完成

_buildRuntime 完成后,AgentSession 就绪。后续流程:

  • 恢复 session 历史(如果有)------agent.state.messages = existingSession.messages
  • session_start 事件------Extension 的 session_start handler 被触发(如 file-trigger 扩展启动文件监听)
  • agent 等待用户输入

三个组件从此持续协作:SettingsManager 提供配置(/reload 时重新读)、ModelRegistry 提供模型列表(/model 切换时查)、ResourceLoader 提供资源(extension handler 被调用时执行)。

六、Q&A

Q1:steeringMode 的 all / one-at-a-time 有什么差别?

控制 steering 队列一次 drain 取多少条消息。

"all":用户连发三条 steering 消息 → 三条全部取出,LLM 下一轮同时看到三条。

"one-at-a-time"(默认):用户连发三条 → 第一轮只取第一条,LLM 处理完再取第二条,再取第三条。

差异在于 LLM 的"消化节奏":

"all" "one-at-a-time"
用户连发 3 条 LLM 一次看到 3 条 LLM 每轮看 1 条,分 3 轮处理
优点 LLM 能综合判断优先级 逐条消化,不被一堆消息淹没
风险 LLM 可能只关注最后一条,忽略前面的 处理慢,3 轮才能处理完
适合 多条消息互相关联(如"先做 A,再做 B,再做 C") 多条消息互相独立或冲突(如"A 方案"然后"B 方案")

Q2:followUpMode 的 all / one-at-a-time 和 steering 有什么差别?

机制完全一样(同一个 PendingMessageQueue.drain()),差异在于触发时机。

steeringMode 控制 steeringQueue------内层循环还在转时的插话。 followUpMode 控制 followUpQueue------内层循环已经停了后的追加任务。

两者是两个独立的 PendingMessageQueue 实例,各自有自己的 mode,互不影响。典型配置:

配置 默认值 为什么
steeringMode "one-at-a-time" 插话是中途打断------逐条消化更安全
followUpMode "all" 追加任务是 agent 已经停了------一次全给 LLM 让它自己安排顺序

关键差异不是机制(都是 drain),而是语义:steering 是"agent 正在干活,插一句话进来"------默认逐条。followUp 是"agent 干完了,还有活要干"------默认全给。

Q3:compaction 的 reserveTokens 的作用是什么,这个值是否应该根据模型的上下文窗口大小设置?

reserveTokens 是给 LLM 回复预留的空间。context window 减去 reserveTokens 是 compaction 触发阈值。

这个值应该根据 context window 调整,但不是线性比例:

context window 建议 reserveTokens 理由
128k 16384(默认) ~12%,够 LLM 生成中等长度回复 + 工具调用
200k 32768 ~16%,更大的窗口可以留更多空间给复杂回复
1M 65536 ~6%,大窗口的回复通常不会超 65k,但留够余量

不要设太大------reserveTokens 越大,触发 compaction 越早,context 利用率越低。另外 reserveTokens 还影响摘要的 maxTokens(摘要最多占 reserveTokens 的 80%),设太小会导致摘要被截断。

Q4:branchSummary 的作用是什么,reserveTokens 和 skipPrompt 这两个设置代表什么?

branchSummary 是切换 session 分支时的摘要配置。

reserveTokens(默认 16384)------控制摘要时的 token 预算。prepareBranchEntriescontextWindow - reserveTokens 作为上限,从尾部往前收集消息。超预算的消息被丢弃。

skipPrompt(默认 false)------控制切分支时是否提示用户:

  • false:弹出"是否摘要旧分支?"提示,用户选 Yes/No
  • true:不提示,默认不摘要,旧分支消息直接丢弃

skipPrompt: true 适合频繁切分支探索不同方案、不需要每次都摘要的场景。skipPrompt: false 适合分支之间有关联、需要摘要保留旧分支信息的场景。

Q5:packages 的作用是什么,官方是否提供了什么推荐的 packages?

packages 让用户从 npm 或 git 安装第三方扩展包------extensions/skills/prompts/themes 的打包分发。

三种来源:

来源 格式 例子
npm npm:包名 或直接 包名 npm:@my-org/pi-tools
git git:URLhttps://github.com/... git:github.com/user/repo
local 本地路径 ./my-tools

安装命令:pi install @my-org/pi-deploy-tools / pi install git:github.com/user/repo / pi remove / pi list

pi 官方没有提供推荐的第三方 packages。pi 的包生态还在早期------目前没有官方维护的包注册表或推荐列表。用户如果要分享扩展,发布到 npm 或放 git 仓库,其他人用 pi install 安装。

Q6:terminal 设置 showImages=true 时会发生什么?

让工具返回的图片在终端里内联渲染,但前提是终端支持图片协议。

两个条件缺一不可:showImages: true + caps.images 不为 null。caps.images 来自 getCapabilities() 检测终端的图片协议支持:

终端 caps.images 支持的格式
Kitty "kitty" 仅 PNG
iTerm2 "iterm2" PNG / JPEG
其他 null 不支持

showImages: false 时图片不渲染------但图片数据仍然发给 LLM。showImages 只控制终端 UI 渲染,不影响发给 LLM 的 context。如果想让 LLM 也看不到图片,用 images.blockImages: true

tmux 下 caps.images 返回 null------即使底层终端是 Kitty/iTerm2,tmux 不转发图片协议。

Q7:shellCommandPrefix 怎么用?

shellCommandPrefix 是 prepend 到 agent 的 bash 工具执行的每条命令前面的前缀,不是给用户输入加前缀。

如果 shellCommandPrefix = "shopt -s expand_aliases",agent 调 bash("git status") 时实际执行:

bash 复制代码
shopt -s expand_aliases
git status

典型用途:

  • 启用 shell alias:"source ~/.bashrc && shopt -s expand_aliases"
  • 激活环境:"conda activate myenv"

前缀在每条 bash 命令前都加------不是只加一次。对 source / conda activate 类命令重复执行无副作用。

Q8:quietStartup 默认值是什么,设置为 true 会发生什么?

默认 false。设为 true 后启动时不显示 logo、快捷键提示、模型作用域列表------pi 启动后直接进入空白输入框。

--verbose CLI 参数覆盖 quietStartup------即使 quietStartup: true,加 --verbose 也显示全部启动信息。

Q9:collapseChangelog 的作用是什么?

默认 false。控制 pi 更新后启动时显示完整 changelog 还是只显示一行摘要。

false:显示完整 changelog(版本号 + 所有更新条目)。 true:只显示一行 Updated to v0.78.0. Use /changelog to view full changelog.,用户需要时手动 /changelog 查看。

Q10:enableInstallTelemetry 的作用是什么?

默认 true。控制 pi 在检测到版本更新后是否向 pi.dev 发送匿名安装 ping。

只发送版本号------不发送用户信息、使用数据或对话内容。请求发到 https://pi.dev/api/report-install?version=xxx,5 秒超时,失败静默。

优先级:PI_OFFLINE 环境变量 > PI_TELEMETRY 环境变量 > settings.enableInstallTelemetry

Q11:settings.prompts 的作用是什么,是配置目录地址还是配置具体的 prompts?

配置的是目录地址或文件路径,不是具体的 prompt 内容。

json 复制代码
{
  "prompts": [
    "~/.pi/agent/prompts/review.md",
    ".pi/prompts/"
  ]
}
  • 文件路径------单个文件注册为一个命令
  • 目录路径------递归扫描所有 .md 文件,每个注册为一个命令

如果不设 prompts,pi 自动扫描默认目录 ~/.pi/agent/prompts/(全局)和 .pi/prompts/(项目级)。

Q12:prompts/ 和 skills/ 的差异是什么?

两种不同的"按需加载的指令包":

prompts/ skills/
文件 xxx.md(一个文件一个命令) xxx/SKILL.md(一个目录一个 skill)
触发方式 只能用户显式触发------/xxx 用户显式 /skill:xxx 或 LLM 自动匹配 description
加载机制 启动时全文加载 三级渐进加载------元数据在 system prompt,正文按需 read
token 成本 不占 system prompt 占 system prompt ~100 token/skill
能带参考文件? 不能 能------references/ + scripts/
用途 预定义常用 prompt 领域知识包

一句话:prompt template 是"快捷发消息",skill 是"按需加载的领域知识包"。

Q13:pi 的非交互模式(print mode / RPC mode)是什么?

pi 有三种运行模式:

模式 触发 做什么 用途
Interactive pi 终端交互式 TUI------用户输入、agent 回复、slash 命令 日常使用
Print pi -p "prompt" 单次执行------发一个 prompt、拿回复、退出。输出 text 或 JSON 脚本调用、CI/CD
RPC pi --mode rpc 无头模式------stdin 接 JSON 命令、stdout 输出 JSON 事件,持续运行 嵌入其他应用(IDE 插件、Web 后端)

三种模式的 agent 机制完全一样------都走 agent loop、工具调用、compaction、session 管理。差异只在输入输出通道:

  • Interactive:终端 TUI 输入输出(键盘 + 屏幕)
  • Print:命令行参数输入、stdout 输出
  • RPC:JSON 协议输入输出(stdin/stdout)

print mode 只有 159 行代码------核心是"调 session.prompt + 把结果输出到 stdout"。RPC mode 的核心是"把 AgentEvent 序列化到 stdout + 从 stdin 读命令"。它们不引入新的 agent 机制,只是换了输入输出方式。

七、系列回顾

11 篇文章从 LLM 调用到配置系统,完整拆解了 pi 的 agent 架构:

yaml 复制代码
文章 1: completeSimple      --- ai 层:同步调用 LLM,拿到 AssistantMessage
文章 2: streamSimple        --- ai 层:流式调用 + EventStream + AssistantMessageEvent 协议
文章 3: agent loop           --- agent 层:双层 while 循环 + 四个钩子 + context 演变
文章 4: 工具体系             --- agent 层:AgentTool 接口 + executeToolCalls 三段拆分 + 7 个内置工具
文章 5: hook 机制            --- agent 层:六个钩子的实现(prepareNextTurn / shouldStopAfterTurn / steering / followUp / beforeToolCall / afterToolCall)
文章 6: skills 机制           --- 扩展层:按需加载的领域知识包 + 三级渐进披露
文章 7: Extension API        --- 扩展层:TypeScript 模块系统 + loader + runner + 六类 API
文章 8: compaction 机制       --- context 管理:context rot + 触发 + 切割 + 结构化摘要 + session 持久化
文章 9: session 管理          --- 持久化:session 树 + JSONL + 分支/fork/switch + 恢复
文章 10: slash 命令系统       --- 交互层:四层来源 + autocomplete + 解析分派 + 三条执行路径
文章 11: 配置系统             --- 基础设施:SettingsManager + ModelRegistry + ResourceLoader + 启动流程

pi 的架构分层

scss 复制代码
┌──────────────────────────────────────────────┐
│ Interactive / Print / RPC                    │ ← 运行模式(输入输出通道)
├──────────────────────────────────────────────┤
│ Slash 命令系统                                 │ ← 用户交互层
├──────────────────────────────────────────────┤
│ Extension API / Skills / Hook                 │ ← 扩展层
├──────────────────────────────────────────────┤
│ Agent Loop (runLoop + streamAssistantResponse)│ ← agent 层
├──────────────────────────────────────────────┤
│ 工具体系 (read/bash/edit/write + executeToolCalls)│ ← 工具层
├──────────────────────────────────────────────┤
│ Compaction / Session 管理                     │ ← context / 持久化层
├──────────────────────────────────────────────┤
│ streamSimple / EventStream (ai 层)            │ ← LLM 调用层
├──────────────────────────────────────────────┤
│ SettingsManager / ModelRegistry / ResourceLoader│ ← 配置 / 基础设施层
└──────────────────────────────────────────────┘

pi 的设计哲学

贯穿 11 篇文章的三个设计理念:

1. 扩展无需 fork------从 hook 到 skill 到 Extension API,pi 把所有扩展点都做成了正式接口。不改源码就能改 agent 的工具集、行为、命令、UI、甚至 LLM provider。这是 pi 和其他 agent 框架(Codex CLI / Claude Code / opencode)的核心差异。

2. 模型主导控制流------agent loop 的行为不是 workflow 编排的,而是 LLM 在每一轮决定"调不调工具、调哪个工具、什么时候停"。pi 的循环骨架只负责"调 LLM → 检查工具调用 → 执行 → 回填 → 继续",不预设步骤。这是 agent 和 workflow 的本质区别。

3. context 是稀缺资源------从 compaction 的结构化摘要、到 skill 的三级渐进披露、到 toolResult 的截断、到 branch summary 的增量更新,pi 在每个环节都控制 context 的 token 成本。context rot 不是理论问题------是所有 LLM 的共性,pi 的设计从第一天就考虑了这一点。

相关推荐
阿里云大数据AI技术1 小时前
DataWorks Data Agent 实战课堂(七):数据治理 Agent——AI 驱动的自动化治理
人工智能·agent
Flynt1 小时前
我给 Claude Code 装了 Ponytail,代码量直接砍了一半
agent·ai编程·claude
DigitalOcean2 小时前
GPT 6 Astra 已上线 DigitalOcean AI 推理云:AGI 时代的计算机操作模型来了
agent
后端小肥肠2 小时前
还在找PPT 生成工具?我集成了 GitHub 高星 Skill,自动匹配最优方案
人工智能·aigc·agent
小哈里2 小时前
【执行】个人操作系统架构图 v1(硬件层,OS层,软件层,Agent层,横向控制面)
系统架构·操作系统·agent·架构图·执行
uncle_ll5 小时前
智能客服实践:微调+RAG双引擎架构落地
llm·agent·智能客服·rag·llamaindex
Canace5 小时前
最新版 Codex 工作流的问题
前端·人工智能·agent
小羊435 小时前
从MCP到A2A:解读Agent互联协议的未来
agent
尘中远7 小时前
给C++工业软件搭建 Agent
开发语言·c++·qt·ai·agent