一、引言:配置系统解决什么问题
不同用户有不同的需求------有人用 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 降级路径
三步:
- 不是
"sse"且没被 fallback 标记 → 尝试 WebSocket - WebSocket 失败(连接错误、超时)→ 标记该 session 为 fallback,降级到 SSE
- 后续请求 → 检查
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 是什么、认证状态如何"。它从多个来源收集模型,合并成统一列表。
模型来源汇总图
1. 内置模型------models.generated.ts
pi 内置的模型清单不是手动维护的------是自动生成的。models.generated.ts(446KB、16871 行)由 scripts/generate-models.ts(2156 行)在构建时生成。
generate-models.ts 做的事:
-
从多个 API 抓取模型元数据:
models.dev(https://models.dev/api.json)------主要来源,提供 Anthropic / Google / OpenAI / Groq / Cerebras 等的模型数据- NVIDIA NIM API------NVIDIA 模型
- OpenRouter API------OpenRouter 的模型列表
- Vercel AI Gateway API------Vercel 网关的模型
-
统一成 pi 的
Model<TApi>格式------每个模型有 id / name / api / provider / baseUrl / reasoning / input / cost / contextWindow / maxTokens 等字段 -
手动修正------代码里有"Fix known mismatches"、"Fix incorrect cache pricing"等逻辑,修正上游数据和实际行为不一致的地方
-
生成
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 完整流程
loadModels(model-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 启动时按什么顺序初始化?这靠 createAgentSession(sdk.ts:166-397)------pi coding-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;
}
两层查找:
- 从 session 恢复 :如果 session 有历史数据(用户之前用过),从 session 的
model_changeentry 恢复上次的模型 - 从 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_starthandler 被触发(如 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 预算。prepareBranchEntries 用 contextWindow - reserveTokens 作为上限,从尾部往前收集消息。超预算的消息被丢弃。
skipPrompt(默认 false)------控制切分支时是否提示用户:
false:弹出"是否摘要旧分支?"提示,用户选 Yes/Notrue:不提示,默认不摘要,旧分支消息直接丢弃
skipPrompt: true 适合频繁切分支探索不同方案、不需要每次都摘要的场景。skipPrompt: false 适合分支之间有关联、需要摘要保留旧分支信息的场景。
Q5:packages 的作用是什么,官方是否提供了什么推荐的 packages?
packages 让用户从 npm 或 git 安装第三方扩展包------extensions/skills/prompts/themes 的打包分发。
三种来源:
| 来源 | 格式 | 例子 |
|---|---|---|
| npm | npm:包名 或直接 包名 |
npm:@my-org/pi-tools |
| git | git:URL 或 https://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 命令 | 日常使用 |
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 的设计从第一天就考虑了这一点。