一次分析请求的完整旅程
在 SmartInspector CLI 里,用户输入 /full 或"帮我全面分析一下性能",会发生这些事:
ini
you> /full
正在启动全量性能分析...
[collector] Starting trace collection...
[collector] Config: duration=10000ms, buffer=65536KB
[collector] Trace saved to /tmp/si_trace_xxx.pb (2.3MB)
[collector] Merged 3 SQL + 2 WS block events -> 4 total
[collector] Analysis complete (4521 bytes)
[analyzer] Analyzing performance...
[analyzer] Analysis complete (1280 chars)
[attributor] Found 6 slices, searching source code...
145.23ms DemoAdapter.onBindViewHolder (java)
89.50ms CpuBurnWorker.run (java)
...
[attributor] Done: 4 attributed, 2 system classes
[reporter] Generating report...
报告已保存至: reports/perf_report_20260422_080015.md
整个过程经历了 4 个核心节点,串行执行:
orchestrator → collector → analyzer → attributor → reporter
每个环节的典型耗时:
- orchestrator:50-100ms(一次 LLM 调用,max_tokens=5)
- collector:10-30s(Perfetto 采集 + SQL 查询)
- analyzer:3-8s(LLM 分析性能数据)
- attributor:5-15s(源码搜索 + LLM 归因)
- reporter:5-10s(LLM 生成报告 + 流式输出)
从用户输入到拿到报告,大概 30-60 秒。如果手动做同样的分析,保守估计 2 小时起。
Agent 间的数据传递
AgentState:共享内存设计
4 个节点之间通过一个 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 语义------每个节点往里追加消息,而不是覆盖。其他字段则是"最后写入者胜出"。
pass-through 机制
每个节点只修改自己负责的字段,其余必须透传。这不是自动的,需要手动调用:
python
def collector_node(state: AgentState) -> dict:
# ... 做采集工作 ...
return {
"messages": [AIMessage(content="[trace collected]")],
"perf_summary": perf_json,
"perf_analysis": state.get("perf_analysis", ""), # 透传
"attribution_data": "", # 重置
"attribution_result": "", # 重置
"_trace_path": trace_path,
}
这个设计有个明显的问题:如果某个节点忘了透传某个字段,下游就拿不到数据。实际开发中被这个坑绊过好几次,字段拼写错误只能靠集成测试发现。
为什么不用 Pydantic Model?TypedDict 没有运行时校验,但性能更好(不做 schema 检查),而且和 LangGraph 的 Annotated 类型系统配合更自然。这是一个有意为之的取舍------在 5 个节点的规模下,手动维护成本还可控。
Orchestrator:一句话路由
用户的自然语言输入,要先经过 orchestrator 判断走哪条路。
设计方案极其简单:few-shot prompt + max_tokens=5。
python
_ROUTE_PROMPT = """Classify this user message. Reply with ONE word only.
Categories (pick ONE):
- full_analysis : wants a COMPLETE performance analysis pipeline
- explorer : wants to SEARCH or READ source code
- android : wants to COLLECT or ANALYZE performance from Android device
- analyze : wants deep interpretation of ALREADY EXISTING perf JSON
- end : general Q&A, advice, or vague analysis request WITHOUT data
Examples:
- "帮我全面分析一下这个页面的性能" → full_analysis
- "搜索一下 LazyForEach 的实现" → explorer
- "采集一下 trace" → android
- "你好" → end
Reply with exactly one word: full_analysis explorer android analyze end"""
LLM 只需要输出一个词。max_tokens=5 硬约束了输出长度。然后代码做字符串匹配:
python
valid = {rd.value: rd for rd in RouteDecision}
decision = RouteDecision.END
for v, rd in valid.items():
if v in raw:
decision = rd
break
为什么不用 function calling?因为这个分类任务太简单了------5 个类别,few-shot 就够了。function calling 的 JSON 解析反而更重。而且 max_tokens=5 把成本压到了极致,一次路由大约消耗 200 input tokens + 1 output token。
条件路由图
orchestrator 的路由决策决定了后续走哪条分支。LangGraph 的条件边(conditional edges)这样定义:
python
# 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",
},
)
# analyzer 之后:/trace 路由直接结束,full_analysis 继续 attributor
builder.add_conditional_edges(
"analyzer",
_route_from_analyzer,
path_map={"attributor": "attributor", "end": END},
)
# 全量管线尾部:attributor → reporter → END
builder.add_edge("attributor", "reporter")
builder.add_edge("reporter", END)
完整的 DAG 长这样:
sql
START → orchestrator
├─→ collector → analyzer → attributor → reporter → END (/full)
├─→ android_expert → analyzer → END (/android + 已有数据)
│ └─→ END (/android 无数据)
├─→ perf_analyzer → END (/analyze)
├─→ explorer → END (源码搜索)
└─→ fallback → END (闲聊/通用问答)
WebSocket:CLI ↔ App 的实时通信
为什么需要 WebSocket
SmartInspector 的 Android 端不是被动采集------它需要在运行时注入 Hook、响应配置下发、上报卡顿堆栈。这些都需要双向通信。
adb shell 做不到:它是单向命令执行,无法维持持久连接、无法推送消息。
架构是这样的:
less
CLI (WS Server, ws://0.0.0.0:9876)
↕ adb reverse tcp:9876 tcp:9876
App (WS Client, 自动重连)
通信协议
JSON 消息,几个核心类型:
App → Server(上报):
config_sync:App 连接后立即推送当前 Hook 配置ack:确认收到 Server 下发的消息(带 msg_id)
Server → App(指令):
config_update:下发新的 Hook 配置start_trace:通知 App 准备好 Hook,即将开始采集 traceget_block_events:请求 App 上报缓存的卡顿事件
ACK 确认机制
关键指令必须确保 App 收到了。方案:每条消息带 msg_id,等 App 回 ack:
python
def send_start_trace(self, timeout: float = 5.0) -> bool:
msg_id = str(uuid.uuid4())
msg = json.dumps({"type": "start_trace", "msg_id": msg_id})
ack_event = threading.Event()
self._pending_acks[msg_id] = ack_event
asyncio.run_coroutine_threadsafe(self._broadcast(msg), self._loop)
ack_event.wait(timeout=timeout) # 阻塞等 ACK
return ack_event.is_set()
App 收到消息后回复 {"type": "ack", "msg_id": "..."}, Server 的 _dispatch 方法设置对应的 Event,阻塞调用就被唤醒了。
心跳检测
WebSocket 层面的心跳,防止僵尸连接:
python
self._server = await websockets.serve(
self._handler, "0.0.0.0", self.port,
ping_interval=20, # 每 20 秒发 ping
ping_timeout=get_ws_ping_timeout(), # 超时未 pong 则断开
)
连接等待的竞态保护
wait_for_connection() 同时等待 WS 连接建立和 config_sync 到达(App 在 onOpen 里自动发送)。这确保 collector 启动时,配置已经就绪。
全链路异常保护
多 Agent 系统里,最怕的不是某个节点出错,而是一个节点崩了,整个会话都废了。SmartInspector 设计了四层保护:
python
REPL 主循环 (全局 try/except)
→ 保留 state,继续接受输入
└── graph.stream() (try/except)
→ 打印 [stream error],不崩溃
└── orchestrator LLM (try/except)
→ 失败走 fallback
└── 各节点 (node_error_handler 装饰器)
→ 统一捕获,返回安全 state
第1层:REPL 主循环
python
while True:
try:
user_input = session.prompt("you> ").strip()
# ...
if user_input.startswith("/"):
state = handle_slash_command(user_input, state)
else:
state = _stream_run(graph, state)
except KeyboardInterrupt:
print("\n [interrupted]", flush=True)
except Exception as e:
print(f"\n [error] {e}", flush=True)
# 关键:state 不变,用户可以继续操作
print(" Session state preserved. Continue or type /help.\n")
无论 graph 执行出什么错,state 保持不变,用户可以继续输入。这在调试时非常实用------某个节点报错了,不用重启整个 CLI。
第2层:graph.stream()
python
for chunk in graph.stream(state, config=config, stream_mode=["updates"], version="v2"):
last_updates = chunk["data"]
# ... 处理输出 ...
LangGraph 内部的节点异常不会直接抛到 stream 外面。但 stream 本身可能因为网络、序列化等问题中断。
第3层: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
异常时返回一个安全的 state 更新------只追加错误消息,其他字段透传。下游节点可以判断 perf_summary 是否为空来决定是否跳过。
第4层:Reporter 的流式重试
Reporter 是最后一个节点,用 LLM stream 输出报告。流式传输容易中断(网络抖动、API 超时),所以加了 fallback:
python
try:
for chunk in llm.stream(messages):
print(chunk.content, end="", flush=True)
full_content += chunk.content
except Exception as e:
# 流断了,用 invoke 重试
response = llm.invoke(messages)
full_content = response.content
流式输出了一半然后断了?没关系,invoke 会用非流式方式重新生成完整内容。代价是用户会看到"断流→重新生成"的跳变,但至少不会拿到一份残缺的报告。
Collector:BlockMonitor 双源合并
这一段值得单独说说。Perfetto 采集到的 trace 数据有一个硬伤:看不到 Java 堆栈。
Perfetto 的 atrace 是 native 层的,它只能捕获 C/C++ 调用链。你的 DemoAdapter.onBindViewHolder 里的 Java 堆栈,Perfetto 一无所知。
SmartInspector 的解决方案:App 端的 BlockMonitor 独立记录主线程卡顿堆栈,通过 WS 上报。Collector 把两个数据源合并。
python
def _merge_block_events(sql_events, ws_events):
"""SQL 有精确时间戳,WS 有堆栈信息,按 msgClass + dur_ms 匹配合并。"""
ws_index = {}
for ev in ws_events:
key = (ev["msg_class"], ev["dur_ms"])
ws_index[key] = ev
merged = []
for sql_ev in sql_events:
result = dict(sql_ev)
key = (extract_msg_class(sql_ev), sql_ev["dur_ms"])
ws_match = ws_index.get(key)
if ws_match and ws_match.get("stack_trace"):
result["stack_trace"] = ws_match["stack_trace"]
merged.append(result)
return merged
匹配逻辑:SQL 事件从 SI$block#MsgClass#250ms 格式中提取 msgClass 和 dur_ms,和 WS 上报的事件按这两个字段做精确匹配。匹配不上时,用 dur_ms 做 fuzzy match(相同耗时但类名略有差异)。
合并后的事件既有 Perfetto 的精确纳秒级时间戳,又有 BlockMonitor 的 Java 堆栈------两全其美。
TokenTracker:线程安全的用量追踪
多 Agent 系统里,LLM 调用分散在各个节点。需要一个统一的地方追踪 token 用量。
python
class TokenTracker:
def __init__(self):
self._lock = threading.RLock()
self._stages: dict[str, dict] = {}
def record(self, stage: str, usage: dict) -> None:
with self._lock:
if stage not in self._stages:
self._stages[stage] = {"input_tokens": 0, "output_tokens": 0, "calls": 0}
self._stages[stage]["input_tokens"] += usage.get("input_tokens", 0)
self._stages[stage]["output_tokens"] += usage.get("output_tokens", 0)
self._stages[stage]["calls"] += 1
用 RLock(可重入锁)而不是 Lock,因为同一线程可能嵌套调用。最终输出一张表:
yaml
Token usage:
Stage Input Output Total Calls
------------------------------------------------------
orchestrator 850 5 855 1
analyzer 3200 890 4090 1
attributor 4500 1200 5700 3
reporter 3800 1500 5300 1
------------------------------------------------------
TOTAL 12350 3595 15945 6
这个数据对优化很有价值------一眼就能看出 attributor 是 token 消耗大户,值得针对性优化。
设计取舍与反思
做对的事
LangGraph 编排代替手动调用链 。builder.py 里 30 行代码定义了整个 DAG,清晰可维护。新增一个 Agent 只需要:加节点 → 加边 → 更新路由映射。如果手动用 if/else 编排,早就成面条代码了。
确定性预计算层。前面第4篇文章详细讲过,6 个预计算模块把数学和逻辑判断从 LLM 中剥离,严重度分类、热点排名、调用链拆解 100% 准确。这是整个系统可靠性的基石。
全链路异常保护。四层保护确保了:任何单点失败都不会导致会话崩溃。用户始终能继续操作。
做得不够好的事
TypedDict 无运行时校验 。字段拼错只能靠测试发现。有一次 attributor 输出了 attribution_results(多了个 s),下游 reporter 读 attribution_result 拿到空值,报告里没有归因结果。排查了半小时才发现是拼写错误。
全局可变状态 。_route_llm 这种模块级变量,测试时需要手动 mock:
python
_route_llm = None # 模块级全局变量
def _get_route_llm():
global _route_llm
if _route_llm is not None:
return _route_llm
_route_llm = ChatOpenAI(...)
return _route_llm
如果用依赖注入,测试会干净很多。
Attributor 手动实现 tool-call loop。Attributor 节点内部用 while 循环手动管理 LLM 的 tool calling(Glob → Grep → Read),控制粒度好但维护成本高。LangChain 的 Agent Executor 或 create_react_agent 能自动处理,但牺牲了细粒度控制。
trace_processor_shell 仅支持 macOS arm64 。Perfetto 的 trace_processor_shell 是预编译二进制,目前只下载了 macOS arm64 版本。Linux 和 Windows 用户无法使用。
如果重新设计
- 用 Pydantic Model 替代 TypedDict------加一层运行时校验,字段拼错立即报错
- 依赖注入替代全局变量------构造函数传入 LLM 实例,测试时传入 mock
- 考虑用 LangGraph 的 ToolNode------替代手动 tool-call loop,减少维护负担
不过,这些都是"如果重新设计"的假设。在当时的约束下(快速验证想法、单人开发),TypedDict + 全局变量的方案是合理的取舍。先让它跑起来,再让它跑得好。
扩展性
当前架构的扩展路径还算清晰:
新平台支持 :新增一个 {Platform}Collector 类,实现和 PerfettoCollector 相同的接口(pull trace、summarize),注册到 builder.py 即可。HarmonyOS、iOS 都可以这样扩展。
新 Agent 插拔:LangGraph 的 DAG 设计天然支持。加一个新节点,定义它的入边和出边,更新 orchestrator 的路由映射。
多模型策略 :不同 Agent 可以用不同模型。orchestrator 用便宜模型(只输出一个词),attributor 用强模型(需要代码理解能力)。通过 SI_ATTRIBUTOR_MODEL 环境变量单独配置。
小结
回顾这个系列,SmartInspector 的核心设计思路可以用三句话概括:
- 确定性代码做确定性的事------FPS 计算、严重度分类、热点排名,这些用代码算,不让 LLM 沾手
- LLM 做 LLM 擅长的事------读源码理解业务逻辑、组织优化建议、生成自然语言报告
- 每个环节都有 fallback------节点崩溃不丢状态,流式中断有重试,WS 断连自动重连
多 Agent 系统的核心不是"怎么调用 LLM",而是怎么管理状态、处理异常、控制复杂度。LLM API 谁都会调,但一个能在生产环境跑起来的 Agent 系统,功夫全在这些"无聊"的工程细节上。
这是「移动端手记」SmartInspector 系列的最后一篇。从工具震撼展示到全链路设计复盘,5 篇文章覆盖了一个 AI 驱动性能分析工具的核心设计。如果你对 AI Agent 工程化感兴趣,欢迎关注专栏,后续会分享更多实战经验。