职责概述

解决的问题:每个用户、每个项目对 CC 的行为偏好都不同——允许哪些工具、用哪个模型、什么格式输出、哪些快捷键。需要一个配置系统记住这些偏好,并且在不同版本升级时自动迁移旧配置,不会因为格式变了就挂掉。

应用场景:① 用户设置"总是允许读文件但写文件要确认" ② 项目级配置 .claude/settings.json 定义团队共享的工具权限 ③ CC 升级后自动把旧格式配置迁移到新格式 ④ 用户自定义快捷键映射 ⑤ 组织管理员下发统一策略覆盖个人配置。

一句话理解:就像 VS Code 的 settings.json——个人设置、项目设置、工作区设置分层叠加,升级时自动迁移。

架构设计

配置层级图

策略层(最高优先级)— policySettings
远程托管设置(API 下发)
MDM 管理设置(macOS plist / Windows HKLM)
managed-settings.json + managed-settings.d/*.json
HKCU 注册表设置(最低策略层)
▼ 覆盖
CLI 标志层 — flagSettings
--settings <path> 指定的设置文件
SDK 内联设置(getFlagSettingsInline)
▼ 覆盖
项目本地层 — localSettings
.claude/settings.local.json(gitignored)
▼ 覆盖
项目共享层 — projectSettings
.claude/settings.json(提交到版本库)
▼ 覆盖
用户全局层(最低优先级)— userSettings
~/.claude/settings.json
~/.claude/cowork_settings.json(cowork 模式)

子系统架构

Settings Engine
配置读取、合并、验证、写入
核心
Migration System
版本升级自动配置迁移
数据
Output Styles
可定制的模型输出风格
UI
Keybindings
上下文感知的快捷键系统
扩展

核心数据流

配置加载流程

1. 按层级加载
getSettingsForSource(source) 按源类型读取对应文件。策略层优先级:remote > MDM > file > HKCU
2. Zod Schema 验证
SettingsSchema().safeParse(data) 验证 JSON 结构。无效权限规则被 filterInvalidPermissionRules() 过滤,不阻塞整文件
3. 文件缓存
settingsCache.ts 缓存解析结果,clone() 防止调用者污染缓存
4. 合并生效
getInitialSettings() 按优先级 mergeWith 各层。策略层 > 标志层 > 本地层 > 项目层 > 用户层

配置写入流程

1. 选择源
policySettings 和 flagSettings 不可写(只读保护)
2. 读取现有值
bypass 缓存直接读取文件,避免 mergeWith 污染缓存对象
3. lodash mergeWith 合并
增量更新:只修改指定键,保留其他配置不变。undefined 值触发删除
4. 写入 + 缓存失效
resetSettingsCache() 清除所有缓存,确保下次读取反映新值

迁移流程

migrateAutoUpdatesToSettings
将 globalConfig.autoUpdates=false 迁移到 settings.json 的 env.DISABLE_AUTOUPDATER,保留用户意图
migrateFennecToOpus
将旧的 fennec 模型别名(fennec-latest → opus)迁移到新名称,仅修改 userSettings
migrateSonnet1mToSonnet45 → migrateSonnet45ToSonnet46
链式迁移:将显式 Sonnet 4.5 字符串迁移到 'sonnet' 别名(自动解析到最新版)
migrateOpusToOpus1m / migrateLegacyOpusToCurrent
Opus 模型版本迁移,确保用户始终使用最新可用模型

关键类型与接口

SettingSource — 配置来源类型

// utils/settings/constants.ts:7-22
export const SETTING_SOURCES = [
  'userSettings',     // ~/.claude/settings.json
  'projectSettings',  // .claude/settings.json
  'localSettings',    // .claude/settings.local.json
  'flagSettings',     // --settings 指定的文件
  'policySettings',   // managed-settings.json / MDM / remote
] as const
export type SettingSource = (typeof SETTING_SOURCES)[number]

SettingsSchema — Zod 验证

// utils/settings/types.ts — 核心验证 Schema
export const PermissionsSchema = lazySchema(() =>
  z.object({
    allow: z.array(PermissionRuleSchema()).optional(),
    deny: z.array(PermissionRuleSchema()).optional(),
    ask: z.array(PermissionRuleSchema()).optional(),
    defaultMode: z.enum(PERMISSION_MODES).optional(),
    disableBypassPermissionsMode: z.enum(['disable']).optional(),
    additionalDirectories: z.array(z.string()).optional(),
  }).passthrough()
)

配置文件路径映射

// utils/settings/settings.ts:274-296
export function getSettingsFilePathForSource(source: SettingSource): string {
  switch (source) {
    case 'userSettings':
      return join(getClaudeConfigHomeDir(), getUserSettingsFilePath())
    case 'projectSettings':
      return join(getOriginalCwd(), '.claude', 'settings.json')
    case 'localSettings':
      return join(getOriginalCwd(), '.claude', 'settings.local.json')
    case 'policySettings':
      return getManagedSettingsFilePath()  // managed-settings.json
    case 'flagSettings':
      return getFlagSettingsPath()
  }
}

OutputStyleConfig — 输出样式

// constants/outputStyles.ts:11-23
export type OutputStyleConfig = {
  name: string
  description: string
  prompt: string                      // 注入到系统提示词的文本
  source: SettingSource | 'built-in' | 'plugin'
  keepCodingInstructions?: boolean
  forceForPlugin?: boolean            // 插件自动激活此样式
}

Keybinding Schema

// keybindings/schema.ts:12-32
export const KEYBINDING_CONTEXTS = [
  'Global', 'Chat', 'Autocomplete', 'Confirmation',
  'Help', 'Transcript', 'HistorySearch', 'Task',
  'ThemePicker', 'Settings', 'Tabs',
  'Attachments', 'Footer', 'MessageSelector',
  'DiffDialog', 'ModelPicker', 'Select', 'Plugin',
] as const

export const KEYBINDING_ACTIONS = [
  'app:interrupt', 'app:exit', 'chat:submit',
  'chat:cycleMode', 'chat:modelPicker',
  'autocomplete:accept', 'confirm:yes', 'confirm:no',
  // ... 80+ 个动作标识符
] as const

迁移函数示例

// migrations/migrateSonnet45ToSonnet46.ts:29-67
export function migrateSonnet45ToSonnet46(): void {
  if (getAPIProvider() !== 'firstParty') return
  if (!isProSubscriber() && !isMaxSubscriber()) return

  const model = getSettingsForSource('userSettings')?.model
  if (model !== 'claude-sonnet-4-5-20250929' &&
      model !== 'claude-sonnet-4-5-20250929[1m]') return

  const has1m = model.endsWith('[1m]')
  updateSettingsForSource('userSettings', {
    model: has1m ? 'sonnet[1m]' : 'sonnet'
  })
  // 记录迁移时间戳(非首次启动才通知)
  if (getGlobalConfig().numStartups > 1) {
    saveGlobalConfig(current => ({
      ...current,
      sonnet45To46MigrationTimestamp: Date.now()
    }))
  }
}

设计模式与亮点

1. First-Source-Wins 策略(策略层)

策略层(policySettings)使用"首个非空源获胜"而非"合并覆盖":

// utils/settings/settings.ts:322-345
if (source === 'policySettings') {
  const remote = getRemoteManagedSettingsSyncFromCache()
  if (remote && Object.keys(remote).length > 0) return remote  // 最高

  const mdm = getMdmSettings()
  if (Object.keys(mdm.settings).length > 0) return mdm.settings

  const { settings: file } = loadManagedFileSettings()
  if (file) return file

  const hkcu = getHkcuSettings()
  if (Object.keys(hkcu.settings).length > 0) return hkcu.settings

  return null
}

这确保了企业策略源的互斥性——远程 API 策略 > 本地 MDM > 文件策略,不会混搭。

2. Drop-in 配置目录

managed-settings.d/ 目录支持多个 JSON 文件独立管理策略片段(类似 systemd/sudoers 的 drop-in 约定)。 文件按字母序排序,后面的覆盖前面的。这让不同团队可以独立管理策略(如 10-otel.json、20-security.json)。

3. 幂等迁移

所有迁移函数都是幂等的——读取特定值、条件匹配才写入。不需要"已完成"标记。 例如 migrateFennecToOpus 只检查 userSettings.model 是否以 'fennec' 开头,已迁移的配置自然不匹配。

4. 输出样式的 Markdown-as-Config

自定义输出样式用 Markdown 文件定义,放在 .claude/output-styles/~/.claude/output-styles/。 文件名即样式名,frontmatter 提供 name/description,正文成为注入系统提示词的 prompt 文本。 这让非技术用户也能创建定制样式。

5. 快捷键的上下文感知解析

快捷键系统按 context(如 'Chat'、'Autocomplete'、'Confirmation')组织。 解析器在匹配时查找所有活跃上下文的绑定,最后一个匹配获胜(用户覆盖默认)。 支持 chord 序列(如 ctrl+x ctrl+k)和 null 解绑定。

6. 缓存隔离

配置系统的缓存策略精心设计:

开发者实践指南

新增配置项只需在 types.ts 的 SettingsSchema 中添加字段,系统自动处理验证和合并。

添加新配置项

步骤 1:在 utils/settings/types.ts 的 Schema 中添加字段:

// 在 SettingsSchema 中添加新字段
myNewFeature: z.boolean().optional()
  .describe('Enable the new feature')

步骤 2:在代码中通过 getSettings_DEPRECATED()getSettingsForSource() 读取

const settings = getSettings_DEPRECATED()
const enabled = settings?.myNewFeature ?? false

步骤 3(可选):通过 /config 面板暴露给用户

添加配置迁移

migrations/ 目录创建新文件:

// migrations/migrateOldToNew.ts
export function migrateOldToNew(): void {
  const settings = getSettingsForSource('userSettings')
  if (settings?.oldKey === undefined) return    // 已迁移或未设置
  updateSettingsForSource('userSettings', {
    oldKey: undefined,    // 删除旧键
    newKey: transform(settings.oldKey),
  })
}

添加自定义输出样式

创建 .claude/output-styles/my-style.md

---
name: My Style
description: A custom output style for my team
keep-coding-instructions: true
---

You are an assistant that always provides concise bullet-point answers.
Focus on actionable steps, avoid lengthy explanations.

自定义快捷键

编辑 ~/.claude/keybindings.json

{
  "$schema": "https://claude.ai/keybindings.schema.json",
  "bindings": [
    {
      "context": "Chat",
      "bindings": {
        "ctrl+shift+s": "chat:stash",
        "ctrl+j": null
      }
    }
  ]
}

架构师决策指南

五层层级的设计意图

配置层级的设计遵循"最小权限"原则:

策略层的"首个源获胜"(而非合并)是企业场景的关键需求——IT 管理员需要确定性地知道哪条策略生效。

迁移系统的幂等性设计

迁移函数没有版本号或完成标记。它们依赖条件守卫实现幂等: 读取特定值,不匹配则跳过。这意味着迁移可以安全地重复执行(如多次启动),也意味着旧迁移函数可以在新版本中删除—— 当目标值不再存在时,守卫条件自然不满足。

输出样式作为提示词工程

输出样式本质上是系统提示词的增量注入。每个样式的 prompt 字段在查询时被追加到系统提示词末尾。 keepCodingInstructions 控制是否保留默认的编码指令。这种设计让输出定制成为一种"提示词模板"机制, 而非代码层面的配置。

快捷键系统的扩展性

快捷键系统使用上下文(context)隔离,新增 UI 场景只需:

  1. schema.tsKEYBINDING_CONTEXTS 添加新上下文名称
  2. defaultBindings.ts 添加默认绑定
  3. 在组件中通过 useKeybinding('action', handler) 注册

用户通过 keybindings.json 覆盖任何默认绑定,null 值解绑。 reservedShortcuts.ts 阻止用户重新绑定 ctrl+c/ctrl+d 等关键快捷键。

性能考量

可视化处理拓扑图

Phase 1 — 五层配置加载与深合并

配置系统从最低优先级到最高优先级逐层叠加。loadSettingsFromDisk() 是核心入口,遍历所有配置源并深合并。

loadSettingsFromDisk()settings.ts:645 — 配置加载入口
插件设置基座getPluginSettingsBase() — 仅 allowlisted key(如 agent)settings.ts:660-668
策略设置 — 首源获胜 (First Source Wins)互斥策略:Remote Managed → MDM → managed-settings.json → HKCUsettings.ts:675-739
Remote API最高优先
空?
MDMHKLM/plist
空?
JSON 文件managed-*.json
空?
HKCU注册表
首个非空源获胜,后续源跳过 — 策略源互斥
逐源读取 + Zod Schema 验证parseSettingsFile() → SettingsSchema().safeParse()settings.ts:741-790
userSettings~/.claude/settings.json
projectSettings.claude/settings.json
localSettings.claude/settings.local.json
flagSettings--settings <path>
文件源叠加 — 所有源参与合并,高优先级覆盖低优先级同名字段
深合并 — lodash mergeWithsettingsMergeCustomizer() — 数组替换(非拼接)settings.ts:538-547
会话级缓存 + 错误收集resetSettingsCache() 清空 → seenFiles 路径去重 → allErrors 去重settingsCache.ts:7-13

Phase 2 — 配置写入与迁移

写入流程绕过缓存直接读取文件,迁移函数按序执行且幂等。

updateSettingsForSource()settings.ts:416-524
目标源是否可写?policySettings / flagSettings 只读保护
拒绝写入策略/标志源不可写
绕过缓存读取文件bypass cache
可写 ↓
mergeWith 增量合并undefined 值触发删除键
Zod 验证合并结果SettingsSchema().safeParse()
原子写入 JSONmarkInternalWrite() 标记
resetSettingsCache()全量清除缓存
迁移执行入口migrations/ — 每次版本升级按序执行
migrateAutoUpdatesenv → settings
migrateFennecToOpus旧模型名
migrateSonnet45→46链式迁移
条件匹配 → 写入幂等:已迁移不触发
条件不匹配 → 跳过无副作用

Phase 3 — 策略互斥 vs 文件叠加

策略源 — 互斥(首源获胜)
Remote ManagedAPI 下发
有内容 → 停止
MDMHKLM / plist
有内容 → 停止
managed-settings.jsondrop-in 目录
有内容 → 停止
HKCU 注册表最低策略
单一权威源
组织管控确定性
文件源 — 叠加(深合并)
userSettings~/.claude/
mergeWith ↓
projectSettings.claude/
mergeWith ↓
localSettings.claude/*.local
mergeWith ↓
flagSettings--settings
多层组合
用户灵活定制
拓扑总结:配置系统采用双合并哲学——策略源互斥确保企业管控的单一权威性,文件源叠加允许用户在不同粒度层灵活定制。Zod Schema 延迟编译优化启动性能,幂等迁移消除版本状态管理复杂性。

核心处理流程详解

配置系统的核心是 五层合并 流程——从最低优先级的插件设置到最高优先级的 CLI 标志,逐层叠加并深合并。整个流程围绕 loadSettingsFromDisk()(settings.ts:645)展开,每次调用都会遍历所有启用的配置源。

1. 插件设置基座
loadSettingsFromDisk() 在 L660-668 以插件设置为最低优先级基座。插件设置只包含 allowlisted 的 key(如 agent),通过 getPluginSettingsBase() 从 plugin 配置中提取 — settings.ts:660-668
2. 策略设置——首源获胜
策略设置采用 "first source wins" 策略(L675-739),按优先级依次尝试:远程管理设置(remote managed)→ MDM(HKLM/macOS plist)→ managed-settings.json 文件 → HKCU 注册表。一旦某个源有内容,后续低优先级源直接跳过。每个源都经过 SettingsSchema().safeParse() 验证(L684-686)— settings.ts:675-739
3. 逐源读取与 Zod 验证
对每个启用的源(userSettings/projectSettings/localSettings/flagSettings),调用 parseSettingsFile()(L749)读取并解析 JSON 文件。解析结果通过 lazySchema(() => SettingsSchema) 延迟编译的 Zod schema 做运行时验证。验证错误被收集到 allErrors 数组,不影响有效配置的加载 — settings.ts:741-790
4. 深合并——lodash mergeWith + 数组替换
所有源按优先级顺序通过 lodash mergeWith 深合并。settingsMergeCustomizer()(L538-547)定义了自定义合并策略:数组执行替换而非拼接——这确保了 permissions.allow 等数组字段的语义是"覆盖"而非"追加" — settings.ts:538-547
5. 会话级缓存与会话重载
合并结果被缓存在会话级缓存中(settingsCache.ts:7-13),后续调用直接返回缓存。配置变更需要重启 CLI 才能生效。当需要确保磁盘最新状态时(如 getSettingsWithSources()),先调用 resetSettingsCache() 清空缓存再重新加载 — settingsCache.ts:7-13settings.ts:836-848
6. 配置写入——merge + 验证 + 标记
updateSettingsForSource()(L416-524)负责写入:先读取目标源当前配置 → 用 mergeWith 合并新设置 → 通过 Zod schema 验证合并结果 → 原子写入 JSON 文件。写入后通过 markInternalWrite() 标记,区分用户手动编辑和程序更新,避免触发不必要的 watcher — settings.ts:416-524
7. 迁移执行——版本升级自动转换
每次版本升级时,migrations/ 目录下的迁移函数按序执行。每个迁移函数检测旧配置格式并自动转换:如 migrateFennecToOpus.ts 将旧模型名映射到新模型名,migrateAutoUpdatesToSettings.ts 将环境变量设置迁移到 settings.json。迁移是幂等的——重复执行不会产生副作用 — migrations/
8. 错误收集与上报
所有源的验证错误通过文件路径 + JSON 路径 + 错误消息三元组去重(L731-735),最终汇总到 SettingsWithErrors 返回给上层。UI 层可以显示具体哪个文件的哪个字段有什么问题,帮助用户快速定位配置错误 — settings.ts:730-735
配置系统的核心权衡是"读多写少"——合并操作只在首次加载和显式刷新时发生,运行时全部走会话缓存。策略设置的"首源获胜"策略与文件设置的"深合并"策略形成鲜明对比:策略源之间是互斥的(同一时间只有一个策略生效),而文件源之间是叠加的(每层都可以覆盖上层的部分字段)。

设计精华

1. 延迟 Schema 编译——lazySchema 模式

SettingsSchema 使用 lazySchema(() => ...)(types.ts)将 Zod schema 的编译延迟到首次使用。这是因为 SettingsSchema 非常庞大(1149 行类型定义),包含大量嵌套的 permission、hook、MCP server 子 schema。如果在模块加载时编译,会显著拖慢 CLI 启动时间。

lazySchema 延迟编译
将昂贵的 schema 编译推迟到首次实际验证时,避免启动时的无效开销
// utils/settings/types.ts — 延迟 Zod schema 编译
export const SettingsSchema = lazySchema(() =>
  z.object({
    permissions: z.union([...]).optional(),
    hooks: HooksSchema.optional(),
    mcpServers: z.record(...).optional(),
    // ... 50+ 字段,嵌套 5+ 层
  })
)
lazySchema 模式在大型配置系统中特别有价值。Zod schema 的编译成本与 schema 复杂度成正比——对于有数百个字段的配置 schema,延迟编译可以节省数十毫秒的启动时间。关键实现是确保 lazy wrapper 本身足够轻量,否则延迟的意义就被抵消了。

2. 策略源互斥 vs 文件源叠加——两种合并哲学

配置系统同时使用了两种截然不同的合并策略:策略设置(policySettings)采用"首源获胜"——远程管理 > MDM > managed-settings.json > HKCU,一旦找到有内容的源就停止查找;文件设置(user/project/local/flag)采用"深合并叠加"——所有源都参与合并,高优先级源的字段覆盖低优先级源的同名字段。

双合并策略
策略互斥确保组织级管控的单一权威性;文件叠加允许用户在不同粒度层定制行为
// settings.ts:675-739 — 策略源互斥(first source wins)
if (source === 'policySettings') {
  // 1. Remote (highest)
  const remoteSettings = getRemoteManagedSettingsSyncFromCache()
  if (remoteSettings && Object.keys(remoteSettings).length > 0) {
    policySettings = SettingsSchema().safeParse(remoteSettings).data
  }
  // 2. MDM — 仅在 remote 无内容时检查
  if (!policySettings) { policySettings = getMdmSettings().settings }
  // 3. managed-settings.json — 仅在 MDM 无内容时检查
  if (!policySettings) { policySettings = loadManagedFileSettings().settings }
}

// settings.ts:673-674 — 文件源叠加(deep merge all)
for (const source of getEnabledSettingSources()) {
  mergedSettings = mergeWith(mergedSettings, settings, settingsMergeCustomizer)
}
这种双策略设计体现了"管控与自由的平衡":组织管理员通过策略源设置不可逾越的边界(互斥 = 唯一权威),而用户在边界内通过文件源灵活定制(叠加 = 多层组合)。这种模式在企业级 SaaS 产品中非常普遍,Claude Code 通过清晰的代码结构将其显式化。

3. 文件路径去重——跨源同一文件防御

某些场景下不同配置源可能指向同一个文件(如 projectSettings 和 localSettings 在非项目目录时路径相同)。seenFiles Set(L746-747)确保每个物理文件只被解析一次,避免重复读取和验证。

文件路径去重
使用 resolve() 后的绝对路径作为去重键,防止同一文件被多个源重复加载
// settings.ts:741-749
const filePath = getSettingsFilePathForSource(source)
if (filePath) {
  const resolvedPath = resolve(filePath)
  if (!seenFiles.has(resolvedPath)) {  // 路径去重
    seenFiles.add(resolvedPath)
    const { settings, errors } = parseSettingsFile(filePath)
    // ...merge
  }
}
这是一个看似简单但经常被遗漏的边界条件。在 monorepo 或 symlink 环境下,不同配置源的路径解析可能收敛到同一个物理文件。不做去重会导致:重复的验证错误消息、重复的文件 I/O、以及合并时的优先级混乱。

4. 迁移幂等性——版本无关的配置兼容

每个迁移函数都是幂等的——重复执行不会改变配置状态。例如 migrateFennecToOpus.ts 只在检测到旧模型名时才替换,如果配置已经是新模型名则直接跳过。这使得迁移可以安全地在每次启动时运行,不需要记录"已执行的迁移"状态。

幂等迁移模式
每个迁移函数先检测条件,条件不满足时直接返回,不产生任何写入
// migrations/migrateFennecToOpus.ts — 典型幂等迁移
// 仅当 model 字段包含旧值时才更新
if (settings.model === 'fennec') {
  updateSettingsForSource('userSettings', { model: 'opus' })
}
幂等迁移消除了"迁移状态管理"这个复杂性来源——不需要版本号、不需要迁移日志、不需要回滚机制。代价是每次启动都要执行所有迁移函数的检测逻辑,但由于这些检测都是简单的字符串比较或类型检查,开销可以忽略。这是"用 CPU 时间换代码简洁性"的典型案例。

Agent 实践借鉴 — 客服 Agent 配置管理设计

一、场景映射:客服 agent 的配置管理挑战

SaaS 客服平台是典型的多租户系统:A 公司用自建 LLM(私有化部署),B 公司用 OpenAI API;A 公司的电话客服要求回复风格正式严谨,B 公司的在线客服偏好活泼亲切;坐席小王想自定义问候语,但不能改系统的 API 地址。CC 的三层配置(全局 → 项目 → 本地)完美映射到客服的三层:平台全局 → 租户 → 坐席个人。CC 的策略层互斥设计则对应平台方对租户的管控边界。

二、借鉴 CC + 客服改造

实践 1:三层配置层级(借鉴 CC 全局/项目/本地分层)

CC 用五个 SettingSource 实现了"全局默认 → 项目覆盖 → 本地定制"的分层体系。客服场景同样需要三层:Platform(平台默认配置)→ Tenant(租户覆盖)→ Agent(坐席个人配置),高优先级覆盖低优先级的同名字段。

// 借鉴 settings.ts 的 SettingSource 分层设计
type ConfigSource = 'platform' | 'tenant' | 'agent'

interface CustomerServiceConfig {
  model: string                    // 使用哪个 LLM
  replyStyle: 'formal' | 'casual' | 'professional'
  greeting: string                 // 问候语
  maxRetries: number               // 最大重试次数
  apiEndpoint: string              // LLM API 地址
  allowedChannels: string[]        // 该租户开通的渠道
  transferRules: TransferRule[]    // 转接人工的规则
  systemPrompt: string             // 系统提示词
}

// 三层配置加载:platform → tenant → agent
function loadEffectiveConfig(
  tenantId: string, agentId: string,
): CustomerServiceConfig {
  // 第一层:平台默认配置(最低优先级)
  const platformDefaults = loadPlatformConfig()
  // 第二层:租户配置(覆盖平台默认)
  const tenantConfig = loadTenantConfig(tenantId)
  // 第三层:坐席个人配置(覆盖租户配置)
  const agentConfig = loadAgentConfig(tenantId, agentId)

  // 深合并:tenant 覆盖 platform,agent 覆盖 tenant
  return deepMergeConfig(
    deepMergeConfig(platformDefaults, tenantConfig),
    agentConfig,
  )
}

// 示例:平台默认用 sonnet,A 租户用自建 LLM,坐席小王不改模型
// Platform: { model: 'sonnet', apiEndpoint: 'https://api.openai.com' }
// Tenant A: { model: 'custom-llm', apiEndpoint: 'https://llm.company-a.com' }
// Agent 小王: { greeting: '您好,我是小王' }  // 只改问候语
// 最终结果: { model: 'custom-llm', apiEndpoint: 'https://llm.company-a.com',
//             greeting: '您好,我是小王', ... }

实践 2:深层合并策略(借鉴 CC 的 mergeWith + 数组替换)

CC 的 settingsMergeCustomizer 确保数组执行替换而非拼接。客服场景也有同样需求——transferRules(转接规则)在租户层配置了就应该完全替换平台默认规则,而不是追加。

// 借鉴 settings.ts:538-547 的 settingsMergeCustomizer
function deepMergeConfig<T>(base: T, override: Partial<T>): T {
  return mergeWith({}, base, override, (baseVal, overVal) => {
    // 数组执行替换,不拼接——转接规则的语义是"覆盖"而非"追加"
    if (Array.isArray(baseVal) && Array.isArray(overVal)) {
      return overVal  // 租户的 transferRules 完全替换平台默认
    }
    // 字符串/数字等基本类型直接覆盖
    if (typeof baseVal !== 'object' || baseVal === null) return overVal
    // 对象递归合并
    return undefined  // 让 mergeWith 默认递归
  })
}

// 反例(错误做法):用 Object.assign 或浅拷贝
// Object.assign(platformDefaults, tenantConfig)
// 问题:嵌套对象会被整体替换而非合并
// 平台: { systemPrompt: { prefix: '你是客服', suffix: '请保持礼貌' } }
// 租户: { systemPrompt: { prefix: '你是 A 公司的客服' } }
// Object.assign 结果: { prefix: '你是 A 公司的客服' }  // suffix 丢了!
// 深合并结果: { prefix: '你是 A 公司的客服', suffix: '请保持礼貌' }  // 正确

实践 3:配置热更新(借鉴 CC 的文件监听 + 缓存失效)

CC 通过 resetSettingsCache() 在写入后清除缓存。客服场景更严格——10 万个在线会话的配置变更必须实时生效,不能重启 agent。

// 借鉴 settingsCache.ts 的缓存失效 + 广播模式
class TenantConfigManager {
  private configCache = new Map<string, { config: CustomerServiceConfig; version: number }>()
  private subscribers = new Map<string, Set<(config: CustomerServiceConfig) => void>>()

  // 管理后台修改租户配置 → 触发热更新
  async updateTenantConfig(tenantId: string, patch: Partial<CustomerServiceConfig>) {
    // 1. 验证配置合法性
    const validation = validateConfig(patch)
    if (!validation.success) {
      throw new Error(`配置校验失败:${validation.errors.map(e => e.message).join('; ')}`)
    }
    // 2. 写入数据库
    await db.tenantConfigs.update(tenantId, patch)
    // 3. 清除缓存(借鉴 resetSettingsCache)
    const newConfig = this.reloadFromDB(tenantId)
    // 4. 广播给该租户所有正在运行的 agent 实例
    this.broadcastToAgents(tenantId, newConfig)
  }

  // 每个 agent 实例订阅自己租户的配置变更
  subscribe(tenantId: string, callback: (config: CustomerServiceConfig) => void) {
    if (!this.subscribers.has(tenantId)) {
      this.subscribers.set(tenantId, new Set())
    }
    this.subscribers.get(tenantId)!.add(callback)
    // 返回取消订阅函数
    return () => this.subscribers.get(tenantId)?.delete(callback)
  }

  private broadcastToAgents(tenantId: string, config: CustomerServiceConfig) {
    const agents = this.subscribers.get(tenantId)
    agents?.forEach(cb => {
      try { cb(config) } catch (e) { console.error('配置热更新回调失败', e) }
    })
  }
}

实践 4:配置校验与友好错误提示(借鉴 CC 的 Zod 验证 + 错误收集)

// 借鉴 settings.ts 的 parseSettingsFile + 错误收集模式
interface ConfigError {
  field: string      // 'transferRules[2].condition'
  message: string    // '条件表达式语法错误'
  suggestion: string // '请参考文档:https://docs.example.com/rules'
}

function validateConfig(config: unknown): { success: boolean; errors: ConfigError[] } {
  const result = CustomerServiceConfigSchema.safeParse(config)
  if (result.success) return { success: true, errors: [] }

  const errors: ConfigError[] = result.error.issues.map(issue => ({
    field: issue.path.join('.'),
    message: issue.message,
    suggestion: getSuggestion(issue.path.join('.'), issue.message),
  }))

  return { success: false, errors }
}

// 友好提示,不是 "schema validation failed"
function getSuggestion(field: string, _msg: string): string {
  if (field === 'apiEndpoint') return '请输入完整的 HTTPS 地址,如 https://api.openai.com'
  if (field.startsWith('transferRules')) return '转接规则格式:{ condition: "关键词", target: "技能组ID" }'
  if (field === 'replyStyle') return '可选值:formal / casual / professional'
  return '请参考配置文档'
}

三、落地清单

必须抄的
  • 分层配置 + 深层合并——Platform/Tenant/Agent 三层,用深合并而非浅拷贝,数组必须替换不拼接
  • 配置校验——每个字段验证 + 友好错误提示,不是 "schema validation failed"
  • 配置热更新——管理后台修改后广播给运行中的 agent 实例,不能要求重启
不需要抄的
  • lazySchema——客服场景 agent 启动不频繁(不像 CLI 每次命令都启动),可以启动时编译 schema
  • .claude/settings.local.json 概念——客服配置在管理后台数据库,不在本地文件系统
  • 模型名迁移——CC 的 migrateFennecToOpus 是因为 API 模型名变了,客服场景的配置迁移需求不同

四、常见坑

  1. 用 Object.assign 做配置合并——嵌套对象被整体替换而非合并,租户只想改 systemPrompt.prefix 却把 systemPrompt.suffix 丢了。必须用深层合并。
  2. 配置热更新丢失进行中的对话状态——配置变更后广播给 agent,但正在执行的对话可能用的是旧配置。必须做"新会话用新配置,旧会话保持旧配置"的灰度切换。
  3. 坐席个人配置覆盖了系统安全字段——坐席不应修改 apiEndpointtransferRules 等系统配置。借鉴 CC 的策略层只读保护,用白名单控制坐席可修改的字段。
  4. 校验错误消息对运营人员不友好——运营人员不是开发者,"Expected string, received number" 毫无意义。必须为每个字段提供中文友好提示和修复建议。

代码索引

文件行数说明
utils/settings/settings.ts~500配置读取、合并、写入核心逻辑
utils/settings/types.ts~200SettingsJson 类型、Zod Schema 定义
utils/settings/constants.ts~100SETTING_SOURCES 定义、源名称映射
utils/settings/validation.ts~200Zod 错误格式化、权限规则过滤
utils/settings/settingsCache.ts~150多级缓存(文件/源/会话)
utils/settings/managedPath.ts~50managed-settings.json 路径管理
utils/settings/changeDetector.ts~80配置变更检测(文件 watcher)
utils/settings/applySettingsChange.ts~100配置变更应用逻辑
utils/config.ts~400全局配置管理(globalConfig)
migrations/migrateAutoUpdatesToSettings.ts~62自动更新配置迁移
migrations/migrateFennecToOpus.ts~46Fennec → Opus 模型名迁移
migrations/migrateSonnet45ToSonnet46.ts~68Sonnet 4.5 → 4.6 别名迁移
migrations/migrateOpusToOpus1m.ts~40Opus → Opus 1m 迁移
migrations/migrateLegacyOpusToCurrent.ts~40旧版 Opus 到当前版迁移
migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.ts~30Bridge → RemoteControl 迁移
constants/outputStyles.ts~217内置输出样式定义(default/Explanatory/Learning)
outputStyles/loadOutputStylesDir.ts~99从 .claude/output-styles/ 加载自定义样式
keybindings/defaultBindings.ts~341默认快捷键绑定(按上下文组织)
keybindings/schema.ts~237Zod Schema、上下文/动作枚举
keybindings/resolver.ts~120快捷键解析器(匹配 + chord 支持)
keybindings/parser.ts~100快捷键字符串解析
keybindings/useKeybinding.ts~80React hook:注册快捷键处理器