让LLM按维度自动切换分析策略:SmartInspector 的 Prompt Skill 系统
问题背景:一个 prompt 装不下所有领域知识
SmartInspector 的分析流水线包含三个 LLM Agent:perf_analyzer(性能分析)、attributor(源码归因)、frame_analyzer(帧级分析)。最初每个 Agent 只有一个固定的 system prompt,写在一对 .txt 文件里。比如 attributor 的 prompt 定义了 SI$ 标签的解析规则、源码搜索策略(Glob→Grep→Read)等。
问题在引入「分析维度」后爆发了。我们新增了 7 个维度:lock_contention(锁竞争)、gc_events(GC)、file_io(文件IO)、sched_latency(调度延迟)、memory_trend(内存趋势)、binder_ipc(Binder IPC)、cpu_throttling(CPU降频)。每个维度都有独立的领域知识------GC 要知道 Concurrent vs Stop-the-World 的区别、锁竞争要懂 futex_wait_queue_me 的含义、文件 IO 要区分 io_wait 和 blocked_function。
如果把这些知识全部塞进一个 prompt,几个现实问题立刻浮现:
- Token 浪费:一次 trace 中通常只有 2-3 个维度有异常数据。比如 CPU 降频没发生,加载 cpu-throttling 的领域知识就是纯浪费。
- 维护困难:每新增一个维度,就要改所有 prompt 文件。而且领域知识和通用指令混在一起,改一个怕影响另一个。
- 无法独立演进:attributor 和 perf_analyzer 都需要同一份 GC 领域知识,但知识是内联在各自的 prompt 里的,改一处忘一处。
解决思路很直接:把领域知识从 prompt 里抽出来,变成独立的 skill 文件;运行时按需加载,只有出现异常数据的维度才注入对应知识。
技能文件的目录设计
技能文件放在 prompts/skills/ 目录下,按用途分两层:
bash
prompts/skills/
├── SKILL.md # 索引文件
├── shared/ # 跨维度共享知识
│ ├── si-tag-system.md # SI$ 标签格式和解析规则
│ └── search-strategy.md # Java/Kotlin/XML 源码搜索策略
└── dimensions/ # 按维度组织的领域知识
├── lock-contention.md # 锁竞争
├── gc-analysis.md # GC 事件
├── io-analysis.md # 文件 IO
├── cpu-scheduling.md # CPU 调度
├── cpu-throttling.md # CPU 降频
├── memory-analysis.md # 内存趋势
├── binder-ipc.md # Binder IPC
├── ui-jank.md # UI/帧率
└── startup.md # 冷启动
shared/ 放所有 Agent 都需要的公共知识,dimensions/ 放每个维度的专属领域知识。每个 skill 文件是一个自包含的 Markdown 文档,包含四个标准段落:
以 lock-contention.md 为例:
markdown
# 锁竞争分析
## 数据源
- SQL 表: `__intrinsic_thread_state` (blocked_function GLOB '*futex*')
- 需要 `sched_switch` ftrace 事件支持
## 领域知识
- futex (Fast Userspace Mutex): Linux 用户空间互斥锁
- blocked_function 常见值: futex_wait_queue_me, futex_wait, do_futex
- 主线程锁竞争直接导致 ANR 和 jank
## 严重度标准
- P0: 主线程 futex 等待 max > 帧预算
- P1: 主线程 futex 等待 max > 5ms
- P2: 其他线程 futex 等待 max > 10ms
## 优化方向
- 减小锁粒度: 使用细粒度锁替代全局锁
- 避免主线程持锁: 将耗时操作移到子线程
这个结构是刻意设计的。数据源 让 LLM 知道 trace 里的哪些 SQL 表和字段与当前维度相关;领域知识 补充 LLM 可能不了解的专业背景;严重度标准 统一了 P0/P1/P2 的判定阈值;优化方向让 LLM 的建议不只是"这里慢了",而是给出具体的修复方向。
对比 gc-analysis.md,同样四段,但内容完全不同:GC 要知道 Concurrent vs Non-concurrent、Alloc vs Explicit 触发原因、堆大小波动的影响。
核心实现:按需加载机制
prompts.py 是整个 skill 系统的核心,提供三个加载函数。
1. load_prompt() --- 加载基础 prompt
最简单的函数,直接读取 prompts/ 下的 .txt 文件:
python
def load_prompt(name: str) -> str:
path = _PROMPTS_DIR / f"{name}.txt"
return path.read_text(encoding="utf-8")
2. load_skill() --- 加载单个 skill 文件(带缓存)
python
_skill_cache: dict[str, str] = {}
def load_skill(name: str, category: str = "dimensions") -> str:
cache_key = f"{category}/{name}"
if cache_key in _skill_cache:
return _skill_cache[cache_key]
base = _DIMENSIONS_DIR if category == "dimensions" else _SHARED_DIR
path = base / f"{name}.md"
if not path.exists():
return ""
content = path.read_text(encoding="utf-8")
_skill_cache[cache_key] = content
return content
两个要点:第一,缓存机制避免重复磁盘 I/O(同一个 GC 知识可能被三个 Agent 各加载一次);第二,category 参数区分 dimensions/ 和 shared/ 子目录。
3. load_skills_for_dimensions() --- 根据数据动态加载
这是最关键的部分。它接收 perf_summary JSON,扫描其中有实际数据的维度,只加载对应的 skill 文件:
python
def load_skills_for_dimensions(perf_json: str) -> str:
data = json.loads(perf_json)
# 1. 检查维度注册表数据
dimensions_data = data.get("dimensions", {})
skill_names: list[str] = []
if dimensions_data:
from smartinspector.collector.dimensions import DimensionRegistry
DimensionRegistry.discover()
for dim in DimensionRegistry.all():
dim_data = dimensions_data.get(dim.name)
if not dim_data:
continue
# 跳过错误或空数据
if isinstance(dim_data, dict) and dim_data.get("error"):
continue
if dim_data in ("", None, [], {}):
continue
skill_names.append(dim.skill_name)
# 2. 检查非维度字段
ft = data.get("frame_timeline") or {}
if ft.get("jank_frames", 0) > 0 or ft.get("jank_detail"):
skill_names.append("ui-jank")
startup = data.get("startup_metrics") or {}
if startup.get("startups") or startup.get("breakdowns"):
skill_names.append("startup")
# 3. 去重并加载
for skill_name in unique_skills:
content = load_skill(skill_name, category="dimensions")
if content:
parts.append(f"
# Knowledge: {skill_name}
{content}")
return "".join(parts)
逻辑分三步:先遍历维度注册表中已注册的所有维度,检查 perf_summary.dimensions.<name> 是否有有效数据;再检查一些非维度的特征字段(如 jank 帧、冷启动指标);最后去重加载。
核心判断逻辑是:只有 dimensions_data.get(dim.name) 非空、非错误、非零值时才加载 。这意味着如果一次 trace 没有出现锁竞争(lock_contention 字段为空),lock-contention.md 就不会被注入到 prompt 中------省下了几百 token。
4. load_prompt_with_skills() --- 静态组合
对于需要固定 skill 的场景(比如 attributor 始终需要 SI$ 标签知识和搜索策略),提供了静态组合函数:
python
def load_prompt_with_skills(name: str, *skill_names: str) -> str:
parts = [load_prompt(name)]
for skill_name in skill_names:
if skill_name.startswith("shared:"):
ref = load_skill(skill_name[7:], category="shared")
else:
ref = load_skill(skill_name, category="dimensions")
if ref:
parts.append(f"
# Knowledge: {skill_name}
{ref}")
return "
".join(parts)
引用格式用 shared: 前缀区分共享技能和维度技能。例如 "shared:si-tag-system" 会加载 shared/si-tag-system.md。
Pipeline 集成:三个 Agent 的用法差异
三个 Agent 对 skill 系统的使用方式各不相同,这是有意为之的------它们的角色和需求不同。
attributor --- 静态 + 动态混合
attributor 的核心任务是源码归因(找到性能热点对应的源码位置)。它有两个层次的知识需求:
静态需求 :每次运行都必须知道 SI$ 标签怎么解析、源码搜索用什么策略。这部分在初始化时通过 load_prompt_with_skills() 一次性加载:
python
# attributor.py:110-114
_system_prompt = load_prompt_with_skills(
"attributor",
"shared:si-tag-system", # SI$ 标签解析规则
"shared:search-strategy", # Glob→Grep→Read 搜索策略
)
动态需求 :归因时如果能知道当前 trace 有哪些维度异常,LLM 就能更好地判断代码模式。比如发现 GC 维度异常时,attributor 在分析 onBindViewHolder 里的对象分配会更敏锐。这部分通过 _build_system_prompt_with_dimensions() 按需叠加:
python
# attributor.py:124-138
def _build_system_prompt_with_dimensions(perf_json: str) -> str:
_, base_prompt = _get_llm()
dim_skills = load_skills_for_dimensions(perf_json)
if dim_skills:
return base_prompt + dim_skills
return base_prompt
实际调用时,_search_group() 在构建 system prompt 时判断是否有 perf_json 可用:
python
# attributor.py:745
system_prompt = _build_system_prompt_with_dimensions(perf_json) if perf_json else _get_llm()[1]
perf_analyzer --- 纯动态加载
perf_analyzer 是性能分析的主力,它的 system prompt 在运行时根据 perf_summary 数据动态组装:
python
# perf_analyzer.py:51-53
dim_skills = load_skills_for_dimensions(perf_json)
system_prompt = _base_prompt + dim_skills if dim_skills else _base_prompt
_base_prompt 是通用分析指令,dim_skills 是当前 trace 有数据的维度对应的领域知识。如果一次 trace 只有 GC 和锁竞争异常,就只加载 gc-analysis.md 和 lock-contention.md,不会加载 IO、内存、Binder 等无关知识。
这种设计的直接好处是减少了 prompt 的噪声。LLM 在处理包含大量无关领域知识的 prompt 时,容易"注意力分散",给出泛泛而谈的分析。只注入相关知识后,输出的针对性和准确度明显提升。
frame_analyzer --- 依赖已有数据
frame_analyzer 用于分析用户在 Perfetto UI 中选中的时间范围。它不一定有独立的 perf_summary,而是复用之前 /full 命令的分析结果:
python
# frame_analyzer.py:115-119
dim_skills = ""
if existing_summary:
dim_skills = load_skills_for_dimensions(existing_summary)
system_prompt = _base_prompt + dim_skills if dim_skills else _base_prompt
这里有个细节:if existing_summary 的判断。frame_analyzer 可以独立运行(比如直接 /frame ts=X dur=Y 而不先跑 /full),此时没有已有数据,就不加载维度技能。这是合理的降级------没有上下文时用通用 prompt 分析,有上下文时叠加维度知识。
维度类与技能文件的对齐
维度注册表中的每个 AnalysisDimension 子类都有一个 skill_name 属性,它建立了维度类和技能文件之间的映射关系:
python
# base.py:44-47
class AnalysisDimension(ABC):
@property
def skill_name(self) -> str:
"""对应的 dimension skill 文件名(不含 .md)。"""
return self.name # 默认等于维度名
大多数维度的 skill_name 就是 name 本身。但有例外------file_io 维度的 skill_name 是 io-analysis:
python
# file_io.py:22-24
@property
def skill_name(self) -> str:
return "io-analysis"
这个映射关系是有意设计的:维度在代码中的标识符用 file_io(符合 Python 的 snake_case 命名规范),而技能文件用 io-analysis(更贴近领域术语)。load_skills_for_dimensions() 通过 dim.skill_name 桥接两端,代码侧和数据侧可以独立命名。
这种松耦合设计在扩展时特别方便。新增维度的标准流程是:
- 在
collector/dimensions/下创建维度类,设置skill_name - 在
prompts/skills/dimensions/下创建对应的.md文件 - 维度注册表的
discover()自动发现新类 load_skills_for_dimensions()自动加载新 skill
不需要改任何已有的代码或 prompt 文件。这是整个系统最重要的特性------零成本扩展。
一次完整的运行流
以一个包含 GC 和锁竞争异常的 trace 为例,看数据在 pipeline 中的流转:
-
collector 阶段 :
PerfettoCollector.summarize()遍历所有注册维度,调用collect()。GC 维度返回了 5 个 GC 事件(max_pause=23.5ms),锁竞争维度返回了 main 线程 futex 等待 12 次。IO 维度返回空数据。最终perf_summaryJSON 的dimensions字段包含gc_events和lock_contention,不含file_io。 -
perf_analyzer 阶段 :
load_skills_for_dimensions(perf_json)扫描 JSON,发现gc_events有数据且无 error,加载gc-analysis.md;发现lock_contention有数据,加载lock-contention.md;发现file_io为空,跳过。最终 system prompt = 通用分析指令 + GC 知识 + 锁竞争知识。 -
attributor 阶段 :
_build_system_prompt_with_dimensions(perf_json)执行同样的扫描,将 GC 和锁竞争知识叠加到归因 prompt 上。LLM 在搜索源码时,会结合 GC 知识识别出onBindViewHolder中的大对象分配模式。 -
frame_analyzer 阶段(如果用户选中了某帧做深度分析):复用步骤 1 的 perf_summary,加载同样的维度技能。
整个过程中,io-analysis.md、cpu-scheduling.md、memory-analysis.md 等 6 个 skill 文件从未被读取,省下了约 2000 token。
设计收益
回顾这套 skill 系统解决了什么问题:
知识持久化 :领域知识不再散落在 prompt 的某个角落,而是独立的 .md 文件,有版本控制,可以独立 review 和修改。修改 GC 的严重度标准不需要动任何 Python 代码或通用 prompt。
领域专业化 :每个 skill 文件遵循统一的结构(数据源→领域知识→严重度→优化方向),但又完全自包含。GC 专家可以只关注 gc-analysis.md,不需要理解整个 prompt 系统。
维护分离:通用 prompt(分析框架、输出格式)和领域知识(GC 类型、futex 含义)彻底解耦。修改通用 prompt 不会意外影响 GC 分析的准确性,反之亦然。
零成本扩展:新增维度只需两步------创建维度类和创建 skill 文件。已有的 pipeline、prompt、agent 无需任何改动。
按需加载:只注入有数据的维度知识,减少了 prompt 长度和 token 消耗,同时降低了无关知识对 LLM 分析的干扰。
当然这不是唯一解。也可以用 RAG 或 function calling 让 LLM 主动查询知识库,但那增加了架构复杂度和延迟。对于 9 个维度、每个 20-30 行 Markdown 的规模,静态文件 + 按需拼接是最简单可靠的方案------没有向量数据库、没有嵌入计算、没有检索准确率问题,读文件就行。