ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

大模型Agent技能体系:定义、调用与编排实战

大模型Agent技能体系:定义、调用与编排实战

把大模型从“能聊天”变成“能干活”,中间最关键的一步是技能体系。我自己内部维护的一套工具包,名字就叫 agent-skills,核心是解决三件事:给 Agent 定义技能、把这些技能安全地暴露给模型调用、再让多个技能编排成一条完整的工作流。这篇文章把这三件事展开讲,包含我踩过的坑、沉淀下来的字段规范,以及一套可以直接拿走改的 Python 代码。适合刚接触 Agent 开发的入门者,也适合那些已经写了不少工具函数、却在调参和编排上经常翻车的同学,看完应该能省下几周自己摸索的时间。

1. 先搞清楚:agent-skills 到底在解决什么问题

1.1 光靠提示词,撑不起稳定的 Agent 行为

很多人第一步尝试是“把工具说明写进提示词”。这个思路确实能跑通一两个 Demo,但放到真实业务场景里马上就会露馅。模型可能在第三轮对话时忘了该用哪个输出格式;可能把技能名称写错;可能明明需要它在本地目录里搜索文件,它却自己编造了一个路径返回给你。这些现象不是模型智商不够,而是“能力边界”没有划清楚。

提示词擅长让模型理解意图,但不擅长让模型可靠地触发一个外部动作。技能体系的核心,就是把“判断”和“执行”分开:模型只负责判断现在应该调用哪个技能,实际执行交给确定性的代码。这样推理出来的动作是可复现的,返回的结果是稳定的。agent-skills 这套工具包听起来像一个框架,但本质上它做的事情,是给 Agent 装上一套“操作说明书 + 执行手柄”。

在这个体系里,每个技能包含名称、描述、参数签名和返回结构。名称是模型引用的唯一标识;描述是给模型的“使用说明”,决定模型什么时候该选它;参数签名决定模型需要生成哪些字段;返回结构决定模型下一轮能拿到什么信息。这四者缺一不可,少了任何一个,技能就可能变成一个“死工具”。

1.2 技能不是普通函数,它需要具备三个特质

有些人会把“技能”直接理解成 Python 函数,然后一股脑塞进一个注册表里。能跑,但要小心:普通函数是给程序员用的,技能是给“模型”用的。两者的最大差异在于,函数有文档注释和 IDE 提示,技能却只能靠名字和描述来让模型理解。所以一条合格的技能,至少得具备三个特质。

第一,自描述。这条技能什么时候该用、什么时候不该用,必须写清楚在 description 里,而且描述要从模型的视角写,不是给人看的注释。第二,可测试。每一条技能都应该能在没有模型的环境中单独调用,给固定输入,看是否返回固定输出。第三,可组合。技能之间要能被编排成流水线,一条技能的返回结果可以作为另一条技能的入参,中间不能有硬编码。

我见过最典型的反例是:有人把二十个工具全部塞进提示词,每个工具的描述只有两三个词。结果在真实对话里,模型选工具的准确率不到一半。后来我们把工具数量收敛到八个,把描述重写成“什么时候用 + 输入是什么 + 输出长什么样”之后,准确率才明显上去。所以技能体系不是代码越多越好,而是每一条都要“自己会说话”。

提示:做技能体系设计时,先限制模型一次能看到的技能数量。二三十条技能堆在模型面前,选择难度会大幅上升;如果技能确实多到一只手数不完,就先加一层“粗分类”,让模型先选分类,再选具体技能。

2. 技能库怎么设计,才不会越做越乱

2.1 每条技能的基础字段,尽量统一

先把我现在固定的技能字段规范贴出来。不管是内部用的小脚本,还是给外部系统提供的接口,我都沿用同一套结构。字段统一之后,写编排引擎、调试工具和权限控制都会省很多力气。

字段名类型作用示例
namestring技能唯一标识,模型通过它选择技能file_search
descriptionstring给模型的完整说明,包含何时使用、输入输出在本地目录中按关键词搜索文件名,适用于用户查找文件场景
parametersobject参数 JSON Schema,模型按它生成参数{ "query": { "type": "string" } }
returnsstring返回结构的格式说明返回一个数组,数组元素为文件路径字符串
categorystring技能分组,用于技能路由检索 / 计算 / 文件操作
strictboolean是否要求参数严格校验true

