拆解 harness9(4):Skills 系统架构

拆解 harness9(4):Skills 系统架构

上一篇文章中,我们深入讲解了 harness9 框架的 Planning 规划模块。本章我们向前推进,详细介绍下 Skills 的系统架构。

TL;DR

harness9 的 Agent Skill 系统解决一个核心矛盾:Agent 需要大量领域知识来完成复杂任务,但把所有知识塞进 System Prompt 会让 LLM 失焦、token 爆炸。

解法是 Progressive Disclosure(渐进式披露)------把 Skill 拆成两层:

层次 内容 注入时机 token 开销
索引层(frontmatter) name + description,一行 每次对话,System Prompt 第三段 固定,极低(~20 token/条)
内容层(body) 完整领域知识正文 仅在 LLM 主动调用 use_skill 按需,用完即留在 ToolResult

关键决策 :召回判断权交给 LLM,不用 RAG 向量检索。LLM 读取索引后,根据当前任务语义自主决定调用哪个 Skill------这意味着 description 字段的质量就是 Skill 召回准确度的唯一控制旋钮。

运行路径(一次 Skill 使用):

复制代码
用户输入 → LLM 读索引 → tool_call: use_skill("go-refactor")
  → GetFullContent 读磁盘 → Skill 正文作为 ToolResult 返回
  → 下一 Turn LLM 以 Skill 正文为知识背景执行任务

与主流方案的本质差异

  • vs 全量注入:System Prompt 里永远只有索引,body 从不污染 System Prompt
  • vs RAG 检索:不需要 embedding 模型,召回是语义判断而非相似度计算
  • vs LangChain Tool:Skill 无副作用、不执行,是声明式知识容器

适合场景:团队编码规范、部署 SOP、调试指南、API 使用手册------任何"需要时才看、不需要就放架子上"的结构化知识。


关于 harness9

harness9 是一款 Local-First、轻量级、功能完备、生产可用的通用 Go Agent 框架。

Star 是对开源工作最直接的支持,欢迎提 Issue 和 PR。


本文你将学到

  • 你将看清 harness9 为何选择"索引先行、全文按需"的两阶段 Skill 设计,而不是将领域知识直接嵌入 System Prompt
  • 你将理解 Progressive Disclosure(渐进式披露)在 LLM 工程中的具体含义,以及它与 RAG 检索在召回策略上的根本差异
  • 你将看懂 IndexUseSkillToolDefaultPromptBuilder 三个组件如何分工,构成完整的"发现---拉取---注入"协议链
  • 你将理解 frontmatter 驱动的 Skill 文件格式如何在"开发者可读"与"机器可解析"之间取得平衡
  • 你将看清 CLI 斜杠命令与 TUI Tab 补全如何绕过 LLM 判断层,作为人工触发的快速通道

System Prompt 里塞知识,会发生什么?

把团队编码规范、部署 SOP、调试指南全部塞进 System Prompt,是最直觉的做法。它也是最快耗尽上下文窗口的做法。

一个中等规模项目,稍微认真地写三到五份规范文档,System Prompt 很快膨胀到 8,000--15,000 tokens。GPT-4o 的 128K 窗口看起来很宽,但实际上下文窗口要被对话历史、工具结果、多轮推理共同消耗。System Prompt 里那些"今天不相关"的知识,仍然占用注意力资源------更糟糕的是,LLM 的注意力分配并非均匀的,过长的 System Prompt 会让模型在关键信息上"失焦"。

harness9 的 Agent Skill 系统给出了一个不同的答案:不要把能力塞进去,而是告诉 LLM 能力在哪里、什么时候去拿。


Skill 文件长什么样?

harness9 的 Skill 以目录为单位组织,每个 Skill 是一个独立的子目录,固定包含一个 SKILL.md 文件:

复制代码
{workdir}/skills/
├── go-refactor/
│   └── SKILL.md
├── deploy-prod/
│   └── SKILL.md
└── debugging-guide/
    └── SKILL.md

SKILL.md 的格式是带 YAML frontmatter 的标准 Markdown:

markdown 复制代码
---
name: go-refactor
description: Use when refactoring Go code --- team conventions and patterns
trigger: "refactor, clean up, restructure, simplify"
---

