ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

agent-skills:从工具堆砌到技能体系的LLM Agent稳定方案

agent-skills:从工具堆砌到技能体系的LLM Agent稳定方案

最早做 Agent 的时候,我总以为核心是“把大模型接进来,给几个工具,让它自己跑”。等真把一个项目跑起来才发现,工具一多,Agent 就变成了脱缰野马:调用错参数、反复试探、上下文漂移,最后在一堆散装函数里迷失方向。后来我把思路从“给 Agent 一堆工具”调整成“给 Agent 一套技能体系”,整个系统才真正稳定下来。这就是我这篇要聊的 agent-skills,一套面向 LLM Agent 的技能封装与调度方案。它不是某个特定框架的独门秘笈,而是一种通用做法:把原子操作按业务场景封装成可注册、可校验、可编排的技能单元,让模型像人一样“会什么就调什么”。不管你是刚入门 LangChain,还是已经在自研 Agent 框架,这篇都适配,我会直接把设计思路、代码骨架和踩坑记录摊开讲。

1. 为什么需要技能体系而不是工具堆砌

1.1 从“工具”到“技能”到底差在哪

很多人觉得工具就是技能,Agent 能调用search()、send_email()、create_ticket(),这不就是会技能了吗?表面上看是的,但实际跑起来会发现一个很尴尬的情况:模型永远在猜“我现在该用哪个函数”,而不是“我现在该完成哪个目标”。举例来说,用户说“帮我把这周的销售数据发邮件给领导”,如果你只给了query_database()和send_email()两个原子工具,模型要先想“是不是得先查库,再取数,再写正文,再发送”,每一步它都在决策,任何一步想歪了,后面全歪。而如果把“汇总最近七天的销售数据并发送邮件”封装成一个技能,模型的任务就从四步决策变成了一步确认:调用这个技能,传两个参数“收件人地址”和“是否包含图表”。人也是一样的,你问一个实习生“会写周报吗”,他说会,不是因为他记得print()每一行,而是因为他知道“写周报”这件事背后包含哪几个动作。Agent 技能体系就是为了让模型也拥有这种“组合记忆”。

那什么才算技能?我认为至少有三个特征:第一,它面向目标,而不是面向函数,技能名就是人类能理解的动作描述;第二,它内部可以有多个步骤,步骤之间是既定流程而非临时决策;第三,它有明确的输入边界和输出边界,参数经过 schema 校验,不会跑着跑着凭空冒出额外副作用。工具是“手”,技能是“驾驶技术”,两者差距在于是否具备稳定的动作序列。

1.2 Agent 技能库要解决的核心问题

我在实际项目里遇到的痛感非常具体。第一是决策开销太大:每调用一次工具,模型上下文就要多一次思考痕迹,工具越多,模型越容易犹豫;技能封装后,一个复杂流程对模型来说只占一个“函数槽”,上下文消耗显著下降。第二是错误率:原子工具没有前置条件检查和参数范围限制,模型经常传负数日期、空字符串、不存在的表名,而技能可以在入口处把参数拦截住。第三是流程不可控:散装工具之间谁先谁后完全由模型临场发挥,同一句话换个说法它会走不同路径,没法做灰度验证;技能库把流程固化下来,行为可预期、可回滚。

还有一个容易被忽略的问题:权限。一个系统里往往有查询类工具、写入类工具、管理类工具,如果全部暴露给模型,风险极高。技能层可以充当权限边界,比如“只读技能包”和“写入技能包”分开装载,模型在不同场景下只看到当前会话允许多调用的技能。这一点在我做企业内部分级 Agent 时尤其重要,不是所有模型都该有能力执行删除命令。

技能库解决的核心问题是“让 Agent 更接近一个专业助手,而不是一个万能遥控器”。万能遥控器每个键都是裸露的,用户得自己组合;专业助手则能把“打开空调”“调温度”“切换模式”打包成“我冷了,开制热”。对 LLM 而言,技能就是它理解世界的最小业务单位。

1.3 一套技能库应该长什么样

