qianwen-cli 架构拆解:分层、双模式与 OAuth 如何塞进一个 ESM 二进制
这是个什么 CLI
@qianwenai/qianwen-cli 是千问的命令行客户端,装成一个 Node 单文件 ESM 二进制。它能浏览模型、查用量和账单、管 OAuth 登录、看本地环境诊断,也能进交互式 REPL。
它有个明显的双面性格:不带参数启动进 REPL(src/repl.ts),带参数就跑一次命令。后者是为脚本、CI 和 AI agent 准备的——输出不能带 ANSI 颜色码,否则管道和解析器会吃瘪。
先看一张图
整张架构图我画在这里:
带暗亮主题切换和导出菜单(右上角可导出 PNG/JPEG/SVG),跟随站点的 prefers-color-scheme 自动切主题。
图的骨架是一条从左到右的主路径:用户 → 入口 → 命令 → 服务 → API 层 → 后端。下面逐段拆。
分层:命令到后端不跳层
代码按层组织,请求自上而下走,高一层不该知道低一层的内部:
commands/ Commander action handlers,解析参数 → 调 service → 渲染services/ 编排,跨调用组合、缓存api/ 传输,base-client → auth-client/api-client → cli-facadeview-models/ 把域类型转成显示行,纯函数,不碰 I/Ooutput/ 按 JSON / text 渲染ui/ Ink(React)组件,交互表格、提示、spinner这条规矩不是摆设。commands/ 里的 action handler 只做三件事:解析 flag、决定格式、调 service 再渲染。它不直接 fetch,也不直接组装视图模型。
举一个真实的 service 编排例子。UsageService 在 createServices() 里这样构造:
const usageService = new UsageService( apiClient, billingService, freetierService, tokenplanService, cache,);一个用量查询要同时动用计费、免费额度、token 套餐三个子服务。这些子服务在同一次 CLI 运行里只构造一次,被共享,所以 FreetierService 的配额缓存能跨命令存活,不用每次重查。
组合根:一个地方管所有装配
src/services/index.ts 的 createServices() 是唯一的装配点。它按依赖顺序把整条服务链搭起来,源码注释里写得很直白:
BaseClient → AuthClient ↘BaseClient → ApiClient → FreetierService ↘ → BillingService ↘ → TokenplanService → UsageService → ModelsService (← FreetierService) → AuthService (← AuthClient)把所有 new 集中在一个函数,好处是依赖都是注入的,不是在服务内部自己 new 出来。这正是它能被单测的原因——tests/helpers/service-mocks.ts 注入假的 ApiClient 和 CachedFetcher,不用碰网络。
返回的 ServiceContainer 把 apiClient、authClient、cache 和十来个 service 一次性交出来。commands 拿到这个容器,只认它暴露的窄接口,不直接碰传输层。
双模式 和 one-shot 怎么切换
bin/qianwen.ts 是真入口。它看 process.argv 有没有参数,走两条路:
if (args.length === 0) { const { startRepl } = await import('../src/repl.js'); await startRepl();} else { const { createProgram } = await import('../src/cli.js'); const program = createProgram(); await program.parseAsync(process.argv);}REPL 是动态 import 的,这样 one-shot 模式不用付加载 REPL 的成本。
模式标记存在 src/utils/runtime-mode.ts 的一个模块级 _isRepl 变量里,REPL 启动时 setReplMode() 置一次。之后所有面向用户的输出都查 isReplMode():
- one-shot 模式输出必须不带 ANSI 颜色码,要能被管道和 grep 干净处理
- 帮助样式(
cli.ts里的styleSectionTitle等)只在 REPL 模式才上色 - 命令提示里的
qianwen前缀,只有 one-shot 模式才加;REPL 里直接写login
写新输出时这条规矩要守住——别硬编码 qianwen 前缀,也别无条件下色。
协议
后端不说 REST,说一套“flat-parameter 协议”。请求靠 product 加 action 两个常量来路由,常量都集中在 src/types/api-routes.ts:
export const API_PRODUCT_GATEWAY = 'sfm_bailian';export const API_PRODUCT_SEARCH = 'aliyun-search-maas';export const API_ACTION_LIST_MODELS = 'ListModelSeries';export const API_ACTION_CONSUME_SUMMARY = 'MaasListConsumeSummary';export const API_ACTION_GET_FUND_ACCOUNT_BALANCE = 'GetFundAccountAvailableAmount';api-client.ts 暴露 callFlatApi 和 callEnvelopeApi 两个方法发请求。cli-facade.ts 再把这些包成 commands 真正消费的窄业务方法——getModels、getModelDetail、listConsumeSummary、describeFqInstance、getAvailableBalance 这类。
传输层和域类型之间还插着 wire-format 适配器(api/adapters/),把扁平的接口响应翻译成 types/ 里的域类型。模型映射逻辑更重一点,单独放在 api/model-mapper/ 下,里面有定价、免费额度、属性各自一套映射。所以 product/action 常量属于 types/api-routes.ts,不能内联到调用处——这是协议层的纪律。
认证 Device Flow 加 keychain
命令在发任何 API 请求前都要先过 ensureAuthenticated() 这道门,它来自 src/auth/credentials.ts。
登录走 OAuth Device Flow(auth/login-flow.ts)。凭证存在 OS keychain 里(auth/keychain.ts),没有 keychain 就退回加密文件(auth/crypto-store.ts)——这里没用 keytar 这种原生绑定。
传输层 src/api/base-client.ts 是 HTTP 底座,包全局 fetch,带超时、注入 auth、错误归一化和调试脱敏。authMode 有 required/optional/none 三档:
if (authMode === 'required') { const creds = resolveCredentials(); if (!creds) throw new Error('Not authenticated. Please login first.'); headers.Authorization = `Bearer ${creds.access_token}`;}redactToken 在调试输出里把 token 抹掉。站点端点都收在 src/site.ts 这一个地方site 的值,别硬编码——这套配置让 CLI 能换皮。
输出:格式怎么决定
格式有三档优先级(src/output/format.ts):--format flag 最高,其次是 config 里的 output.format,最后是 TTY 检测(交互式落 table,管道落 JSON)。非法 --format 值直接在 stderr 报结构化错误退出,挡住 agent 传一个不支持的格式。
退出码集中管(src/utils/exit-codes.ts):0 成功,1 通用/用法错,2 认证失败,3 网络错,4 配置/参数错,130 中断。JSON 错误走固定的 { error: { code, message, exit_code } } 形状,统一从 handleError 出,不手写。
这套设计的目标很明确
构建管线和注入的全局变量
scripts/build.ts 编排构建bin/qianwen.ts 打成 dist/bin/qianwen.js,prod 跑 Terser 混淆,默认不产出 sourcemap,然后跑一遍 smoke test(除非 --skip-smoke)。
构建时通过 tsup define 注入几个全局变量,用到的地方必须显式声明:
__VERSION__来自package.json版本,在src/index.ts和src/api/base-client.ts用__BUILD_TIME__、__NODE_ENV____ERROR_VERBOSITY__(取suppress/graceful/verbose),测试里固定为verbose
引用这些全局的新代码,要加一句 declare const __FOO__: ... 带个测试/dev 回退字面量,不然构建会断。prod 只发二进制入口,index.ts/cli.ts/repl.ts 这些库导出只在 dev 模式构建。
一些取舍
这套分层看着重,但有几个地方是刻意留的弹性。view-model 这层如果某个命令是纯 JSON 输出,可以跳过——但要在改动说明里说清楚跳了。Commander 的内部属性访问全走 src/utils/commander-helpers.ts,因为 prod 的属性 mangling 会把下划线前缀的字段打乱,直接读会断。
服务层不引新的抽象,除非有两个真实调用方需要。组合根和 types/api-routes.ts 常量是指定的扩展点——扩它们,别 fork 它们。
一句话总结这套架构想干嘛:把一个对 agent 友好、对人也能看的 CLI,用清晰的分层和一个集中的装配点管住,让传输、协议、认证这些容易发散的东西各自有边界。
参考资料
- QianWen-AI/qianwen-cli 仓库 — 全文所有代码事实的一手源,版本 v1.3.0(commit
b2da57f) - 架构图 qianwen-cli-architecture.html — 本文配套的架构图,暗亮主题可切换,可导出 PNG/JPEG/SVG
- src/services/index.ts —
createServices()组合根与依赖装配顺序 - src/types/api-routes.ts — flat-parameter 协议的 product/action 常量
- src/output/format.ts — 输出格式优先级与 TTY 回退逻辑
- src/auth/credentials.ts —
ensureAuthenticated认证门与凭证读写