· 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 是「上下文工程」的集大成者。它用文件系统做知识的分层供给,用元数据触发解决稀缺性,用脚本解决确定性,用统一规范解决可维护性——最终把老师傅脑子里那些「看一眼就知道」的隐性经验,显性化成了可版本管理、可评测、可迭代的工程资产。

面试里我没把这条逻辑链讲顺。现在,它在我自己的博客里了。

Share:

回看感悟累计回看 — 次

加载中…

Back to Blog

Related Posts

View All Posts »
阶段 1 进阶:摘要压缩算法

AI 大模型 Ragent 项目:长会话 Token 爆炸?会话摘要压缩策略与水位线机制深度解析

当多轮对话聊到 30 甚至 50 轮,滑动窗口滑走了开头的关键约束、全量保留又会挤爆 Token 上下文,RAG 系统该如何破局?深入剖析 Ragent 记忆系统中的增量摘要压缩算法、lastMessageId 水位线推进机制、攒批压缩优化(summaryBatchSize)、Prompt 绝对禁止记录答案的设计哲学以及 Redisson 分布式锁防重实践。

阶段 1:会话记忆系统

AI 大模型 Ragent 项目:会话记忆系统设计与多轮对话状态管理实践

大语言模型 API 天然是无状态的,如何让每次独立的请求表现为连贯自然的连续对话?深入剖析 Ragent 问答流水线阶段一(loadMemory)背后的三层记忆架构、异步并发拉取与容错降级、滑动窗口规整(normalizeHistory)、loadAndAppend 时序避坑以及结构化 Prompt 上下文注入顺序。