Lab:Skill 抽象实现与任务封装

通过将一个多步 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 小时

一. 实验目标

  1. 识别 Skill 核心要素:亲手构建对外契约、内部工具箱、执行逻辑、输出规范与错误处理边界。
  2. 体验决策权内移:通过对比同一任务在 Tool Calling 和 Skill 两种模式下的执行过程,解释"决策权在哪里闭合"如何改变模型行为。
  3. 理解接口语义升维:观察 Skill 如何将"模型需要反复思考选择哪个工具"转变为"模型只需调用一个任务接口"。
  4. 掌握流程知识封装:解释 Skill 为何是封装了流程知识的可复用单元,而非多个 Tool 的简单组合。

二. 前置知识与环境准备

  • 前置依赖 :你已完成了 Tool Calling Lab,拥有可运行的 src/toolbox.pysrc/llm_client.pysrc/agent_loop.py。本实验将复用并扩展它们。

  • API 准备 :使用上一轮已配置的 DASHSCOPE_API_KEY

  • 依赖安装

    bash 复制代码
    pip 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.Skillrun() 方法完全在本地执行,不依赖模型

2.内部使用的 _internal_toolbox 对外界(包括模型)完全不可见

3.允许使用 Python 标准库和 openai SDK。

四. 核心设计指引

1. 基准场景:一个需要多步决策的任务

我们选定一个具体的任务贯穿整个实验:"帮我查一下张三的工号,然后查一下他的主管是谁。" 在纯 Tool Calling 模式下,模型需要看到两个独立的工具(get_employee_idget_manager),自己决策先调用哪个、如何传递中间结果,经历至少两轮 Think→Act→Observe。这就是我们随后要封装的起点。

2. Skill 封装:将流程收入黑盒

你将创建一个 EmployeeInfoSkill,其内部结构为:

  • 对外契约 :名称 get_employee_info,描述"根据员工姓名,返回其工号和主管姓名"。
  • 内部工具箱 :注册 get_employee_idget_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_idget_manager 两个工具时,它的思考空间与面对一个 get_employee_info 时有什么不同?这对模型犯错概率和响应效率有什么影响?

3. Skill 的边界设计

在你的设计中,Skill 内部如果出现"工号查不到"这类业务异常,你是选择在 Skill 内部消化并返回明确信息,还是让模型去处理这个异常?两种选择的权衡是什么?这反映了 Skill 的什么设计原则?

4. Skill 与 Tool Calling 的关系

Skill 自身也是一种 Tool(通过 to_tool_definition() 暴露),但它和普通的 Tool 有什么区别?为什么说 Skill 是 Tool Calling 体系上的"递归抽象"?

资源附录