TECH NOTE
5298 字 26 分钟 系列 11 / 2211 实战:用 Skills 搭一个 SQL 助手(按需加载「查表手册」)
ON THIS PAGE / 目录22 节
一句话讲透:技能(Skill)就是一本放在书架上的说明书。Agent 脑子里只贴一张目录——“销售分析手册""库存管理手册”,真要用哪本,再伸手抽出来翻;而不是一上来就把几十本手册全糊它脸上。
这个教程要解决什么
场景:给一家大企业做一个”帮你写 SQL”的助手。这家公司每个业务线(vertical)一套库,或者干脆一个巨型库塞了几千张表。朴素做法:把所有表结构(schema)都写进系统提示词,让模型”看着办”。
为什么不行:
- 贵:提示词每轮都要重发一遍,token(词元)按次计费,几千张表 = 每轮都在烧钱。
- 慢:输入越长,首字延迟越高。
- 吵:眼前摆着两百张不相关的表,模型反而更容易选错表、编错字段。
- 协同烂:所有业务线的表结构挤在同一个提示词里,谁改谁背锅,最后变成没人敢动的祖传字符串。
目标:只加载当前这个问题真正需要的那 1~2 个技能,其余一律不进上下文。这就是原文反复讲的渐进式披露(progressive disclosure)。
| 维度 | 全量塞进系统提示 | 渐进式披露(本教程) |
|---|---|---|
| 每轮 token | 所有表结构之和 | 技能目录 + 命中的那一个技能 |
| 新增一条业务线 | 改核心提示词,风险外溢 | 加一个技能,互不影响 |
| 谁维护 | 只有写提示词的人 | 各业务线自己维护 |
| 代价 | —— | 首次命中要多一次工具调用 |
📎 官方教程:SQL Agent tutorial(带查询执行、报错纠正、结果校验的完整 SQL Agent) —— 本教程只讲”渐进式披露”这一个模式,它能套到任何领域。
整体架构
flowchart TD Start([用户:帮我写 SQL<br/>查高价值客户]) --> SystemPrompt[Agent 看到技能目录 Skill Catalog:<br/>sales_analytics / inventory_management] SystemPrompt --> Decide{需要销售表结构吗} Decide --> LoadSkill[调工具 Tool:load_skill<br/>skill_name = sales_analytics] LoadSkill --> Schema[表结构 Schema 载入上下文:<br/>customers、orders + 业务规则] Schema --> WriteQuery[Agent 依据手册写 SQL] WriteQuery --> Response([返回符合业务规则的 SQL])这张图在说什么:
① 常驻上下文的只有技能目录(名字 + 一句简介),成本极低,作用相当于书架上的索引卡。
② 要不要加载、加载哪一个,是模型自己决定的——它发起一次工具调用(tool call),不是你在代码里写 if/else 去挑。
③ 技能全文是通过工具返回值流回对话的(在 LangChain 里就是一条 ToolMessage)。从那一刻起,这轮对话就”读过这本书”了。
渐进式披露的 token 账:
第 1 轮:系统提示 + 技能目录(200 token) ─▶ 模型决定加载哪个技能第 2 轮:+ 被加载技能全文(+2000 token) ─▶ 模型据手册写 SQL未加载的技能:0 token(这就是省钱的地方)如果 200 个技能全塞进去:2000 × 200 = 40 万 token ─▶ 直接爆窗口原文还顺手列了三个好处:团队各写各的技能(自治,跟其他多 Agent 架构一样);技能加到几十上百个也不会撑爆上下文;只有一个 Agent、一条对话线程,历史简单,不用做跨 Agent 的消息转发。
💡 提示:Anthropic 把这套做法总结成三层结构——元数据 → 核心内容 → 详细资源,逐层按需下钻。原文认为,技能式的渐进式披露可以看成一种 RAG(Retrieval-Augmented Generation,检索增强生成):每个技能就是一个检索单元,只不过检索不是靠向量或关键词,而是靠”模型自己调工具去翻”。
分步拆解
§0 环境准备(原文 Setup)
安装:pip install langchain(uv 用户 uv add langchain)。
LangSmith 追踪:强烈建议开。技能是”偷偷往上下文里塞内容”的机制,不上 trace,你根本不知道它到底加载了哪个技能、加载了几次。设两个环境变量 LANGSMITH_TRACING="true"、LANGSMITH_API_KEY="..." 即可(或在 Python 里写 os.environ[...])。
选模型:统一用 init_chat_model,换供应商只改一个字符串(API Key 同样走环境变量,如 os.environ["OPENAI_API_KEY"])。原文给了 7 个供应商的 Tab,这里压成一张表;每个 Tab 里还并列了”直接实例化 Model Class”的写法(如 ChatOpenAI(model="gpt-5.5")),效果等价,不再展开:
| 供应商 | 安装 | init_chat_model 写法 |
|---|---|---|
| OpenAI | pip install -U "langchain[openai]" | init_chat_model("gpt-5.5") |
| Anthropic | langchain[anthropic] | init_chat_model("claude-sonnet-4-6") |
| Azure | langchain[openai] | init_chat_model("azure_openai:gpt-5.5", azure_deployment=...) |
| Google Gemini | langchain[google-genai] | init_chat_model("google_genai:gemini-3.7-flash") |
| AWS Bedrock | langchain[aws] | init_chat_model("us.anthropic.claude-sonnet-4-6", model_provider="bedrock_converse") |
| HuggingFace | langchain[huggingface] | init_chat_model("microsoft/Phi-3-mini-4k-instruct", model_provider="huggingface") |
| OpenRouter | langchain-openrouter | init_chat_model("auto", model_provider="openrouter") |
§1 定义技能:技能就是一个”有名字的说明书”
干什么:先把技能的数据结构定下来。每个技能三件套:name(唯一标识,模型靠它点名)、description(1~2 句简介,常驻系统提示)、content(全文,加载时才进上下文)。为什么这么分:这个”目录轻、正文重”的切分就是整个模式的地基——便宜的信息常驻,贵的信息按需。
from typing import TypedDict
class Skill(TypedDict): """一个可以按需披露给 agent 的技能。""" name: str # 技能唯一标识,load_skill 靠它点名 description: str # 1-2 句简介,写进系统提示词给模型看 content: str # 技能全文(表结构 + 业务规则),按需加载然后准备技能清单,本教程就两个:销售分析、库存管理。
SKILLS: list[Skill] = [ { "name": "sales_analytics", "description": "销售数据分析(客户、订单、营收)的表结构与业务逻辑。", "content": """# 销售分析表结构### customers- customer_id (PRIMARY KEY)# ...(略:name / signup_date / status / customer_tier)### orders- order_id / customer_id / order_date- status (pending/completed/cancelled/refunded)- total_amount / sales_region# ...(略:order_items 表、业务规则、示例 SQL)""", }, {"name": "inventory_management", "description": "库存管理…(略)", "content": "# ...(略)"},]附:技能正文里到底写什么(原文 Accordion)
原文两个技能的正文结构完全一致,都是三段:表结构 Tables → 业务规则 Business Logic → 示例查询 Example Query。以 sales_analytics 为例:
## 表结构 Tablescustomers: customer_id / name / email / signup_date / status / customer_tierorders: order_id / customer_id / order_date / status / total_amount / sales_regionorder_items: item_id / order_id / product_id / quantity / unit_price / discount_percent
## 业务规则 Business Logic活跃客户 = status = 'active' AND signup_date <= CURRENT_DATE - INTERVAL '90 days'营收口径 = 只算 status = 'completed' 的订单;total_amount 已含折扣客户生命周期价值 CLV = 该客户所有已完成订单金额之和高价值订单 = total_amount > 1000
## 示例查询 Example Query(一条"上季度营收 Top 10 客户"的可照抄 SQL)这才是技能真正的价值:表结构数据库里能查到(甚至能自动生成),但”营收只算 completed""total_amount 已经扣过折扣”这类口径,只有人知道。示例 SQL 则相当于给模型一个能照着改的答案格式。
§2 技能加载工具:load_skill
干什么:给模型一个”取书”的工具——传技能名,返回技能全文。为什么:模型读不到你的 Python 变量,它只有工具。工具返回值会被包成一条 ToolMessage 塞进对话,于是”手册内容”就合法地进了上下文。
from langchain.tools import tool
@tooldef load_skill(skill_name: str) -> str: """把某个技能的完整内容加载进 agent 的上下文。
当你需要处理某类请求的详细信息时使用。
Args: skill_name: 要加载的技能名 """ for skill in SKILLS: if skill["name"] == skill_name: return f"Loaded skill: {skill_name}\n\n{skill['content']}"
# 没找到:把可选技能列出来,让模型自己纠正 available = ", ".join(s["name"] for s in SKILLS) return f"Skill '{skill_name}' not found. Available skills: {available}"⚠️ 原文如此:这个工具的 docstring 里举例写的是
"expense_reporting"、"travel_booking",而本教程压根没有这两个技能——明显是从别的示例抄过来的。示例名和实际技能清单对不上,模型容易被带偏,自己写的时候记得改。
小细节:找不到技能时别直接报错崩掉,而是把”可用技能清单”回给模型——这是让 Agent 自我纠错(self-correction)的标准套路。
§3 技能中间件:把”目录”塞进系统提示
干什么:写一个自定义中间件(middleware),在每次调用模型之前,把技能目录追加到系统提示词后面。为什么不用改 system_prompt:技能列表是动态的,写死在 create_agent(system_prompt=...) 里就没法随着运行时变化。而中间件能在每次模型调用前改请求,这正是它该干的活。
from typing import Callablefrom langchain.agents.middleware import ModelRequest, ModelResponse, AgentMiddlewarefrom langchain.messages import SystemMessage
class SkillMiddleware(AgentMiddleware): """把技能目录注入系统提示词的中间件。"""
tools = [load_skill] # 类变量注册工具,agent 就能看到 load_skill
def __init__(self): # 由 SKILLS 生成"目录"文本:- **技能名**: 一句话简介 self.skills_prompt = "\n".join( f"- **{s['name']}**: {s['description']}" for s in SKILLS )
def wrap_model_call( self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse] ) -> ModelResponse: """同步路径:把技能目录追加到系统提示词。""" addendum = ( f"\n\n## Available Skills\n\n{self.skills_prompt}\n\n" "当你需要某类请求的详细信息时,调用 load_skill 工具。" ) # 注意是"追加内容块",不是覆盖原来的系统提示 new_content = list(request.system_message.content_blocks) + [ {"type": "text", "text": addendum} ] modified = request.override(system_message=SystemMessage(content=new_content)) return handler(modified)💡 提示:本教程图省事,在
__init__里读技能清单。生产环境更推荐放进before_agent这类节点式钩子,这样新增/修改技能后能定期刷新,不用重启进程。
§4 组装 Agent
干什么:把模型、系统提示、中间件、checkpointer 拼成一个 Agent。
from langchain.agents import create_agentfrom langgraph.checkpoint.memory import InMemorySaver
agent = create_agent( model, system_prompt=( "你是一个 SQL 查询助手,帮用户针对业务数据库写查询。" ), middleware=[SkillMiddleware()], # 关键:挂上技能中间件 checkpointer=InMemorySaver(), # 记住对话,多轮才有上下文)checkpointer 的作用:按 thread_id 存消息历史,下一轮接着聊;它也是后面自定义状态(state)能存下来的前提。
§5 验证渐进式披露真的发生了
干什么:问一个”必须懂销售库才答得对”的问题,然后看消息序列。
from langchain_core.utils.uuid import uuid7
config = {"configurable": {"thread_id": str(uuid7())}}result = agent.invoke( {"messages": [{"role": "user", "content": ( "写一条 SQL,找出上个月下单金额超过 $1000 的所有客户" )}]}, config,)
for message in result["messages"]: message.pretty_print() # 逐条打印:Human / AI(工具调用) / Tool / AI(答案)期望看到的关键片段(原文输出,已截断):
================= Ai Message =================Tool Calls: load_skill (call_abc123) Args: skill_name: sales_analytics================= Tool Message =================Loaded skill: sales_analytics...(技能全文:表结构 + 业务规则)...================= Ai Message =================SELECT DISTINCT c.customer_id, c.name, c.email, c.customer_tierFROM customers c JOIN orders o ON c.customer_id = o.customer_idWHERE o.total_amount > 1000 AND o.status = 'completed' -- 出自技能里的业务规则 AND o.order_date >= CURRENT_DATE - INTERVAL '1 month'怎么判断成功了:模型先发 load_skill 工具调用 → 拿到 ToolMessage → 才写 SQL;而且 SQL 里出现了”只算 completed""金额 > 1000”这类它原本不可能知道的口径——说明手册真被读进去了。
§6 进阶:用自定义状态(state)加硬约束
要解决什么:第 5 步全靠模型自觉。万一它不加载技能就直接写 SQL 呢?那就用 state 把”已经加载过哪些技能”记下来,再在别的工具里硬拦。
from typing import NotRequiredfrom langchain.agents.middleware import AgentState
class CustomState(AgentState): skills_loaded: NotRequired[list[str]] # 记录已加载的技能名load_skill 改成返回 Command,一次同时更新消息和状态:
from langgraph.types import Commandfrom langchain.tools import tool, ToolRuntimefrom langchain.messages import ToolMessage
@tooldef load_skill(skill_name: str, runtime: ToolRuntime) -> Command: """把某个技能的完整内容加载进 agent 的上下文。""" for skill in SKILLS: if skill["name"] == skill_name: return Command(update={ "messages": [ToolMessage( content=f"Loaded skill: {skill_name}\n\n{skill['content']}", tool_call_id=runtime.tool_call_id, )], "skills_loaded": [skill_name], # 记一笔账 }) # 没找到同样要回一条 ToolMessage,否则工具调用没有结果 return Command(update={"messages": [ToolMessage( content=f"Skill '{skill_name}' not found.", tool_call_id=runtime.tool_call_id)]})再写一个”只有加载过技能才准用”的工具:
@tooldef write_sql_query(query: str, vertical: str, runtime: ToolRuntime) -> str: """为某个业务线写入并校验 SQL 查询。必须先加载对应技能。""" skills_loaded = runtime.state.get("skills_loaded", []) if vertical not in skills_loaded: return ( f"Error: 你必须先加载 '{vertical}' 技能," f"才能了解表结构再写查询。请调用 load_skill('{vertical}')。" ) return f"SQL Query for {vertical}:\n\n```sql\n{query}\n```\n\n已通过校验"最后把中间件指向自定义状态、把新工具挂上(system_prompt、checkpointer 都不用动):
class SkillMiddleware(AgentMiddleware[CustomState]): state_schema = CustomState # 声明扩展后的状态 tools = [load_skill, write_sql_query] # 暴露两个工具 # ...其余实现不变效果:模型抢跑时会收到一条错误,提示它先去加载技能——用”错误信息”当方向盘,把它掰回来。
⚠️ 原文如此:
write_sql_query号称 “Query validated against … schema”,但它其实只检查了技能有没有加载,对 SQL 本身没做任何校验,返回的那句”已通过校验”只是拼出来的字符串。别被这行输出骗了。
如果约束是”只读 + 限定表”怎么办(以下为笔记补充,非原文内容):思路一样,把校验写死在工具里,不靠提示词。
import reALLOWED_TABLES = {"customers", "orders", "order_items"} # 表白名单
def guard(query: str) -> str | None: """返回错误文案 = 拦截;返回 None = 放行。""" if re.search(r"\b(insert|update|delete|drop|alter|truncate)\b", query, re.I): return "Error: 只允许只读查询(SELECT)。" used = set(re.findall(r"\b(?:from|join)\s+([A-Za-z_]\w*)", query, re.I)) if used - ALLOWED_TABLES: return f"Error: 不允许访问这些表:{sorted(used - ALLOWED_TABLES)}" return None一句话:提示词负责”引导”,state + 工具校验负责”兜底”。真正不能越界的事(写库、删表、跨业务线读数据)永远要放在代码里拦。
实现变体:存哪、怎么发现、怎么加载(原文 Accordion)
本教程用”内存里的 Python 字典 + 工具直接查表”,但每个环节都能换:
| 环节 | 可选做法 |
|---|---|
| 存储 | 内存(本教程,最快);文件系统(Claude Code 的路子,技能 = 目录 + 文件,用 read_file 读);远端(S3 / 数据库 / Notion / 内部 API) |
| 发现(模型怎么知道有哪些技能) | 系统提示里列目录(本教程);扫目录;查技能注册中心;用一个工具动态列出来 |
| 加载策略 | 一次全量加载(本教程);分页加载;技能内部再检索(grep 相关章节);分层下钻(先概览再细节) |
技能多大才值得”按需加载”(原文自称是”未校准的心智模型,请按你的系统实测调”):
- 小技能(低于 1K 词元,约 750 个英文词):直接放系统提示,还能吃 prompt caching,又快又省。
- 中等技能(1K–10K 词元):本教程的甜区,按需加载最划算。
- 大技能(超过 10K 词元,或超过上下文窗口的 5%–10%):必须上分页 / 检索 / 分层,否则一次加载就吃掉一大块窗口。
代价也很直白:内存最快,但改技能要重新部署;文件或远端存储能热更新,但多一次 I/O 和出错面。
渐进式披露与上下文工程(原文 Accordion)
渐进式披露本质上是**上下文工程(context engineering)**的一个技巧:管理”什么时候、让 Agent 看到什么”。
还能再进一步:按需加载 few-shot 示例。
- 用户问:“找出 6 个月没下单的客户”
- Agent 先加载
sales_analytics技能(表结构到手) - 再按语义或标签捞 2~3 条相关示例 SQL(找流失客户、按日期过滤、customers join orders)
- Agent 拿着”表结构 + 可照抄的范式”写查询
“按需加载表结构 + 动态挑示例”这套组合,就是能撑住大规模知识库、又让输出接地气的上下文工程套路。
关键机制专讲
① 技能到底是什么?——提示词,不是代码
技能(skill)是一段自成体系的专业说明:讲清楚这个领域有哪些表、字段什么含义、业务口径怎么算、最好照着什么格式写。它主要靠提示词影响行为,也可以顺带说明”该用哪个工具""示例代码长什么样”。
所以:技能不是插件、不是函数、不是新的执行单元。它就是”写给模型看的内部文档”。原文说得很直白:技能的价值在于把团队里的隐性知识(口径、坑、惯例)固化成模型能读的文本。
② load_skill + 中间件:内容是怎么进上下文的?
两个角色,分工明确:
- 中间件(
SkillMiddleware)负责”目录”:在wrap_model_call里把技能名 + 简介追加到系统提示的内容块(content_blocks),再用request.override(system_message=...)把改过的请求交下去。注意是追加,不是覆盖原来的系统提示。 - 工具(
load_skill)负责”正文”:模型点名要哪本书,工具把全文当返回值交出去,框架会把它包成一条ToolMessage追加进消息列表——从这一轮起,正文就一直躺在对话历史里了。
一个容易忽略的点:中间件里的 tools = [load_skill] 是类变量,靠它把工具注册给 Agent;进阶版还要补一句 state_schema = CustomState。
③ 渐进式披露为什么省 token?代价是什么?
省钱的逻辑就是”不用的不付钱”:常驻的只有目录(几十到几百 token),正文只在命中时才进上下文。
代价有三个,都得认:
- 多一轮模型调用:先决定加载、再写答案,首次命中某个技能时延迟明显变高(原文明确列了这条 trade-off)。
- 可能加载错、或加载了不用:模型选错技能就白花 token;而且一旦加载,正文会一直留在历史里,不会自动卸载。
- 流程控制弱:基础版只能靠提示词引导,没法硬性要求”必须先 A 后 B”——要硬约束就得像 §6 那样自己写 state 和校验逻辑。
④ 用 state 加业务约束:怎么落到代码里?
三件套:
- 扩状态:
class CustomState(AgentState)加一个skills_loaded字段; - 记状态:
load_skill返回Command(update={"messages": [...], "skills_loaded": [...]}),消息和状态一起更新; - 查状态:别的工具里读
runtime.state.get("skills_loaded", []),不满足就直接返回错误文案。
要点:约束要写成”模型读得懂的错误信息”,而不是抛异常中断。告诉它”你没加载技能,请先调 load_skill('sales_analytics')”,模型下一轮就会自己去补——这比硬报错友好得多。至于只读、表白名单这类安全边界,就用代码拦(见 §6 的 guard)。
⑤ 为什么这里不用多开 Agent?(与 Subagents 的区别)
| 技能 Skills | 子 Agent Subagents | |
|---|---|---|
| 上下文 | 共享同一条对话历史 | 每个子 Agent 独立上下文,只把结论回传给主 Agent |
| 切换成本 | 一次工具调用,线程不变 | 启动一个新 Agent,消息要序列化/转发 |
| 适合 | 知识、规则类的”查手册” | 高噪声、可隔离、需要独立工具集的子任务 |
| 调试 | 一条线程看到底 | 跨 Agent 追踪,链路更长 |
判断标准很简单:你要的是”多一点知识”,还是”多一个干活的人”? 写 SQL 缺的是口径和表结构——这是知识问题,读本书就够了,没必要为它开一个 Agent、多维护一套上下文。原文也把”单 Agent、单对话线程,历史更简单”列为渐进式披露的收益之一。
完整代码在哪
- 原文(含完整可运行脚本):https://docs.langchain.com/oss/python/langchain/multi-agent/skills-sql-assistant
- 完整示例见原文 Complete example:那个折叠块里是把技能定义、
load_skill、SkillMiddleware、create_agent、示例调用拼在一起的整段脚本。运行前按原文提示装langchain langchain-openai langgraph、配好 API Key,并把模型初始化换成你用的供应商。 - 本文代码块是骨架节选(长函数做了删减,标了
# ...(略)),目的是让你看清结构,不保证逐字复制就能跑;跑通以原文完整脚本为准。
小结
- 技能 = 说明书,不是代码。
name + description常驻系统提示当目录,content全文按需加载。 load_skill负责取正文,中间件负责贴目录;正文以ToolMessage形式进入对话历史,中间件用wrap_model_call追加系统提示。- 渐进式披露就是”不用的不付钱”:省 token、让团队各写各的技能、还能架住上百个技能;代价是多一轮模型调用,且加载后不会自动卸载。
- 想加硬约束就上 state:
skills_loaded记账 + 工具里校验;安全边界(只读、限定表)永远写在代码里,别写在提示词里。 - 这类问题是”缺知识”,不是”缺人手”——所以单 Agent + 技能就够了,不必上子 Agent。
一句话选型
该用 Skills:知识、规则、口径类内容量大又不常全用;多团队各自维护自己的领域文档;技能会持续增加、需要热更新;你要的是”同一张嘴,知道得更多”。
别用 Skills:技能很小(低于 1K 词元)——直接塞系统提示 + prompt caching 更简单;任务要的是不同的工具集或完全隔离的上下文——那该用子 Agent;需要严格顺序或人工审批的流程——提示词管不住,得靠图的显式节点或 state 校验;内容其实是确定性映射(查表就能算出来)——那就别叫模型,直接写 Python。