系列文章:从零实现一个通用智能体
本篇主题:/model命令、Provider 配置、模型选择与 API Key 存储
代码版本:mx-claw 模型管理模块当前实现
| 项目 | 内容 |
|---|---|
| 文章类型 | 工程实现笔记 |
| 涉及模块 | apps/cli/src/commands/model、packages/model-provider |
| 关键技术 | Commander、Inquirer、TypeScript、OpenAI-compatible API、Keychain、AES-GCM |
| 阅读重点 | Provider 与模型的关系、会话级切换、API Key 安全存储 |
这篇文章记录 mx-claw /model 命令的完整实现。内容从命令目录和数据模型开始,依次介绍
Provider 配置流程、全局默认模型、会话临时切换、动态模型发现和配置文件写入,最后重点
说明 API Key 为什么不能进入普通配置,以及系统 Keychain、外部 Secret Source 和 AES-GCM
文件回退如何衔接。
做 coding agent 到模型配置这一步,问题会突然变具体。
表面上 /model 只是让用户选个模型,但真正写起来,它至少牵扯四件事:
文章会沿着 mx-claw 当前的真实实现,从命令目录一直走到 /models 请求和 Secret Store。
本篇包括模型选择、Provider 切换、临时模型切换、模型发现,以及API Key存储。
/model 不是一个几百行的文件,而是拆成:
textapps/cli/src/commands/model/ index.ts 命令入口 handler.ts 子命令分发 interactive.ts 交互向导和临时切换 provider-selection.ts Provider/模型菜单数据 session.ts 配置变化后的会话同步 __tests__/ handler.test.ts
index.ts 只做一层上下文适配:
tsasync execute(context) {
if (!context.model) {
writeLine(context.output, 'Model configuration is unavailable in this runtime.')
return 'continue'
}
await handleModelCommand({
args: context.args,
confirm: context.prompts.confirm,
core: context.core,
discoverModels: discoverProviderModels,
output: context.output,
provider: context.model.provider,
prompt: context.prompts.prompt,
readSecret: context.prompts.readSecret,
selectModel: context.prompts.selectModel,
selectProviderModel: context.prompts.selectProviderModel,
selectProvider: context.prompts.selectProvider,
session: context.session,
store: context.model.store,
})
return 'continue'
}
这一层不解析参数,也不写交互流程。它只负责把 REPL、Inquirer、AgentCore 和配置服务接到一起。
真正的命令分发在 handler.ts:
text/model 配置 Provider,并选择默认模型 /model configure [provider] 显式配置 Provider /model switch 在当前 Provider 内临时换模型 /model default ... 修改全局默认模型 /model status 查看状态 /model list 查看全部模型 /model refresh [provider] 从 /models 发现模型
这样拆之后,命令、交互、会话同步和持久化不会搅在一起。
一开始我只有一个 model 字段,后来发现这个数据结构不够用。
同一个 Provider 下面经常有多个模型,比如 DeepSeek 的 deepseek-flash 和
deepseek-v4-pro。用户切换这两个模型时,不应该重新配置 API Key,也不应该重新填
Base URL。
所以 ProviderConfig 现在长这样:
tsexport interface ProviderConfig {
name: string
kind: 'builtin' | 'custom'
baseURL: string
model: string
models: string[]
apiKey: string
requiresApiKey: boolean
configured: boolean
}
字段含义要分清:
baseURL:该 Provider 的 API 地址;apiKey:只在运行时内存中出现,写 config.json 时会被清空;models:用户配置的可用模型名称;model:该 Provider 当前的默认模型;activeProvider:全局配置里当前生效的 Provider。完整配置是:
tsexport interface ModelConfig {
version: 1
activeProvider: string
providers: Record<string, ProviderConfig>
}
模型 ID 统一成:
textprovider/model
例如:
textopenai/gpt-4o deepseek/deepseek-reasoner ollama/qwen2.5-coder
Provider 负责连接信息,模型 ID 负责具体选择。这两件事分开后,模型切换就不用碰密钥和 Base URL。
我中途把交互改过一次。
最初 /model 直接列出全部 provider/model,这在 Provider 少的时候没问题,但用户看到
的是:
textopenai/gpt-4o-mini openai/gpt-4o deepseek/deepseek-chat deepseek/deepseek-reasoner ollama/llama3.2
问题是一个还没配置好的 DeepSeek 模型也会混在列表里。用户很难分清自己是在“配置 Provider”,还是在“临时换模型”。
最后我把两个动作拆开。
配置 Provider:
text/model
临时换模型:
text/model switch
/model 的流程是:
text选择 Provider -> Base URL -> 模型名称列表 -> API Key -> 选择默认模型 -> 保存并生效
实际交互大概是这样:
text? Select a model provider ❯ openai deepseek ollama Base URL [https://api.openai.com/v1] Model names, comma-separated [gpt-4o-mini, gpt-4o] API Key for openai (input hidden): ? Select the default model ❯ gpt-4o-mini gpt-4o
也可以绕过菜单:
text/model configure deepseek
对已经配置过的 Provider,第二次修改会先确认是否覆盖。对没配置的 Provider,不会弹 覆盖确认。
配置成功后会同时修改:
activeProvider;model;但不会删除其他 Provider。openai、deepseek、ollama 各自保留自己的 Base URL、
模型列表和密钥。
这部分逻辑在 configureInteractively() 里。关键是“先把所有输入收完,再一次性保存”:
tsconst updated = await options.store.configureProvider(
selected,
{
apiKey,
baseURL,
model: defaultModel,
models,
},
overwrite,
)
不要在填 Base URL 时保存一次,填模型时再保存一次,最后填 Key 又保存一次。中途取消会 留下半配置状态,后面很难判断 Provider 到底可用不可用。
这里有个很容易混掉的状态:
textconfig.activeProvider + provider.model // 全局默认 session.model // 当前会话实际模型
我一开始让所有切换都写配置,结果用户只是临时想换个模型,下一次启动却被永久改了默认值。
现在的规则是:
text/model switch /model gpt-4o /model use openai gpt-4o
只修改当前会话。
修改全局默认要用:
text/model default openai/gpt-4o
switchModel() 里通过 persist 参数区分:
tsif (persist) {
const updated = await options.store.selectProvider(providerName, modelName)
syncSessionModel(options.core, options.provider, updated, options.session.sessionId)
writeLine(options.output, `Default model: ${providerName}/${modelName}`)
return
}
options.provider.setConfig(config)
options.core.selectModel(options.session.sessionId, `${providerName}/${modelName}`)
临时切换也不会改 Provider:
tsif (!persist && providerName !== config.activeProvider) {
throw new Error(
`Temporary model switch cannot change provider ` +
`(${config.activeProvider} -> ${providerName}). ` +
`Use /model default to change the default provider.`,
)
}
这看起来有点“限制用户”,但状态更清楚。换 Provider 和换模型本来就是两个动作。
/model switch 菜单会给当前模型加标记:
text❯ deepseek-v4-pro · current deepseek-flash
第一版就有个 bug。
我先临时切到 deepseek-v4-pro,再次打开 /model switch,current 还标在
deepseek-flash。原因是当时 current 取的是:
tsconfig.providers[config.activeProvider].model
这是全局默认,不是当前会话模型。
修复后优先读 session.model:
ts// 临时切换只改 session.model;再次打开菜单时必须优先标记会话中的实际模型。
const current = modelOptions.some((option) => option.value === options.session.model)
? options.session.model
: defaultModel
当前模型目录有三路来源:
text内置推荐模型 + 用户手动添加 + Provider /models 动态发现
内置名称写在 defaultModelConfig():
ts{
version: 1,
activeProvider: "deepseek",
providers: {
openai: {
name: "openai",
kind: "builtin",
baseURL: "https://api.openai.com/v1",
model: "gpt-4o-mini",
models: ["gpt-4o-mini", "gpt-4o"],
apiKey: "",
requiresApiKey: true,
configured: false,
},
deepseek: {
name: "deepseek",
kind: "builtin",
baseURL: "https://api.deepseek.com",
model: "deepseek-flash",
models: ["deepseek-flash", "deepseek-v4-pro"],
apiKey: "",
requiresApiKey: true,
configured: false,
},
ollama: {
name: "ollama",
kind: "builtin",
baseURL: "http://127.0.0.1:11434/v1",
model: "llama3.2",
models: ["qwen2.5-coder", "deepseek-r1"],
apiKey: "",
requiresApiKey: false,
configured: false,
},
},
}
内置目录保证离线也能使用,不至于打开 /model 先等一次网络请求。
用户也可以手动加:
text/model add-model openai gpt-custom
/models 动态发现动态发现走 OpenAI-compatible 的 /models:
tsexport async function discoverProviderModels(provider, options = {}) {
const fetchImpl = options.fetchImpl ?? fetch
const headers = { accept: 'application/json' }
if (provider.apiKey) {
headers.authorization = `Bearer ${provider.apiKey}`
}
const response = await fetchImpl(`${provider.baseURL}/models`, {
headers,
method: 'GET',
})
if (!response.ok) {
const detail = (await response.text()).slice(0, 1_000)
throw new Error(`Model discovery failed (${response.status}): ${detail}`)
}
return extractModelNames(await response.json())
}
刷新结果会合并,而不是覆盖:
text/model refresh deepseek
这样内置模型、用户手动模型和 API 返回模型可以同时存在。
不过动态发现也要注意,/models 返回的是当前 Base URL 和 API Key 对应服务真正公开的
模型。账号页面、第三方控制台、网关别名和 API 模型 ID 可能不是同一个东西。
默认路径:
text~/.mx-claw/config.json
可以用 MX_CLAW_HOME 改目录。CLI 第一次 load() 后会把配置放进进程快照:
tsasync load(): Promise<ModelConfig> {
if (this.#config) {
return cloneConfig(this.#config)
}
// 读取 config.json,查询 Secret Store,然后保存快照
}
这样做有两个结果:
save() 后立刻更新快照,当前会话马上生效;config.json,当前进程不会自动重读,需要重新打开 CLI。写配置时用临时文件加原子替换,权限固定成 0600:
tsawait writeFile(temporaryPath, `${JSON.stringify(sanitizedConfig, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600,
})
await rename(temporaryPath, this.configPath)
await chmod(this.configPath, 0o600)
配置里只保存 Provider 元数据。真正写盘前,apiKey 会被清掉:
tsconst sanitizedConfig = {
...config,
providers: Object.fromEntries(
Object.entries(config.providers).map(([name, provider]) => [
name,
{ ...provider, apiKey: '' },
]),
),
}
把 API Key 写进 config.json 最省事,但问题也很直接:
json{
"apiKey": "sk-..."
}
用户发个 issue、贴个配置、备份目录、同步到网盘,Key 就出去了。
就算文件是 0600,也不能把它当普通配置。配置需要被读取、修改、分享,密钥不应该进入
这个生命周期。
textconfig.json Provider、Base URL、模型名称、当前选择 Secret Store API Key 日志 不记录 Key 终端输出 只显示 [configured]
ProviderConfig 在内存里仍然有 apiKey,因为发请求需要它。但它不是可持久化字段。
我抽象了一个很小的接口:
tsexport interface SecretReader {
readonly backendName: string
get(key: string): Promise<string | null>
}
export interface SecretStore extends SecretReader {
set(key: string, value: string): Promise<void>
delete(key: string): Promise<void>
}
默认读取顺序:
text外部只读 Secret Command -> macOS Keychain / Linux Secret Service -> AES-256-GCM 加密文件
写入顺序:
text系统安全存储 -> 不可用时回退加密文件
这套顺序参考了 Hermes 的外部 Secret Source,并把系统安全存储放在本地文件之前。最终做成 了适合 mx-claw 的组合。
macOS 上默认用 Keychain。
service 名不是写死的 mx-claw,而是把 MX_CLAW_HOME 哈希进去:
tsconst profileHash = createHash('sha256')
.update(homeDir)
.digest('hex')
.slice(0, 12)
return `mx-claw-${profileHash}`
这样默认目录、测试目录和不同 profile 不会互相拿到对方的 Key。
account 名使用:
textprovider:openai provider:deepseek provider:ollama
写入时不能直接把 Key 放进 security add-generic-password -w ...,因为进程参数可以被
其他进程看到。
我的做法是把 Key 转 hex,通过 security -i 的 stdin 写:
tsconst hexValue = Buffer.from(value, 'utf8').toString('hex')
const command =
`add-generic-password -U ` +
`-a ${quoteSecurityArgument(key)} ` +
`-s ${quoteSecurityArgument(this.#service)} ` +
`-X "${hexValue}"\n`
const result = await this.#runner('/usr/bin/security', ['-i'], {
input: command,
})
security -i 有大约 4 KiB 的行缓冲。超长内容不再改用 argv,而是直接判定安全后端不可用,
让上层回退到加密文件。为了兼容去牺牲密钥可见性,不划算。
Linux 上尝试 secret-tool:
tssecret-tool store --label "mx-claw provider:openai" \
service mx-claw-<hash> account provider:openai
读取:
textsecret-tool lookup service <service> account <key>
删除:
textsecret-tool clear service <service> account <key>
如果系统没装 secret-tool,或者 Secret Service 不可用,命令失败后回退到加密文件。
Windows 先用 AES 文件。
回退存储由两个文件组成:
textsecrets.json IV、GCM tag、密文 secrets.key 32 字节随机主密钥
两个文件都是 0600。
加密使用 aes-256-gcm,每次写入生成新的 12 字节 IV:
tsconst key = await this.#readKey()
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, iv)
const encrypted = Buffer.concat([
cipher.update(JSON.stringify(secrets), 'utf8'),
cipher.final(),
])
const payload = {
version: 1,
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
data: encrypted.toString('base64'),
}
GCM 除了加密,还会校验数据是否被改过。解密时:
tsdecipher.setAuthTag(Buffer.from(payload.tag, 'base64'))
密文文件也采用临时文件加 rename,避免写到一半留下损坏文件。
必须说清楚:这层方案防的是“磁盘上直接出现明文 Key”,不是防同用户恶意程序。只要能读
secrets.key 和 secrets.json,就能解密。它不能替代 OS Keychain。
参考 Hermes 加了只读外部来源:
bashMX_CLAW_SECRET_COMMAND='vault read mx-claw' mx-claw
helper 会收到:
textMX_CLAW_SECRET_KEY=provider:openai
它可以只输出一个值:
textsk-...
也可以输出一组:
textprovider:openai=sk-... provider:deepseek=sk-...
实现时故意只给 helper 最小环境变量:
tsconst env = { MX_CLAW_SECRET_KEY: key }
for (const name of ['PATH', 'HOME', 'USER', 'LOGNAME', 'TMPDIR', 'LANG', 'LC_ALL']) {
if (process.env[name]) env[name] = process.env[name]
}
超时 3 秒,stdout 最多 1 MiB,stderr 捕获但不回显。helper 失败时按没有值处理。
外部来源是只读的。它提供 Key 后,本地 set() 不会把值再复制到 Keychain 或文件里。否则
用户以为自己在用 Vault,结果磁盘上又多了一份。
向导里不能把 Key 作为普通 prompt 读。readSecret() 使用 raw mode,不显示字符:
textAPI Key for deepseek (input hidden):
粘贴快捷键仍然有效,只是没有回显。粘贴完直接回车。
输出里永远只显示:
textAPI Key: [configured]
不会打印前缀、末尾四位或长度。
旧的快捷命令仍然兼容:
text/model set openai api-key <key>
但更推荐:
text/model set openai api-key
后者不会让 Key 进入 shell history。
不是有 Base URL 就算配置完成。当前判断是:
tsexport function isProviderReady(provider: ProviderConfig): boolean {
return Boolean(
provider.baseURL.trim() &&
provider.model.trim() &&
provider.models.length > 0 &&
(!provider.requiresApiKey || provider.apiKey.trim()),
)
}
OpenAI、DeepSeek 必须有 Key;Ollama 可以没有。
如果模型刷新时 Provider 还没就绪,会直接跳过:
textRefresh skipped: deepseek needs configuration
这一步很有必要。不发请求比发一个必然 401 的请求更清楚。
模型请求失败不应该把 CLI 一起带走。
REPL 捕获回合异常:
textRequest failed: ... The session is still active. Try again or type /help.
Agent Core 用 try/finally 恢复会话状态:
tstry {
for await (const chunk of provider.stream(...)) {
// 产生流式事件
}
} finally {
session.runtimeState = 'idle'
session.updatedAt = now()
}
Provider 抛错、消费者提前退出,会话都不能卡在 running。


本文作者:繁星
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!