我理想中的技能库分四层:接入层、注册层、执行层、观测层。接入层负责把外部能力(数据库、API、文件系统、脚本)转换成标准动作;注册层维护一份技能清单,每个技能包含名称、描述、参数 schema、依赖条件和超时设置;执行层负责任务调度、并发控制、错误捕获和重试;观测层记录每次调用的输入、输出、耗时、Token 消耗,为后续优化提供数据。这四层缺一不可,只做注册不执行是花架子,只做执行不观测是盲人摸象。

从数据模型上看,技能库的核心是“技能描述符”。我一般用 Pydantic 模型定义,包括name、description、parameters、handler、permission五个字段。name和description给模型看,用于意图匹配;parameters给校验器用,确保入参合法;handler是真正的可执行代码句柄;permission是运行时权限标记。注册之后,技能库对外只暴露两个方法:list_skills()和execute_skill(name, params),前者给模型做选择,后者做实际执行。内部再怎么复杂,外面只留这两个入口,模型的认知负担就会小很多。

我见过有人把技能定义堆成一个大 JSON,几十个函数塞在同一个文件里,最后模型没法区分“查询本月订单”和“查询本月订单明细”有什么区别。所以设计时一定要把“描述”当成一等公民来对待,后续我会专门讲描述怎么写得让模型一眼就懂。现在先记住这条原则:技能库是给模型配备的“能力目录”,目录编得好不好,直接影响模型用得准不准。

2. 技能封装:注册、描述与参数规范

2.1 最小的技能定义长什么样

先给一个最小可用的技能定义示例,我习惯用 Python 加 Pydantic 来写:

from pydantic import BaseModel, Field from typing import Literal, Optional class SalesReportParams(BaseModel): recipient_email: str = Field(description="收件人邮箱,必须是合法邮箱格式") include_chart: bool = Field(default=True, description="是否附带趋势图") period: Literal["7d", "30d"] = Field(default="7d", description="统计周期") class SalesReportSkill: name: str = "generate_sales_report" description: str = ( "当用户需要把销售数据汇总并以邮件形式发送时使用。" "适用场景:发送周报、月报、销售趋势汇总。" ) params: type[BaseModel] = SalesReportParams permission: str = "write"

这段代码里没有真正的“逻辑”,它只是描述符。重点是把name写成一个动宾短语,description写清楚“什么时候用、能做什么”,参数用Field(description=...)给足语义。很多初学者把参数描述写得很糊,比如period只写“周期”,模型根本不知道填7d还是30d,有了 Literal 约束和示例说明才能减少错误。

有了描述符之后,真正的函数体可以单独写,注册时再绑定:

def _send_sales_report(params: SalesReportParams, context: dict): query_result = load_sales_data(params.period) chart_path = render_trend_chart(query_result) if params.include_chart else None send_email(params.recipient_email, "销售周报", build_body(query_result), chart_path) return {"status": "ok", "sent_to": params.recipient_email}

为什么要拆成描述符和函数体?因为描述符是给模型和校验器看的,函数体是给执行器看的,两者职责不同。模型不需要知道函数体内部调用了哪些接口,它只需要知道“调这个技能,传这几个参数,能得到什么结果”。

2.2 描述怎么写 LLM 才不误解

描述是整个技能库里最容易被低估的部分。同样一个技能,描述写得好,模型能在第一轮就正确选到;写不好,模型会反复在其他技能之间横跳。我总结了几条实践经验。

第一,开头直接说要“触发条件”,不要绕弯子。例如:“当用户提到订单退款、售后处理、退费申请时使用”,比“这是一个退款处理技能”有效得多。模型做意图识别本质是在做文本匹配,触发条件越贴近用户的真实表达,命中率越高。第二,明确写出“不要什么时候用”。给正面的触发说明加一句反例,比如“注意:仅用于已支付订单,未支付订单请调用 remind_payment”,能大幅减少误召。第三,描述里避免写流程细节,只写最终效果。模型不需要知道函数内部先查表还是先调接口,写多了反而干扰它判断,它只看输出目标和适用边界。

还可以做一个描述质量的自检:把技能描述盖住函数名,拿给另一个同事看,问他“如果用户说 X,你会觉得这个技能能用吗”?如果他能答对,说明描述合格。不要用大模型去生成那种“优美的废话”,比如“本技能用于高效地实现销售报表的自动化生成与邮件分发”,这种句子信息量很低,真正有用的信息是“什么时候触发”和“输入什么”。

