Agent Harness 从用到会造:Skill 不是一次 API 调用,是一段会复用的行为

22 阅读1分钟

这是《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. 目录结构

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 模块文件,产出一段结构化摘要。

# 一份可复用的 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——后者就是组合的入口。

#!/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,真跑一遍

现在准备两个任务。它们指向不同的文件、要不同的风格,因为要验的正是"换任务不改内部实现"。

{
  "file_path": "runner.py",
  "project_root": "tools/harness_lab/skill_demo",
  "style": "concise"
}
{
  "file_path": "subject.py",
  "project_root": "tools/harness_lab/skill_demo",
  "style": "detailed"
}

跑之前先记下 Skill 定义的哈希,跑完再记一次——如果两次任务之间它变了,那"复用"就不成立:

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):

{
 "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"
 }
}
{
 "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 文件。

{
  "file_path": "README.md",
  "project_root": "tools/harness_lab/skill_demo",
  "style": "concise"
}
python3 runner.py skills/summarize_module.yaml tasks/task_c.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 两次,把结果合成一份概览:

# 上层 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"的任务去跑它:

{
  "module_a": "runner.py",
  "module_b": "README.md",
  "project_root": "tools/harness_lab/skill_demo"
}
python3 runner.py skills/overview_two_modules.yaml tasks/task_d.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 定义,就是注册表的行格式。