乐于分享
好东西不私藏

GStack 的 SKILL.md 模板系统是怎么设计的?一文讲透"代码即文档"

GStack 的 SKILL.md 模板系统是怎么设计的?一文讲透"代码即文档"

2026 年 3 月,Y Combinator 的 CEO Garry Tan 公布了一组数据:过去 60 天,他在全职运营 YC 的同时,用一个叫 gStack 的工具完成了 60 万行生产代码。日均 1-2 万行,一周内 14 万行代码、362 次提交。

一个人,干了一支小工程团队的活。

消息传开后,很多人去看了 gStack 的源码。28 个专业 Skill,覆盖产品讨论、代码审查、QA 测试、浏览器自动化。每个 Skill 背后都有一个 SKILL.md 文件——AI 执行任务时读取的操作手册。

但真正让这套系统跑起来的,不是 Skill 本身,而是生成这些 Skill 文档的模板系统

这篇文章就拆解这个模板系统:它解决什么问题、怎么工作的、有哪些设计原则。最后我会用自己仿写 content-workflow 的真实经历,告诉你哪些坑可以避开。


一、为什么 AI Agent 需要"操作手册"

先想一个问题:你给新员工交代任务,可以说一遍,他不懂会追问。AI 不行。

AI 的特点是执行速度快但容错率低。你给它的文档写错一个命令,它不会说"这个命令好像不对"——它会直接用错误的命令执行下去,然后整个任务失败。

人读文档出了错,问一句就解决了。AI 读文档出了错,整个任务就废了。

所以 gStack 的做法是:给每个 Skill 写一份精确到每一步的操作手册,也就是 SKILL.md。AI 启动时读取这份手册,按步骤执行。手册里写什么,AI 就做什么。不多不少。

问题来了:gStack 有 28 个 Skill,每个 Skill 的手册还要区分 Claude Code 和 Codex 两个环境。手动维护 56 份文档?不现实。

这就是模板系统要解决的事。


二、模板系统的核心机制

2.1 三层架构

gStack 的模板系统只有三个角色:

SKILL.md.tmpl(人写)
       ↓
gen-skill-docs.ts(脚本处理)
       ↓
SKILL.md(AI 用)

模板文件 SKILL.md.tmpl 是人写的,里面用 {{PLACEHOLDER}} 标记需要自动生成的部分。构建脚本 gen-skill-docs.ts 读取模板,找到占位符,调用对应的函数生成内容,最后输出 AI 实际使用的 SKILL.md

人只需要维护模板,文档的生成和同步全部交给脚本。

2.2 占位符与 RESOLVER

模板里长这样:

{{PREAMBLE}}

# browse


{{COMMAND_REFERENCE}}

{{PREAMBLE}}{{COMMAND_REFERENCE}} 就是占位符。脚本处理时,用正则表达式 \{\{(\w+)\}\} 匹配所有占位符,然后查一个叫 RESOLVERS 的字典,找到对应的生成函数:

const RESOLVERS: Record<string, (ctx: TemplateContext) => string> = {
  PREAMBLE
: generatePreamble,
  COMMAND_REFERENCE
: generateCommandReference,
  SNAPSHOT_FLAGS
: generateSnapshotFlags,
  QA_METHODOLOGY
: generateQAMethodology,
  // ... 共 21 个

};

核心替换逻辑就几行:

let content = tmplContent.replace(/\{\{(\w+)\}\}/g, (match, name) => {
  const
 resolver = RESOLVERS[name];
  if
 (!resolver) throw new Error(`Unknown placeholder: {{${name}}}`);
  return
 resolver(ctx);
});

[数据来源:gStack/scripts/gen-skill-docs.ts]

注意这里的设计:遇到未知占位符直接报错,不会静默跳过。这就是"强制验证"——模板写了占位符但没注册 RESOLVER,构建就过不了。

2.3 PREAMBLE:最复杂的占位符

21 个占位符里,{{PREAMBLE}} 是最特殊的一个。它不是一段静态文字,而是由 10 个子函数组合生成的初始化脚本:

function generatePreamble(ctx: TemplateContext): string {
  return
 [
    generatePreambleBash
(ctx),              // bash 初始化
    generateUpgradeCheck
(ctx),              // 升级检查
    generateLakeIntro
(),                     // "Boil the Lake" 原则
    generateTelemetryPrompt
(ctx),           // 遥测提示
    generateAskUserFormat
(ctx),             // 交互格式
    generateCompletenessSection
(),          // 完整性原则
    generateRepoModeSection
(),              // 仓库模式
    generateSearchBeforeBuildingSection
(),  // 建造前搜索
    generateContributorMode
(),              // 贡献者模式
    generateSecurityPrompt
(ctx),            // 安全提示
  ].join('\n\n');
}

这 10 个子函数覆盖了 AI 执行任务前需要知道的所有上下文:环境怎么初始化、遇到测试失败怎么办、该不该搜一下再动手、安全边界在哪。

[数据来源:gStack/scripts/gen-skill-docs.ts generatePreamble 函数]

说白了,PREAMBLE 就是 AI 的"开机自检流程"。每个 Skill 启动时都先跑一遍,确保环境正确、认知对齐。


三、核心设计原则:"代码即文档"

模板系统背后有一个一以贯之的理念:代码即文档(Code as Documentation)。

唯一真相

gStack 的设计是:源代码是文档的唯一来源。不是先写代码再补文档,而是文档从代码自动生成。

最典型的例子是 commands.ts。这个文件定义了 gStack 所有的命令——名称、参数、用途。它同时被三个地方引用:

  • server.ts 运行时读它做命令分发
  • gen-skill-docs.ts 构建时读它生成文档
  • skill-check.ts 测试时读它做健康检查

命令定义只写一次,三个消费者各取所需。

构建时同步

gStack 不是"写完代码记得更新文档",而是"写完代码跑一下构建,文档自动更新"。

bun run gen:skill-docs

一条命令,28 个 Skill 的 SKILL.md 全部重新生成。你改了 commands.ts 里的命令定义,跑完构建,browse 的 SKILL.md 里的命令参考部分就自动更新了。

强制验证

gStack 在 commands.ts 末尾加了一段验证代码:

const allCmds = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
const
 descKeys = new Set(Object.keys(COMMAND_DESCRIPTIONS));

for
 (const cmd of allCmds) {
  if
 (!descKeys.has(cmd))
    throw
 new Error(`COMMAND_DESCRIPTIONS missing: ${cmd}`);
}

你加了新命令但忘了加描述?构建直接报错。不是提醒,是报错。

[数据来源:gStack/browse/src/commands.ts 验证代码段]

双环境兼容

gStack 同时支持 Claude Code 和 Codex 两个 AI 环境。两个环境的路径、frontmatter 格式、安全机制都不一样。

模板系统通过构建时的自动转换解决这个问题:

if (host === 'codex') {
  content = content.replace(/~\/\.claude\/skills\/gstack/g,
    '~/.codex/skills/gstack'
);
  content = content.replace(/\.claude\/skills\/gstack/g,
    '.agents/skills/gstack'
);
}

一份模板,跑两次构建,输出两个版本。人只维护一份,不会出现"改了 Claude 版忘了改 Codex 版"的情况。

[案例位置:gStack-03 中 Claude vs Codex 路径差异的具体对比]


四、实战:从零搭一个内容创作工作流

理论讲完了,下面用我自己仿写 content-workflow 的过程,演示怎么把这套模板系统用到自己的项目里。

第一步:创建目录和模板

content-workflow/
├── SKILL.md.tmpl           # 人写的模板
├── SKILL.md                # 脚本生成的产物
├── references/
│   ├── content-angles.md   # 内容角度库
│   ├── content-stages.md   # 阶段定义
│   └── platform-specs.md   # 平台规格
└── scripts/
    └── gen-content-skill-docs.ts

模板文件的 frontmatter 声明 Skill 的名称和允许使用的工具:

---
name:
 content-workflow
version:
 1.0.0
description:
 |
  端到端内容创作工作流。包含选题策划、大纲生成、初稿写作、
  审核润色、发布准备全流程。
allowed-tools:
  -
 Read
  -
 Write
  -
 Edit
  -
 WebSearch
  -
 WebFetch
  -
 AskUserQuestion
---

第二步:定义占位符和 RESOLVER

内容创作工作流有 5 个阶段,每个阶段对应一个占位符:

       
                                           
