PostHog Skills 管理实战:使用skill-*MCP 工具高效创建、更新与维护团队技能
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
本篇指南围绕 PostHog 项目中面向 Agent 的 "Working with Skills" 技能文档展开,系统讲解如何通过 PostHog 暴露的skill-*系列 MCP 工具发现、读取、创建、更新与重构团队共享技能(Skill)。阅读完本文,你将掌握工具选型决策树、渐进式披露(progressive disclosure)的读取纪律、基于base_version的乐观并发控制、大技能(多文件)的高效维护套路,以及从本地 SKILL.md 目录整体迁移到 PostHog Skills Store 的完整流程,并了解这些操作背后的服务层与数据模型实现。
一、定位:这份技能文档教什么
working-with-skills是 PostHog 仓库中随构建管线(hogli build:skills)一起发布的 Agent 技能之一,其入口文件位于 products/skills/skills/working-with-skills/SKILL.md。它的定位很明确:不重复介绍工具本身(那是配套技能 skills-store 的职责),而是补充「决策树、效率原则、常见陷阱」三块进阶指导,帮助 Agent 在调用任何skill-*工具、撰写或编辑共享技能、或排查技能写入被拒问题时少走弯路。
仓库中这两份技能的关系,正如 products/skills/skills/README.md 所概括:
- skills-store—— 发现和使用 PostHog 中存储的团队共享技能,覆盖
skill-list到skill-archive的全部工具面; - working-with-skills—— 管理技能的实操手册:选哪个写入原语、何时用什么、大技能如何规模化维护、并发怎么处理。
本文默认你已了解工具面本身(可先阅读 skills-store/SKILL.md),重点展开 working-with-skills 的方法论,并下沉到 PostHog 源码验证每一个结论。
二、四条核心操作原则
原文档开篇给出四条贯穿全文的原则,它们是后面所有决策树的出发点:
- 渐进式披露不可妥协(Progressive disclosure is non-negotiable)。列表接口只返回描述,
skill-get只返回正文 + 文件清单,skill-file-get才返回单个文件内容。永远不要「以防万一」地预加载 bundled 文件——每个被预加载的脚本都是当前任务的无效上下文。 - 选择能完成工作的最小写入原语。一次针对性的
edits或file_edits比整体替换 body 或整个 bundle 更便宜、更安全、在版本历史里更清晰。 - 读取是廉价的,并发覆盖是昂贵的。任何写操作前,必须先通过
skill-get(或上一次写操作的响应)拿到最新的version,并作为base_version传入。 - 创作遵循 Agent Skills 规范。
name保持 kebab-case,description要富含触发词,body 保持简短,臃肿内容放进 bundled 文件。
第 2、3 条原则在后端有直接对应的实现约束,我们在第六、八节结合源码详细展开。
三、工具选型决策树:先想清楚再调用
原文档给出了一棵完整的决策树,这里完整保留:
Need to know what's available? └─► skill-list (names + descriptions only) Need to use / inspect a specific skill? └─► skill-get (body + file manifest, NO file contents) └─► skill-file-get (one file, on demand, only as referenced) Authoring a brand new skill? └─► skill-create (body + all initial files in one call) Editing an existing skill? ├─ Body change? │ ├─ Substantial rewrite ............. update(body=...) │ └─ Surgical tweak .................. update(edits=[{old, new}, ...]) ├─ Bundled file content change? │ └─ update(file_edits=[{path, edits:[...]}, ...]) ├─ Add / remove / rename a file? │ ├─ Add ............................. skill-file-create │ ├─ Delete .......................... skill-file-delete │ └─ Rename .......................... skill-file-rename └─ Wholesale bundle reset (rare!) ....... update(files=[...]) # replaces ALL files Renaming the skill itself? └─► skill-rename (keeps versions, files, and owners) Want a fork as the starting point? └─► skill-duplicate (then update the copy) Done with a skill entirely? └─► skill-archive (hides ALL versions; cannot be undone)决策树隐含了一个最常见的反模式:为了改一段正文和一个脚本,就同时调用update(body=...)加一大坨files=[...]。正确的做法是把它拆成两次更窄的调用(update(edits=[...])加update(file_edits=[...])),甚至一次update同时携带edits与file_edits——第六节会给出合并调用的完整示例。
从源码看工具的真实面
这套skill-*工具在仓库中的实现位于 products/skills/backend/tools/skills.py,共 8 个工具类,全部继承ee.hogai.tool.MaxTool:
| MCP 工具 | 后端实现类 | 所需权限 |
|---|---|---|
skill-list | ListLLMSkillsTool | llm_skillviewer |
skill-get | GetLLMSkillTool | llm_skillviewer |
skill-file-get | GetLLMSkillFileTool | llm_skillviewer |
skill-create | CreateLLMSkillTool | llm_skilleditor |
skill-update | UpdateLLMSkillTool | llm_skilleditor |
skill-file-create/skill-file-delete/skill-file-rename | 走skill_services.py中的create_skill_file/delete_skill_file/rename_skill_file | llm_skilleditor |
skill-archive | ArchiveLLMSkillTool | llm_skilleditor |
工具在 MCP 层的完整定义(含 scopes、annotations、参数描述)见 products/skills/mcp/tools.yaml。其中skill-get/skill-list的idempotent: true与readOnly: true标注,从工具面印证了「读取是廉价的」这一原则。
四、先发现,再获取(Discover before you fetch)
正确用法是先用skill-list找到技能,而不是直接抓取:
posthog:skill-list { "search": "fractal" }skill-list只返回名字和描述。阅读描述本身就是全部目的——在拉取任何 body 之前先选对技能。如果search不足以收窄结果,可以不带参数列出再人工扫描,但不要盲目地逐个抓取候选技能正文。
从源码看,ListLLMSkillsTool的实现印证了这一点:_list_skills对name和description做大小写不敏感的子串过滤(Q(name__icontains=search) | Q(description__icontains=search)),返回时通过_format_skill_summary只格式化- {name} (v{version}): {description}一行摘要,并且结果上限为MAX_LIST_RESULTS = 50,超出会在响应末尾提示传入search收窄。对应的测试见 products/skills/backend/tools/test_skills.py(如test_search_filters_by_name_and_description验证按fractal搜索只命中make-fractals)。
而skill-get应当每个任务、每个技能只调用一次,而不是每问一次就调一次。把 body 缓存到工作记忆里;只有当怀疑技能在你手中发生了变化(例如写入时收到409,见第八节「并发」)才重新获取。
五、高效阅读大技能:把清单当索引
大技能(长 body、大量 bundled 文件)正是惰性加载最关键的场景。推荐流程:
skill-get(skill_name=...)—— 读取body+files[]文件清单(清单只含路径和 content_type,不含内容)。- 扫描 body 的目录 / 标题。body 应当已经告诉你哪个文件对应哪个任务——这正是「body 保持简短、按路径引用文件」的原因。
- 对 body 为当前任务明确指向的每个文件,调用
skill-file-get(file_path=...),其余全部跳过。 - 如果 body 写着「罕见场景 Y 见 scripts/X」,而你并不处于场景 Y,就不要去取
scripts/X。
拿不准时,宁少勿多——下一轮随时可以再取一个文件。
源码佐证:skill-get 确实不含文件内容
GetLLMSkillTool的_fetch_skill_with_files拉取技能及其文件行后,_format_skill_detail只把清单渲染成- scripts/mandelbrot.py (text/x-python)这样的行,随后才输出 body。测试 test_skills.py 的test_returns_full_skill_with_file_manifest专门断言:"scripts/mandelbrot.py (text/x-python)" in result,同时"print('mandelbrot')" not in result——即 body 被加载、文件内容绝不随skill-get返回。
GetLLMSkillFileTool则按需返回单文件内容。值得注意的是它在读取前做了路径清洗(posixpath.normpath折叠..段、拒绝绝对路径、拒绝../../前缀),因此用scripts/../foo.py这类变体是无法绕过路径校验的。前端同样遵循按需加载:products/skills/frontend/skillFileLogic.ts中的loadContent只在用户展开文件时通过llmSkillsNameFilesRetrieve拉取单个文件内容——「点到哪,取到哪」是整个产品层的统一约定。
六、创作新技能:一次调用、完整落地
创建一个新技能时,应该用一次skill-create调用同时带上 body和初始文件——技能直接以version: 1完整落地。不要先建空技能、再做 N 次skill-file-create追加,那是 N 个多余版本和 N 次多余往返,毫无收益。
posthog:skill-create { "name": "my-skill", "description": "What it does AND when to use it. Include trigger keywords.", "body": "# my-skill\n\n## When to use\n...\n## Workflow\n...", "license": "MIT", "compatibility": "Requires Python 3.10+", "allowed_tools": ["Bash", "Write"], "metadata": { "author": "me", "category": "..." }, "files": [ { "path": "scripts/foo.py", "content": "...", "content_type": "text/x-python" }, { "path": "references/primer.md", "content": "...", "content_type": "text/markdown" } ] }创作规则要点
description是发现面。它是skill-list唯一返回的东西。要富含触发词(用户可能怎么说)并诚实描述边界(这个技能做什么、不做什么)。源码中SPEC_DESCRIPTION_MAX_LENGTH为 1024 字符(数据库列宽 4096 仅为兼容历史行),create_skill/publish_skill_version都会在超长时抛出LLMSkillDescriptionTooLongError。name—— kebab-case、最多 64 字符、无首尾或连续连字符。服务层用正则^[a-z0-9](https://link.gitcode.com/i/4cb0b03aa5246ac2914cd1274d30dbad)?$加"--" not in value校验(skill_name_is_well_formed,见 skill_services.py)。另外有两类名字会被拒绝:RESERVED_SKILL_NAMES(new、scouts、review-hog、community,与/skills路由冲突)和与 PostHog 内置技能重名的名字(bundled_skill_name_error,内置名单由 bundled_skills.py 扫描products/*/skills目录与 context-mill 技能集合得出)。- body ≤ ~500 行。冗长前言、完整 SQL、整段示例载荷、可运行代码都应放进
references/、assets/或scripts/。body 的职责是路由到这些文件,而不是内联它们。 - 文件布局约定——
scripts/放可执行代码,references/放散文式文档和示例,assets/放模板 / 数据。Agent 只凭清单也能据此定位。 allowed_tools是请求而非授权。从文件读取技能的 harness(zip 导出、git marketplace、content=fullbundle)会把该列表视为预授权;而通过 MCP 加载技能(默认content=stubbundle)的 harness 会忽略它,直到用户批准该授权。只列技能真正使用的工具,因为扩充列表就是扩大请求面。未声明的工具不会被预授权——harness 可能询问用户、拒绝调用或不暴露该工具;部分产品会强制执行该列表,漏掉的工具会让调用直接失败。从源码看,CreateSkillArgs.allowed_tools的字段描述原样写明了「a harness that loads the skill over MCP ignores the list until the user approves that grant」,且服务层check_allowed_tool_name会拒绝含空白的工具名(因为 Agent Skills 规范将 allowed-tools 序列化为空格分隔字符串,含空格的名字导出后会碎裂成多个工具)。- 以
## Related skills结尾。当存在相邻技能时,用短列表给出`skill-name`条目,每条配一句交接理由("when to jump there"),让一次技能调用种下下一个技能的发现线索。只按名字引用(不要带路径——相邻技能常常位于其他产品),且只列真正的下一步,不要罗列产品里的一切。
从源码看 create 的落库行为
create_skill(skill_services.py)在事务内对(team, name)加行锁以阻止并发创建,写入version=1、is_latest=True,通过LLMSkill.objects.bulk_create批量落文件并借SkillDigestManager自动盖章内容摘要(digest),同时用seed_skill_owner把创建者设为默认 owner。创建成功后,响应会带上新版本号,技能立即对全团队可见(get_latest_skills_queryset只筛is_latest=True的行)。
七、更新既有技能:最小原语优先
最常见的错误是用update(body=..., files=[...])做一个小改动。这虽然能工作,但会:往返传输整个技能、让版本历史中的 diff 难以阅读、而且一旦files不完整就有丢文件的风险。应当始终选用最小原语。
7.1 先读后写,捕获version
posthog:skill-get { "skill_name": "my-skill" }记下返回的version——它要作为每次写入的base_version。一次写入成功后,响应会包含新的version,后续写入要用它继续链式推进。
7.2 body:整体替换 vs 增量编辑
重构 body 结构时用整体替换:
posthog:skill-update { "skill_name": "my-skill", "body": "# my-skill\n\nNew body...", "base_version": 7 }只微调几行时用增量编辑(小改动首选——更易审查、错误面更小):
posthog:skill-update { "skill_name": "my-skill", "edits": [ { "old": "Use Pillow for rendering.", "new": "Use Pillow ≥10.0 for rendering." }, { "old": "## Old section title", "new": "## New section title" } ], "base_version": 7 }约束:每条edits[].old必须在当前 body 中恰好匹配一次;body与edits在同一调用中互斥。
源码层面,apply_skill_body_edits(skill_services.py)逐条顺序应用编辑:old匹配 0 次抛「未找到」、匹配多次抛「请提供更多上下文使其唯一」(附带edit_index定位第几条编辑出错),最终结果超过MAX_SKILL_BODY_BYTES(1,000,000 字节)也会被拒绝。UpdateLLMSkillTool则在一开始就强制body与edits二选一(Pass either 'body' or 'edits', not both.)。
7.3 bundled 文件内容编辑
file_edits原地修补一个或多个既有文件——未涉及的文件原样结转。这是修改脚本逻辑或修正 reference 文档错别字时的正确原语:
posthog:skill-update { "skill_name": "my-skill", "file_edits": [ { "path": "scripts/foo.py", "edits": [{ "old": "ITERATIONS = 100", "new": "ITERATIONS = 250" }] }, { "path": "references/primer.md", "edits": [{ "old": "## Outdated header", "new": "## Updated header" }] } ], "base_version": 7 }file_edits不能新增、删除或重命名文件——只能修补既有文件。结构性变更请用按文件工具。实现上,_resolve_file_edits先从当前版本取出全部文件,逐条校验目标路径存在(不存在抛LLMSkillEditError并附file_path),再对每条路径调用apply_skill_file_edits(同样要求old恰好匹配一次、结果不超过MAX_SKILL_FILE_BYTES即 1MB)。
7.4 单次调用合并 body 与文件编辑
当一个变更同时跨 body 与既有文件时,可以在一次skill-update中合并edits与file_edits,发布一个连贯的新版本:
posthog:skill-update { "skill_name": "my-skill", "edits": [{ "old": "## Configuration", "new": "## Setup" }], "file_edits": [ { "path": "scripts/run.py", "edits": [{ "old": "DEBUG = False", "new": "DEBUG = True" }] } ], "base_version": 7 }7.5 文件路径参数命名(动手前先读这段)
同一个概念——bundled 文件的路径——在请求中的位置不同、字段名就不同,这是最容易凭记忆出错的地方。只有一条规则:
file_path—— 当路径属于URL的一部分时(skill-file-get、skill-file-delete)。这两个工具按路径读/删单个文件,也都接受path并归一化为file_path,所以清单里的 key 可以直接抄用。path—— 当路径是body 字段时:skill-file-create、files=[{path, content, content_type}]数组、以及file_edits=[{path, edits}]。old_path/new_path——skill-file-rename的 body 字段。
记忆口诀:path是文件对象上的字段名(它紧挨着content),所以一切携带文件对象的调用都用path;那两个用 URL 寻址文件的工具才用file_path。拿不准时查工具输入 schema,而不是猜。tools.yaml中skill-file-get/skill-file-delete的param_overrides明确给file_path配了aliases: [path],与文档描述完全一致。
7.6 新增、删除、重命名文件
每个操作独立成一次调用,每次都发布一个新版本:
posthog:skill-file-create { "skill_name": "my-skill", "path": "scripts/julia.py", "content": "...", "base_version": 7 }posthog:skill-file-delete { "skill_name": "my-skill", "file_path": "scripts/old.py", "base_version": 8 }posthog:skill-file-rename { "skill_name": "my-skill", "old_path": "scripts/julia.py", "new_path": "scripts/julia_set.py", "base_version": 9 }skill-file-rename是真正的移动——它把既有内容原样带过去,无需重新发送。内容不变时,永远优先于「删除 + 创建」。从服务层看,create_skill_file/delete_skill_file/rename_skill_file都复用_select_latest_for_write做版本校验(含base_version对比、MAX_SKILL_VERSION上限检查),并通过_create_next_version_with_files将旧版本is_latest=False、新版本version + 1、文件集按需增删改名,全程事务保护。文件数量上限为MAX_SKILL_FILE_COUNT = 200,同名文件路径会抛LLMSkillFilePathConflictError。
7.7 什么时候才用update(files=[...])(罕见)
向skill-update传files会替换整个 bundle——数组里没列出的文件全部被丢弃。它只适合有意的整体清空重灌(例如导入一棵全新的本地 SKILL.md 目录树)。几乎其他所有情况都应优先file_edits+ 按文件 CRUD。这与服务层行为一致:publish_skill_version中,当files is not None时直接bulk_create全新文件集;只有当files is None时才走_copy_files把旧文件结转(并按需应用file_edits覆盖部分内容)。
八、大技能(10+ 文件)的工作纪律
- 把清单当作索引。
skill-get的files[]就是你的地图。把每个任务步骤映射到一个文件,只取那一个。 - 把结构性变更串成序列,而不是开叉。比如要重命名三个文件,就顺序执行:
rename → rename → rename,每一步都用上一步响应里的version链式推进。这产生三个可审查的小版本,而不是一个巨大的update(files=[...])大杂烩。 - 保持编辑局部化。一次
skill-update用file_edits同时改五个文件是没问题的;但一次update(files=[...])携带十个完整文件体,几乎总是说明你本该用file_edits。 - 先重构 body 本身。如果 body 超过约 500 行,正确的下一步通常是先把内容拆进新的 bundled 文件,再继续加料,而不是放任 body 膨胀。
从模型层看,LLMSkill(models/skills.py)的版本字段(version、is_latest、deleted、version_description)和约束(unique_llm_skill_version_per_team、unique_llm_skill_latest_per_team)为「每个写入产生一个不可变版本」提供了数据库级保障;每个技能最多 2000 个版本(MAX_SKILL_VERSION),触顶后需要归档重建才能继续发布——这进一步说明了「用最小原语少产生版本」的工程价值。
九、并发控制:base_version是必填项
每个写工具都接受base_version,永远传入它:
- 服务端把
base_version与当前最新版本比较。一致则写入成功,新版本号 =base_version + 1。 - 不一致则拒绝写入(说明技能被别人更新了)。此时重新
skill-get,把改动对账到新 body 上,用新的version重试。 - 一次写入成功后,响应包含新的
version。你控制范围内的连续写入直接用该版本号链式推进即可——不要在连续写入之间重新get。
跳过base_version不会更快——它只是把干净的「别人赢了竞态」错误,变成对别人工作的静默覆盖。
从源码看,publish_skill_version在事务内用select_for_update锁定当前最新行,然后严格比较base_version != current_latest.version,不等即抛LLMSkillVersionConflictError(current_version=...);UpdateLLMSkillTool会把它转译为带当前版本号的友好提示("Refetch withget_llm_skilland retry the update with the new base_version.")。409 冲突正是第八节提到的「技能在你手中变了」的信号,此时重新skill-get一次即可。
十、常见陷阱清单
原文档列出的陷阱,逐条记录如下:
skill-list不带 search 就把每个 body 都抓一遍—— 违背渐进式披露。先读描述。skill-get之后预取每个 bundled 文件—— 在内层犯同样的错。按 body 的指示按需取。- 用
update(body=..., files=[...])做一行修复—— 往返整个技能、diff 不可读、有丢文件风险。用edits/file_edits。 - 本意是加一个文件却用
update(files=[...])—— 会把没列出的文件全部丢掉。用skill-file-create。 - 用「删除 + 创建」代替重命名—— 丢失内容历史,还多涨一个版本号。
- 链式写入后仍用陈旧的
base_version—— 要从上一次写入的响应里读version,而不是最初那次get的。 - 漏掉
base_version—— 等于接受静默覆盖。一旦做过get,就始终带上它。 description为空或含糊—— 技能通过skill-list搜索时几乎不可发现。把 description 当作触发契约。- 长 body 却没有任何 bundled 文件—— body 超过约 500 行时,重构进
references/和scripts/,别让它继续膨胀。 - 一次 update 混用
body和edits—— 两者互斥,二选一。 - 瞎猜
pathvsfile_path——skill-file-get和skill-file-delete用file_path(在 URL 里);create、rename(old_path/new_path)、files、file_edits用path(是 body 字段)。参见第七节 7.5。
十一、归档技能:不可撤销
skill-archive按名字隐藏某个技能的每一个活跃版本。它不是按版本作用域的,且无法撤销——该技能会从整个团队的skill-list和skill-get中消失。
posthog:skill-archive { "skill_name": "my-skill" }归档前,如果需要检视或复制,先skill-get。归档适合彻底退役一个技能;要移除单个 bundled 文件用skill-file-delete;要回滚内容则发布新版本而不是归档。
实现上,archive_skill在事务内把所有版本行置为deleted=True, is_latest=False(软删除),并顺带clear_skill_owners——owner 行按(team, skill_name)逻辑键绑定,若不清理,后来复用该名字的技能会继承归档技能的 owner。ArchiveLLMSkillTool的响应会明确提示「This cannot be undone」。
十二、把本地 SKILL.md 目录树移植进 PostHog
把本地技能文件夹(例如my-skill/SKILL.md加scripts/、references/、assets/)迁入 PostHog 时:
- 读取本地
SKILL.md。其 frontmatter 映射到name、description、license、compatibility、allowed_tools、metadata;frontmatter 之后的正文就是body。 - 遍历 bundled 子目录,把每个文件收集为
{ path, content, content_type }。 - 一次
posthog:skill-create带上全部内容——技能直接以version: 1完整落地。不要拆成一次 create + N 次 file-create。
创建完成后,技能立即通过skill-get对全团队可用。这也与仓库的构建管线呼应:hogli build:skills把products/*/skills渲染进dist/skills.zip随宿主分发(见 bundled_skills.py 的注释),本地的创作流程走hogli init:skill -- --product skills --name my-new-skill,本地联调用hogli sync:skill -- --name working-with-skills同步到.agents/skills/(见 products/skills/skills/README.md)。
十三、什么时候不该用技能
不是所有持久化提示都该进 Skills Store:
- 一次性任务指令属于对话,不属于技能。
- 个人草稿本属于 Agent 记忆或本地文件。
- 代码不是技能——如果它是某个服务要运行的东西,它属于仓库。
一个合格的技能应当:可复用、能被 description 发现、并且值得长期维护其正确性的成本。用第八节的版本纪律和第九节的并发规范去维护它,base_version会替你挡住并发写入的竞态,而渐进式披露则让你的每次任务只消耗真正需要的上下文。
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考