# Go 重构指南

## 重构前必做

1. 运行 `go vet ./...` 确认无静态分析错误
2. 运行 `go test ./...` 确认测试全部通过
3. 查看 git diff 确认修改范围

frontmatter 只有三个字段:name(唯一标识)、description(向 LLM 展示的索引描述)、trigger(可选的触发词提示,仅作文档说明,不做自动匹配)。

这个设计的关键在于 frontmatter 与 body 的分离。frontmatter 是注入 System Prompt 的部分------只有 name 和 description;body 是按需加载的部分------完整的领域知识正文。两者物理上在同一个文件里,逻辑上属于不同的加载阶段。

parseFrontmatter 的实现极度简洁,零依赖,手写解析:

go 复制代码
func parseFrontmatter(content string) (name, description, trigger, body string) {
    const delim = "---\n"
    if !strings.HasPrefix(content, delim) {
        return "", "", "", content
    }
    rest := content[len(delim):]
    idx := strings.Index(rest, "\n---\n")
    if idx == -1 {
        return "", "", "", content
    }
    fm := rest[:idx]
    body = strings.TrimPrefix(rest[idx+len("\n---\n"):], "\n")
    // ...逐行 key:value 解析
}

没有引入 YAML 解析库。这是有意为之的权衡:frontmatter 的字段集是固定且极小的(三个字段),手写解析完全覆盖需求,同时避免了一个间接依赖。harness9 的设计哲学之一是"极少的直接依赖数",Skill 格式解析是这一哲学的具体体现。


为什么不用 RAG?

Progressive Disclosure(渐进式披露)这个词来自 UX 设计领域,指的是"只在用户需要时展示复杂信息"。harness9 把它搬进了 LLM 工程的上下文管理层。

要理解它的价值,先对比两种常见的替代方案:

方案 A:全量注入 System Prompt。所有 Skill 全文在启动时写入 System Prompt。简单,但 token 固定消耗,LLM 注意力被非相关内容分散,且每次对话都携带这些"可能永远不会用到"的内容。

方案 B:RAG 检索。用 embedding 向量检索相关 Skill,把最相关的片段注入上下文。token 效率高,但引入了向量数据库依赖、嵌入模型调用、相似度阈值调参等工程复杂度。更关键的是,召回是被动的------检索时机和检索策略由框架决定,而非 LLM。

harness9 的方案 :LLM 主动拉取。System Prompt 只注入 Skill 索引(每条一行,name + description),LLM 在执行任务时自主判断需要哪个 Skill,通过调用 use_skill 工具拉取全文,工具结果作为 Observation 注入当前轮次的上下文。

这里有一个关键的架构决策:召回的判断权交给了 LLM,而不是框架层的检索算法

这意味着:

  • 不需要 embedding 模型,不需要向量数据库
  • LLM 可以基于完整的任务语义判断需要哪个 Skill,而不是基于 token 相似度
  • Skill 的描述文案(description 字段)成了影响召回质量的核心变量------这是可以被开发者精确控制的文本,而不是浮动的相似度分数

缺点也是显然的:LLM 必须先看到任务,才能判断需要哪个 Skill,这比 RAG 多一次 LLM 调用轮次。这是 harness9 明确选择接受的权衡。

Progressive Disclosure 背后的四个工程原理

Progressive Disclosure 不只是"延迟加载",它背后有三层更深的工程原理。

原理一:LLM 注意力分配不是均匀的

Transformer 的注意力机制并不会对上下文里的每个 token 一视同仁。实验性地观察(以及 Lost in the Middle 等研究)表明:上下文窗口里越靠前和越靠后的内容,被"关注到"的概率越高;中间部分容易被压低。

这意味着,把 10 个 Skill 的全文(假设每个 1,000 token,共 10,000 token)堆在 System Prompt 里,实际上大部分内容处于"注意力低谷"。LLM 会知道那里有什么,但不一定能精准引用。

反过来,当 LLM 通过 use_skill 拉取 Skill 正文后,内容作为 ToolResult 出现在当前 Turn 的末尾------这是注意力最高的位置。相同的文本,因为出现位置不同,被模型有效利用的概率更高。

