1. 为什么 LangGraph 项目里 Skill 要分四种形态
先说结论:LangGraph 里的 Skill 不是只有一种写法。Inline、File-based、External、Meta 这四种形态,分别对应"规则稳定但简短""需要版本管理""复用别人写好的""让 Agent 自己造技能"四类真实需求。如果你正在用 LangGraph 搭内容智能体、代码助手或者多工具 Agent,选错形态的代价很直接——要么把几百行知识塞进一个 Python 字符串里没法维护,要么给一条三行的格式规则硬拆成目录加 references,白白增加加载开销。
我见过最常见的翻车场景是这样的:一开始所有 Skill 都写成 Inline 字典,{"name": "seo", "description": "...", "instructions": "..."},跑得挺顺。等到 SEO 检查项从 3 条涨到 9 类、每类还要配详细指南时,这个字符串膨胀到两千多字,改一次要翻半天,还没法做 diff review。反过来,有人一上来就把"输出用 Markdown 标题分级"这种一句话规则也拆成SKILL.md + references/,结果 L1 发现阶段就要扫一堆目录,加载链路变长,收益却几乎为零。
所以四种形态的本质区别,是技能来源与定义方式的分类,而不是"高级/低级"的等级划分。它和 Progressive Disclosure(L1/L2/L3 分层加载)是正交的两件事:无论 Inline 还是 File-based,都可以按 L1 元数据 → L2 正文 → L3 资源的方式按需加载;External 和 File-based 在加载逻辑上完全一致,只是根路径不同;Meta 则是特殊形态,它的"执行结果"是生成一个新的SKILL.md文件。
四种形态的核心定义可以这样对照:
| 形态 | 定义来源 | 典型场景 |
|---|---|---|
| Inline | Python 内联字典{name, description, instructions} | 代码稳定、无需外部文件的简单规则 |
| File-based | SKILL.md + references/,符合 agentskills.io 目录结构 | 本地目录、需要 L3 资源的复杂技能、可版本控制 |
| External | 与 File-based 结构相同,来源为社区仓库或外部路径 | 克隆/下载目录、复用他人已写好的技能 |
| Meta | 技能的输出是新的SKILL.md | 技能 + 规范资源、按需创建新能力、自我扩展 |
举个具体例子帮你建立直觉:Inline 适合"用项目符号输出清单";File-based 适合blog-writer(内含references/style-guide.md);External 适合从社区仓库克隆来的content-research-writer;Meta 则是一个skill-creator,它的 instructions 描述"如何撰写 SKILL.md",resources 嵌入规范与示例,LLM 据此生成新技能文件。
理解了这层,后面的配置和代码才有落脚点。接下来我先说清楚接入侧要准备什么,再进入可复制的配置和验证。
2. TaoToken 前置:把模型调用通道先打通
在写 LangGraph 图之前,得先保证 LLM 调用是通的。LangGraph 本身不绑定模型供应商,它通过langchain-openai这类适配层调用。我这里用 TaoToken 作为统一入口,原因是它同时提供 OpenAI 兼容的 Base URL 和多种模型 ID,切换模型只改一个字符串,不用动图结构。
你需要准备三样东西,我把它叫"三件套":Base URL、API Key、Model ID。
Base URL 用https://taotoken.net/api,这是 OpenAI 兼容端点,langchain-openai的ChatOpenAI直接认。API Key 在控制台的 API Keys 页面创建,形如sk-...。Model ID 按你的任务选,做技能生成这种需要稳定输出的场景,选一个指令遵循能力强的即可。
创建 Key 的入口在这里:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite
如果你还没决定用哪个模型,可以先在模型对话页面试几条 prompt,看看指令遵循和 JSON 输出稳定性:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite
接入文档在这里,遇到参数问题可以对照:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
有一点要提醒:不要把 Key 硬编码进skill_factory_graph.py。我习惯用.env加python-dotenv,这样本地跑和 CI 跑用的是同一套读取逻辑。下面这段就是最小可用的环境配置,注意BASE_URL指向 TaoToken 的 API 端点,MODEL填你选定的模型 ID:
# demo_codes/.env OPENAI_API_KEY=sk-你的Key BASE_URL=https://taotoken.net/api MODEL=gpt-4o-mini然后在 Python 侧统一读取,封装成一个SkillFactoryConfig,后面所有节点都复用它,避免每个节点各自os.getenv造成不一致:
# config.py import os from dataclasses import dataclass from dotenv import load_dotenv load_dotenv() @dataclass class SkillFactoryConfig: api_key: str = os.getenv("OPENAI_API_KEY", "") base_url: str = os.getenv("BASE_URL", "https://taotoken.net/api") model: str = os.getenv("MODEL", "gpt-4o-mini") _config = SkillFactoryConfig()这里有个容易忽略的点:ChatOpenAI的base_url参数如果传空字符串,某些版本会回退到默认的 OpenAI 端点,导致 401。所以我在run_meta_skill里写的是base_url=config.base_url if config.base_url else None,显式处理空值。这个细节在排障章节还会再提。
前置准备好之后,就可以进入四种形态的具体配置了。下面这一节是全文最需要你动手复制的部分。
3. 可复制配置:四种形态的 Skill 定义与统一加载
这一节给出可以直接落地的文件结构和配置片段。我按"目录结构 → Inline 定义 → File-based 的 SKILL.md → External 目录 → Meta 的 references"的顺序展开,每一块都能单独复制使用。
先看整体目录结构,这是后面所有路径引用的基准:
18_skills_3/ ├── 18_skills_3.md └── demo_codes/ ├── skill_inline.py # Inline 形态定义 ├── skill_loader_ext.py # 多形态统一加载 ├── skill_meta.py # Meta 技能运行器 ├── skill_factory_graph.py # LangGraph 图 ├── skills_library/ # File-based 技能 │ ├── blog-writer/ │ ├── seo-checklist/ │ └── ... ├── external_skills/ # External 技能(模拟社区) │ └── content-research/ ├── skill_creator/ # Meta 技能 │ ├── SKILL.md │ └── references/ │ ├── skill-spec.md │ └── example-skill.md ├── main.py, main.ipynb ├── README.md └── requirements.txt3.1 Inline 形态:一个 Python 字典搞定
Inline 形态最省事,技能直接写在代码里。适合那种"规则稳定、改动频率低、不需要外部参考资料"的场景。下面这个skill_inline.py定义了两个技能,format-output管输出格式,concise-response管简洁度:
# skill_inline.py from typing import Dict, List, Optional INLINE_SKILLS: List[Dict[str, str]] = [ { "name": "format-output", "description": "统一输出格式:使用 Markdown 标题分级、项目符号列表,避免冗长句子。", "instructions": ( "输出时遵循以下规则:\n" "1. 使用 Markdown 标题分级(## / ###);\n" "2. 并列内容用项目符号列表;\n" "3. 单句不超过 40 字,避免嵌套从句;\n" "4. 代码块必须标注语言。" ), }, { "name": "concise-response", "description": "保持回答简洁,先给结论再给理由。", "instructions": "先输出一句话结论,再分点补充理由,总长度控制在 300 字以内。", }, ] def get_inline_skill_l1() -> List[Dict[str, str]]: """返回 Inline 技能的 L1 元数据(name + description)。""" return [ {"name": s["name"], "description": s["description"]} for s in INLINE_SKILLS ] def get_inline_skill_l2(name: str) -> Optional[str]: """返回 Inline 技能的 L2 正文(instructions)。""" for s in INLINE_SKILLS: if s["name"] == name: return s["instructions"] return None def has_inline_skill(name: str) -> bool: return any(s["name"] == name for s in INLINE_SKILLS)注意get_inline_skill_l1只返回 name 和 description,不返回 instructions。这是刻意的——L1 阶段只暴露元数据,让 LLM 做技能选择时上下文尽量小;真正要用的时候才通过get_inline_skill_l2取正文。这就是 Progressive Disclosure 在 Inline 形态上的落地方式。
3.2 File-based 形态:SKILL.md 加 references
当技能需要大量参考资料时,就该用 File-based。它的目录结构遵循 agentskills.io 规范:一个技能一个目录,目录里放SKILL.md,需要的话再加references/子目录。下面以blog-writer为例:
--- name: blog-writer description: 撰写技术博客,遵循风格手册与段落模板。 --- # Blog Writer 你是一个技术博客写作助手。撰写时遵循以下流程: 1. 先确定读者画像与核心论点; 2. 开头用具体场景切入,避免空泛概述; 3. 正文按"问题 → 方案 → 验证"组织; 4. 需要风格细节时,调用 load_skill_resource 加载 references/style-guide.md。 ## 资源 - references/style-guide.md:风格手册,含禁用词与段落模板。对应的references/style-guide.md放详细规则,比如禁用词列表、段落长度建议、代码示例格式。这样 L2 只加载SKILL.md正文,L3 才按需加载风格手册,token 消耗可控。
seo-checklist同理,它的SKILL.md描述检查流程,references/seo-guidelines.md放 9 类检查项的详细指标。
3.3 External 形态:复用社区技能
External 和 File-based 的结构完全一样,区别只在根路径。我把社区来源的技能放在external_skills/下,比如从社区仓库克隆来的content-research:
external_skills/ └── content-research/ ├── SKILL.md └── references/ └── research-methods.md加载逻辑上,External 和 File-based 走同一套代码,只是传入的external_root不同。这样设计的好处是:Agent 不需要关心技能来自代码、本地目录还是外部仓库,统一通过load_skill_unified处理。
3.4 Meta 形态:让 Agent 自己写技能
Meta 技能的特殊之处在于,它的"执行结果"是生成一个新的SKILL.md。skill_creator目录里放的是规范与示例,供 LLM 参考:
skill_creator/ ├── SKILL.md └── references/ ├── skill-spec.md # agentskills.io 规范摘要 └── example-skill.md # 一个完整示例技能skill_meta.py里的run_meta_skill负责加载这两个 references,拼进 prompt,调用 LLM 生成新技能,再写入skills_library/{name}/SKILL.md。核心逻辑如下:
# skill_meta.py(节选) def run_meta_skill(user_demand, skills_library=None, config=None, verbose=False): config = config or SkillFactoryConfig() skills_library = Path(skills_library) if skills_library else DEFAULT_SKILLS_LIBRARY if not config.api_key: return "", "[未配置 OPENAI_API_KEY]" skill_spec, example_skill, creator_instructions = _load_meta_references() prompt = ChatPromptTemplate.from_template(META_SYSTEM_PROMPT) llm = ChatOpenAI( model=config.model, api_key=config.api_key, base_url=config.base_url if config.base_url else None, temperature=0.3, ) chain = prompt | llm response = chain.invoke({ "skill_spec": skill_spec, "example_skill": example_skill, "creator_instructions": creator_instructions, "user_demand": user_demand, }) content = (response.content if hasattr(response, "content") else str(response)).strip() # 确保以 --- 开头,剥离 LLM 可能多输出的解释 if "---" in content: content = content[content.index("---"):] name = _extract_skill_name_from_output(content) or "generated-skill" safe_name = _sanitize_skill_name(name) output_dir = skills_library / safe_name output_dir.mkdir(parents=True, exist_ok=True) output_path = output_dir / "SKILL.md" output_path.write_text(content, encoding="utf-8") return safe_name, str(output_path)META_SYSTEM_PROMPT把规范、示例、创建指令和用户需求四块拼在一起,要求 LLM 直接输出完整SKILL.md,从---开始到正文结束,不要任何解释。这个约束很关键,否则 LLM 容易在前面加一句"好的,我来帮你创建",导致 frontmatter 解析失败。
3.5 统一加载器:一个函数吃四种形态
四种形态最终要收敛到一个加载入口,否则每个节点都要写 if-else 判断来源。skill_loader_ext.py提供三个函数:discover_all_skills、load_skill_unified、load_skill_resource。
# skill_loader_ext.py(节选) def discover_all_skills(inline_l1, file_root=None, external_root=None): """汇总 Inline、File-based、External 的 L1 元数据。 去重:若 name 重复,以 Inline > File > External 优先保留。 """ file_root = Path(file_root) if file_root else DEFAULT_SKILLS_LIBRARY external_root = Path(external_root) if external_root else DEFAULT_EXTERNAL_SKILLS seen, result = set(), [] for meta in inline_l1: name = meta.get("name", "") if name and name not in seen: seen.add(name) m = dict(meta); m["_source"] = "inline" result.append(m) for meta in _discover_from_dir(file_root, "file"): name = meta.get("name", "") if name and name not in seen: seen.add(name); result.append(meta) for meta in _discover_from_dir(external_root, "external"): name = meta.get("name", "") if name and name not in seen: seen.add(name); result.append(meta) return result去重优先级 Inline > File > External 是有意设计的:如果本地已经用 Inline 覆盖了某个技能,就不该被外部同名技能顶掉。load_skill_unified则按同样的顺序查找 L2 正文,找到即返回。
到这里,四种形态的定义和加载都齐了。下一节进入 LangGraph 图,把路由、发现、选择、加载、执行串起来,并给出验证请求和成功结果。
4. 验证请求:LangGraph 图编排与运行结果
这一节把前面的模块组装成一张 LangGraph 图,然后跑两个真实请求验证:一个是任务执行(用现有技能写博客),一个是技能创建(Meta 生成新技能)。
4.1 状态定义与图结构
先定义状态。SkillFactoryState用TypedDict,字段覆盖整条链路:
# skill_factory_graph.py(节选) from typing import TypedDict, NotRequired class SkillFactoryState(TypedDict): user_task: str intent: str # "create_skill" | "execute_task" l1_skills: list selected_skill_names: list l2_content: dict l3_content: dict created_skill_name: str # meta 分支:新技能名 created_skill_path: str # meta 分支:写入路径 final_response: str verbose: NotRequired[bool]图结构是条件路由:route_node判断意图,create_skill走meta_skill_node直接结束,execute_task走discover → select → load_l2 → execute链路。
# skill_factory_graph.py(节选) from langgraph.graph import StateGraph, START, END def build_graph(): g = StateGraph(SkillFactoryState) g.add_node("route", route_node) g.add_node("meta_skill", meta_skill_node) g.add_node("discover", discover_node) g.add_node("select", select_node) g.add_node("load_l2", load_l2_node) g.add_node("execute", execute_node) g.add_edge(START, "route") g.add_conditional_edges( "route", lambda s: s["intent"], {"create_skill": "meta_skill", "execute_task": "discover"}, ) g.add_edge("meta_skill", END) g.add_edge("discover", "select") g.add_edge("select", "load_l2") g.add_edge("load_l2", "execute") g.add_edge("execute", END) return g.compile()route_node用 LLM 判断意图,prompt 里明确要求只返回create_skill或execute_task,不要解释。未配置 API 或任务为空时默认execute_task,保证图不会卡死。
4.2 运行任务执行请求
装好依赖后,先跑一个任务执行请求:
cd demo_codes pip install langgraph langchain-openai langchain-core python-dotenv tiktoken python main.py "写一段关于 LangGraph 的博客开头,并检查 SEO"预期输出(verbose 模式)大致如下:
[route] 用户输入: 写一段关于 LangGraph 的博客开头... [route] 意图: execute_task [discover] 汇总 Inline、File-based、External 技能... [discover] 发现 5 个技能: ['format-output', 'concise-response', 'blog-writer', 'seo-checklist', 'content-research'] [discover] L1 token: 328 [select] 调用 LLM 进行技能语义匹配... [select] 选中技能: ['format-output', 'blog-writer', 'seo-checklist'] [load_l2] 加载 L2: ['format-output', 'blog-writer', 'seo-checklist'] [load_l2] format-output: 89 tokens [load_l2] blog-writer: 499 tokens [load_l2] seo-checklist: 499 tokens [load_l2] L2 总计: 1087 tokens [execute] 执行任务,技能: ['format-output', 'blog-writer', 'seo-checklist'] [execute] LLM 调用 (迭代 1)... [execute] LLM 请求 2 次工具调用 [execute] [Tool] load_skill_resource_tool({'skill_name': 'blog-writer', 'resource_path': 'references/style-guide.md'}) [execute] -> L3 加载 blog-writer:references/style-guide.md, 285 tokens [execute] [Tool] load_skill_resource_tool({'skill_name': 'seo-checklist', 'resource_path': 'references/seo-guidelines.md'}) [execute] -> L3 加载 seo-checklist:references/seo-guidelines.md, 280 tokens [execute] LLM 调用 (迭代 2)... [execute] LLM 返回最终回复(无工具调用) [execute] L3 总计: 565 tokens, 最终回复: 2847 字符这段日志把四种形态的协作讲清楚了:discover阶段汇总了 Inline(format-output、concise-response)、File-based(blog-writer、seo-checklist)、External(content-research)三类来源;select用 LLM 语义匹配选出三个相关技能;load_l2只加载选中技能的正文;execute阶段 LLM 主动调用load_skill_resource工具加载 L3 资源。整个过程 L1 只花 328 token,L2 花 1087 token,L3 按需花 565 token,没有一次性把所有内容塞进上下文。
4.3 运行技能创建请求
再跑一个 Meta 请求,让 Agent 自己造技能:
python main.py "帮我创建一个用于检查 Python 代码安全的技能"预期输出:
[route] 意图: create_skill [meta_skill] 执行 Meta 技能:生成新 SKILL.md... [meta_skill] 加载 skill-creator 的 references... [meta_skill] 调用 LLM 生成 SKILL.md... [meta_skill] 已写入: skills_library/python-security-review/SKILL.md [meta_skill] 已创建技能「python-security-review」,路径:skills_library/python-security-review/SKILL.md。可通过 discover 发现并用于后续任务。生成后,你可以直接查看文件确认 frontmatter 是否完整:
cat skills_library/python-security-review/SKILL.md如果 frontmatter 里有name和description,正文有清晰的检查流程,就说明 Meta 链路是通的。这个新技能下次跑任务执行请求时,会被discover_node自动发现,无需重启或手动注册。
4.4 用 Notebook 观察全流程
如果你更喜欢交互式调试,打开main.ipynb,把VERBOSE = True,依次运行"图展示 → 任务执行示例 → 技能创建示例 → 形态对比"四个 cell。每个节点的_log(state, msg, node)都会输出到 notebook,工具调用的参数和返回也能看到。我调试select_node的语义匹配时,就是靠这个日志发现 LLM 偶尔会返回带空格的技能名,后来在解析时加了.replace(" ", "-")才稳定。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
这一节按真实报错来。四种形态的代码跑起来,最容易在这几个地方卡住。
报错一:401 Unauthorized / invalid_api_key
这是最高频的。原因通常是.env没被加载,或者base_url传了空字符串导致回退到默认端点。排查顺序:先确认demo_codes/.env存在且OPENAI_API_KEY有值;再确认SkillFactoryConfig里load_dotenv()在读取环境变量之前执行;最后检查ChatOpenAI初始化时base_url是否显式传了https://taotoken.net/api。如果base_url是空字符串,改成None或直接传正确值。
# 错误写法:空字符串会触发回退 llm = ChatOpenAI(model=config.model, api_key=config.api_key, base_url="") # 正确写法 llm = ChatOpenAI( model=config.model, api_key=config.api_key, base_url=config.base_url if config.base_url else None, )报错二:local proxy failed / connection error
这个报错说明请求根本没发出去,通常是网络层配置问题。检查你的运行环境是否能正常访问https://taotoken.net/api,可以用curl先探一下:
curl -I https://taotoken.net/api如果返回 4xx 而不是连接超时,说明网络通,问题在鉴权或路径;如果直接超时,检查本地网络设置。注意不要在代码里硬编码任何网络代理配置,保持环境干净。
报错三:reading 'choices' / KeyError: 'choices'
这个报错说明返回体结构不符合预期,常见于base_url指向了非 OpenAI 兼容端点,或者模型 ID 写错。langchain-openai期望返回体里有choices字段。排查:确认BASE_URL是https://taotoken.net/api(注意结尾没有多余斜杠),确认MODEL是有效的模型 ID。如果模型 ID 拼错,有些网关会返回错误结构而不是标准 404,就会触发这个 KeyError。
报错四:OAuth / authentication 相关
如果你在别的工具里配过 OAuth 流程,切到 API Key 模式时容易残留旧配置。检查环境变量里有没有冲突的OPENAI_ORG_ID、OPENAI_PROXY之类,清掉再跑。LangGraph 本身不涉及 OAuth,它只认 API Key。
报错五:Meta 生成的 SKILL.md 解析失败
如果run_meta_skill返回[生成内容为空]或写入的文件没有 frontmatter,多半是 LLM 在输出前加了说明文字。META_SYSTEM_PROMPT里已经要求"不要输出任何解释或前后缀",但如果模型不听话,可以在解析时做兜底:找到第一个---的位置,从那里截断。代码里已经这么处理了,如果还失败,把temperature降到 0.1 再试。
报错六:External 技能加载不到
discover_all_skills扫不到external_skills/下的技能,通常是目录结构不对。确认每个技能是独立子目录,子目录里有SKILL.md,且 frontmatter 的name字段存在。如果name缺失,_discover_from_dir会跳过它。
排障时如果拿不准是 Key 问题还是模型问题,可以先用模型对话页面单独发一条请求验证通道:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite
确认通道没问题后,再回到代码里查配置。接入参数的完整说明在文档里:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
6. 选型思路与后续扩展
四种形态不是互斥的,一个项目里通常混用。我的选型经验是这样:先问三个问题——这条规则会不会经常改?需不需要外部参考资料?是不是别人已经写过?如果都不需要,用 Inline;需要版本管理和参考资料,用 File-based;别人写过且可信,用 External;希望 Agent 自己扩展能力,用 Meta。
具体到内容智能体这个案例,format-output和concise-response用 Inline,因为它们稳定且短;blog-writer和seo-checklist用 File-based,因为要配风格手册和检查指南;content-research用 External,因为可以直接复用社区成果;skill-creator用 Meta,因为它要生成新技能。这套组合跑下来,L1 发现阶段只花 328 token,比把所有技能正文都塞进上下文省了一个数量级。
后续可以往两个方向扩展。一是 External 的真实拉取,把git clone或技能安装命令集成进discover_node之前,从真实社区仓库同步技能到external_skills/。二是 Meta 的校验增强,生成SKILL.md后调用校验工具检查 frontmatter 和 references 路径是否合法,不合法就回炉重生成。这两步做完,技能工厂就从"能跑"变成"能长期维护"。
最后提醒一句:使用 External 技能前务必审阅SKILL.md和references/,技能指令会直接影响 Agent 行为;Meta 生成的技能建议先人工复核再投入生产。如果你要长期跑编码类 Agent,可以考虑 Coding Plan 降低调用成本:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite
需要管理多个 Key 或查看用量时,控制台在这里:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite
如果你在用 Claude Code 做技能文件的批量编辑,接入配置参考:https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claude_code&utm_campaign=rewrite
把skill_loader_ext.py的discover_all_skills和load_skill_unified这两个函数吃透,四种形态的差异就只剩路径不同了。