parameters 部分我建议直接使用 JSON Schema 的子集,不要自己发明格式。模型在生成结构化输出时,对 JSON Schema 的理解已经比较稳定;自己发明一套格式虽然能跑,但后续的扩展、校验和工具集成都会变成负担。我实际使用的子集只包含 type、description、enum、default、required 这几个字段。够用,而且不容易让模型生成违反 schema 的复杂嵌套。

2.2 description 怎么写,模型才不会误解

在 agent-skills 的实践里,description 是对最终效果影响最大的字段。它不是给人看的注释,而是一段给模型读的“精炼操作手册”。怎么写这件事,我总结了三个原则。

第一,包含触发条件。直接告诉模型什么场景下应该调用,什么场景下不要调用。比如一条天气预报技能的 description 可以写成:“当用户询问天气情况时使用;输入城市名称和可选的日期;返回天气概况与温湿度。”这比只写“天气查询”可靠得多。

第二,把返回值形态亮出来。模型调用完技能之后,不会像人类一样去读源码,它只能看到返回的片段。如果你在 description 里提前写明“返回一个数组,数组每个元素包含 date 和 temperature 字段”,模型在接收结果时就能把它理解成事实,而不是自己发挥补全。

第三,明确禁用条件。如果某些场景不能调用,直接写在 description 末尾。比如一条文件删除技能,就应该写明“仅在用户明确表达删除意图时使用;对话里出现模糊表达时不要使用”。

另一个我踩过的坑:不要为同一种功能重复注册多个技能。比如同时存在 search_file_by_name 和 search_file_content,但描述写得差不多,模型经常选错。正确做法是合并成一条 search_files,用参数来区分搜索类型。

2.3 参数设计的边界,决定了下游的稳定程度

参数设计是另一个容易出问题的地方。我现在有一条铁律:参数类型的复杂度必须低于模型生成能力的舒适区。整型、字符串、布尔值是最好的选择。复杂嵌套对象能不出现就不出现,因为模型在生成多层 JSON 时,漏字段、错类型的问题会明显增加。

如果确实需要结构化数据,比如查询条件里既有时间范围又有排序方式,我的做法是把参数拍平。不要设计成{ "filter": { "start": "...", "end": "..." } },而是直接设计成{ "start": "...", "end": "..." }。原因很简单:模型生成扁平 JSON 的成功率高得多,解析和校验也简单。所有技能参数必须提供默认值,缺省情况下也能正常执行。

对于参数里的枚举值,用 enum 字段约束。模型遇到这种约束时,大概率会选择一个合法枚举值;如果没有 enum,它可能会编造一个超出预期的值。另外,参数描述里不要写“如果为空则自动设为当前时间”这种规则,给默认值的逻辑应该放在代码里,不要指望模型去实现。

2.4 技能分类的粒度,决定路由的顺畅程度

技能少的时候不需要分类,技能一多,分类粒度就变成关键问题。我试过用“功能模块”来分,比如文件、网络、数据库;也试过用“用户意图”来分,比如查询、修改、删除。实际效果来看,在 agent-skills 里更好用的是“功能模块 + 触发频度”混合分类:高频技能独立暴露,低频技能放进对应分类,由模型先选分类再选技能。

分类太过粗放也不行。比如把二十个技能都放在“工具”分类下,等于没分;把每个技能都单独建立分类,又会让路由层变得臃肿。我自己习惯是每个分类下放三到八条技能,超过八条就继续拆。这个数字不是硬性规则,但基本能让模型在选择时不会出现明显的犹豫和错误。

3. Python 实现:一套可以直接用的技能注册器

3.1 先写最基础的 Skill 类和注册器

下面这段代码是我在 agent-skills 工具包里的基础骨架。它不依赖任何外部框架,核心思路是“每个技能就是一个自带元信息的可调用对象”。这样做的好处是,技能的定义和实现可以解耦,技能库能够单独测试,实现也可以在不停机的情况下替换。

