报告生成:怎么让AI输出结构化内容
性能分析跑完了,数据有了,归因也做了------最后一步,把结果喂给LLM,让它生成一份人能看懂的报告。
听起来简单对吧?实际上这是整个SmartInspector pipeline里最"不可控"的环节。LLM不是函数,你传进去同样的prompt,出来的格式可能完全不同。这篇文章讲我怎么从"让LLM自由发挥"一步步驯服到"稳定输出结构化报告"。
先看问题在哪
最初的设计非常朴素:把perf数据拼成一段文本,扔给LLM说"帮我分析一下",然后直接输出结果。
python
# 初版,简单粗暴
messages = [
SystemMessage(content="你是性能分析专家"),
HumanMessage(content=f"以下是性能数据:{perf_data}"),
]
response = llm.invoke(messages)
跑起来之后遇到三个大问题:
- 格式不稳定:有时候输出Markdown表格,有时候输出纯文本列表,有时候还给你加个"总结"和"展望"
- 关键信息丢失:10条归因结果,报告里只写了6条,剩下4条被LLM"精简"掉了
- 内容冗余:LLM会把输入数据原封不动复述一遍,导致报告比输入还长
这三个问题不是个别case,而是几乎每次跑都会出现。原因很本质:LLM是语言模型,不是报告生成器。它的训练目标是"生成合理的文本",不是"按格式输出结构化数据"。
第一层驯服:Prompt Engineering
驯服LLM的第一步是写一个足够严格的prompt。SmartInspector的report-generator.txt经过了十几次迭代,核心思路是:
明确告诉LLM"不要做什么"比告诉它"做什么"更有效。
markdown
# 输出规则
1. 不要重复输出报告头部(测试概要、性能总览等表格),这些已作为参考数据提供
2. 你只需要生成:
- 问题列表 --- 按P0/P1/P2分级
- 附录 --- 采集工具和原始数据位置
3. 如果没有性能问题,输出一句话:"未发现显著性能问题"
关键设计决策:把报告头部和LLM生成分离。头部(测试概要、性能总览表格)是确定性数据,用纯Python生成,不让LLM碰。LLM只负责"问题分析"这一块------因为只有这部分需要语言理解能力。
python
# reporter/__init__.py
# 报告头部用纯代码生成,不经过LLM
header_md = _build_report_header(perf_json, trace_path)
# LLM只生成问题分析部分
full_content = generate_report(report_prompt, user_content)
# 最终报告 = 确定性头部 + LLM分析
complete_report = header_md + "\n" + full_content
这个设计的trade-off很明显:牺牲了LLM对整体报告的掌控力,换来了格式的绝对稳定。头部表格永远是对齐的,数据永远是准确的,不会出现LLM"编造"FPS数字的情况。
第二层驯服:强制不遗漏
解决了格式问题,下一个难题是"遗漏"。LLM看到10条归因结果,觉得第3条和第7条"类似",就给合并了,甚至直接跳过。
这在技术写作里是优点(精炼),但在性能报告里是灾难------用户花了时间跑分析,结果你不告诉他某个方法有性能问题?
解决方案是在prompt里加计数约束:
bash
# 问题生成规则
必须为"源码归因结果"中的每一条记录生成一个独立的问题条目。
不可遗漏、不可合并。
生成问题列表前,先清点"源码归因结果"中的条目总数,
确保每个条目都在问题列表中有对应的问题条目。
输出前逐条检查,不可遗漏。
实测效果:加了这条规则后,归因条目的覆盖率从70%左右提升到95%以上。剩下5%的遗漏基本是LLM确实判断两条记录属于同一根因的合理合并------这种情况下允许合并但要求在"现象"里列出所有方法。
第三层驯服:输入裁剪和优先级排序
前面说的问题都是"LLM输出端"的,但还有一类问题出在"输入端":数据太长,被截断了。
SmartInspector一次完整分析的用户输入可能有上万字符(归因结果、性能数据、线程状态分析......),而LLM的context window不是无限的。如果归因数据排在后面,前面塞了一大堆帧时间线数据,归因结果可能被截断------LLM根本看不到。
解决方案是显式控制输入顺序和截断策略:
python
# reporter/__init__.py
# IMPORTANT: attribution section MUST come first
# 归因数据排在最前面,确保不被截断
user_parts: list[str] = []
# 归因优先级最高
user_parts.extend(format_attribution_section(attribution_result))
# 性能数据次之
if perf_json:
user_parts.extend(format_perf_sections(perf_json))
user_parts.append(header_md)
# LLM分析结论放最后(可被截断)
if perf_analysis:
user_parts.append(f"## 性能分析\n{perf_analysis}")
截断时按段落边界切割,不会从中间截断一条归因记录:
python
if estimated_tokens > MAX_REPORT_INPUT_TOKENS:
sections = user_content.split("\n\n")
truncated: list[str] = []
total = 0
for sec in sections:
if total + len(sec) > target_chars and truncated:
break
truncated.append(sec)
total += len(sec)
user_content = "\n\n".join(truncated) + "\n\n[... 数据过长已截断 ...]"
这个设计的核心思想:归因结果是不可压缩的(每条都是一个问题),性能数据是可压缩的(LLM只需要关键指标)。所以归因排前面,数据排后面。
第四层驯服:预计算结论
还有一个发现:LLM在做一些判断时经常犯错,比如判断"这个切片是被IO阻塞还是代码执行慢"。
但这个判断其实是确定性的------Perfetto的线程状态数据已经告诉你了Running占多少、Sleeping占多少。不需要LLM来猜。
所以SmartInspector引入了预计算结论(deterministic hints):
python
# formatter.py
from smartinspector.agents.deterministic import compute_hints
hints = compute_hints(perf_json)
if hints:
user_parts.append(f"## 预计算结论\n{hints}")
compute_hints跑10个确定性分析模块,输出中文文本格式的结论:
python
def compute_hints(perf_json: str) -> str:
sections = [
_detect_empty_scenario(data), # 空场景检测
_classify_severity(data), # 严重度分级
_compute_call_chain_distribution(data), # 调用链分布
_rank_rv_hotspots(data), # RV热点排名
_correlate_jank_frames(data), # 卡顿帧关联
_identify_cpu_hotspots(data), # CPU热点
_analyze_thread_state(data), # 线程状态
_analyze_io_slices(data), # IO分析
_analyze_compose_slices(data), # Compose重组
_analyze_memory(data), # 内存分析
]
return "\n\n".join(s for s in sections if s)
这些结论直接告诉LLM:"这个切片主导状态是Sleeping,根因是IO阻塞",LLM只需要据此生成自然语言的描述和建议。把"判断"交给确定性代码,把"表达"交给LLM。
流式输出:一行一行打出来
最后一个细节:报告是流式输出的。
python
# generator.py
full_content = ""
try:
for chunk in llm.stream(messages):
token = chunk.content
if token:
print(token, end="", flush=True)
full_content += token
except Exception as e:
# Stream failed --- retry with invoke
logger.warning("Stream interrupted (%s), retrying...", e)
response = llm.invoke(messages)
full_content = response.content
为什么报告要一行一行打出来?不是炫技,是因为一次完整分析要跑2-3分钟。如果最后生成报告这一步还要等30秒什么都不显示,用户体验会非常焦虑。流式输出让用户看到"正在生成",心理上好受很多。
另外加了一个fallback机制:如果stream中断(网络抖动、API超时),自动切换到非流式的invoke重试。这个设计在生产环境里救过好几次。
JSON报告:给机器看的结构化输出
除了Markdown给人看的报告,SmartInspector还支持JSON格式的结构化报告,给CI/CD流水线用:
python
# json_formatter.py
def format_json_report(perf_json, perf_analysis, attributable, trace_path, target):
report = {
"version": "1.0",
"timestamp": datetime.datetime.now().isoformat(),
"target": {"package": target},
"summary": _extract_summary(perf_data),
"issues": _extract_issues(perf_data, attributable),
"metrics": _extract_metrics(perf_data),
}
return report
这里有个有意思的设计:issues的严重度分级是用纯代码算的,不走LLM:
python
def _classify_issue_severity(dur_ms: float) -> str:
if dur_ms > 16.67: # 超过一帧时间
return "P0"
if dur_ms >= 4.0: # 超过帧预算的25%
return "P1"
return "P2"
这个阈值不是拍脑袋定的------是基于设备的刷新率动态计算的。60Hz设备阈值是16.67ms,120Hz设备自动调整为8.33ms。这就是_detect_frame_budget_ms干的事,从Perfetto数据里检测实际刷新率,然后推导帧预算。
总结:确定性优先,LLM兜底
回过头看,整个报告生成的架构思路是:
- 能确定的部分用确定性代码:报告头部、严重度分级、阈值计算、线程状态判断
- 必须用LLM的部分严格约束:prompt里明确格式、计数、优先级
- 数据流向上保证优先级:归因数据排最前(不可截断),辅助数据排最后(可截断)
- 格式上双轨输出:Markdown给人 + JSON给机器
最终的效果:SmartInspector的报告生成跑了几百次,格式一致性保持在95%以上。剩下5%的"不稳定"基本是LLM在措辞上的差异------这反而是优点,每次报告读起来不完全一样,不像模板。
但如果你问我最大的教训是什么?是这句:别指望LLM做它不擅长的事。格式化、计数、数学计算、精确引用------这些确定性代码1行就能搞定的事,别丢给LLM,它真的做不好。