占位符对应阶段
{{TOPIC_PLAN}}选题策划
{{OUTLINE_GEN}}大纲生成
{{DRAFT_WRITING}}初稿写作
{{REVIEW_STEPS}}审核润色
{{PUBLISH_PREP}}发布准备
       
     

在脚本里注册 RESOLVER:

const RESOLVERS: Record<string, (ctx: TemplateContext) => string> = {
  PREAMBLE
: () => generatePreamble(),
  TOPIC_PLAN
: generateTopicPlan,
  OUTLINE_GEN
: generateOutlineGen,
  DRAFT_WRITING
: generateDraftWriting,
  REVIEW_STEPS
: generateReviewSteps,
  PUBLISH_PREP
: generatePublishPrep,
};

模板里只放占位符,具体内容全部由 RESOLVER 函数生成:

## Stage 1: 选题策划

{{TOPIC_PLAN}}

## Stage 2: 大纲生成

{{OUTLINE_
GEN}}

第三步:处理参考文件

内容角度库有 8 种内容类型、80 多个标题模板。平台规格覆盖 6 个平台。

错误做法:把这些内容直接塞进 SKILL.md(我一开始就是这么干的,文件膨胀到 20KB+)。

正确做法:放在 references/ 目录下,在 SKILL.md 里只放路径引用:

内容角度库详见 [references/content-angles.md](references/content-angles.md),
执行时读取该文件获取 8 种内容角度。

AI 需要时按需读取,不会一股脑全加载。

第四步:构建验证

bun run scripts/gen-content-skill-docs.ts --dry-run

先 dry-run 预览,确认没有未知占位符、没有格式问题,再正式生成。


五、踩过的坑和实际教训

仿写过程中踩了几个坑,值得提前知道。

坑一:内容重复冗余

现象:SKILL.md 膨胀到 500+ 行,每个阶段的内容出现了 2-3 遍。

原因:模板里硬编码了一版内容,RESOLVER 函数里又生成了一版。两份内容同时出现在最终产物里。

解决:模板里只放占位符,具体内容全部交给 RESOLVER。一个内容只有一个来源。

坑二:参考文件内联导致膨胀

现象:content-angles.md 和 platform-specs.md 的全部内容被读出来塞进 SKILL.md,文件超过 20KB 被截断。

原因:RESOLVER 里用 fs.readFileSync 把参考文件内容原样注入。

解决:改为路径引用,AI 按需读取。SKILL.md 体积从 20KB 降到 4KB。

坑三:占位符是空壳

现象{{PREAMBLE}}{{COMPLETION_STATUS}} 注入的只是一句"使用时需替换"。

原因:占位符注册了但函数没实现,占位符变成了无意义的占位文字。

解决:要么实现真正的功能(PREAMBLE 改为执行前检查指令),要么直接删掉(COMPLETION_STATUS 当前不需要就删)。

坑四:路径硬编码

现象:脚本里用 path.join(ROOT, '..', '..', '..') 定位项目根目录,换一台机器或改一层目录就崩。

解决:改为向上查找 .gitAGENTS.md 来动态定位项目根:

function findProjectRoot(startDir: string): string {
  let
 dir = startDir;
  while
 (dir !== path.dirname(dir)) {
    if
 (fs.existsSync(path.join(dir, '.git'))) return dir;
    dir = path.dirname(dir);
  }
  throw
 new Error('Project root not found');
}

结语

gStack 的模板系统看起来简单——模板加占位符加构建脚本——但它解决的是 AI 工程化中一个基础问题:怎么让 AI 的"知识"永远准确、永远最新

对于人来说,文档不准确可以追问。对于 AI 来说,文档就是它的全部认知。文档错了,AI 就错了。模板系统保证了文档从代码自动生成,不会和代码脱节。

如果你想给自己的 AI Agent 搭一套类似的系统,核心就三步:

  1. 1. 建目录SKILL.md.tmpl + scripts/gen-skill-docs.ts
  2. 2. 写占位符:识别哪些内容应该自动生成,用 {{PLACEHOLDER}} 标记
  3. 3. 跑构建:一条命令生成最终产物,验证无误后提交

gStack 开源在 GitHub(https://github.com/garrytan/gstack),MIT 协议。fork 下来,改成你自己的模板系统,让 AI 用上永远准确的操作手册。