一个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.py 和 nodes/,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 有两个理由:
- 性能:Pydantic 每次状态更新都要做 schema 校验,在 5 个节点的高频传递中会有额外开销
- 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.py 的 main() 函数是用户看到的入口。它做了几件事:
- 参数解析 :
--source-dir、--debug - 环境检查:adb 是否可用、API key 是否配置
- 启动 WS Server:自动监听端口、设置 adb reverse
- 创建 graph、初始化 state
- 进入 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 的理由:
- checkpointer 是现成的。跨轮对话的状态持久化,自己实现要处理序列化、去重、并发安全,LangGraph 的 MemorySaver 直接给了
- streaming 是现成的 。
graph.stream()把节点输出变成异步迭代器,不用自己管 asyncio - 条件路由是声明式的 。
add_conditional_edges比一堆 if-elif 清晰得多 - 错误隔离 。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 就够了。