2.3 参数 Schema 决定调用成败

参数 Schema 是技能库防呆设计的关键。你不约束参数,模型就敢传recipient_email="老板"、period="上周五",一旦进入函数体,各种隐错就来了。所以参数设计一定要做到三点:类型收敛、范围收敛、默认值兜底。

类型收敛用 Pydantic 的严格类型或者 Literal 做。例如period用 Literal 限定成只能选7d或30d,模型传了"last_week"就会在校验阶段被拦下,不会漏到函数体里。范围收敛用 Field 描述加gt、lt这类校验器,比如导出条数如果限制le=10000,模型就不会尝试拉全表。默认值兜底则要保证“不传也能跑”,不是每个参数都要必填,给合理的默认值能显著降低模型的决策压力。

我还要强调一个容易踩的坑:参数数量不要超过五个。模型在做 Function Calling 的时候,参数越多,生成正确 JSON 的概率越低,五个以上参数的错误率明显上升。如果某个技能需要七个参数,那就把技能拆成两级:外层技能收三四个参数,内层通过配置上下文补齐剩余字段。这就像给人填表,表太长没人想填,Agent 也一样。

校验失败的日志也别只记录错误,要把完整的调用参数、技能名称、模型返回原文都记录下来。我排错时最依赖的信息就是“模型当时到底传了什么值”,只有看到原始值才能判断是模型理解错了,还是我们的参数描述有歧义。这条经验帮我省了至少一周的排错时间。

3. 实操:手写一个轻量 Agent 技能库

3.1 注册中心的设计

技能库的心脏是一个注册中心。它的职责是把所有技能描述符集中管理,提供查询列表,并在执行时定位到对应的 handler。我用一个非常简单的类来实现:

class SkillRegistry: def __init__(self): self._skills: dict[str, dict] = {} def register(self, skill_cls): descriptor = skill_cls() if descriptor.name in self._skills: raise ValueError(f"Duplicate skill name: {descriptor.name}") self._skills[descriptor.name] = { "descriptor": descriptor, "handler": descriptor.run, "params_model": descriptor.params, } return skill_cls def list_skills(self, permission_mask=None): result = [] for name, item in self._skills.items(): desc = item["descriptor"] if permission_mask and desc.permission != permission_mask: continue result.append(desc.to_schema()) return result def execute(self, name: str, params: dict): if name not in self._skills: raise KeyError(f"Unknown skill: {name}") item = self._skills[name] validated = item["params_model"].model_validate(params) return item["handler"](validated)

注册顺序需要注意:技能之间如果有依赖关系,比如generate_sales_report依赖load_sales_data,那么load_sales_data应该注册为内部服务而不是独立技能。否则模型可能会跳过外层技能直接调用内层函数,流程控就失效了。我的做法是内外分离:对模型可见的只有“可执行技能”,内部函数统一放在services目录,不注册到公开列表里。

这个注册中心只有几十行,但它撑起了整个技能体系。你可以把它扩展到支持 YAML 配置加载、技能分组、条件注册等能力,但核心逻辑不变:先注册,后查询,再执行。不要一开始就设计成微服务架构,技能库的瓶颈从来不是注册中心的性能,而是技能的语义质量。

3.2 让 LLM 能发现技能:Function Calling 接入

注册中心只是对 Python 程序可见,要让 LLM 知道有哪些技能,必须把它们翻译成模型接口能识别的 JSON Schema。以 OpenAI Function Calling 为例,我们的list_skills()返回的每个技能都要转换成如下格式:

def build_openai_function_schema(skill_schema: dict) -> dict: params = skill_schema["params_model"].model_json_schema() return { "type": "function", "function": { "name": skill_schema["name"], "description": skill_schema["description"], "parameters": params, }, }

这样接入之后,模型会在每次会话开始时看到一份“可调用技能清单”。如果清单里有 30 个技能,模型每次决策都要把这 30 个函数的描述全部塞进上下文,既费 Token 又容易互相干扰。我建议做一个粗粒度的“意图路由”:先让模型判断用户请求属于哪个域(销售、售后、人事),再只加载该域的技能子集项。技能库的分组能力在这里就派上用场了,每个技能声明一个domain字段,路由层根据领域标签过滤。

