一个CLI工具的架构是怎么搭起来的

一个CLI工具的架构是怎么搭起来的

SmartInspector 的 graph 包只有 4 个文件、不到 300 行代码,但它承载了整个多 Agent 调度的核心逻辑。这篇文章聊聊它是怎么从「一个文件搞定一切」演变成现在的模块化结构的,以及每个设计决策背后的理由。

起点:一切都在一个文件里

SmartInspector 最早的样子,跟大多数 CLI 工具一样------一个 main.py 包打天下:

复制代码
接收用户输入 → 调 LLM → 解析结果 → 打印输出

没有 AgentState,没有 graph,甚至没有流式输出。用户输入一句话,等个十来秒,结果一次性刷出来。

能用,但问题很快就来了。

第一个痛点:状态管理 。当我想支持多轮对话------比如用户先执行了 /full 采集性能数据,接着又问"刚才采集的数据里 CPU 热点在哪"------我需要把上一次的采集结果保存下来,传给后续的分析节点。一个 main.py 里的局部变量根本搞不定这事。

第二个痛点:扩展性 。最初的实现只有「分析」这一个能力,后来要加源码搜索、要加 Android 采集、要加报告生成......每加一个能力,if-elif 链就长一截,代码的圈复杂度直线上升。

第三个痛点:错误隔离。一个节点崩了,整个程序就挂了。没有"这个节点失败了但其他节点继续"的容错机制。

到这个阶段,重构成多模块架构已经不是「锦上添花」,而是「不得不做」了。

拆分策略:graph 包的四层结构

重构的核心思路是借鉴 LangGraph 的 State Graph 模式,但把关注点拆到四个文件里:

bash 复制代码
graph/
├── state.py      # 状态定义 + 工具函数
├── builder.py    # 图的拓扑结构
├── streaming.py  # 流式执行器
├── cli.py        # REPL 入口
└── nodes/        # 各个 Agent 节点

为什么是这个拆法?不是刻意追求"干净",而是每个文件的职责确实不同:

  • state.py 只管"数据长什么样",不关心数据怎么流动
  • builder.py 只管"节点怎么连",不关心节点内部做什么
  • streaming.py 只管"怎么跑起来",不关心图长什么样
  • cli.py 只管"用户交互",不关心 AI 逻辑

这四层各管各的,改一层不影响其他层。实际开发中,我加新节点的时候只需要动 builder.pynodes/,state 和 streaming 基本不用碰。

State:用 TypedDict 而不是 Pydantic

状态定义是整个架构的地基。SmartInspector 用的是 TypedDict:

python 复制代码
class AgentState(TypedDict):
    messages: Annotated[list, operator.add]
    perf_summary: str            # collector 输出的 JSON 性能摘要
    perf_analysis: str           # analyzer 输出的分析结论
    attribution_data: str        # attributor 提取的可归因切片
    attribution_result: str      # attributor 输出的归因结果
    trace_duration_ms: int       # CLI 参数
    trace_target_process: str    # CLI 参数
    skip_wait: bool              # 冷启动跳过等待标志
    _route: str                  # orchestrator 路由决策
    _trace_path: str             # trace 文件路径

第一个设计决策:messages 用 operator.add 语义,其他字段用 last-write-wins

messages 是对话历史,每个节点都会往里追加消息,所以用 Annotated[list, operator.add] 让 LangGraph 自动做列表拼接。而 perf_summary 这类字段,任何节点写入都会覆盖前值------采集节点写一次,后面的节点不应该再改它。

第二个设计决策:不用 Pydantic Model

这在 Python AI 工具链里算是个小众选择。主流做法是用 Pydantic 做状态校验------字段类型不对直接报错,而不是悄悄出 Bug。但我选 TypedDict 有两个理由:

  1. 性能:Pydantic 每次状态更新都要做 schema 校验,在 5 个节点的高频传递中会有额外开销
  2. LangGraph 兼容性 :LangGraph 的 Annotated 类型系统和 TypedDict 配合更自然,不需要额外的适配

