· liyu · rag-agent · 29 min read · — 次浏览
把面试没答好的题补上:Agent Skill 深度解析与 Spark 报错排错 Skill 设计
面试官问「讲讲 Skill 是什么,再设计一个排查 Spark 报错的 Skill」。这篇从渐进式披露的三层加载原理讲到 Skill 与 RAG、MCP、微调的生态位辨析,再给出一个可落地的 spark-error-troubleshooting 完整设计——把这道题答成体系。
零、这道题为什么值得单独写一篇
上一场面试里,面试官从我的开源项目聊到 Agent 工程,抛出了一个问题:
「你提到给 Agent 做过 Skill。先讲讲 Skill 到底是什么,它解决了什么问题?然后假设让你设计一个专门排查 Spark 报错的 Skill,你会怎么做?」
当时我答得零碎:说到了「相当于给模型外挂操作手册」,也说到了「能把团队经验沉淀下来」,但两条线都没展开,中间的逻辑断层全靠面试官自己脑补。
事后复盘,这道题其实有一个非常清晰的作答骨架——是什么(原理)→ 和谁比(生态位)→ 怎么做(落地),正好对应从宏观到细节的三层递进。这篇文章就按这个骨架,把这道题完整重答一遍。
一、宏观:Skill 到底是什么
1.1 从一个真实的失效场景说起
先看一个所有做过大模型应用的人都遇到过的场景。我把一段 Spark 报错堆栈贴给通用大模型,让它帮我排查:
Exception in thread "main" org.apache.spark.SparkException:
Job aborted due to stage failure: Task 47 in stage 12.0 failed 4 times,
most recent failure: ... java.io.IOException: Failed to connect to host模型的回答通常是一份「泛泛而谈的通用检查清单」:检查网络连通性、确认 executor 是否存活、查看 shuffle 服务配置……每一条都对,但每一条都没有击中要害。
而我们团队的老师傅看一眼就知道:「先看是不是那几个大表 join 触发的倾斜,上个季度就是这个问题把 shuffle 拖挂的。」
这个差距来自三个失效原因:
1. 参数知识是通用的,团队经验是私有的
—— 模型在预训练语料里学过所有公开的 Spark 报错模式,
但「我们集群的 external shuffle service 曾因端口被占用而假死」
这种私有经验,模型永远不可能知道。
2. 就算写进 Prompt,上下文也装不下
—— 排错经验、集群环境、历史故障案例、参数规范……
这些知识以远超上下文窗口的速度持续增长。
3. 经验沉睡在人脑和聊天记录里,不可复用
—— 老师傅的直觉没有被固化为任何可执行、可传递的资产。1.2 一句话定义
Skill 是一个存放在文件系统里的「领域知识包」:一个文件夹,里面有一份 SKILL.md(元数据 + 操作手册),可选地附带参考文档、脚本和模板。Agent 根据当前任务,按需把对应的 Skill 加载进上下文。
这就是 Anthropic 在 2025 年提出的 Agent Skills 规范的核心形态,也是 Claude Code 中 .claude/skills/ 目录下的东西。以排错类 Skill 为例,它的长相是:
spark-error-troubleshooting/
├── SKILL.md # 元数据(frontmatter)+ 排错工作流总纲
├── references/ # 深度参考文档,按需加载
│ ├── memory-errors.md
│ └── shuffle-errors.md
├── scripts/ # 可执行脚本,输出才进上下文
│ └── extract_stack.py
└── assets/ # 模板、速查表等静态资源
└── config-checklist.md理解它最准确的类比是:Skill 是写给模型看的 SOP(标准作业程序)、runbook(运维手册)。它承载的是程序性知识(procedural knowledge,即「怎么做」),而不是陈述性知识(declarative knowledge,即「是什么」)——这个区分后面辨析生态位时会反复用到。
1.3 它解决的核心矛盾
Skill 的全部设计哲学,都在回应一个矛盾:
领域知识无限增长,而上下文窗口是稀缺资源。
即使模型有 200K 的窗口,也不可能、更不划算把所有知识常驻上下文——每一个常驻 token 都在每一轮对话中被重复处理(无前缀缓存时还要重复计费),而其中 99% 的内容与当前任务无关。
Skill 给出的关键洞察是:知识应该像操作系统里的虚拟内存一样按需分页供给,而不是全部常驻。 具体怎么「按需」,就是下一节的渐进式披露。
二、原理:渐进式披露(Progressive Disclosure)
渐进式披露是 Skill 机制的灵魂,它把一次知识供给拆成了三层,逐层付费、逐层深入:
渐进式披露的三层加载模型
┌─────────────────────────────────────────────────────────┐
│ Level 1:元数据常驻(目录页) │
│ • 只有 name + description,每个 Skill 约 100 token │
│ • 所有 Skill 的元数据常驻 system prompt │
│ • 模型据此判断「当前任务是否与某个 Skill 相关」 │
├─────────────────────────────────────────────────────────┤
│ Level 2:正文按需加载(翻开某一章) │
│ • 命中后,才读取 SKILL.md 的正文(建议 < 500 行 / 5k token) │
│ • 加载的是操作手册主干:工作流、决策树、护栏条款 │
├─────────────────────────────────────────────────────────┤
│ Level 3:资源深加载(查阅附录) │
│ • 正文里链接的 references/*.md,用到哪篇读哪篇 │
│ • scripts/ 下的脚本按需执行——只有输出进上下文,源码不必进 │
└─────────────────────────────────────────────────────────┘2.1 算一笔 token 账
假设系统里有 10 个 Skill,每个正文 3k token:
方案 A:全部塞进 system prompt
常驻成本 ≈ 10 × 3k = 30k token,每一轮对话都重复支付
方案 B:渐进式披露
常驻成本 ≈ 10 × 100 = 1k token(只有元数据)
命中后额外付出 ≈ 3k(正文)+ 若干(用到的参考文档)
且 90% 的对话一轮 Skill 都不会命中,正文成本为 0差异是 30 倍量级的,而且方案 A 里那 30k token 绝大多数时间是「死重」。
2.2 第三层的隐藏优势:脚本不占推理 token
Level 3 里最有工程价值的一点常被忽略:脚本可以被执行,而源码不必进上下文。一个 100 行的日志提取脚本,模型只需要知道「调用它、传什么参数、怎么读输出」,执行结果是 20 行结构化摘要——用 20 行 token 的成本,完成了「让模型去吞 3 万行原始日志」根本不可能完成的任务。
这背后是一个更重要的分工原则:确定性的脏活交给代码,token 留给推理。 正则匹配这种事,模型做又贵又抖,脚本做又快又稳。
2.3 谁来触发?
以 Claude Code 为例,Skill 有两种触发方式:
- 模型主动触发(model-invoked):模型阅读所有 Skill 的元数据,根据当前任务语义判断是否相关,相关则自主加载——这是 Skill 的默认方式,也是「渐进式」得以成立的前提;
- 用户显式触发(slash command):用户输入
/skill-name强制加载,适合「我就要跑这个流程」的场景。
注意区别:触发决策是模型基于 description 的语义判断,不是关键词检索。这也意味着 description 的写法(第四节)直接决定 Skill 的生死。
三、生态位:Skill vs System Prompt vs RAG vs MCP vs 微调
这是面试官最爱追问的部分。一张表先立住全景:
┌──────────┬────────────────┬──────────────┬───────────────┬───────────────┬────────────────┐
│ 维度 │ System Prompt │ RAG │ MCP / 工具 │ 微调 │ Skill │
├──────────┼────────────────┼──────────────┼───────────────┼───────────────┼────────────────┤
│ 承载知识 │ 全局指令与角色 │ 事实性语料 │ 外部能力接口 │ 能力与风格 │ 程序性领域知识 │
│ 知识类型 │ 是什么/守什么 │ 是什么 │ 能做什么 │ 擅长怎么做 │ 该怎么做 │
│ 供给方式 │ 全量常驻 │ 按查询检索片段 │ 运行时调用 │ 训练时固化参数 │ 按任务触发按需加载│
│ 更新成本 │ 改代码重发 │ 重建索引 │ 维护服务 │ 重新训练 │ 改文件,秒级 │
│ token 成本 │ 每 token 常驻 │ 相关片段 │ 工具 schema │ 推理时零额外 │ 三级渐进 │
│ 版本管理 │ 困难 │ 困难 │ 中等 │ 困难 │ Git 管理,天然 │
└──────────┴────────────────┴──────────────┴───────────────┴───────────────┴────────────────┘表格之外,三条高频追问的标准答案:
追问 1:有了 RAG,为什么还要 Skill?
二者承载的知识类型不同。RAG 检索的是事实片段:按查询相似度把语料切碎、排序、取 top-k。而排错是典型的程序性任务——需要的是「先看什么、再看什么、如何交叉验证」的完整决策树,而不是一堆按相似度排序后互相割裂的段落。把 runbook 切碎塞进向量库,检索回来的碎片恰恰丢失了「流程」这个最值钱的结构。
另外供给机制也不同:RAG 依赖相似度检索,存在召回失败与噪声片段问题;Skill 的触发是模型基于元数据的显式语义判断,命中的是人工策展过的完整单元。可以粗暴地记:海量事实找 RAG,成体系的流程找 Skill。
追问 2:有了 MCP,为什么还要 Skill?
二者正交,回答口诀是:MCP 管连接(connection),Skill 管方法(know-how)。
MCP 负责把外部系统的能力暴露成工具——工具从哪来;Skill 负责教模型怎么组织这些工具完成任务——流程怎么走。举例:MCP 给了模型一个「查询 Spark History Server」的工具,但模型未必知道「拿到 Stage 耗时分布后,先看单个 task 的 max/median 比值来判断数据倾斜」这个方法论。连接 + 方法,缺一不可。
追问 3:为什么不直接微调?
微调改的是模型参数,Skill 是文件系统里的文本。后者成本几乎为零、Git 可版本管理与评审、当天生效、失败可回滚、内容可解释可归因。微调解决的是「能力和风格」问题(让模型更擅长某种推理模式),Skill 解决的是「知识和流程」问题(把私有经验装进去)。排错经验这种高频迭代、强版本化的内容,用微调承载是高射炮打蚊子,还打不准。
四、规范:一个标准的 Skill 怎么写
4.1 结构规范
Agent Skills 是一份开放规范,核心约束不多但很硬:
---
# SKILL.md frontmatter
name: spark-error-troubleshooting
# name 规范:小写字母、数字、连字符,最长 64 字符
description: >-
(最长 1024 字符,第三人称书写)
必须同时回答两件事:这个 Skill 做什么 + 什么时候该用它
---
(正文:操作手册主干)description 是唯一的常驻元数据,也是触发器的全部依据——一个 Skill 的成败,八成写在 description 里。
4.2 五条写作原则
1. description = 写什么 + 何时用 + 触发词
站在使用者的视角枚举「什么任务会出现时该想起我」,
关键词要覆盖真实用户会说的词(中英文、术语、口语变体)。
2. 正文克制:写「工作流」而不是「百科」
SKILL.md 正文建议 < 500 行。它是操作手册的目录和主干,
不是知识本身——知识下沉到 references/。
3. 参考文档按主题拆文件,宁可多、不要大
每个文件聚焦一个子主题,保证 Level 3 加载的粒度足够细,
用到哪篇读哪篇,绝不「为了查一个问题加载全部知识」。
4. 确定性工作交给脚本
字符串提取、格式转换、配置校验、日志匹配——
凡是能用代码确定性完成的,一律写成脚本,不要消耗推理 token。
5. 自由度分级供给
流程固定的步骤 → 低自由度:直接给脚本或伪代码;
需要判断的步骤 → 高自由度:给原则、边界条件和反例。还有两条护栏式的细节:不要把时间敏感的信息写死在 Skill 里(写「如何查询最新值」而不是写死数值);对容易越界的地方明确写「不要假设、不要编造,超出收录范围时明说」。
五、实战:设计 Spark 报错排错 Skill
铺垫了四节,现在回答第二问。我的第一步不是动手写文件,而是做需求分析——领域特征决定架构。
5.1 需求分析:Spark 报错的四个领域特征
特征 1:日志海量、噪声大
一个失败的 job 可能吐出几万行日志、上千行堆栈。
→ 推论:提取关键信息是确定性字符串处理,必须交给脚本。
特征 2:根因藏在「Caused by」链深处
表层异常往往是笼统的 SparkException,
真正的根因要沿堆栈向下剥,藏在最后一个 Caused by 里。
→ 推论:需要一条固定的「剥洋葱」流程,适合低自由度输出。
特征 3:错误族稳定且有限
大数据开发多年沉淀的报错高度模式化:
OOM 族、Shuffle 族、数据倾斜族、序列化族、SQL 解析族、依赖冲突族。
→ 推论:天然适合按「错误族」拆分参考文档,精确控制 Level 3 粒度。
特征 4:诊断强依赖环境上下文
同样一条堆栈,executor 内存 2G 和 8G、shuffle partitions 200 和 2000,
结论完全不同。
→ 推论:需要脚本收集 spark-submit 配置,与错误模式交叉验证。四个特征,分别对应架构里的四个部件。这就是「从宏观到细节」的推演路径。
5.2 总体设计:目录结构
spark-error-troubleshooting/
├── SKILL.md # 排错工作流总纲(Level 2)
├── references/ # 错误模式库,按错误族拆分(Level 3)
│ ├── memory-errors.md # OOM 族:executor / driver / 容器超限
│ ├── shuffle-errors.md # Shuffle 族:FetchFailed、连接失败
│ ├── skew-diagnostics.md # 数据倾斜族:长尾 task、聚合倾斜
│ ├── serialization-errors.md # 序列化族:Task not serializable
│ ├── sql-analysis-errors.md # SQL 族:AnalysisException、类型不匹配
│ └── dependency-errors.md # 依赖族:NoSuchMethodError、类冲突
├── scripts/ # 确定性处理层
│ ├── extract_stack.py # 从日志中提取异常摘要
│ └── check_config.py # spark-submit 配置启发式校验
└── assets/
└── config-checklist.md # 常用参数速查表5.3 SKILL.md 之一:frontmatter(触发器设计)
---
name: spark-error-troubleshooting
description: >-
Debug Apache Spark job failures and analyze driver/executor logs.
Covers executor OOM, container killed by YARN, shuffle fetch failures,
data skew (long-tail tasks), Task not serializable,
AnalysisException, and fat-jar dependency conflicts.
Use when the user pastes a Spark stack trace, reports a failed
stage/task or a killed executor, or asks why a Spark job
crashed, hung, or slowed down. Do not use for non-Spark
big-data systems (Flink, Hive on MR).
---逐句拆解这份 description 的设计意图:
- 前两句(what):说清能力边界,且密集埋入真实报错关键词——
executor OOM、fetch failures、Task not serializable,这些词既是给模型看的语义描述,也是触发时的匹配锚点; - 第三句(when):枚举用户真实的表达方式——「贴堆栈」「问为什么挂了/为什么慢」,覆盖口语变体;
- 第四句(negative trigger):明确排除场景,防止误触发到 Flink/Hive 的报错上。正负两个方向都写,触发才稳。
5.4 SKILL.md 之二:正文(排错工作流)
正文不是知识堆砌,而是一套带护栏的工作流:
## Spark 报错排查工作流
### 第 1 步:提取异常摘要(不要直接阅读原始日志)
运行 `python scripts/extract_stack.py <log_path>`,
得到结构化摘要:异常族、最后一条 Caused by、涉及 stage/task。
禁止在未提取前把原始日志读入上下文。
### 第 2 步:按异常族查阅对应模式库
根据摘要中的异常族,只读取 references/ 下对应的一个文件:
- OutOfMemoryError / Container killed → memory-errors.md
- FetchFailedException / shuffle 连接失败 → shuffle-errors.md
- 单个 task 显著长尾、stage 后期集中失败 → skew-diagnostics.md
- Task not serializable → serialization-errors.md
- AnalysisException → sql-analysis-errors.md
- NoSuchMethodError / ClassNotFoundException → dependency-errors.md
### 第 3 步:配置交叉验证
运行 `python scripts/check_config.py` 获取配置体检结果,
将模式库中的「高概率根因」与实际配置逐条核对,不许跳过。
例:模式库提示 executor OOM 常因 memoryOverhead 不足,
必须先确认用户配置中 overhead 实际取值,才能下结论。
### 第 4 步:输出诊断报告
固定格式:根因结论 → 证据链(日志行 + 配置项)→ 修复方案。
修复方案分两级:配置级(给出参数与建议值)/ 代码级(给出改法)。
### 第 5 步:未知模式的护栏
若异常不匹配任何已收录模式,明确说明「未匹配已知错误族」,
只给出通用排查路径(Spark UI → 失败 stage → 失败 task 日志)。
禁止为了给出「看起来完整的答案」而编造不存在的错误模式。注意自由度的分级:第 1、3 步是低自由度(命令固定、流程死);第 2、4 步是中自由度(映射关系定,判断归模型);第 5 步是护栏条款——对排错场景,宁可承认「不认识」,也不能编一个置信度 100% 的错误根因,这在生产上是会误导运维操作的。
5.5 references/:错误模式库的组织
每个错误族文件内部遵循统一的三段式结构,以 memory-errors.md 为例:
## 识别特征
- `java.lang.OutOfMemoryError: Java heap space`
- `Container killed by YARN for exceeding memory limits`
- `Memory is over ... spark.memory.fraction`(统一内存管理相关)
## 根因清单(按历史出现概率排序)
1. memoryOverhead 未配置或过小 —— 堆外内存(网络缓冲、JVM 开销)
被算进容器限额,容器被 YARN 杀掉(堆栈特征:进程被 kill 而非 Java OOM)
2. 单个 partition 数据量过大 —— 大表 join / groupBy 后单 key 巨大,
单 task 内聚出超大集合(堆栈特征:OOM 出现在聚合或 join 算子处)
3. driver 端 collect / broadcast 过大 —— OOM 发生在 driver
(特征:`Total size of serialized results ... bigger than spark.driver.maxResultSize`)
## 修复方案
### 配置级
- `spark.executor.memoryOverhead`:默认为 executorMemory × 0.10,
shuffle 密集或含堆外操作时建议 0.20 起
- `spark.sql.shuffle.partitions`:过小会放大单 partition 体积
### 代码级
- 避免在 driver 端 collect 大结果;大变量走 broadcast 时先评估体积
- 聚合倾斜见 skew-diagnostics.md(加盐、两阶段聚合)三段式的好处:模型按「识别特征 → 根因清单 → 修复方案」的固定动线走,证据链完整,不会跳步。第 3 条里还体现了模式库之间的交叉引用——OOM 和倾斜经常互为因果,文档之间要能互相导航。
5.6 scripts/:确定性处理层
# scripts/extract_stack.py(示意)
import re
import sys
# 各错误族的特征正则:确定性的活,交给确定性工具
ERROR_FAMILIES = {
"memory": r"OutOfMemoryError|Container killed by YARN",
"shuffle": r"FetchFailedException|Failed to connect to host",
"serialize": r"Task not serializable",
"sql": r"AnalysisException|cannot resolve",
"dependency": r"NoSuchMethodError|ClassNotFoundException",
}
def extract(log_path: str) -> str:
text = open(log_path, encoding="utf-8", errors="ignore").read()
report = []
# 1. 异常族归类
for family, pattern in ERROR_FAMILIES.items():
if re.search(pattern, text):
report.append(f"异常族: {family}")
# 2. 根因链:Spark 的根因常在最后一个 Caused by
causes = re.findall(r"Caused by:\s*(.+)", text)
if causes:
report.append(f"根因链末端: {causes[-1].strip()}")
# 3. 定位信息:失败 stage 与 task
for m in re.findall(r"Task (\d+) in stage (\d+\.?\d*) failed", text):
report.append(f"失败定位: stage={m[1]} task={m[0]}")
return "\n".join(report) if report else "未匹配已知错误族"
if __name__ == "__main__":
print(extract(sys.argv[1]))设计要点:几万行日志进脚本,出来的摘要只有几行。脚本的职责边界是「提取与归类」,不下结论——根因推断和方案匹配仍由模型结合 references 完成,因为那需要跨证据的推理,正是模型擅长的部分。再次强调分工:确定性归代码,概率性归模型。
5.7 评测与迭代:怎么证明这个 Skill 有效
不评测的 Skill 等于没写。我的方案是用历史真实故障构建回归集:
语料:整理团队过往 on-call 记录中的真实故障 case,脱敏后取 30 例,
每例 = 原始日志 + 人工确认的根因 + 实际修复方案。
指标:
① top-1 根因命中率 —— 首选诊断是否命中真实根因
② 平均 token 消耗 —— 渐进式披露是否真的省钱
③ 平均定位轮次 —— 需要几轮交互才能给出结论
对照组:同一回归集,跑「无 Skill 的裸模型」。迭代飞轮也很朴素:每次模型诊断失败的 case,先判断失败原因——是模式库没收录(补 references/)、是触发没命中(改 description)、是流程跳步(加护栏条款)——然后改完重跑全量回归,保证修一个旧 case 不劣化其他 case。这个飞轮跑起来,Skill 就从「一次性配置」变成了「持续增值的经验资产」。
5.8 踩坑清单:五个反模式
① description 写成抽象口号
「帮助用户解决大数据问题」——模型无法判断何时该触发。
必须枚举具体报错关键词和真实用户表达。
② SKILL.md 正文写成百科全书
把所有 Spark 知识塞进正文,Level 2 加载一次就爆,
渐进式披露形同虚设。知识必须下沉到 references/。
③ 让模型直接读原始日志
几万行日志吞进上下文,又贵又容易截断。
提取类工作必须脚本化。
④ 用 Skill 存事实性海量知识
「收录所有 Spark JIRA 案例」该用 RAG。
Skill 承载的是策展后的流程,不是原始语料。
⑤ 不设护栏,允许编造
排错场景下,「自信地给出错误根因」比「承认不知道」危害大得多。
未知模式必须显式声明,并降级到通用排查路径。六、收束:把这道题答成体系
回头看,这道面试题的标准作答框架可以压缩成三句话:
30 秒版:
Skill 是文件系统里的领域知识包,靠渐进式披露三层加载解决
「知识无限 vs 上下文稀缺」的矛盾;它与 RAG(事实 vs 流程)、
MCP(连接 vs 方法)正交互补。
3 分钟版:
宏观(是什么、为什么)→ 原理(三级渐进披露 + token 账)
→ 生态位(五者对比 + 三条追问)→ 规范(frontmatter + 五原则)
→ 实战(领域特征推演架构 + 工作流 + 评测飞轮)。而比框架更重要的,是我事后才想透的本质:Skill 是「上下文工程」的集大成者。它用文件系统做知识的分层供给,用元数据触发解决稀缺性,用脚本解决确定性,用统一规范解决可维护性——最终把老师傅脑子里那些「看一眼就知道」的隐性经验,显性化成了可版本管理、可评测、可迭代的工程资产。
面试里我没把这条逻辑链讲顺。现在,它在我自己的博客里了。
回看感悟累计回看 — 次
加载中…