# -*- coding: utf-8 -*- from typing import Any, Callable, Dict, Optional import json class Skill: def __init__( self, name: str, description: str, parameters: Optional[Dict[str, Any]] = None, category: str = "general", strict: bool = True, ): self.name = name self.description = description self.parameters = parameters or {"type": "object", "properties": {}} self.category = category self.strict = strict self.implementation: Optional[Callable] = None def register(self, fn: Callable): """把一个可调用对象绑定成技能执行体""" self.implementation = fn return self def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]: if self.implementation is None: return {"success": False, "error": "skill implementation missing"} try: result = self.implementation(**arguments) return {"success": True, "result": result} except TypeError as e: return {"success": False, "error": f"parameter mismatch: {e}"} except Exception as e: return {"success": False, "error": str(e)} class SkillRegistry: def __init__(self): self._skills: Dict[str, Skill] = {} def add(self, skill: Skill) -> Skill: if skill.name in self._skills: raise ValueError(f"skill {skill.name} already exists") self._skills[skill.name] = skill return skill def get(self, name: str) -> Optional[Skill]: return self._skills.get(name) def list_skills(self) -> str: """把技能列表渲染成描述文本,方便放进系统提示词""" lines = [] for skill in self._skills.values(): lines.append(f"- {skill.name}: {skill.description}") return "\n".join(lines) def has_skill(self, name: str) -> bool: return name in self._skills

这个注册器的核心价值在于:定义阶段只描述技能长什么样,实现阶段再绑定具体函数。在搭 Agent 的初期,这个分离看起来像是多写了几行代码,但当技能库长到几十条时,这种结构能省下大量维护成本。

3.2 两个可以直接落地的技能例子

我挑两个最常用的技能来演示:一个本地文件搜索,一个安全的算术计算器。前者展示如何和文件系统交互,后者展示如何在不引入危险操作的情况下处理数学表达式。

# 技能一:按关键词搜索文件名 import os def search_files(path: str, keyword: str, max_results: int = 10) -> Dict[str, Any]: results = [] if not os.path.isdir(path): return {"error": f"path not found: {path}"} for root, _dirs, files in os.walk(path): for name in files: if keyword.lower() in name.lower(): results.append(os.path.join(root, name)) if len(results) >= max_results: break return {"total": len(results), "results": results[:max_results]} search_skill = Skill( name="search_files", description="在本地目录中按关键词搜索文件名,当用户需要查找文件时使用;输入目录路径和关键词,返回匹配的文件路径列表;最多返回 max_results 个结果。", parameters={ "type": "object", "properties": { "path": {"type": "string", "description": "要搜索的根目录绝对路径"}, "keyword": {"type": "string", "description": "文件名关键词"}, "max_results": {"type": "integer", "description": "最大返回条数,默认 10", "default": 10}, }, "required": ["path", "keyword"], }, category="检索", ).register(search_files)
# 技能二:安全的算术计算器,只允许四则运算 import re def safe_calculator(expression: str) -> Dict[str, Any]: expr = expression.strip() if not re.fullmatch(r"[0-9+\-*/().\s]+", expr): return {"error": "expression contains unsupported characters"} if re.search(r"(?:\*\*|//|import|exec|eval)", expr, re.IGNORECASE): return {"error": "expression rejected"} try: value = eval(expr, {"__builtins__": {}}, {}) if isinstance(value, (int, float)): return {"result": value} return {"error": "result is not a number"} except ZeroDivisionError: return {"error": "division by zero"} except Exception as e: return {"error": f"invalid expression: {e}"} calculator_skill = Skill( name="safe_calculator", description="计算四则运算表达式,当用户需要做算术计算时使用;输入一个包含数字和 + - * / 的表达式,返回计算结果。", parameters={ "type": "object", "properties": { "expression": {"type": "string", "description": "算术表达式,例如 (3+5)*2"}, }, "required": ["expression"], }, category="计算", ).register(safe_calculator)

注意,这里为了演示简洁我对 eval 做了白名单字符加黑名单词法过滤。更稳妥的方案是写一个只包含四则运算的递归解析器,大约三十行就能完成,线上环境建议用那种方式,彻底避开动态执行。

3.3 让模型输出和技能调用顺利接上

技能定义好之后,剩下的问题是:模型怎么知道要调用它?我的做法是让模型在每轮对话末尾输出一个 JSON 对象,结构固定为 name 和 arguments。然后解析这个 JSON,到注册器里找到对应技能并执行。这套约定比让模型直接打印“我要调用 xxx 工具”要可靠得多。