代价是什么?是运行时没有类型校验。字段拼错了、类型传错了,只有到实际使用时才会暴露。这在 5 个节点的规模下还能接受,但如果节点数超过 10 个,我可能会重新考虑 Pydantic。

pass-through 机制

每个节点只修改自己负责的字段,其余必须透传。这不是自动的,需要手动调用:

python 复制代码
def _pass_through(state: AgentState, *, extra_keys: tuple = ()) -> dict:
    """Build a dict of pass-through fields from *state*."""
    keys = _PASS_THROUGH_KEYS + extra_keys
    return {k: state.get(k, "") for k in keys}

这背后有个真实的教训。早期版本没有 pass-through 机制,每个节点都要手动写一堆 state.get("xxx", "") 的透传代码。某个节点忘了透传 perf_summary,下游的 analyzer 拿到空字符串,跑出来一份"无数据可分析"的报告。排查了半天才发现是透传漏了。

把这个逻辑抽成 _pass_through 工具函数后,节点的返回值变得非常干净:

python 复制代码
@node_error_handler("collector")
def collector_node(state: AgentState) -> dict:
    # ... 采集逻辑 ...
    return {
        "messages": [AIMessage(content="[trace collected]")],
        "perf_summary": perf_json,
        "_trace_path": trace_path,
        **_pass_through(state),
    }

错误兜底:node_error_handler

还有一个经常被忽略的设计:节点级别的错误捕获。

python 复制代码
def node_error_handler(node_name: str):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(state: AgentState) -> dict:
            try:
                return func(state)
            except Exception as e:
                print(f"  [{node_name}] ERROR: {e}", flush=True)
                return {
                    "messages": [AIMessage(content=f"[{node_name}] Error: {e}")],
                    **_pass_through(state),
                }
        return wrapper
    return decorator

装饰器模式,给每个节点套一层 try-catch。节点崩了不会导致整个图中断,而是返回一条错误消息,让下游节点至少能看到发生了什么。

这个设计是在开发过程中被逼出来的------attributor 节点在搜索源码时,偶尔会因为文件编码问题抛异常。没有这层保护的话,一次编码错误就能让整个分析流程功亏一篑。

Builder:图的拓扑结构

builder.py 是整个架构里改动最少、但最关键的文件。它定义了节点之间的连接关系:

python 复制代码
def create_graph():
    builder = StateGraph(AgentState)

    # 注册所有节点
    builder.add_node("orchestrator", orchestrator_node)
    builder.add_node("collector", collector_node)
    builder.add_node("analyzer", analyzer_node)
    builder.add_node("attributor", attributor_node)
    builder.add_node("reporter", reporter_node)
    # ... 其他节点

    # 入口:START → orchestrator
    builder.add_edge(START, "orchestrator")

    # orchestrator 根据意图路由
    builder.add_conditional_edges(
        "orchestrator",
        route_from_orchestrator,
        path_map={
            "android_expert": "android_expert",
            "perf_analyzer": "perf_analyzer",
            "explorer": "explorer",
            "fallback": "fallback",
            "collector": "collector",
        },
    )

    # 全链路:collector → analyzer → attributor → reporter → END
    builder.add_edge("collector", "analyzer")
    builder.add_edge("attributor", "reporter")
    builder.add_edge("reporter", END)
    # ... 条件分支

注意这里的设计:orchestrator 是一个条件分支节点,它决定后续走哪条路径。而 collector → analyzer → attributor → reporter 是一条固定的流水线。

这就是为什么选 LangGraph 而不是自己写调度。如果只是线性流水线,一个 for 循环就够了。但实际业务中,用户可能只想做源码搜索(走 explorer),也可能只想分析已有的 JSON 数据(走 analyzer),还可能要跑完整的采集-分析-归因-报告链路(走 collector → analyzer → attributor → reporter)。这种多路径的路由逻辑,用 if-else 写起来会非常混乱,而 LangGraph 的 add_conditional_edges 把它变成了声明式的配置。

