用Typer从零搭一个AI命令行工具我踩过的6个坑2026年Claude Code、Bolt、各路AI工具都在推命令行界面。以前CLI是极客专属现在成了每个AI工具的标配。开发者对CLI的期待变了——不能再是能用就行得像一个正经产品。最近我从头搭了一个AI对话CLI用的是Typer框架支持OpenAI和Anthropic双平台跑了真实项目从零到发布全流程。这篇文章不是安装教程是踩坑实录。每一个你可能绕过去的地方我都真实踩过了。一、为什么选TyperPython做CLI有三个主流选项Click、argparse原生、Typer。argparse太底层写复杂命令要自己处理大量重复逻辑。Click成熟稳定但语法偏传统。Typer的优势在于类型提示和自动文档生成——你写Python类型注解它自动生成CLI参数。fromtypingimportOptionalimporttyper apptyper.Typer()app.command()defgreet(name:str,formal:boolFalse):问候命令ifformal:print(fGood day,{name}!)else:print(fHey,{name}!)运行python main.py greet --help自动得到完整帮助文档不需要额外配置。Typer 0.21稳定生态完整Rich输出支持好。这是选它的核心原因。二、项目结构从目录规划开始CLI工具的项目结构和普通Python脚本完全不同。你的代码要被安装、被调用、被做shell补全需要一个正经的Python包结构。ai-chat-cli/ ├── pyproject.toml # 项目元数据入口点定义 ├── README.md └── src/ └── ai_chat_cli/ ├── __init__.py ├── main.py # CLI命令入口 ├── config.py # 配置管理 ├── client.py # LLM API客户端 ├── history.py # 对话历史管理 └── tokenizer.py # Token计数器pyproject.toml里的[project.scripts]是整个安装流程的关键[project] name ai-chat-cli version 0.1.0 description AI命令行对话工具 requires-python 3.10 dependencies [ typer0.21.0, rich13.0.0, openai1.0.0, anthropic0.40.0, tiktoken0.7.0, python-dotenv1.0.0, ] [project.scripts] ai-chat ai_chat_cli.main:app [build-system] requires [hatchling] build-backend hatchling.buildai-chat就是用户安装后在终端里敲的命令名。ai_chat_cli.main:app指向main.py里的Typer实例。安装命令很简单pipinstall-e.-e表示editable模式代码改了不需要重装。三、配置管理踩坑点1配置是最容易出问题的模块。我的设计原则是命令行参数 环境变量 默认值优先级依次递减。importosfrompathlibimportPathfromtypingimportLiteral,Optionalimportdotenv dotenv.load_dotenv()classConfig:PROVIDER:Literal[openai,anthropic]openaiMODEL:strgpt-4o-miniOPENAI_API_KEY:Optional[str]NoneANTHROPIC_API_KEY:Optional[str]NoneMAX_TOKENS:int4096MAX_HISTORY_LENGTH:int50HISTORY_FILE:PathPath.home()/.ai_chat_history.jsonSTREAMING:boolTrueVERBOSE:boolFalsedef__init__(self):# 从环境变量读取self.OPENAI_API_KEYos.getenv(OPENAI_API_KEY)self.ANTHROPIC_API_KEYos.getenv(ANTHROPIC_API_KEY)# 命令行参数可以覆盖环境变量ifos.getenv(AI_PROVIDER):self.PROVIDERos.getenv(AI_PROVIDER)ifos.getenv(AI_MODEL):self.MODELos.getenv(AI_MODEL)defvalidate(self)-bool:验证API密钥是否配置ifself.PROVIDERopenaiandnotself.OPENAI_API_KEY:print(错误未设置 OPENAI_API_KEY)print(方法1export OPENAI_API_KEYsk-xxx)print(方法2在当前目录创建 .env 文件写入 OPENAI_API_KEYsk-xxx)returnFalseifself.PROVIDERanthropicandnotself.ANTHROPIC_API_KEY:print(错误未设置 ANTHROPIC_API_KEY)returnFalsereturnTrue踩坑1Typer的config命令名被内部占用定义了一个命令叫config_cmd注册命令时用的是app.command()。跑起来才发现Typer的全局选项里有--config自定义命令config会冲突报错Error: No such command config. Did you mean config-cmd?解决换成setup或其他未占用名称或者显式用app.command(namecfg)指定别名。四、LLM客户端踩坑点2和3这是最复杂的模块也是踩坑最多的地方。OpenAI和Anthropic的消息格式差异两个平台的消息格式不一样。OpenAI用的是{role: user, content: ...}Anthropic的system消息是独立字段不是role: system。# OpenAI格式messages[{role:system,content:你是助手},{role:user,content:你好}]# Anthropic格式system是独立参数responseclient.messages.create(modelclaude-3-5-haiku-20241022,system你是助手,# 独立的system参数messages[{role:user,content:你好}],max_tokens4096)我的解决思路统一用OpenAI格式存储历史调用Anthropic时做一次格式转换。def_to_anthropic_format(self,messages):将统一格式转换为Anthropic格式system_promptanthropic_messages[]formsginmessages:ifmsg[role]system:system_promptmsg[content]else:anthropic_messages.append({role:msg[role],content:msg[content]})returnsystem_prompt,anthropic_messages踩坑2流式输出的降级处理流式输出体验好但不稳定。网络抖动、超时、API服务端问题都可能导致流式中断。我用了降级策略流式失败时自动切换到非流式不报错用户无感知。def_stream_openai(self,messages):try:responseself._client.chat.completions.create(modelself.model,messagesmessages,max_tokensconfig.MAX_TOKENS,streamTrue,)full_response[]print()# 换行开始流式输出forchunkinresponse:ifchunk.choicesandchunk.choices[0].delta.content:contentchunk.choices[0].delta.contentprint(content,end,flushTrue)full_response.append(content)print()# 流式结束换行return.join(full_response)exceptExceptionase:print(f\n[流式输出失败:{e}]切换非流式...)# 降级到非流式responseself._client.chat.completions.create(modelself.model,messagesmessages,max_tokensconfig.MAX_TOKENS,streamFalse,)returnresponse.choices[0].message.contentor踩坑3Anthropic的流式API完全不同Anthropic的流式API和OpenAI是两套完全不同的实现。OpenAI用streamTrue参数Anthropic用上下文管理器。def_stream_anthropic(self,system,messages):try:withself._client.messages.stream(modelself.model,systemsystem,messagesmessages,max_tokensconfig.MAX_TOKENS,)asstream:full_response[]print()foreventinstream:ifevent.typecontent_block_delta:ifevent.delta.text:print(event.delta.text,end,flushTrue)full_response.append(event.delta.text)print()return.join(full_response)exceptExceptionase:print(f\n[Anthropic流式失败:{e}])return# 降级处理这两个流式API的差异是文档里没有明确说清楚的必须实际踩过才知道。五、Token计数踩坑点4Token计数有两个用途估算费用、控制上下文长度。主流方案是用tiktoken库它是OpenAI开源的精确tokenizer比按字符数估算准很多。try:importtiktoken _HAS_TIKTOKENTrueexceptImportError:_HAS_TIKTOKENFalsedefnum_tokens_from_messages(messages,modelgpt-4o-mini):if_HAS_TIKTOKEN:encodingtiktoken.encoding_for_model(model)else:# 备用粗估total_charssum(len(str(v))forminmessagesforvinm.values())chinesesum(1forminmessagesforcinstr(m.get(content,))if\u4e00c\u9fff)returnint(chinese(total_chars-chinese)/4)踩坑4tokenizer版本不兼容tiktoken对模型名称有要求gpt-4o可能不在tiktoken的模型列表里需要做fallbackdefnum_tokens_from_messages(messages,modelgpt-4o-mini):if_HAS_TIKTOKEN:try:encodingtiktoken.encoding_for_model(model)exceptKeyError:# 模型不在列表里用默认编码器encodingtiktoken.get_encoding(cl100k_base)六、对话历史管理历史管理需要处理三个场景加载、保存、自动修剪。classHistoryManager:def__init__(self,history_fileNone):self.history_filehistory_fileorconfig.HISTORY_FILE self._history:List[Dict][]defload(self):ifnotself.history_file.exists():return[]withopen(self.history_file,r,encodingutf-8)asf:self._historyjson.load(f)returnself._historydefsave(self):self.history_file.parent.mkdir(parentsTrue,exist_okTrue)withopen(self.history_file,w,encodingutf-8)asf:json.dump(self._history,f,ensure_asciiFalse,indent2)defprune(self):上下文太长时自动删最早的对话ifnotself._history:return# 简单策略保留最近的消息对iflen(self._history)config.MAX_HISTORY_LENGTH:self._historyself._history[-config.MAX_HISTORY_LENGTH:]七、主命令入口Typer的全局选项在app.callback()里定义子命令用app.command()。apptyper.Typer(nameai-chat,helpAI命令行对话工具,)app.callback()defcallback(ctx:typer.Context,provider:strtyper.Option(None,-p,--provider),model:strtyper.Option(None,-m,--model),verbose:booltyper.Option(False,-v,--verbose),):ifprovider:config.PROVIDERproviderifmodel:config.MODELmodel config.VERBOSEverboseapp.command()defchat(message:Optional[str]None):ifmessage:_single_chat(message)else:_interactive_chat()app.command(ask)defask(message:strtyper.Argument(...,help要提问的内容)):快捷单次提问_single_chat(message)运行效果$ ai-chat --help ╭─ Options ─────────────────────────────────────╮ │ --provider -p TEXT AI提供商 │ │ --model -m TEXT 模型名称 │ │ --verbose -v 详细输出 │ ╰───────────────────────────────────────────────╯ ╭─ Commands ───────────────────────────────────╮ │ chat 与AI对话 │ │ ask 快速单次提问 │ │ setup 查看和修改配置 │ │ history 管理对话历史 │ ╰───────────────────────────────────────────────╯ $ ai-chat setup --list Provider: openai Model: gpt-4o-mini OpenAI Key: 已设置 Anthropic Key: 未设置八、完整踩坑清单搭这个工具踩了6个坑汇总一下序号坑严重程度解决方案1Typer的config命令名被内部占用高换用setup等其他名称2OpenAI和Anthropic消息格式不兼容高统一存储OpenAI格式调用时转换3Anthropic流式API和OpenAI完全不同高分别实现不共用代码4tiktoken模型名不匹配导致KeyError中try/except加fallback到cl100k_base5流式输出网络失败没有降级中异常时自动切换非流式6pyproject.toml缺少README导致安装失败低加上readme README.md九、进阶扩展方向这个基础版本可以往几个方向扩展1. Shell补全Typer内置Shell补全支持但需要额外安装# Bashai-chat --install-completionbash~/.bashrc# Zshai-chat --install-completionzsh~/.zshrc# Fishai-chat --install-completion fish补全安装后输入ai-chat ask 能自动补全选项。2. 多轮Agent模式加上工具调用能力让AI能执行shell命令、读文件、搜索网络。这是Claude Code的核心能力。3. 发布到PyPIpipinstallbuild twine python-mbuild twine upload dist/*发布后全世界都能pip install ai-chat-cli安装你的工具。总结用Typer搭AI CLI比想象中更简单也比想象中更容易踩坑。核心经验三条1. 选对框架省大量时间。Typer的类型推断自动文档比argparse少写一半代码。2. API兼容层要早做。OpenAI和Anthropic的差异从第一天就想清楚怎么处理别等产品做完了再重构。3. 流式输出必须有降级。网络不稳定是常态没有降级方案的工具在生产环境会频繁挂掉。完整项目代码在workspace的ai-chat-cli/目录下可以直接pip install -e .安装运行。