def parse_skill_call(raw_text: str) -> Optional[Dict[str, Any]]: text = raw_text.strip() if not text.startswith("{"): # 支持模型将 JSON 包在代码块里的情况 if text.startswith("```"): lines = text.splitlines() text = "\n".join(lines[1:-1]) try: obj = json.loads(text) except json.JSONDecodeError: return None if "name" not in obj or "arguments" not in obj: return None return obj def run_skill_call(registry: SkillRegistry, parsed: Dict[str, Any], max_retry: int = 1) -> Dict[str, Any]: skill = registry.get(parsed["name"]) if skill is None: return {"success": False, "error": f"unknown skill: {parsed['name']}"} arguments = parsed.get("arguments", {}) for attempt in range(max_retry + 1): # 对必填但缺失的字段,尝试用默认值补齐 if hasattr(skill, "parameters") and skill.parameters.get("required"): for field in skill.parameters["required"]: if field not in arguments: defaults = skill.parameters.get("properties", {}) if field in defaults and "default" in defaults[field]: arguments[field] = defaults[field]["default"] else: return {"success": False, "error": f"missing required arg: {field}"} result = skill.execute(arguments) if result["success"]: return result if attempt < max_retry: # 参数格式错误时,可以把错误信息重新喂给模型,让其重新生成参数 reparsed = parse_skill_call("请重新生成技能参数") if reparsed: arguments = reparsed["arguments"] return result

这个桥接层的职责是入参校验、默认值补齐、失败重试。不建议在这里做太重的逻辑。比如模型返回了一个不存在的技能名,最好不要直接报错退出,而是把“不存在”的提示重新喂给模型,让它换个选择。

3.4 技能单独测试的脚本,建议一开始就写好

我在开发 agent-skills 的时候,最受益的一件事是为每条技能配了独立的测试脚本。测试脚本不依赖模型,直接构造参数调用技能,断言返回结果。这样每次改动技能实现时,都能在三秒内知道有没有破坏原有行为。

def test_search_files(): import tempfile, os with tempfile.TemporaryDirectory() as tmp: for name in ["report_2024.txt", "notes.md", "data.csv"]: open(os.path.join(tmp, name), "w").close() result = search_files(tmp, "report") assert result["total"] == 1 assert "2024" in result["results"][0] def test_calculator(): assert safe_calculator("(3+5)*2")["result"] == 16 assert safe_calculator("1/0")["error"] == "division by zero" assert safe_calculator("import os")["error"] is not None

这套测试脚本的价值,在技能数量增多后特别明显。你永远不会后悔在前期花半小时给技能写测试,但大概率会后悔在改了某个公共函数后,发现三个技能悄悄坏了。

4. 技能编排:让多条技能串成一条工作流

4.1 顺序流水线:最简单也最稳的编排

当业务流程是固定步骤时,比如“先查资料再生成报告”,可以直接用顺序执行。核心是定义一个 step 列表,每一步指定技能名,并把上一步的结果传给下一步需要的数据。我封装过一个很轻量的顺序流水线:

def run_pipeline(registry: SkillRegistry, steps: list) -> Dict[str, Any]: context = {} logs = [] for step in steps: skill_name = step["skill"] arguments = {k: resolve_value(v, context) for k, v in step.get("arguments", {}).items()} skill = registry.get(skill_name) if skill is None: return {"success": False, "logs": logs, "failed_step": skill_name} result = skill.execute(arguments) logs.append({"step": skill_name, "result": result}) if not result["success"]: return {"success": False, "logs": logs, "failed_step": skill_name} context.update(result.get("result", {})) return {"success": True, "logs": logs, "context": context} def resolve_value(value, context): if isinstance(value, str) and value.startswith("$"): key = value[1:] return context.get(key, value) return value

这个流水线的核心是 context 字典。比如第一步检索出文件路径,第二步处理文件时,argument 里写$file_path就会被替换成上下文里的实际值。这种编排的优点是简单、可预测、容易调试;缺点是它不接纳分叉,第二步是否需要执行完全取决于第一步结果时,就用不了。

4.2 条件分支和状态机:应对真实流程的复杂度

