从零做一个自己的 CLI引言装过 Claude Code 或 Codex CLI 的人都有同感终端里敲一行命令在哪个项目文件夹都能用不用点开浏览器也不用先找脚本在哪。这篇文章做两件事第一给已经能跑的Agent加一层CLI 外壳让它像上述工具一样随处可用第二讲清楚两种流式输出在实际开发里分别适合什么场景。文中的完整示例开源在 GitHubhttps://github.com/SWUSTcyt/travel-cli一个带工具调用与流式输出的 Agent CLI用旅游规划作演示场景。会一点 Python、有 API Key 就能跟着玩安装、环境变量与运行命令见仓库根目录README.md正文不展开pip install。文章目录从零做一个自己的 CLI引言一、CLI 速览是什么、为什么要做二、外层封装Typer 小例子 两层结构2.1 先建立直觉2.2 外壳长什么样三、Agent 核心注册工具、创建 Agent、Loop3.1 注册工具给 Agent 一双手3.2 Loop想一步、做一步、再想一步3.3 外壳怎么接到核心四、两种流式区别与开发中怎么选4.1 阶段流 --stream stage4.2 字段流 --stream instant4.3 怎么选一张表结语一、CLI 速览是什么、为什么要做CLICommand Line Interface命令行界面就是用键盘输入命令、在终端里拿结果。和 GUI点按钮的图形界面相对GUICLI操作鼠标点键盘敲命令例子网页 ChatGPTtravel run 杭州一日游一条命令通常长这样travel run帮我规划杭州一日游--streamstage │ │ │ │ 命令名 子命令 你的需求 可选参数为什么要做成 CLI两点就够随处可用——像 Claude Codepip install一次后任意目录都能敲不必cd到脚本文件夹。好分享、好复现——教程里写一行命令读者复制就能跑。如果只是自己试 Agent用python main.py或input()完全没问题要发 GitHub、写教程、给朋友用就需要再包一层 CLI。Agent 逻辑不用重写只是加外壳。示例项目travel-cli用「旅游规划」作演示但外壳封装、Loop、流式选型适用于任意 Agent 场景。二、外层封装Typer 小例子 两层结构2.1 先建立直觉你在终端敲travel run 杭州一日游 ↓ cli.pyTyper 外壳—— 解析命令和参数 ↓ runner.py loops/Agent 核心—— 搜网页、查地图、多轮推理重点Typer 和 Agently没有关系。Typer 只管「用户敲了什么」Agent 框架只管「怎么干活」。我们在现有 Agent 核心外面套了一层通用的 Python CLI 封装。[图两层结构示意——上方 Typer 外壳下方 Agent 核心]2.2 外壳长什么样摘自 travel-cli 的cli.py精简版importasyncioimporttyperfromtravel_cli.runnerimportrun_travel apptyper.Typer()app.command(run)defrun_cmd(request:str,stream:str|NoneNone,):asyncio.run(run_travel(request,streamstream))if__name____main__:app()逐行白话代码少但语法容易懵代码什么意思import typer引入第三方库 Typer专门用来写 CLIapp typer.Typer()创建一个 CLI「应用」对象app.command(run)装饰器把紧挨着的函数注册成子命令run你在终端敲travel run就会进这个函数request: str命令里杭州一日游这类文字会传进这个参数stream: str | None None可选参数不传就走默认批处理传stage/instant走流式asyncio.run(run_travel(...))Agent 内部是异步的async defTyper 函数是同步的用这一行把两者接上app()启动 CLI开始解析你在终端输入的内容再配一行pyproject.toml[project.scripts] travel travel_cli.cli:app执行pip install -e .后系统里就多了一个全局命令travel——和装 Claude Code 后在任意目录敲claude是同一类体验。对比没有外壳每次cd进项目目录再python xxx.py有外壳任意目录travel run ...三、Agent 核心注册工具、创建 Agent、Loop外壳只负责「接命令」。真正干活的在agent.py和loops/里。3.1 注册工具给 Agent 一双手摘自agent.pyfromagentlyimportAgentlyfromagently.builtins.actionsimportBrowse,Searchasyncdefbuild_travel_agent(workdir:str):agentAgently.create_agent()agent.set_agent_prompt(system,你是旅游辅助助手……)Search().register_actions(agent.action)Browse().register_actions(agent.action)awaitagent.async_use_mcp(fhttps://mcp.amap.com/mcp?key...)agent.action.register_bash_sandbox_action(allowed_workdir_roots[workdir],...)returnagent四步理解create_agent()—— 创建一个 Agent 实例。set_agent_prompt—— 设定角色与行为边界示例里是旅游助手可按业务替换。注册工具—— 搜索、读网页、高德地图 MCP、受控 bash相当于「能调用的能力」。use_actions在 runner 里调用—— 从注册表里激活要用的工具。3.2 Loop想一步、做一步、再想一步默认travel run走loops/batch.py。核心是Reason → Act → Reason → …循环flowTriggerFlow(nametravel_agent_loop)flow.chunkasyncdefreason(data):# 模型看用户需求和历史决定调工具还是给出最终回答decisionawaitresponse.async_get_data(...)print(f[Plan · Step{step}] …)# 终端里看到的 Planflow.chunkasyncdefact(data):# 按模型指定真正执行 search / browse / 地图 等resultawaitagent.action.async_execute_action(name,kwargs)print(f[Execute] …)# 终端里看到的 Execute终端里的[Plan]、[Execute]就是这样来的。默认模式下 Loop 全部跑完最后汇总[最终回答]——适合「我不着急只要结果」。3.3 外壳怎么接到核心runner.py里的run_travel()做编排agentawaitbuild_travel_agent(workdir)activate_travel_tools(agent)ifstreamisNone:resultawaitrun_batch_loop(agent,request)# 默认elifstreamstage:flowawaitbuild_stage_stream_flow(agent,request)resultawaitconsume_runtime_stream(execution,stage)else:flowawaitbuild_instant_stream_flow(agent,request)...项目结构壳 vs 核travel_cli/ ├── cli.py ← 外壳Typer 命令 ├── runner.py ← 编排选哪种跑法 ├── agent.py ← 核心工具注册 ├── loops/ ← 核心Loop 流式逻辑 └── display/ ← 核心流式事件打印到终端四、两种流式区别与开发中怎么选Agent 任务有时要跑一两分钟。干等像「卡死了」流式就是边跑边把进度推到终端类似 ChatGPT 逐字输出。travel-cli 提供两种流式对应两个命令行开关。4.1 阶段流--stream stage观察粒度一整步 Plan、一整步 Execute。# loops/stage_stream.py —— 每完成一步往流里推一条事件awaitdata.async_put_into_stream({phase:plan,# 或 executestep:step,reasoning:...,})# display/console.py —— 终端边读边打印asyncforrawinexecution.get_async_runtime_stream(...):print_stream_event(raw)终端示例[Plan · Step 1] tool: 先搜索杭州攻略… [Execute] search → … [Plan · Step 2] tool: 阅读网页…实际开发适合进度条、日志面板、运维监控——用户只需知道「现在在搜索 / 查地图」。4.2 字段流--stream instant观察粒度更细——模型结构化输出里哪个字段先写好就先推送。asyncforstreaming_datainresponse.get_async_generator(typeinstant):ifstreaming_data.event_typedone:awaitdata.async_put_into_stream({phase:plan_field,path:streaming_data.path,# 如 type、tool_namevalue_preview:str(streaming_data.value)[:100],})终端会多出行如↳ field 1.type tool ↳ field 1.tool_name search [Plan · Step 1] tool: …实际开发适合调试模型决策、精细 UI提前显示「即将调用 search」、可观测性要求高的场景。4.3 怎么选一张表默认travel run--stream stage--stream instant看到什么跑完后汇总每步 Plan / Execute每字段 每步复杂度最低中等较高典型场景脚本、CI、批处理终端进度、日志调试、精细 UI何时够用只要最终结果任务长、要「还在跑」要看模型字段级决策[图默认 run 与--stream stage终端输出对比截图]经验法则先默认跑通用户-facing 的 CLI 加stage排查模型行为或做高级 UI 再上instant。结语总结一下CLI 是外壳Agent 是核心——Typer 让你像 Claude Code 一样随处敲命令Agently 负责注册工具、跑 Loop。流式不是必选项默认模式够用就上默认要给用户「还在跑」的反馈用--stream stage要更细的可观测性用--stream instant。示例代码https://github.com/SWUSTcyt/travel-cli 欢迎 Star。留言区也可以说说你想给什么样的 Agent 加 CLI