4个Agent如何协作分析一次Android卡顿——全链路设计复盘

一次分析请求的完整旅程

在 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,即将开始采集 trace
  • get_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 用户无法使用。

如果重新设计

  1. 用 Pydantic Model 替代 TypedDict------加一层运行时校验,字段拼错立即报错
  2. 依赖注入替代全局变量------构造函数传入 LLM 实例,测试时传入 mock
  3. 考虑用 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 的核心设计思路可以用三句话概括:

  1. 确定性代码做确定性的事------FPS 计算、严重度分类、热点排名,这些用代码算,不让 LLM 沾手
  2. LLM 做 LLM 擅长的事------读源码理解业务逻辑、组织优化建议、生成自然语言报告
  3. 每个环节都有 fallback------节点崩溃不丢状态,流式中断有重试,WS 断连自动重连

多 Agent 系统的核心不是"怎么调用 LLM",而是怎么管理状态、处理异常、控制复杂度。LLM API 谁都会调,但一个能在生产环境跑起来的 Agent 系统,功夫全在这些"无聊"的工程细节上。


这是「移动端手记」SmartInspector 系列的最后一篇。从工具震撼展示到全链路设计复盘,5 篇文章覆盖了一个 AI 驱动性能分析工具的核心设计。如果你对 AI Agent 工程化感兴趣,欢迎关注专栏,后续会分享更多实战经验。

相关推荐
好家伙VCC3 小时前
# React发散创新:从状态管理到自定义Hook的极致实践与性能优化在现代前端开发
java·javascript·python·react.js·性能优化
0xDevNull1 天前
Java 深度解析:for 循环 vs Stream.forEach 及性能优化指南
java·开发语言·性能优化
Wect1 天前
深度解析前端性能优化
前端·面试·性能优化
Captain_Data1 天前
SQL优化实战:如何让查询速度提升10倍
数据库·sql·mysql·性能优化·数据分析
小江的记录本1 天前
【分布式】分布式核心组件——分布式ID生成:雪花算法、号段模式、美团Leaf、百度UidGenerator、时钟回拨解决方案
分布式·后端·算法·缓存·性能优化·架构·系统架构
weixin199701080162 天前
当当商品详情页前端性能优化实战
性能优化
kyriewen2 天前
你的首屏慢得像蜗牛?这6招让页面“秒开”
前端·面试·性能优化
空中海2 天前
第十一章:iOS性能优化、测试与发布
ios·性能优化