这是《Agent Harness 从用到会造》第 9 讲。
前面八讲,我们一直在给 Harness 搭骨架:循环、工具、状态、错误处理、上下文管理。到这一讲,要往骨架里塞一件更"软"的东西------Skill。
很多人第一次接触 Skill 这个概念,脑子里冒出来的画面是"一个函数":给它输入,它返回输出,完事。于是他们把 Skill 写成 def do_something(x): return y,注册进工具表,然后发现------这东西在第二个任务里就不好使了,得改内部实现,改完第一个任务又坏了。
问题出在认知上。Skill 不是一次 API 调用,它是一段会复用的行为。 这句话是本讲的全部判断。下面我先把它拆成三层机制,然后建一个能跑的台架,用真实输出把每一层验一遍:换目标文件、换风格、被边界拒绝、被上层组合。跑出来的完整记录冻结在 out/harness/L09/skill_demo_run.txt,你可以在自己机器上重跑同一份。
原理:为什么"一次调用"和"一段行为"是两回事
一次调用是无状态的,Skill 要带上下文与约束
先看最朴素的工具调用长什么样。假设 Harness 里注册了一个工具 read_file(path),模型决定调用它,Harness 执行,把文件内容塞回消息列表。这个过程里,read_file 本身不记得任何东西:它不知道上一次读了什么,不知道这次读是为了什么,不知道读完之后要拿这些内容干嘛。它是无状态的------同样的输入永远给同样的输出,调用之间互不影响。
无状态是工具的美德。工具就该这样:简单、可预测、可缓存、可并发。read_file 不需要知道你在写教程还是在改 bug,它只管把字节读出来。
但 Skill 不是工具。Skill 要解决的问题是"一类任务该怎么干",而"怎么干"这件事天然带状态、带上下文、带约束。
举个例子。假设你要让 Agent 干这件事:"把这个仓库里所有 Python 文件的类型注解补全。" 你会怎么写?
如果你把它写成一个工具 add_type_hints(repo_path),那它就是一个黑盒函数:进去一个路径,出来一堆改动。问题是,这个黑盒内部要做的决策太多了------用 Optional[X] 还是 X | None?遇到无法推断的类型是跳过还是标 Any?改完之后要不要跑一遍类型检查?这些决策依赖项目约定、依赖当前 Python 版本、依赖团队风格。把它们全塞进一个函数,函数会膨胀成几百行,而且换一个项目就得重写。
正确的做法是把它写成一个 Skill。Skill 里包含的不只是"做什么",还有:
- 上下文 :这个项目用哪个 Python 版本、类型检查器是 mypy 还是 pyright、有没有现成的
pyproject.toml配置。 - 约束 :不许改函数签名、不许动
__init__.py的导出、遇到# type: ignore就跳过。 - 流程:先扫描 → 再分类 → 再逐个改 → 最后跑检查 → 检查不过就回滚。
- 判断点:哪些情况该停下来问人,哪些情况可以自己决定。
这些东西合起来,才是"一段行为"。它比一次调用重得多,但也正因为重,它才能被复用------因为下一个项目大概率也需要"补类型注解",只是上下文和约束不同。
所以第一层区别是:工具是动词,Skill 是"带着条件的动词 + 一套判断"。 工具回答"能做什么",Skill 回答"在这类情况下,应该怎么做,以及什么时候不该做"。
可组合 = 能被别的 Skill 调用,也能调用别的
第二层区别更关键:可组合性。
一个函数当然也能调用别的函数,那为什么说 Skill 的可组合是另一回事?因为 Skill 的组合发生在行为层,不是函数层。
还是那个补类型注解的例子。假设你已经有了几个 Skill:
scan_python_files:扫描仓库,列出所有 Python 文件及其 AST 摘要。infer_types:给定一个函数体,推断参数和返回值的类型。apply_patch:把一处改动安全地写回文件,带备份和回滚。run_type_check:跑类型检查器,返回结构化结果。
现在写 add_type_hints 这个 Skill,它内部就是把这四个 Skill 串起来:scan_python_files → 对每个文件调 infer_types → 用 apply_patch 写回 → 最后 run_type_check 验证。
注意这里发生了什么:add_type_hints 没有重新实现 扫描、推断、写回、检查中的任何一件。它做的是编排 。而它自己,又可以被更上层的 Skill 调用------比如一个 modernize_legacy_repo 的 Skill,内部会依次调用 add_type_hints、add_docstrings、upgrade_dependencies。
这就是可组合的含义:Skill 是行为层的积木,能往上叠,也能往下拆。
对比一下函数组合。函数组合的接口是"参数进、返回值出",一旦参数形状变了,调用方全得改。Skill 组合的接口是"意图进、结果出",中间的状态、上下文、约束由 Skill 自己管。add_type_hints 内部从"逐个文件处理"改成"批量处理",只要它的对外意图不变("给这个仓库补上类型注解"),调用它的 modernize_legacy_repo 一行都不用动。
这就是为什么本讲的验证标准是:同一个 Skill 在两个不同任务里复用,第二次不需要改内部实现。 如果第二次要改内部实现,说明你把任务特有的东西写进 Skill 里了,它就不是可复用的行为,只是一次性的脚本。
Skill 的边界是"什么时候不该用"
第三层,也是最容易被忽略的一层:Skill 的价值有一半在"边界"上。
一个 Skill 定义得好不好,不看它"能处理多少情况",看它"能不能干净地说出自己不处理哪些情况"。
回到 add_type_hints。它应该明确声明:
- 不处理动态生成的代码(
exec、eval拼出来的)。 - 不处理没有类型检查器配置的项目------因为无法验证,改了等于没改。
- 不处理单文件超过某个规模的模块------推断代价太高,收益不确定。
- 遇到
# type: ignore一律跳过,不试图"修复"它。
这些"不做"的声明,才是 Skill 能被信任的原因。因为调用方(可能是人,也可能是另一个 Skill)知道:只要我把任务交给它,它要么按约定做完,要么明确告诉我它做不了,绝不会悄悄干出格的事。
没有边界的 Skill 是危险的。它会"尽力而为",然后在某个边缘情况上做出你完全没预期的改动。这种 Skill 在第一个任务里可能表现很好,在第二个任务里就会咬你一口。
所以判断一个 Skill 成熟不成熟,有个很实用的标准:看它的定义里,"不该用"的部分占多少。 全是"能做什么"的,是工具;有清晰"不做什么"的,才是 Skill。
动手做:写一份可复用的 Skill 定义 + 运行脚本
光讲原理没用。这一讲直接把台架建出来:tools/harness_lab/skill_demo/,四个任务,跑一次就能看到复用、拒绝、组合三种结果。全部产物 311 行,其中包括 1 份 Skill 定义、1 份上层编排定义、1 个运行器和 4 个任务配置。
1. 目录结构
bash
tools/harness_lab/skill_demo/
├── runner.py # 加载定义 → 边界检查 → 执行步骤
├── subject.py # 第二个被摘要的目标(证明换任务不用改 Skill)
├── skills/
│ ├── summarize_module.yaml # 原子 Skill:只读模块摘要
│ └── overview_two_modules.yaml # 上层 Skill:只编排,不实现摘要
└── tasks/
├── manifest.json # 复现入口
├── task_a.json # runner.py + concise
├── task_b.json # subject.py + detailed
├── task_c.json # README.md → 应当被拒绝
└── task_d.json # 两个模块 → 应当 partial
2. Skill 定义:把边界写成判据
Skill 定义用 YAML 写,因为它要同时表达"做什么"和"约束/边界",纯代码不好读。我们定义一个 summarize_module Skill:给定一个 Python 模块文件,产出一段结构化摘要。
yaml
# 一份可复用的 Skill 定义:做什么 + 上下文 + 约束 + 边界 + 步骤 + 输出契约。
name: summarize_module
version: 1
intent: >
读取一个 Python 模块文件,产出一段结构化摘要,
包含:模块职责、公开接口、外部依赖、可疑点。
context:
requires:
- file_path # 目标模块路径
- project_root # 项目根目录,用于解析相对导入
optional:
- style # 摘要风格:concise | detailed,默认 concise
constraints:
- 只读,绝不修改目标文件
- 不执行目标模块的任何代码(不 import、不 eval)
- 摘要长度上限 400 字(concise)或 1200 字(detailed)
- 遇到语法错误时,直接返回错误,不做猜测性摘要
boundaries: # 不是散文,是 runner 会逐条求值的判据
- {check: suffix_in, values: [".py"], reason: "not a .py file"}
- {check: exists, reason: "file not found"}
- {check: max_lines, value: 2000, reason: "file too large"}
steps:
- id: read
action: read_text
args: { path: "{{ file_path }}" }
- id: parse
action: parse_python_ast
args: { source: "{{ read.output }}" }
- id: extract
action: extract_module_facts
args: { tree: "{{ parse.output }}", project_root: "{{ project_root }}" }
- id: render
action: render_summary
args:
facts: "{{ extract.output }}"
style: "{{ style | default('concise') }}"
output:
type: object
fields:
responsibility: string
public_api: list
external_deps: list
suspicious: list
truncated: bool
这份定义里有几个地方值得停下来看。
context 和 constraints 是分开的。 上下文是"我需要知道什么",约束是"我必须遵守什么"。前者是输入,后者是纪律。很多人的 Skill 定义把这两者混在一起,结果调用方不知道该传什么、也不知道 Skill 会守什么规矩。
boundaries 是可求值的判据,不是散文。 这一版我把它从中文描述改成了 check/values/reason 三条规则,runner 拿到定义后会逐条求值。这一步很关键:边界如果只是"文档里写着不该处理 .md",那它永远不会生效;写成判据,README.md 才真的进不来。边界不是建议,是闸门。
output 有契约。 调用方可以依赖 responsibility、public_api 这些字段一定存在。这样上层 Skill 才能安全地组合它。契约里还带 truncated: bool------调用方不用猜摘要有没有被截断。
3. 运行器:先查边界,再读文件
运行脚本做三件事:加载 Skill 定义、检查边界、按步骤执行。它同时支持两种步骤:action 调内置动作,skill 调另一个 Skill------后者就是组合的入口。
python
#!/usr/bin/env python3
"""Skill 运行器:加载定义 → 边界检查 → 按步骤执行(步骤可调用别的 Skill)。
cd tools/harness_lab/skill_demo
python3 runner.py skills/summarize_module.yaml tasks/task_a.json
python3 runner.py --all # 按 tasks/manifest.json 跑全部任务
"""
import ast
import json
import re
import sys
from pathlib import Path
import yaml
HERE = Path(__file__).resolve().parent
SKILLS = HERE / "skills"
TASKS = HERE / "tasks"
def load_skill(skill_path) -> dict:
with Path(skill_path).open("r", encoding="utf-8") as f:
return yaml.safe_load(f)
def load_registry() -> dict:
return {p.stem: load_skill(p) for p in sorted(SKILLS.glob("*.yaml"))}
# ---------- 边界:声明在 YAML 里,逐条求值,先于任何执行 ----------
def check_boundaries(skill: dict, ctx: dict) -> tuple[bool, str]:
"""返回 (是否通过, 拒绝原因)。boundaries 不是散文,是 runner 会求值的判据。"""
file_path = Path(ctx["file_path"])
for rule in skill.get("boundaries", []):
check = rule["check"]
if check == "suffix_in" and file_path.suffix not in rule["values"]:
return False, f"boundary: {rule['reason']} ({file_path.suffix})"
if check == "exists" and not file_path.exists():
return False, f"boundary: {rule['reason']} ({file_path})"
if check == "max_lines":
n = len(file_path.read_text(encoding="utf-8").splitlines())
if n > rule["value"]:
return False, f"boundary: {rule['reason']} ({n} lines > {rule['value']})"
return True, ""
# ---------- 动作:Skill 步骤的实现 ----------
def read_text(path: str) -> str:
return Path(path).read_text(encoding="utf-8")
def parse_python_ast(source: str) -> ast.Module:
return ast.parse(source)
def extract_module_facts(tree: ast.Module, project_root: str = ".") -> dict:
"""从 AST 里抽取模块事实。纯静态,不执行任何代码。"""
public_api, external_deps, suspicious = [], [], []
for i, node in enumerate(tree.body):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
if not node.name.startswith("_"):
public_api.append({"kind": "function", "name": node.name})
elif isinstance(node, ast.ClassDef):
if not node.name.startswith("_"):
public_api.append({"kind": "class", "name": node.name})
elif isinstance(node, ast.Import):
for alias in node.names:
external_deps.append(alias.name.split(".")[0])
elif isinstance(node, ast.ImportFrom):
if node.module:
external_deps.append(node.module.split(".")[0])
elif isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
if isinstance(node.value.value, str) and i != 0:
suspicious.append("module-level bare string (possible stray docstring)")
return {
"public_api": public_api,
"external_deps": sorted(set(external_deps)),
"suspicious": suspicious,
}
def render_summary(facts: dict, style: str = "concise") -> dict:
limit = 400 if style == "concise" else 1200
names = [item["name"] for item in facts["public_api"]]
lines = [f"{len(names)} public symbols: " + ", ".join(names[:8])
+ ("..." if len(names) > 8 else "")]
if style == "detailed":
deps = ", ".join(facts["external_deps"]) or "(none)"
lines.append(f"external deps: {deps}")
lines.append(f"suspicious: {len(facts['suspicious'])} found")
text = "\n".join(lines)
return {
"responsibility": text[:limit],
"public_api": facts["public_api"],
"external_deps": facts["external_deps"],
"suspicious": facts["suspicious"],
"truncated": len(text) > limit,
"style": style,
}
def merge_summaries(a, b) -> dict:
modules = [x for x in (a, b) if x]
return {"modules": modules, "partial": not (a and b)}
ACTIONS = {
"read_text": read_text,
"parse_python_ast": parse_python_ast,
"extract_module_facts": extract_module_facts,
"render_summary": render_summary,
"merge_summaries": merge_summaries,
}
# ---------- 执行 ----------
TEMPLATE = re.compile(r"^\{\{\s*([\w.]+)\s*(?:\|\s*default\('([^']*)'\))?\s*\}\}$")
def resolve(value, scope):
"""把 `{{ a.b }}` 或 `{{ x | default('y') }}` 解析成 scope 里的值。"""
if not isinstance(value, str):
return value
m = TEMPLATE.match(value.strip())
if not m:
return value
cur = scope
for part in m.group(1).split("."):
if isinstance(cur, dict) and part in cur:
cur = cur[part]
else:
return m.group(2)
return cur
def run_skill(skill: dict, ctx: dict, registry: dict) -> dict:
if skill.get("boundaries"):
ok, reason = check_boundaries(skill, ctx)
if not ok:
return {"status": "rejected", "reason": reason}
scope = dict(ctx)
partial = False
for step in skill.get("steps", []):
sid, args = step["id"], step.get("args") or {}
if "skill" in step:
sub = registry.get(step["skill"])
if sub is None:
return {"status": "error", "reason": f"unknown skill: {step['skill']}"}
sub_ctx = {k: resolve(v, scope) for k, v in args.items()}
result = run_skill(sub, sub_ctx, registry)
if result["status"] != "ok":
partial = True
else:
fn = ACTIONS.get(step["action"])
if fn is None:
return {"status": "error", "reason": f"unknown action: {step['action']}"}
result = {"output": fn(**{k: resolve(v, scope) for k, v in args.items()})}
scope[sid] = result
last = scope[skill["steps"][-1]["id"]]
out = last.get("output") if isinstance(last, dict) else last
return {"status": "partial" if partial else "ok", "output": out}
def main() -> None:
if sys.argv[1:2] == ["--all"]:
registry = load_registry()
for entry in json.loads((TASKS / "manifest.json").read_text(encoding="utf-8")):
ctx = json.loads((TASKS / entry["task"]).read_text(encoding="utf-8"))
r = run_skill(registry[entry["skill"]], ctx, registry)
print(f"== {entry['task']} [{entry['skill']}] -> {r['status']}")
print(json.dumps(r, ensure_ascii=False, indent=1))
return
registry = load_registry()
skill = load_skill(sys.argv[1])
ctx = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8"))
print(json.dumps(run_skill(skill, ctx, registry), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
这个脚本刻意做得很小,但结构是对的:边界检查在最前面,任何执行之前。 注意 run_skill 里边界检查发生在 read_text 之前,所以一个 .md 文件根本不会被读进来解析,而是直接返回 rejected 和一个原因。这就是把 boundaries 从文档变成闸门。
resolve() 是组合的另一半:步骤参数里的 {{ extract.output }} 会去当前作用域取值,{{ style | default('concise') }} 在没有传 style 时兜底。有了它,上层 Skill 写两个 skill: 步骤就够了,不需要知道子 Skill 内部怎么实现。
4. 两个任务,同一份 Skill,真跑一遍
现在准备两个任务。它们指向不同的文件 、要不同的风格,因为要验的正是"换任务不改内部实现"。
json
{
"file_path": "runner.py",
"project_root": "tools/harness_lab/skill_demo",
"style": "concise"
}
json
{
"file_path": "subject.py",
"project_root": "tools/harness_lab/skill_demo",
"style": "detailed"
}
跑之前先记下 Skill 定义的哈希,跑完再记一次------如果两次任务之间它变了,那"复用"就不成立:
bash
cd tools/harness_lab/skill_demo
sha256sum skills/summarize_module.yaml
python3 runner.py skills/summarize_module.yaml tasks/task_a.json
python3 runner.py skills/summarize_module.yaml tasks/task_b.json
sha256sum skills/summarize_module.yaml
实际输出(公开接口明细省略,完整记录见 out/harness/L09/skill_demo_run.txt):
json
{
"status": "ok",
"output": {
"responsibility": "11 public symbols: load_skill, load_registry, check_boundaries, read_text, parse_python_ast, extract_module_facts, render_summary, merge_summaries...",
"external_deps": [
"ast",
"json",
"pathlib",
"re",
"sys",
"yaml"
],
"suspicious": [],
"truncated": false,
"style": "concise"
}
}
json
{
"status": "ok",
"output": {
"responsibility": "3 public symbols: load_config, ConfigError, fetch_remote\nexternal deps: json, os\nsuspicious: 1 found",
"public_api": [
{
"kind": "function",
"name": "load_config"
},
{
"kind": "class",
"name": "ConfigError"
},
{
"kind": "function",
"name": "fetch_remote"
}
],
"external_deps": [
"json",
"os"
],
"suspicious": [
"module-level bare string (possible stray docstring)"
],
"truncated": false,
"style": "detailed"
}
}
两条结论可以直接读出来。
第一,目标换了 :task_a 摘的是 runner.py(11 个公开符号、6 个外部依赖),task_b 摘的是完全没有关系的 subject.py(3 个公开符号、2 个外部依赖、1 处可疑的模块级裸字符串)。Skill 定义没有增加任何和文件相关的分支。
第二,风格换了 :concise 只回一行职责描述,detailed 会多带依赖清单和可疑点计数。这个差异不是写死的 if,而是 YAML 里 style 这个可选上下文 + 输出契约里的字段决定的。
两次 sha256sum 的结果完全一致(5ec5828bdc37...)。这就是"第二次不需要改内部实现"的实证:同一份定义,两个无关目标,零改动。
5. 边界:拒绝是一等输出
再试一个会被边界拦下的任务:目标给一个 Markdown 文件。
json
{
"file_path": "README.md",
"project_root": "tools/harness_lab/skill_demo",
"style": "concise"
}
bash
python3 runner.py skills/summarize_module.yaml tasks/task_c.json
输出是:
json
{
"status": "rejected",
"reason": "boundary: not a .py file (.md)"
}
注意这里没有"尽力而为"------Skill 明确拒绝了,而且拒绝发生在读文件之前。调用方拿到 rejected 和原因,可以决定换一个 Skill,或者报错给人。拒绝是 Skill 的一等公民输出,不是失败。 这一条如果只写在注释里,README.md 会被当源码读进去,然后在 ast.parse 那里炸出一个语法错误------错误信息会指向"Markdown 不是 Python",而不是"你给错了 Skill"。两者对调用方的含义完全不同。
6. 组合:上层只编排,失败要可见
最后验证可组合性。写一个上层 Skill,它调用 summarize_module 两次,把结果合成一份概览:
yaml
# 上层 Skill:不实现摘要,只编排两个 summarize_module,合出一份概览。
name: overview_two_modules
version: 1
intent: 对两个模块分别摘要,合并成一份概览。
context:
requires: [module_a, module_b, project_root]
constraints:
- 任一子 Skill 被拒绝时,整体标记为 partial,不静默忽略
steps:
- id: sum_a
skill: summarize_module
args: { file_path: "{{ module_a }}", project_root: "{{ project_root }}" }
- id: sum_b
skill: summarize_module
args: { file_path: "{{ module_b }}", project_root: "{{ project_root }}" }
- id: merge
action: merge_summaries
args: { a: "{{ sum_a.output }}", b: "{{ sum_b.output }}" }
output:
type: object
fields: { modules: list, partial: bool }
用一份"一个合法模块 + 一个 Markdown"的任务去跑它:
json
{
"module_a": "runner.py",
"module_b": "README.md",
"project_root": "tools/harness_lab/skill_demo"
}
bash
python3 runner.py skills/overview_two_modules.yaml tasks/task_d.json
json
{
"status": "partial",
"output": {
"modules": [
{
"responsibility": "11 public symbols: load_skill, load_registry, check_boundaries, read_text, parse_python_ast, extract_module_facts, render_summary, merge_summaries...",
"external_deps": [
"ast",
"json",
"pathlib",
"re",
"sys",
"yaml"
],
"suspicious": [],
"truncated": false,
"style": "concise"
}
],
"partial": true
}
}
status 是 partial,modules 里只有 1 个成功结果,partial: true。这里刻意让它失败一次,因为组合最容易骗人的地方就是"子步骤挂了但整体看起来正常"。约束里写死"任一子 Skill 被拒绝时,整体标记为 partial,不静默忽略"------这条约束在 runner 里是真实执行的。
overview_two_modules 没有重新实现任何摘要逻辑,它只是编排 ;summarize_module 也完全不知道自己被谁调用,它只管按契约干活。这就是行为层的组合:上层依赖的是"意图进、结果出",不是子 Skill 的步骤编号。
常见坑:这一讲踩到的三个
第一,YAML 里的参数名和函数签名对不上,报错却是 TypeError。 我最初在步骤里写 ast: "{{ parse.output }}",而动作函数的参数叫 tree,第一次跑直接抛 extract_module_facts() got an unexpected keyword argument 'ast'。这类错误不会在加载定义时报,只会在执行到那一步时报------所以定义里的参数名要跟动作签名一张表对齐,别指望它自己猜。
第二,两个任务的输出如果完全一样,这个"实验"就是空的。 第一版 render_summary 对 concise 和 detailed 只在截断长度上不同,而实际摘要只有一行、远没到上限,于是两个任务的输出逐字相同,跑完什么也证明不了。验证差异必须落在可观察的输出字段上,而不是"代码里有两套逻辑"。
第三,边界写进文档 ≠ 边界被执行。 台架第一版把边界写成中文描述,runner 只认 file_path。结果 task_c 的 README.md 照样被读进来,然后在 ast.parse 处失败。把边界改成 check/values/reason 判据、并且在 run_skill 最前面求值之后,拒绝才真正发生在"读文件"之前。判据的位置决定了错误信息有没有用。
把判断落到实处:三个可操作的结论
讲到这里,回到开头的判断,把它变成你能直接用的三条。
第一,写 Skill 之前先问"这是行为还是动作"。 如果它只是"读一个文件""发一个请求",那是工具,别包装成 Skill。如果它是"在这类情况下,按这套约束,走这个流程,遇到这些情况就停",那才是 Skill。判断标准很简单:它有没有"什么时候不该用"? 没有,就还是工具。
第二,Skill 的接口是意图,不是参数。 调用方说"给这个仓库补类型注解",而不是"传给我 files 列表、encoding、max_workers"。参数是 Skill 内部的事,对外只暴露意图和上下文。这样上层才能组合,下层才能替换实现。本讲的 overview_two_modules 就是这么写的:它只传 module_a、module_b、project_root,不关心子 Skill 用什么解析器、截断到多少字。
第三,边界要写成代码,不是写成注释。 boundaries 必须在执行前被求值,拒绝必须是一等输出。一个只会说"好的我来试试"的 Skill,在第二个任务里就会变成负债。反过来,边界写得越清楚,上层越敢把任务整段交给它------因为它知道最坏的结果是 rejected,不是一个改坏了的文件。
课后思考题
第一题:你的 Skill 里,哪一部分是任务特有的、不该进 Skill?
思路:拿你手上任意一个 Skill,把它的定义逐行过一遍。凡是出现具体项目名、具体路径、具体业务字段的地方,问一句:"换一个项目,这行还成立吗?" 如果不成立,它就不该在 Skill 里,应该在上下文里------由调用方传进来。Skill 里留下的,应该是"无论哪个项目都成立的行为模式"。这条线划清楚了,Skill 才能复用;划不清,它就会在第二个任务里逼你改内部实现。
第二题:两个 Skill 冲突时,谁来裁决?
思路:冲突有两种。一种是能力重叠 ------两个 Skill 都能干同一件事,比如 summarize_module 和 summarize_package 都能处理单文件。另一种是约束冲突 ------A 要求"绝不修改文件",B 要求"必须写回改动"。第一种靠边界 解决:让每个 Skill 的 boundaries 足够精确,重叠区自然缩小。第二种靠编排层解决:冲突不该由 Skill 自己裁决,而该由调用它们的上层 Skill 或 Harness 决定------因为只有上层知道当前任务的意图。Skill 自己裁决冲突,等于把任务意图硬编码进行为,那就又回到"不可复用"了。
如果你手上已经有 Skill,翻到它的 boundaries 那一段数一数:里面是三条可求值的判据,还是三句给自己看的注释?评论区报个数。
下一讲,我们把这个 skill_demo 扩成一个真正的 Skill 注册表:多个 Skill 共存、按意图路由、冲突检测自动化。到那时你会发现,今天写的这份 YAML 定义,就是注册表的行格式。