Progressive Disclosure 不只节省 token,更重要的是把知识放在对 LLM 来说最有效的注意力位置上

原理二:索引是元认知层,正文是执行层

这是 harness9 设计里最精妙的一点。System Prompt 里的 Skill 索引(name + description)不是供 LLM"阅读"的内容,而是供 LLM"决策"的信号。

复制代码
## 可用 Skills
- go-refactor: Use when refactoring Go code --- team conventions and patterns
- deploy-prod: Use when deploying to production environment

LLM 读到这两行,不会去"理解 Go 重构规范",而是把它们存成一种元认知:"我有这两项能力,当任务属于这类时我可以调用。" 这是认知负担极低的操作------仅仅是记住"能力边界",不是消化知识。

当用户提问"帮我清理这个 handler,太乱了"时,LLM 不需要检索,直接从元认知层触发:go-refactor 的 description 里有 refactoring,匹配。于是发起 use_skill("go-refactor") 调用。

这是语义路由,不是相似度匹配。LLM 理解了任务语义,再主动查找合适的知识------而不是把任务 embedding 后和所有 Skill 做向量距离计算。两者在准确率曲线上的行为完全不同:RAG 对"措辞接近但语义不同"的输入容易误召回,而 LLM 语义判断对措辞变化更鲁棒。

原理三:description 字段是唯一的召回控制杆

在 RAG 系统里,召回质量受到多个变量影响:embedding 模型选择、分块策略(chunk size / overlap)、相似度阈值、文档预处理方式......这些参数大多不透明,调参需要实验。

Progressive Disclosure 把这些变量压缩成了一个:description 字段的文本质量

这是一个完全透明、可精确控制的文本变量。开发者可以像优化 prompt 一样优化 description:

yaml 复制代码
# 差的 description:含糊,触发条件模糊
description: "Go 代码相关的规范"

# 好的 description:行为触发词明确,任务场景具体
description: "Use when refactoring Go code --- covers naming, error handling, interface design, and test coverage conventions for this team"

description 写得越精确,LLM 的召回越准确。这是工程师可以直接干预和调优的文本,没有任何黑箱成分。

更进一步:harness9 允许 Skill 文件在运行时被修改(GetFullContent 不缓存,每次读磁盘),意味着 description 也可以在不重启 Agent 的情况下热更新。这给了开发者一种迭代 Skill 描述文案的快速反馈循环。

原理四:ToolResult 位置 vs System Prompt 位置的上下文生命周期差异

Skill 内容注入 ToolResult(而非 System Prompt)还有一个重要的架构含义:生命周期受 Compactor 管理

harness9 的 SummarizationCompactor 在上下文到达 80% 阈值时触发,将历史消息(包括 ToolResult)压缩为摘要。这意味着 Skill 的全文内容最终会被摘要,不会永久占用上下文。

相比之下,System Prompt 是不参与压缩的固定开销------放进去就永远在那里,即使那个知识早已用完。把 Skill body 放在 ToolResult 里,是把它纳入了上下文的"正常消耗---压缩---释放"生命周期,而不是永久锁在 System Prompt 里。

下面这张时序图展示了完整的 Progressive Disclosure 运作路径:

复制代码
启动时
  ┌─────────────────────────────────────────────────┐
  │  System Prompt(固定开销)                        │
  │  ...                                             │
  │  ## 可用 Skills                                  │  ← 索引层:元认知信号
  │  - go-refactor: Use when refactoring Go code...  │    每条 ~20 token,n 个 Skill
  │  - deploy-prod: Use when deploying to prod...    │    固定消耗,不随 Skill 正文大小变化
  └─────────────────────────────────────────────────┘

Turn N(LLM 判断需要 Skill)
  LLM 输出: tool_call → use_skill("go-refactor")
  框架: GetFullContent → 读磁盘 → 返回 Skill body

  ┌─────────────────────────────────────────────────┐
  │  ToolResult(动态,在会话历史里)                  │
  │  [go-refactor 完整正文,1,200 token]              │  ← 内容层:执行时注意力最高点
  │  # Go 重构指南                                    │    随任务需要而出现
  │  ## 重构前必做                                    │    最终被 Compactor 摘要压缩
  │  1. 运行 go vet...                               │
  └─────────────────────────────────────────────────┘