还有一个细节:OpenAI 的函数名不能随意包含特殊字符,注册技能时如果用的是中文短语,务必转换成英文或拼音,否则接口直接报错。我自己习惯用下划线英文命名,保持verb_noun结构,比如send_email、create_task。描述字段用中文写没问题,函数名保持 ASCII 更稳。

接入之后别急着上线,先做一轮“邀请测试”:拿十几条真实用户话术,观察模型第一轮选中的技能是否和人工判断一致。准确率低于 80% 就回头改描述,不要通过加大系统提示词硬掰。系统提示词是全局的,为单个技能加说明会污染所有技能的选择效果。

3.3 执行与错误处理

执行层最常见的需求是超时控制、并发限制和错误重试。模型可能同时请求两三个技能,但有些技能涉及外部 API,并发量上来后容易把下游打崩;有些技能是幂等的可以反复重试,有些技能是非幂等的重试会造成重复写数据。我通常在技能描述符里增加两个字段:max_concurrency和retryable。

class BaseSkill: max_concurrency: int = 1 retryable: bool = False timeout_seconds: int = 30

执行器在线程池或者 asyncio 中运行技能时,先检查当前技能并发数是否达到上限,达到就排队等待;执行时用asyncio.wait_for做超时;如果超时且retryable=True,退避重试最多两次。这个机制看起来简单,但能挡住很多线上事故。我见过因为模型循环调用同一个写接口,把测试环境数据库灌成重复数据的情况,就是因为没有加幂等控制和并发限制。

错误返回也要规范化。技能执行失败时,不要只返回异常堆栈给模型,模型看到一堆英文异常不会处理,反而会硬着头皮重试。要返回结构化错误信息,包括错误码、用户可理解的错误描述、可能的修正方向:

{ "error": "INVALID_PARAMS", "message": "收件人邮箱格式不正确,请提供合法邮箱地址", "suggestion": "检查邮箱地址是否包含 @ 符号和域名后缀" }

模型拿到这类错误信息,下一步才能做出正确修正。记住一个原则:执行结果不是给程序员看的,是给模型看的。模型同时也是你的“用户”,错误信息写得越像人能看懂的提示,模型恢复得就越快。

4. 常见问题与排查技巧实录

4.1 模型反复改错技能参数

这是我在接入 Function Calling 后遇到的第一个高发问题。现象是模型已经选了正确的技能,但是在生成参数时不断报错或者来回修改。排查时我先看日志里的原始参数,发现很多值都是“语义正确但格式不对”,比如把7d写成7 days,把邮箱写成领导@xx。后来我把参数描述的每个字段都附上了示例值,错误率立刻下降了一半。

但如果遇到模型死循环调用同一个技能,可能是结果返回格式没让模型“满意”。比如某个查询技能返回了空数组,模型会认为查询不够彻底,反复换参数重试。解决办法是在结果里明确写一句“当前条件无数据,如需扩大范围请让用户确认”,直接把模型的下一步引导到“询问用户”而不是“自动重试”。还有一个隐藏坑:技能描述里如果出现“如果失败请重试”这种字眼,模型会非常听话地重试,千万别在描述里给这类暗示。

最后说一个硬规则:校验层的错误次数要统计,同一个技能在单次会话里连续失败超过三次,就强制切换到“人工确认模式”,不再让模型自动重试。这个护栏能避免很多无意义的 Token 消耗。

4.2 技能之间的上下文传递丢失

技能库做大了以后,模型经常需要先执行search_customer拿到 customer_id,再调用create_order去下单。问题来了:第二次调用时,模型是应该把 customer_id 作为参数直接传给第二个技能,还是技能库自动从上下文里取?我建议直接传递,而不是依赖隐式状态。每个技能都应该是无状态的纯函数,上下文只保留在会话层,技能之间不共享内存变量。