有个细节值得说:MemorySaver 的 serde 配置

python 复制代码
serde = MemorySaver().serde.with_msgpack_allowlist(
    [("smartinspector.graph.state", "RouteDecision")],
)
return builder.compile(checkpointer=MemorySaver(serde=serde))

RouteDecision 是一个自定义的 str Enum,msgpack 默认不认识它,需要显式注册到 allowlist 里。这个坑花了我不少时间排查------序列化失败不会直接报错,而是静默丢数据,导致路由状态丢失。

Streaming:为什么流式输出这么重要

streaming.py 只有一个核心函数 _stream_run,但它解决了一个体验问题:不要让用户对着空白等 30 秒

python 复制代码
def _stream_run(graph, state):
    config = {"configurable": {"thread_id": "main"}}
    print("\nai> ", end="", flush=True)

    for chunk in graph.stream(
        state,
        config=config,
        stream_mode=["updates"],
        version="v2",
    ):
        for node_name, node_state in chunk["data"].items():
            # 流水线节点不打印中间输出,只打印 LLM 节点的回复
            for msg in node_state.get("messages", []):
                content = getattr(msg, "content", "")
                if content:
                    print(content, flush=True)

LangGraph 的 stream_mode=["updates"] 会把每个节点的输出作为一个 chunk 推出来。我们拿到 chunk 后,只打印 LLM 节点(orchestrator、analyzer 等)的回复内容,跳过 collector、attributor 这些后台节点的技术性输出。

这里有个微妙的设计选择:流水线节点(collector、attributor)的输出打不打给用户看?

最初版本是全部打印的,结果 /full 命令跑起来时,终端会被一堆 JSON 和进度信息淹没。后来改成了「只打印最终报告 + 节点状态摘要」,体验好很多。但调试时又需要看到中间数据,所以加了个 --debug 标志来控制输出粒度。

状态持久化:跨轮对话的关键

_stream_run 的另一个职责是维护跨轮对话的状态。用户第一轮做了采集,第二轮想基于采集数据做分析,这需要 collector 写入的 perf_summary 在第二轮还活着。

实现方式是用 LangGraph 的 checkpointer(MemorySaver):

python 复制代码
# 用 get_state() 获取最终状态,而不是手动合并
final_state = graph.get_state(config)
result = {
    "perf_summary": final_state.values.get("perf_summary", state.get("perf_summary", "")),
    "perf_analysis": final_state.values.get("perf_analysis", state.get("perf_analysis", "")),
    # ...
}

注意这里用了 graph.get_state(config) 而不是手动合并 chunks。早期版本是手动合并的,结果遇到一个问题:LangGraph 的 Annotated[list, operator.add] 语义下,messages 会在 checkpointer 里自动累积,如果手动再合并一次就会重复。改成从 checkpointer 读取最终状态后,这个问题就消失了。

CLI 入口:REPL 循环

cli.pymain() 函数是用户看到的入口。它做了几件事:

  1. 参数解析--source-dir--debug
  2. 环境检查:adb 是否可用、API key 是否配置
  3. 启动 WS Server:自动监听端口、设置 adb reverse
  4. 创建 graph、初始化 state
  5. 进入 REPL 循环

REPL 循环本身很简洁:

python 复制代码
while True:
    user_input = session.prompt("you> ").strip()
    if user_input.startswith("/"):
        state = handle_slash_command(user_input, state)
    else:
        state["messages"] = state["messages"] + [
            {"role": "user", "content": user_input}
        ]
        state = _stream_run(graph, state)

斜杠命令(/full/clear/help 等)绕过 LLM graph 直接处理,自然语言输入才走 graph。这个区分很重要------/full 是确定性操作(启动采集流水线),没必要经过 LLM 路由。