后续轮次压缩时
  SummarizationCompactor 将 ToolResult 摘要为
  "[已加载 go-refactor 规范,要点:go vet 先行、接口在调用方定义...]"
  → Skill body 完成使命,Token 释放

三种方案的对比总结

维度 全量注入 RAG 检索 Progressive Disclosure
Skill 数量扩展性 差(线性增长) 好(索引线性,body 按需)
工程复杂度 极低 高(向量库 + embedding 模型) 低(零外部依赖)
召回准确率控制 N/A 调参(不透明) 优化 description(透明)
LLM 注意力效率 低(知识在中段,注意力低谷) 中(片段注入位置不确定) 高(ToolResult 在当前 Turn 末尾)
上下文生命周期 永久占用 System Prompt 进入会话历史 进入会话历史,受 Compactor 管理
额外 LLM 调用轮次 0 0(框架触发) +1(LLM 决策调用)

额外的 +1 Turn 是 Progressive Disclosure 唯一付出的代价。harness9 认为这是值得的------换来的是透明的召回控制、更好的注意力效率、以及零外部依赖。


三层懒加载

harness9 的 Skill 加载分三个明确的层次,各层职责不重叠:

第一层:LoadSkills(启动时,目录扫描)

go 复制代码
func LoadSkills(skillsDir string) (*Index, error) {
    entries, err := os.ReadDir(skillsDir)
    if errors.Is(err, fs.ErrNotExist) {
        return &Index{}, nil  // 目录不存在,静默返回空 Index
    }
    // ...
    for _, entry := range entries {
        if !entry.IsDir() {
            continue  // 只处理子目录,顶层散落文件被忽略
        }
        filePath := filepath.Join(skillsDir, entry.Name(), "SKILL.md")
        data, err := os.ReadFile(filePath)
        // ...
        name, desc, trigger, _ := parseFrontmatter(string(data))
        if name == "" || desc == "" {
            // 跳过无效 frontmatter,打印 warn 日志
            continue
        }
        loaded = append(loaded, Skill{
            Name: name, Description: desc, Trigger: trigger,
            filePath: filePath,  // 只记录路径,不缓存正文
        })
    }
    return &Index{skills: loaded}, nil
}

注意 Skill.filePath 是未导出字段,且此时只记录文件路径,不读取文件正文。这是懒加载的物理证据:启动阶段的 I/O 只有 frontmatter,body 不进内存。

第二层:Index(运行时,索引与按需读取)

go 复制代码
func (idx *Index) GetFullContent(name string) (string, error) {
    for _, s := range idx.skills {
        if s.Name == name {
            data, err := os.ReadFile(s.filePath)  // 每次调用时读磁盘
            if err != nil {
                return "", fmt.Errorf("读取 skill %q 失败: %w", name, err)
            }
            _, _, _, body := parseFrontmatter(string(data))
            return strings.TrimSpace(body), nil
        }
    }
    return "", fmt.Errorf("skill %q 不存在,可用技能: %s", name, idx.availableNames())
}

GetFullContent 每次调用都重新读磁盘。没有内存缓存。这个决策值得关注:Skill 文件通常是开发者运行期间可能修改的内容(调整规范、更新 SOP),不缓存意味着修改立即生效,下次 use_skill 调用拿到的就是新版本。对于一个开发期工具,这个取舍是合理的。

第三层:UseSkillTool(工具层,执行 LLM 决策)

go 复制代码
func (t *UseSkillTool) Execute(_ context.Context, args json.RawMessage) (string, error) {
    var a useSkillArgs
    if err := json.Unmarshal(args, &a); err != nil {
        return "", fmt.Errorf("参数解析失败: %w", err)
    }
    if a.SkillName == "" {
        return "", fmt.Errorf("skill_name 不能为空")
    }
    return t.index.GetFullContent(a.SkillName)
}