具体的做法是:把前一个技能的关键输出结构化成context_variables放到 Agent 的 Running Context 中,模型看到了也会把它填到下一个技能参数里。你需要检查的是,技能的返回值结构是否足够清晰。例如search_customer返回{"customer_id": 12345, "name": "张三"},模型一看就知道拿customer_id去填下一个参数;如果返回的是{"data": [{"id": 12345, "name": "张三"}]},模型可能理解不了应该拿哪个字段。所以技能输出的字段命名也要贴近业务直觉,和参数 schema 的字段名保持一致,能极大减少传递错误。

还有一类上下文漂移发生在长时间会话中:前五轮已经查过用户所在城市,第十轮模型调用天气技能时又去问用户要城市。这种时候可以给技能增加infer_from_context的能力,让它可以读取会话记忆中的前置字段。但前提是会话记忆的结构化程度要高,不能是一堆聊天记录堆在那里。

4.3 沙箱环境与依赖隔离

技能库涉及的第三方依赖五花八门,有的技能要连数据库,有的技能要调内部 HTTP API,有的技能要跑一段动态生成的代码。全部塞进同一个 Python 进程,迟早会因为依赖版本冲突或者异常进程拖垮整个 Agent。我在早期吃过一次亏:某个技能里引入的库把全局 logging 配置改了,结果所有后续技能日志全丢了,排查了很久才发现。

之后的做法是:常规技能用进程级隔离,通过multiprocessing或者asyncio.subprocess执行;高风险技能(比如执行用户提供的代码、写入文件、操作远程环境)放到 Docker 容器里,给只读文件系统、禁用网络或者限制出网地址。写操作类的高危技能,还会加一层审批流转:模型对接人的地方生成一个请求,人点确认后才真正执行,而不是模型自己完成全链路。

如果你暂时不具备 Docker 环境,至少要在函数装饰器上加一个requires_sandbox=True标记,日志里单独记录这类技能的调用,方便事后再审计。沙箱不是越严越好,而是和技能权限匹配:只读查询不需要开 Docker,写操作必须有隔离,这是成本和安全之间的取舍。

4.4 技能版本管理

技能不是写一次就永久不变的。我维护技能库期间改过各种东西:描述措辞、参数默认值、内部调用的 API 地址、依赖版本。刚开始没做版本管理,出过一次事故:线上 Agent 突然开始用新接口调用方式,和旧数据格式不兼容,整个任务链路断了一整天。痛定思痛之后,我给每个技能增加了一个version字段,注册中心只保留最新版本,但执行日志和观测系统里会记录当时的版本号和技能源码 hash。

如果你需要同时稳定运行多个独立技能的“你感觉不错”的版本,可以通过技能包进行版本化发布。技能包本质上是一个固定版本的技能集合,统一标记release_tag,比如v1.2.0。Agent 启动时指定加载某个发布包,不回源到最新代码,这样就能避免“昨天还好好的,今天突然变了”的问题。每次更新发布包之前,我会跑一遍回归用例:抽二十条历史话术,对比新旧包在技能选中的结果差异,差异超过预期就回滚。

版本管理这件事听起来很重,其实落地很轻,就是在注册表里加一列版本号、一个发布清单。不做的话,出了问题根本没法快速还原。

5. 一点经验谈

开头我说过,把工具堆砌变成技能体系之后,Agent 的可靠性会上一个台阶。实际操作中我最大的感受是:技能库设计好坏不取决于代码写得有多花哨,而取决于“模型对你的技能目录有没有稳定的第一印象”。描述写得清楚、参数约束到位、执行结果结构化,这三件事做到位了,模型就像一个受过良好培训的新员工,指哪打哪。相反,哪怕底层能力再强,模型面对一份混乱的技能清单也会手足无措。

最后分享一个细节:每次技能库上线前,我会准备一个 mock 的模型调用记录,把用户最常说的二十句话一条条读给技能清单“听”,看模型会不会选对。这步不花太多时间,却能把绝大部分问题暴露在上线之前。如果你正在搭自己的 agent-skills,我建议你从一门最常用的小业务技能开始,把它做得足够扎实,再逐步往外扩展,而不是一开始就铺一大堆技能。技能库是长出来的,不是规划出来的。

RELATED READING

延伸阅读

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