通过将一个多步 Tool Calling 流程封装为 Skill------一个内部闭环、对外只暴露任务的自主执行单元------来理解 Skill 区别于 Tool Calling 的本质特征:决策权内移和接口语义升维。
| 阶段 | 预估耗时 | 建议 |
|---|---|---|
| 环境确认 | 5 min | 确认上一轮 Tool Calling 代码可运行,API Key 有效 |
实现 skill.py |
35 min | 重点理解 run() 如何将 _internal_toolbox 传给 executor |
| 构建测试 1 实例 | 15 min | 注册内部工具、编写 executor,验证独立运行 |
| 调试测试 1-3 | 30 min | 核心阶段:打通独立执行、Tool Calling 多步、Skill 单次调用 |
| 调试测试 4-6 | 25 min | 验证内部错误处理、决策权对比与可复用性 |
| 撰写实验报告 | 30 min | 结合运行现象回答分析题 |
| 总计 | 约 2.5 小时 |
一. 实验目标
- 识别 Skill 核心要素:亲手构建对外契约、内部工具箱、执行逻辑、输出规范与错误处理边界。
- 体验决策权内移:通过对比同一任务在 Tool Calling 和 Skill 两种模式下的执行过程,解释"决策权在哪里闭合"如何改变模型行为。
- 理解接口语义升维:观察 Skill 如何将"模型需要反复思考选择哪个工具"转变为"模型只需调用一个任务接口"。
- 掌握流程知识封装:解释 Skill 为何是封装了流程知识的可复用单元,而非多个 Tool 的简单组合。
二. 前置知识与环境准备
-
前置依赖 :你已完成了 Tool Calling Lab,拥有可运行的
src/toolbox.py、src/llm_client.py、src/agent_loop.py。本实验将复用并扩展它们。 -
API 准备 :使用上一轮已配置的
DASHSCOPE_API_KEY。 -
依赖安装:
bashpip install openai pytest
三. 项目脚手架
text
skill_lab/
├── src/
│ ├── toolbox.py # 复用上一轮,新增内部工具
│ ├── llm_client.py # 复用,无需修改
│ ├── agent_loop.py # 复用,无需修改
│ └── skill.py # ★本实验核心:Skill 类
├── tests/
│ └── test_skill.py # 全部 6 个测试(已为你准备好)
└── requirements.txt
你必须实现的接口(严禁修改签名):
python
from src.toolbox import Toolbox
class Skill:
"""
自主执行单元:封装完成一类任务所需的完整闭环。
模型看到的只是一个高层的"任务接口",
内部的工具调用、流程决策、异常处理对模型透明。
"""
def __init__(self, name: str, description: str, parameters: dict):
"""
name: 模型看到的 Skill 名称,如 "send_report"
description: 模型看到的任务描述
parameters: 模型需要提供的输入参数 JSON Schema
"""
self.name = name
self.description = description
self.parameters = parameters
# 内部工具箱:对模型不可见
self._internal_toolbox = Toolbox()
# 执行逻辑:一个 callable,接收参数字典,返回结果字符串
self._executor = None
def set_executor(self, executor: callable):
"""
设置 Skill 的自主执行逻辑。
executor 签名: def execute(params: dict, toolbox: Toolbox) -> str
"""
self._executor = executor
def run(self, params: dict) -> str:
"""执行 Skill,返回最终结果字符串。"""
if not self._executor:
return "错误:Skill 未配置执行逻辑"
# 将内部工具箱传给执行器,让它能调用内部工具
return self._executor(params, self._internal_toolbox)
def to_tool_definition(self) -> dict:
"""
将 Skill 包装为一个对模型可见的 Tool 定义。
模型只能看到 name、description、parameters,完全不知道内部结构。
"""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters
}
}
!请遵循以下约束:
1.
Skill的run()方法完全在本地执行,不依赖模型 。2.内部使用的
_internal_toolbox对外界(包括模型)完全不可见 。3.允许使用 Python 标准库和
openaiSDK。
四. 核心设计指引
1. 基准场景:一个需要多步决策的任务
我们选定一个具体的任务贯穿整个实验:"帮我查一下张三的工号,然后查一下他的主管是谁。" 在纯 Tool Calling 模式下,模型需要看到两个独立的工具(get_employee_id、get_manager),自己决策先调用哪个、如何传递中间结果,经历至少两轮 Think→Act→Observe。这就是我们随后要封装的起点。
2. Skill 封装:将流程收入黑盒
你将创建一个 EmployeeInfoSkill,其内部结构为:
- 对外契约 :名称
get_employee_info,描述"根据员工姓名,返回其工号和主管姓名"。 - 内部工具箱 :注册
get_employee_id和get_manager(对模型不可见)。 - 执行逻辑 :一个函数,接收
params,在内部调用_internal_toolbox,先拿工号,再查主管,最后拼接结果。 - 关键:Skill 执行时,模型完全不知道内部调用了两次工具。Skill 的运行不经过模型的循环,而是由你写的执行逻辑直接完成。
3. 决策权转移的可视化
通过在测试中捕获调用次数,你可以看到:
- Tool Calling 模式:模型发起了两次工具调用,一轮接一轮。
- Skill 模式 :模型只看到一次工具调用(调用 Skill),Skill 内部自行完成了两次子工具调用。 思考:谁在编排流程?前者是模型,后者是 Skill 的执行逻辑。
4. 常见阻碍
- executor 逻辑写错导致测试 1 不过 :executor 本质是线性调用,出错通常是参数传递问题(如把
name传给了city),请仔细核对字典键名。 - 模型在测试 3 中不调用 Skill :Skill 的
description必须清晰明确;如果模型犹豫,可适当在AgentLoop的 System Prompt 中加强引导。 - 测试 5 需要侵入 AgentLoop 加计数器 :无需修改
AgentLoop源码,在测试中使用 Monkey-patch(动态替换toolbox.execute方法)即可无侵入地记录调用次数。 - 模型 API 随机性:大模型输出具有随机性,若偶发未调用工具,重新运行即可。理解趋势比单次通过更重要。
五. 测试用例
请将以下代码完整放入 tests/test_skill.py。测试将直接调用真实的千问 API,请确保环境变量已配置。
测试 1:Skill 内部的自主执行(脱离模型)
目标:验证 Skill 作为一个独立的执行单元,不依赖任何 LLM 就能完成内部多步调用。
通过标准:
- 直接调用
skill.run({"name": "张三"}),不经过模型。 - 返回结果中必须同时包含工号
"E042"和主管"李四"。
python
import pytest
from src.toolbox import Toolbox
from src.skill import Skill
def test_skill_standalone_execution():
# 模拟数据库
db = {"张三": "E042", "E042": "李四"}
def get_employee_id(name: str) -> str:
return db.get(name, "未找到")
def get_manager(emp_id: str) -> str:
manager = db.get(emp_id, "未知")
return f"主管是{manager}"
# 创建 Skill
skill = Skill(
name="get_employee_info",
description="根据员工姓名,返回其工号和主管姓名",
parameters={
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"]
}
)
# 注册内部工具
skill._internal_toolbox.register(
"get_employee_id", "根据姓名返回工号",
{"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},
get_employee_id
)
skill._internal_toolbox.register(
"get_manager", "根据工号返回主管",
{"type": "object", "properties": {"emp_id": {"type": "string"}}, "required": ["emp_id"]},
get_manager
)
# 编写执行逻辑
def executor(params, toolbox):
name = params["name"]
emp_id = toolbox.execute("get_employee_id", {"name": name})
if "未找到" in emp_id:
return f"未找到员工 {name}"
manager_info = toolbox.execute("get_manager", {"emp_id": emp_id})
return f"{name}的工号是{emp_id},{manager_info}"
skill.set_executor(executor)
# 直接执行,不经过模型
result = skill.run({"name": "张三"})
assert "E042" in result and "李四" in result, f"Skill 执行结果不正确: {result}"
测试 2:Tool Calling 模式下的多步决策(基准对比)
目标:记录同一个任务在纯 Tool Calling 下的表现,为对比提供基线。
通过标准:
- Agent 通过多轮工具调用(先工号后主管)完成任务。
- 最终回答中必须同时包含
"E042"和"李四"。
python
from src.llm_client import LLMClient
from src.agent_loop import AgentLoop
def test_tool_calling_multi_step():
db = {"张三": "E042", "E042": "李四"}
def get_employee_id(name: str) -> str:
return db.get(name, "未找到")
def get_manager(emp_id: str) -> str:
manager = db.get(emp_id, "未知")
return f"主管是{manager}"
toolbox = Toolbox()
toolbox.register("get_employee_id", "根据姓名返回工号",
{"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},
get_employee_id)
toolbox.register("get_manager", "根据工号返回主管",
{"type": "object", "properties": {"emp_id": {"type": "string"}}, "required": ["emp_id"]},
get_manager)
llm = LLMClient()
agent = AgentLoop(llm, toolbox, max_rounds=5)
answer = agent.run("张三的工号是什么?他的主管是谁?")
assert "E042" in answer and "李四" in answer, f"Tool Calling 结果不正确: {answer}"
测试 3:Skill 模式------模型只看到任务,而非步骤
目标:展示 Skill 对模型的接口升维效果,模型只需单次调用即可完成任务。
通过标准:
- 全局工具箱只包含一个 Skill(作为 Tool)。
- Agent 运行后,最终回答中必须同时包含
"E042"和"李四"。
python
def test_skill_mode_single_call():
db = {"张三": "E042", "E042": "李四"}
def get_employee_id(name: str) -> str: return db.get(name, "未找到")
def get_manager(emp_id: str) -> str: return f"主管是{db.get(emp_id, '未知')}"
# 构建 Skill (复用测试1的逻辑)
skill = Skill("get_employee_info", "根据员工姓名,返回其工号和主管姓名",
{"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]})
skill._internal_toolbox.register("get_employee_id", "查工号", {"type": "object", "properties": {"name": {"type": "string"}}}, get_employee_id)
skill._internal_toolbox.register("get_manager", "查主管", {"type": "object", "properties": {"emp_id": {"type": "string"}}}, get_manager)
def executor(params, toolbox):
emp_id = toolbox.execute("get_employee_id", {"name": params["name"]})
manager = toolbox.execute("get_manager", {"emp_id": emp_id})
return f"{params['name']}的工号是{emp_id},{manager}"
skill.set_executor(executor)
# 将 Skill 包装为外部工具箱中的唯一工具
global_toolbox = Toolbox()
global_toolbox.register(
skill.name, skill.description, skill.parameters,
lambda **kwargs: skill.run(kwargs)
)
llm = LLMClient()
agent = AgentLoop(llm, global_toolbox, max_rounds=3)
answer = agent.run("帮我查一下张三的工号和他的主管是谁?")
assert "E042" in answer and "李四" in answer, f"Skill 模式结果不正确: {answer}"
测试 4:Skill 内部错误处理------模型感知不到
目标:验证 Skill 内部消化错误、对外只返回最终结果的能力。
通过标准:
- 内部工具第一次返回错误,执行逻辑自动重试并成功。
skill.run返回的结果中包含"E042"和"李四"。- 内部工具的实际调用次数为 2 次(证明发生了重试)。
python
def test_skill_internal_error_handling():
call_counter = 0
def unreliable_emp_id(name):
nonlocal call_counter
call_counter += 1
if call_counter == 1:
return "错误:服务超时,请重试"
return "E042"
def get_manager(emp_id):
return "主管是李四"
skill = Skill("get_info", "获取员工信息", {"type": "object", "properties": {"name": {"type": "string"}}})
skill._internal_toolbox.register("get_employee_id", "查工号", {"type": "object", "properties": {"name": {"type": "string"}}}, unreliable_emp_id)
skill._internal_toolbox.register("get_manager", "查主管", {"type": "object", "properties": {"emp_id": {"type": "string"}}}, get_manager)
def executor(params, toolbox):
name = params["name"]
for attempt in range(3):
emp_id = toolbox.execute("get_employee_id", {"name": name})
if "超时" not in emp_id:
break
manager = toolbox.execute("get_manager", {"emp_id": emp_id})
return f"{name}工号{emp_id},{manager}"
skill.set_executor(executor)
result = skill.run({"name": "张三"})
assert "E042" in result and "李四" in result
assert call_counter == 2, f"内部应重试一次,实际调用了 {call_counter} 次"
测试 5:决策权对比------谁在思考下一步?
目标:通过捕获调用次数,定量对比两种模式下"模型决策次数"与"工具执行次数"。
通过标准:
- Tool Calling 模式下,全局
Toolbox.execute被调用 至少 2 次。 - Skill 模式下,全局
Toolbox.execute仅被调用 1 次(即调用 Skill 本身)。
python
def test_decision_locus_comparison():
db = {"张三": "E042", "E042": "李四"}
def get_employee_id(name: str) -> str: return db.get(name, "未找到")
def get_manager(emp_id: str) -> str: return f"主管是{db.get(emp_id, '未知')}"
# --- 场景 A: Tool Calling ---
toolbox_a = Toolbox()
toolbox_a.register("get_employee_id", "查工号", {"type": "object", "properties": {"name": {"type": "string"}}}, get_employee_id)
toolbox_a.register("get_manager", "查主管", {"type": "object", "properties": {"emp_id": {"type": "string"}}}, get_manager)
# Monkey-patch 记录调用次数
original_execute_a = toolbox_a.execute
calls_a = []
def tracked_execute_a(name, args):
calls_a.append(name)
return original_execute_a(name, args)
toolbox_a.execute = tracked_execute_a
agent_a = AgentLoop(LLMClient(), toolbox_a, max_rounds=5)
agent_a.run("查张三工号和主管")
# --- 场景 B: Skill ---
skill = Skill("get_info", "查员工信息", {"type": "object", "properties": {"name": {"type": "string"}}})
skill._internal_toolbox.register("get_employee_id", "查工号", {"type": "object", "properties": {"name": {"type": "string"}}}, get_employee_id)
skill._internal_toolbox.register("get_manager", "查主管", {"type": "object", "properties": {"emp_id": {"type": "string"}}}, get_manager)
skill.set_executor(lambda p, t: f"{p['name']}工号{t.execute('get_employee_id', {'name': p['name']})},{t.execute('get_manager', {'emp_id': t.execute('get_employee_id', {'name': p['name']})})}")
toolbox_b = Toolbox()
toolbox_b.register(skill.name, skill.description, skill.parameters, lambda **kw: skill.run(kw))
original_execute_b = toolbox_b.execute
calls_b = []
def tracked_execute_b(name, args):
calls_b.append(name)
return original_execute_b(name, args)
toolbox_b.execute = tracked_execute_b
agent_b = AgentLoop(LLMClient(), toolbox_b, max_rounds=3)
agent_b.run("查张三工号和主管")
assert len(calls_a) >= 2, f"Tool Calling 应调用至少2次工具,实际: {calls_a}"
assert len(calls_b) == 1, f"Skill 模式全局只应调用1次工具,实际: {calls_b}"
测试 6:可复用性验证------同一 Skill 处理不同输入
目标:证明 Skill 封装的是"解决一类问题的能力",可反复调用。
通过标准:
- 用
"张三"调用 Skill,结果包含"E042"。 - 用
"王五"调用 Skill,结果包含"赵六"。
python
def test_skill_reusability():
db = {"张三": "E042", "E042": "李四", "王五": "E099", "E099": "赵六"}
def get_employee_id(name: str) -> str: return db.get(name, "未找到")
def get_manager(emp_id: str) -> str: return f"主管是{db.get(emp_id, '未知')}"
skill = Skill("get_info", "查员工信息", {"type": "object", "properties": {"name": {"type": "string"}}})
skill._internal_toolbox.register("get_employee_id", "查工号", {"type": "object", "properties": {"name": {"type": "string"}}}, get_employee_id)
skill._internal_toolbox.register("get_manager", "查主管", {"type": "object", "properties": {"emp_id": {"type": "string"}}}, get_manager)
def executor(params, toolbox):
emp_id = toolbox.execute("get_employee_id", {"name": params["name"]})
manager = toolbox.execute("get_manager", {"emp_id": emp_id})
return f"{params['name']}工号{emp_id},{manager}"
skill.set_executor(executor)
assert "E042" in skill.run({"name": "张三"})
assert "赵六" in skill.run({"name": "王五"})
六. 思考检验
1. 决策权内移的本质
Skill 的 executor 函数里的流程逻辑,如果用 Tool Calling 模式实现,那一部分逻辑原本由谁承担?这种转移带来了什么好处?
2. 接口语义升维的体现
当模型面对 get_employee_id 和 get_manager 两个工具时,它的思考空间与面对一个 get_employee_info 时有什么不同?这对模型犯错概率和响应效率有什么影响?
3. Skill 的边界设计
在你的设计中,Skill 内部如果出现"工号查不到"这类业务异常,你是选择在 Skill 内部消化并返回明确信息,还是让模型去处理这个异常?两种选择的权衡是什么?这反映了 Skill 的什么设计原则?
4. Skill 与 Tool Calling 的关系
Skill 自身也是一种 Tool(通过 to_tool_definition() 暴露),但它和普通的 Tool 有什么区别?为什么说 Skill 是 Tool Calling 体系上的"递归抽象"?
资源附录
- 实现源码:agent-grok-labs
- 实验文档:密码:84k2