跳到正文

TECH NOTE

5298 字 26 分钟 系列 11 / 22

11 实战:用 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 写法
OpenAIpip install -U "langchain[openai]"init_chat_model("gpt-5.5")
Anthropiclangchain[anthropic]init_chat_model("claude-sonnet-4-6")
Azurelangchain[openai]init_chat_model("azure_openai:gpt-5.5", azure_deployment=...)
Google Geminilangchain[google-genai]init_chat_model("google_genai:gemini-3.7-flash")
AWS Bedrocklangchain[aws]init_chat_model("us.anthropic.claude-sonnet-4-6", model_provider="bedrock_converse")
HuggingFacelangchain[huggingface]init_chat_model("microsoft/Phi-3-mini-4k-instruct", model_provider="huggingface")
OpenRouterlangchain-openrouterinit_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 为例:

## 表结构 Tables
customers: customer_id / name / email / signup_date / status / customer_tier
orders: order_id / customer_id / order_date / status / total_amount / sales_region
order_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
@tool
def 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 Callable
from langchain.agents.middleware import ModelRequest, ModelResponse, AgentMiddleware
from 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_agent
from 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_tier
FROM customers c JOIN orders o ON c.customer_id = o.customer_id
WHERE 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 NotRequired
from langchain.agents.middleware import AgentState
class CustomState(AgentState):
skills_loaded: NotRequired[list[str]] # 记录已加载的技能名

load_skill 改成返回 Command,一次同时更新消息和状态:

from langgraph.types import Command
from langchain.tools import tool, ToolRuntime
from langchain.messages import ToolMessage
@tool
def 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)]})

再写一个”只有加载过技能才准用”的工具:

@tool
def 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 re
ALLOWED_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 示例。

  1. 用户问:“找出 6 个月没下单的客户”
  2. Agent 先加载 sales_analytics 技能(表结构到手)
  3. 再按语义或标签捞 2~3 条相关示例 SQL(找流失客户、按日期过滤、customers join orders)
  4. 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 加业务约束:怎么落到代码里?#

三件套:

  1. 扩状态:class CustomState(AgentState) 加一个 skills_loaded 字段;
  2. 记状态:load_skill 返回 Command(update={"messages": [...], "skills_loaded": [...]}),消息和状态一起更新;
  3. 查状态:别的工具里读 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,并把模型初始化换成你用的供应商。
  • 本文代码块是骨架节选(长函数做了删减,标了 # ...(略)),目的是让你看清结构,不保证逐字复制就能跑;跑通以原文完整脚本为准。

小结#

  1. 技能 = 说明书,不是代码。name + description 常驻系统提示当目录,content 全文按需加载。
  2. load_skill 负责取正文,中间件负责贴目录;正文以 ToolMessage 形式进入对话历史,中间件用 wrap_model_call 追加系统提示。
  3. 渐进式披露就是”不用的不付钱”:省 token、让团队各写各的技能、还能架住上百个技能;代价是多一轮模型调用,且加载后不会自动卸载。
  4. 想加硬约束就上 state:skills_loaded 记账 + 工具里校验;安全边界(只读、限定表)永远写在代码里,别写在提示词里。
  5. 这类问题是”缺知识”,不是”缺人手”——所以单 Agent + 技能就够了,不必上子 Agent。

一句话选型#

该用 Skills:知识、规则、口径类内容量大又不常全用;多团队各自维护自己的领域文档;技能会持续增加、需要热更新;你要的是”同一张嘴,知道得更多”。

别用 Skills:技能很小(低于 1K 词元)——直接塞系统提示 + prompt caching 更简单;任务要的是不同的工具集或完全隔离的上下文——那该用子 Agent;需要严格顺序或人工审批的流程——提示词管不住,得靠图的显式节点或 state 校验;内容其实是确定性映射(查表就能算出来)——那就别叫模型,直接写 Python。

11 实战:用 Skills 搭一个 SQL 助手(按需加载「查表手册」)
https://aicooleo.cn/blog/posts/multiagent-11-case-skills/
作者
aicooleo
发布于
2026-08-29
许可协议
CC BY-NC-SA 4.0
多 Agent 精讲 · 第 11 / 22 篇 返回合集