真实业务里,“下一步做哪个技能”往往取决于当前条件。这时候我喜欢用最朴素的状态机思路:每个状态对应一个决策函数和一个动作技能,决策函数读上下文返回下一个状态名,动作技能负责执行实际业务。

class SimpleStateMachine: def __init__(self, registry: SkillRegistry): self._registry = registry self._states = {} self._context = {} def add_state(self, name, decision_fn, skill_name=None): self._states[name] = { "decision": decision_fn, "skill": skill_name, } def run(self, start_state: str, max_steps: int = 10) -> Dict[str, Any]: current = start_state steps = 0 logs = [] while current not in ("end", "failed") and steps < max_steps: state = self._states.get(current) if not state: return {"success": False, "error": f"unknown state {current}"} if state["skill"]: skill = self._registry.get(state["skill"]) if skill: result = skill.execute(self._context) logs.append({"state": current, "skill": state["skill"], "result": result}) if not result["success"]: return {"success": False, "logs": logs} self._context.update(result.get("result", {})) else: return {"success": False, "error": f"skill not found: {state['skill']}"} next_state = state["decision"](self._context) logs.append({"state": current, "next_state": next_state}) current = next_state steps += 1 if current == "end": return {"success": True, "logs": logs, "context": self._context} return {"success": False, "error": "max steps exceeded", "logs": logs}

状态机不是银弹,但它比一堆 if-else 好维护得多。现场复盘的时候,每一步从哪个状态来、到哪个状态去,都能从日志里复现。当流程分支较多时,这种结构能让你把复杂逻辑拆成一个个小决策函数,而不是堆在同一个函数里。

4.3 技能之间的数据传递,别把大文本塞回模型

数据传递是最容易忽视的细节。很多初学者做多步工具调用时,会把上一步技能的完整返回结果原封不动拼进下一步的提示词,再让模型继续推理。这样做有几个坏处:一是上下文长度被白白浪费;二是模型要处理大量无关内容,容易抓错重点。

正确做法是,每个技能返回结果里只留下下一步需要的核心字段。比如文件分析技能返回“文档包含 3000 个字符、标题是 xxx、包含 12 个数字”,而不是把 3000 个字符的原文全部返回。这要求在定义返回结构时,就提前想清楚下游会用到哪些字段。

数据传递尽量走上下文对象,而不是隐式塞进系统提示词。上下文对象本质上就是一个 JSON 字典,技能跑完以后往里面写字段,后续技能从里面读取字段。这样即使流程很长,也能随时看出哪个技能依赖哪个字段,排查问题非常管用。

4.4 并发技能的注意点:资源占用与共享状态

有些业务流程里,几个技能之间没有先后依赖,可以并发执行。比如同时查天气、查日历、查订单状态。并发能缩短整体耗时,但要注意两个问题:资源占用和共享状态。

资源占用方面,如果技能内部是 IO 密集型任务,比如读写文件、请求外部接口,可以用线程池;如果是计算密集型,比如处理大数组,建议用进程池。这部分要结合自身业务量评估,别一上来就上并发,否则本地文件搜索这种小操作也能把 CPU 拉满。

共享状态方面,并发技能写同一个 context 会互相覆盖。我的建议是:并发阶段里的技能只读公共上下文,各自把结果放在独立字段里,等全部结束后再做合并。简单说,就是在编排引擎里加一个“并发池”,每个分支技能有自己的临时上下文,汇合时再统一 merge。

5. 调试技能体系:常见问题与我自己惯用的土办法

5.1 一份速查表,把高频问题一次讲清楚

下表是我在实际开发中总结出来的最高频问题,基本覆盖了大部分常见的翻车场景。

现象根本原因排查思路
模型完全不调用技能描述里没写清触发时机,或模型压根没被告知可用技能清单检查系统提示词里是否有技能列表;单独干跑验证描述
技能调用成功,但参数总是缺参数 schema 太复杂,嵌套层级太多把参数拍平,减少必填项,多给默认值
返回格式不稳定对模型输出格式约束不够,允许了自由文本限定输出必须为 JSON;对返回值做结构化解析
技能执行超时外部接口慢,或技能内部变成了阻塞任务给所有执行函数加超时机制;必要时先返回“处理中”再异步回填结果
模型反复调用同一个技能没有设置步数上限,或返回结果不够简洁导致模型误判在编排层加 max_steps;精简返回字段
技能执行成功但模型乱用结果返回结果里冗余字段太多,模型抓错了重点精简字段;把关键结果放前面;在 description 里写明返回结构