UseSkillTool 是工具注册表中的普通工具,通过 Go 的结构类型(Structural Typing)隐式满足 tools.BaseTool 接口------注意代码注释里明确写了"无需 import tools 包",这避免了一个循环导入。


Skill 索引注入在哪里?

DefaultPromptBuilder.Build() 按照固定顺序组装 System Prompt,Skills 索引在第三段:

复制代码
1. 基础 prompt(角色定义 + 工作目录 + 当前日期 + 工作准则)
2. AGENTS.md(用户项目规范,不存在时静默跳过)
3. 可用 Skills(索引摘要,Index 为空时跳过整块)
4. 任务管理(todo_write 指引,仅在工具已注册时注入)
5. 大输出文件检索(OffloadHook 指引)
6. 长期记忆(MEMORY.md 物化视图)

Skills 段落的实际文本形态:

复制代码
## 可用 Skills

需要时使用 `use_skill` 工具加载任意 Skill 的完整内容。

- go-refactor: Use when refactoring Go code --- team conventions and patterns
- deploy-prod: Use when deploying to production environment
- debugging-guide: Use when debugging runtime errors or performance issues

Index.Summary() 生成的是每行 - name: description\n 格式的纯文本列表,token 消耗极低。三个 Skill 的索引大约占 60--90 tokens,无论 Skill 正文多长,System Prompt 里这一段的 token 开销是固定的。

这里有一个测试用例值得作为规范性文档引用:

go 复制代码
// Progressive Disclosure:skill 全文不能出现在 System Prompt 中
if strings.Contains(prompt, "Always run go vet first.") {
    t.Error("prompt must NOT contain skill body content (progressive disclosure violated)")
}

这条断言不只是测试,它是协议的不变量声明:body 永远不能渗透进 System Prompt。


LLM 怎么用 Skill?

一次完整的 Skill 使用,在 ReAct 循环中的形态如下:

复制代码
Turn N(用户提交任务):
  LLM 收到 System Prompt(含 Skills 索引)+ 用户 prompt
  LLM 输出: tool_call → use_skill(skill_name="go-refactor")

  框架执行 use_skill:
    Index.GetFullContent("go-refactor")
    → 读取 skills/go-refactor/SKILL.md
    → parseFrontmatter → 返回 body

  工具结果作为 Observation 注入 Turn N 上下文

Turn N+1:
  LLM 收到完整的 go-refactor 指南内容(在 ToolResult 中)
  LLM 在这份指南的指导下执行实际重构任务
  LLM 输出: 后续工具调用(read_file、edit_file 等)

Skill 的全文内容出现在 ToolResult(工具观察结果)里,而不是 System Prompt 里。这是上下文注入位置的关键差异:ToolResult 是会话历史的一部分,随着压缩轮次可以被摘要或裁剪;System Prompt 是永久开销,不参与压缩。


两种触发方式

harness9 的 Skill 系统设计了两条触发路径,对应不同的使用场景。

路径一:Tool Calling(主路径)

LLM 自主判断,通过 use_skill 工具调用触发。这是生产场景的主流路径,LLM 根据任务语义决定是否需要 Skill,以及需要哪个。

路径二:Slash Command(人工快速通道)

CLI REPL 模式下,用户可以直接输入 /skill-name 绕过 LLM 判断:

go 复制代码
func resolvePrompt(input string, idx *skills.Index) (prompt string, ok bool) {
    if !strings.HasPrefix(input, "/") || idx == nil {
        return input, true
    }
    rest := strings.TrimPrefix(input, "/")
    name, extra, _ := strings.Cut(rest, " ")
    extra = strings.TrimSpace(extra)

    body, err := idx.GetFullContent(name)
    if err != nil {
        log.Print(logfmt.FormatMsg("skills", fmt.Sprintf("激活失败: %v", err)))
        return "", false
    }
    if extra == "" {
        return body, true
    }
    return body + "\n\n" + extra, true
}

/go-refactor 清理 main.go 会把 Skill 正文和附加指令拼接后作为 prompt 直接发给 LLM。这相当于把 Skill 内容从 ToolResult 层提升到了 User Message 层------LLM 在第一个 Turn 就能看到完整的 Skill 内容,不需要额外的工具调用轮次。