prompt_toolkit 而不是裸 input() 的原因也很实际:历史记录和 Tab 补全FileHistory 保存了用户的输入历史,WordCompleter 对斜杠命令做补全。这两个功能在频繁调试时非常方便。

为什么不自己写调度

最后一个值得讨论的问题:为什么用 LangGraph 而不是自己实现一个简单的调度器?

SmartInspector 的 graph 结构其实不复杂------一个路由节点 + 几条固定流水线。自己写一个调度器,核心逻辑大概 100 行就够了。

我选 LangGraph 的理由:

  1. checkpointer 是现成的。跨轮对话的状态持久化,自己实现要处理序列化、去重、并发安全,LangGraph 的 MemorySaver 直接给了
  2. streaming 是现成的graph.stream() 把节点输出变成异步迭代器,不用自己管 asyncio
  3. 条件路由是声明式的add_conditional_edges 比一堆 if-elif 清晰得多
  4. 错误隔离 。LangGraph 的节点执行本身就有异常捕获机制,配合 node_error_handler 双重保险

代价是什么?LangGraph 引入了一个不轻的依赖(langgraph-core + langchain-core),而且它的 API 偶尔会变(version="v2" 就是适配新版 API 的参数)。对于一个 5 个节点的 CLI 工具来说,这个依赖确实有点重。但如果未来节点数增长到 10+,或者要支持并行节点执行,LangGraph 的价值就会体现出来。

我的建议是:节点数 < 5 的时候,自己写调度更轻量;节点数 ≥ 5 或者有复杂路由需求的时候,上 LangGraph。

小结

从单文件到模块化,SmartInspector 的架构演进路线是:

diff 复制代码
main.py(单文件)
  ↓ 状态管理需求
+ state.py(TypedDict 定义)
  ↓ 扩展性需求
+ builder.py(图拓扑)+ nodes/(节点实现)
  ↓ 体验需求
+ streaming.py(流式输出)
  ↓ 交互需求
+ cli.py(REPL 入口)

每一步都不是提前规划的,而是被实际问题逼出来的。这大概就是「演进式架构」的真实样子------不是一开始就想清楚,而是在过程中不断调整。

如果你正在做一个类似的多 Agent CLI 工具,我的建议是:先从单文件开始,遇到痛点再拆分。过早的模块化会带来不必要的复杂度,而等到真正需要时再拆,你对"该拆成什么样"的理解会更清晰。


下一篇:意图路由------用 5 个 Token 决定该找谁。聊聊 orchestrator 的 few-shot 分类实现,以及为什么 max_tokens=5 就够了。

相关推荐
Yunzenn2 小时前
零基础复现Claude Code(四):双手篇——赋予读写文件的能力
开源·github
叹一曲当时只道是寻常3 小时前
Reference 工具安装与使用教程:一条命令管理 Git 仓库引用与知识沉淀
人工智能·git·ai·开源·github
har3 小时前
Claude Code Trace 可视化神器:Token 分析 + Agent 回放 + Session 对比,全有了
开源
Beginner x_u3 小时前
前端八股整理(工程化 01)|Git 常见命令、rebase/merge、pull/fetch 与前端性能优化
前端·性能优化·git 常见命令
月诸清酒4 小时前
AI 科技日报 (通义新开源模型27B参数打赢编程旗舰)
人工智能·开源
扬帆破浪4 小时前
免费开源的WPS AI插件 察元AI助手:generateMultimodalAsset:类型校验与分支派发
人工智能·开源·ai编程·wps
扬帆破浪4 小时前
免费开源的WPS AI插件 察元AI助手:installGlobalErrorLogger:启动写盘与 Vue 错误钩子
人工智能·开源·ai编程·wps
code_pgf4 小时前
PaLM-E 的改进版本及开源可行方案综述及讨论
开源·palm
南村群童欺我老无力.4 小时前
鸿蒙ForEach渲染列表的唯一性约束与性能优化
华为·性能优化·harmonyos