这个表里的每一条,我基本都踩过至少一次。尤其是“模型完全不调用技能”,十次里有八次不是模型问题,而是 description 没写清楚。把描述改成“当用户需要…时使用”的句式之后,效果立刻不一样。所以真的建议,先别急着怀疑模型,先回去看描述。

5.2 调试的三件土办法:日志、干跑、回退

第一件是给所有技能调用加结构化日志。我在注册器的 execute 方法里统一记录技能名、入参、返回结果、耗时四样东西,日志格式固定为 JSON 一行一条。这样一次异常行为发生后,我能直接回看模型到底调了什么、拿到了什么,不用靠猜。

第二件是干跑测试。把模型从系统提示词里摘掉,手工构造一份技能调用 JSON,直接喂给注册器执行。这样能快速确认问题究竟在模型侧还是技能实现侧:固定输入能跑通,就是模型产出有问题;固定输入都出错,就是技能本身有 Bug。三分钟能完成,效率非常高。

第三件是回退。如果某个技能一直触发不了,就先别纠结实现,回退到“纯提示词模式”,让模型直接用文本返回结果,不做工具调用,先验证业务流程本身成不成立。流程成立后再接入技能,这时候再出错,问题就一定在技能层,范围瞬间缩小。这三个办法组合起来,我基本没遇到过超过半小时还定位不了的问题。

5.3 一次典型调试过程的完整复盘

拿一个真实场景举例:我在开发一个内部信息检索 Agent 时,模型经常在用户问“昨天会议纪要在哪”时,不调用 search_files,反而直接回答“我没有权限”。第一次遇到,我以为模型太保守,于是修改系统提示词,强调“你有工具,请使用工具”,但效果不明显。

仔细看日志之后发现,模型确实没有一次触发 search_files 的尝试,而且对工具存在性似乎没有感知。这时候我做了一件事:把技能清单重新打印出来看了一遍。结果发现问题出在 description 上——我写的是“用于搜索文件名”,没有写“当用户询问会议纪要、文档、报告等资料时使用”。模型面对“昨天会议纪要在哪”这样一个口语化表达,根本不知道这属于“搜索文件名”的范畴。

把 description 改成“当用户需要查找本地文件、文档、资料时使用;根据关键词搜索文件名并返回路径”之后,模型立刻就能正确调用了。这个案例说明,干跑和日志都只是定位手段,真正修复还是要回到描述设计上。技能体系的问题,最终都要落回到“模型理解”这个根上。

6. 还能往哪个方向扩展

技能体系做到一定程度,自然会产生一些更高阶的需求,我简单列几个方向供有兴趣往下做的人参考。

第一个是技能自动发现。当技能库超过几十条时,让模型一次看到全部技能已经不现实。可以做一个“技能索引 + 粗筛”机制,先用关键词匹配或向量检索拉出候选技能,再让模型在候选集里做选择。

第二个是技能的版本管理。技能是代码,版本升级逃不掉。每条技能可以加一个 version 字段,并在返回结果里带上版本号,方便定位“是不是因为改了实现导致行为变化”。能力越大,越要在细节上留线索。

第三个是权限控制。不是所有技能都适合让模型自由调用。我在技能定义里加了一个 permission 字段,分公开和执行两种:公开技能模型可以直接调用,执行级技能需要在代码里校验用户身份或二次确认。这样能让敏感操作保持可控,避免模型在错误时机触发危险动作。

这些扩展方向都不算难,但需要底层结构从一开始就别写死。这也是我反复强调字段规范要统一的原因:宁可花两天时间把注册器设计好,也不要急着写完二十个工具函数再返工。

我在实际使用中最深的一条体会是:不要把技能体系设计当成“注册一堆函数”那么简单,它真正设计的是模型和环境之间的边界。边界清晰,Agent 就稳定;边界模糊,就一定会翻车。希望这份基于 agent-skills 实践整理出来的内容,能帮你少走几步弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进