TUI 模式下,Tab 补全会把 Skills 名称和内置命令合并在一个补全列表里,/ 前缀触发,并用青色(Color("14"))高亮区分。这是一个细节:skillStyle 和普通命令用不同颜色,视觉上的区分反映了语义上的区分------Skill 是用户定义的领域能力,内置命令是框架控制命令。


Skill 调用失败了怎么办?

GetFullContent 在 Skill 不存在时的错误信息是刻意设计的:

go 复制代码
return "", fmt.Errorf("skill %q 不存在,可用技能: %s", name, idx.availableNames())

错误信息包含了所有可用的 Skill 名称。由于这个错误会通过 IsError: trueToolResult 返回给 LLM,LLM 可以读到"我调用的 Skill 不存在,但可用的有这些",然后根据这个列表修正调用,或者选择另一个 Skill,或者直接回答用户说没有对应的领域知识。这是 harness9 工具失败设计的通用模式------错误不终止循环,而是作为 Observation 让 LLM 自愈。

Skill 目录不存在时返回空 Index,静默不报错。Index.IsEmpty() 为 true 时,DefaultPromptBuilder 跳过整个 Skills 段落。框架的零配置运行原则:没有 Skill 文件,Agent 照常工作,只是没有这个能力扩展机制。


Skill 不是 Tool,也不是 Memory

LangChain 的 Tool 是可执行的函数,有输入输出,执行会产生副作用。

OpenAI Agents SDK 的 file_search 是 RAG 检索,把相关文档片段注入上下文,召回权在 embedding 模型。

harness9 的 Skill 是声明式的领域知识容器,不执行,不产生副作用,内容是静态的 Markdown 文本,加载后作为 LLM 的认知上下文而非工具调用结果。

它更接近于一种"协议约定":Skill 的 description 字段是 LLM 决策层的输入,Skill 的 body 是 LLM 执行层的输入,两者分属不同的时间点和上下文位置。

CrewAI 的 Agent Role 是在 Agent 初始化时固定的,无法在运行时扩展。harness9 的 Skill 是运行时按需拉取的,LLM 在一次 Interaction 中可以先后加载多个 Skill,每个 Skill 对应一个子任务的领域知识,任务完成后这些内容留在会话历史里,等待被压缩器处理。


结语

harness9 的 Agent Skill 系统核心只有一个判断:LLM 比检索算法更擅长判断"现在需要什么知识"。

这个判断成立的前提是:任务语义对 LLM 是透明的,而 Skill 的描述文案对 LLM 是足够清晰的。这把工程师的工作重心从"调参 embedding 相似度阈值"转移到了"写好 Skill 的 description"------一件更接近人类直觉的事。

留一个思考:当 Skill 数量增长到数十个时,索引本身也会消耗可观的 token。harness9 当前没有对索引做进一步的分层或分组------这是下一个有趣的设计问题。


相关推荐
小七-七牛开发者1 小时前
周一上线|瑞幸把咖啡做进 CLI,Fable 5 短暂登场,Stonk Rider 骑上 K 线图
ai·chatgpt·大模型·agent·claude·codex·skill·claudecode·ai coding
Solis程序员1 小时前
Raft:分布式系统的定海神针
java·分布式·kafka·rabbitmq·agent·raft
云烟成雨TD1 小时前
Agent Scope Java 2.x 系列【13】权限系统
java·人工智能·agent
冰^2 小时前
AI CC Switch 解决了什么?
人工智能·gpt·网络协议·chatgpt·github·aigc
悟空码字2 小时前
把 Claude Code 变成你的架构顾问:如何用“隐式重构模式”自动消除代码坏味道
ai·大模型·agent·智能体·claude code
knight_9___2 小时前
AI Agent 是什么?
人工智能·python·agent·rag·mcp
辞忧九千七2 小时前
解锁Claude Code超强能力:DeepSeek V4无缝接入指南
ai·deepseek·claude code
rising start2 小时前
ReAct Agent:让 AI 学会思考与行动
人工智能·agent
cuso4win2 小时前
Agent 项目里的 Eval 到底是什么?怎么分类?不同项目应该怎么评测?
笔记·python·agent·eval