ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

agent-skills 实战:从对话到任务执行,构建能干活的 AI Agent

agent-skills 实战:从对话到任务执行,构建能干活的 AI Agent

1. 从“会聊”到“会做”:agent-skills 到底在解决什么问题

如果你最近在折腾 AI Agent,大概率会遇到一个很尴尬的局面:模型聊起天来头头是道,一旦让它真正去干点活,比如读一份本地文档、调一个接口、跑一段脚本、把结果整理成表格,它就开始“表演式执行”——嘴上说得热闹,实际啥也没干成。这个落差,就是agent-skills这类东西要填的坑。

我先把话说直白点:agent-skills 不是某个具体的库,而是一类“给 Agent 装技能”的设计思路和工程实践。你可以把它理解成给一个聪明但没上过手的实习生,配一套标准作业手册加工具箱。模型本身负责“想”,skills 负责“做”,两者拼起来,Agent 才真正具备端到端的任务能力。

它解决的问题非常具体:把大模型从“对话接口”升级成“任务执行体”。一个没有 skills 的 Agent,能力边界就是它训练时见过的东西;一个有了 skills 的 Agent,能力边界变成了“你能给它定义多少可调用的动作”。这个差别是数量级的。

适合看这篇内容的人,我大致分三类。第一类是正在做 AI 应用开发、想让自己的产品从“聊天框”进化成“能办事”的工程师;第二类是想用 Agent 自动化日常重复工作的效率玩家,比如批量处理文件、自动整理资料、定时抓取信息;第三类是对 Agent 架构好奇、想搞清楚“工具调用”背后到底怎么运转的技术爱好者。不管你是哪一类,只要你能写一点 Python 或者看得懂配置,这篇里的思路和步骤你都能直接抄。

我自己的判断是,agent-skills 的核心价值不在于“多了一个功能”,而在于它重新定义了人和模型的分工:人负责把复杂任务拆成可复用的技能,模型负责在合适的时机选择并组合这些技能。这个分工一旦跑通,你做的就不再是一个聊天机器人,而是一个能干活的数字员工。

2. 整体设计思路:为什么是“技能”而不是“一个大提示词”

2.1 从“万能提示词”到“技能模块化”的必然转向

早期大家做 Agent,习惯把所有能力塞进一个超长提示词里:你是一个助手,你可以读文件、可以算数、可以查资料、可以写代码……结果就是提示词越写越长,模型越来越糊涂,稍微复杂点的任务就开始胡编。这个路子的根本问题在于:提示词是“描述”,不是“能力”。你告诉模型“你可以读文件”,它并不会真的去读,它只会假装读了然后编一个内容出来。

agent-skills 的思路完全不一样。它把每一个能力做成一个独立的、有明确输入输出的技能单元,每个技能背后要么是一段真实执行的代码,要么是一个明确的工具接口。模型要做的不是“回忆怎么读文件”,而是“判断现在该调用读文件这个技能,并把参数填对”。这就把“会不会”的问题,转化成了“选不选得对”的问题,而后者对模型来说简单得多。

我打个生活化的比方。传统提示词就像你给一个新员工一本厚厚的员工手册,让他自己翻着找该干嘛;agent-skills 就像你给他一个工具箱,每个工具上贴着标签写着用途,他只需要根据当前情况挑对工具就行。显然第二种方式出错率低得多。

2.2 技能拆分的粒度怎么把握

这是实操里最容易翻车的地方。拆得太粗,一个技能干十件事,模型填参数就填不明白;拆得太细,几十个技能摆在那,模型选择的时候直接选择困难。我的经验是遵循一个原则:一个技能只做一件可以用一句话说清楚的事,并且这件事的输入输出是确定的。

比如“读取指定路径的文件内容”是一个好技能,“处理文件”就是一个坏技能,因为后者太模糊。再比如“把文本翻译成英文”是好技能,“帮我处理一下这段文字”就是坏技能。判断标准很简单:如果你没法用一句话准确描述这个技能什么时候用、需要什么参数、返回什么结果,那它就还没拆到位。

还有一个容易被忽略的点:技能之间要尽量正交。也就是说,两个技能的功能不要大面积重叠。如果“读取文件”和“读取文本文件”同时存在,模型就会在两者之间反复横跳,浪费调用次数还容易出错。宁可让一个技能稍微通用一点,也不要留一堆功能相近的选项。

2.3 为什么要把技能和执行环境隔离开

这一点很多人一开始不理解,觉得多此一举。但只要你跑过稍微复杂点的 Agent,就会明白隔离的重要性。技能执行往往涉及文件读写、网络请求、命令执行这些有副作用的操作,如果这些操作和模型推理混在一起,一旦模型判断失误,后果可能很麻烦——比如误删文件、误发请求。

agent-skills 的常见做法是:模型只负责“决定调用哪个技能、传什么参数”,真正的执行交给一个独立的执行层。执行层可以做权限校验、参数检查、超时控制、结果截断。这样即使模型抽风,执行层也能兜住。这个设计思路和传统软件里的“输入校验”是一个道理,只不过这里的“输入”变成了模型生成的调用请求。

我在实际项目里就吃过亏。早期图省事,让模型直接生成 shell 命令然后执行,结果有一次模型把一个路径参数理解错了,差点在一个重要目录上执行了删除操作。从那以后我就坚持:凡是涉及副作用的技能,执行层必须做白名单和参数校验,模型再聪明也不能给它无限制的执行权。

3. 核心细节解析:一个技能到底由哪些部分组成

3.1 技能描述:模型能不能选对,全看这一段

技能描述是模型选择技能的唯一依据,它的质量直接决定了 Agent 的准确率。很多人写技能描述就一句话“读取文件”,然后抱怨模型老是选错。问题就出在描述太单薄。

一个好的技能描述应该包含四个要素:这个技能做什么、什么时候该用、需要哪些参数、返回什么结果。我通常会用这样的结构来写:

  • 功能说明:一句话说清楚这个技能的核心动作
  • 使用场景:列举两到三个典型触发情境
  • 参数说明:每个参数的名字、类型、含义、是否必填
  • 返回说明:返回值的结构和含义

举个例子,一个“查询天气”的技能,描述不该只是“查天气”,而应该是“根据城市名称查询当前天气状况,适用于用户询问某地天气、需要出行建议等场景,参数为城市名(字符串,必填),返回温度、天气状况、湿度三个字段”。这样模型在判断时就有足够的信息做决策。

提示:技能描述里的用词要尽量贴近用户可能的自然语言表达。如果用户习惯说“今天热不热”,那描述里最好也带上“热不热”这类口语词,能显著提升匹配率。

3.2 参数设计:让模型“填得对”比“填得多”更重要

参数设计是另一个重灾区。我见过太多技能,参数列表长得像一份表单,模型填起来错误百出。核心原则是:参数越少越好,类型越简单越好,能默认的就别让模型填。

具体来说,参数设计要遵循几条经验。第一,必填参数控制在三个以内,超过三个模型填错的概率会明显上升。第二,优先用枚举类型而不是自由文本,比如“时间范围”这个参数,与其让模型自由填“最近一周”,不如给它几个固定选项让它选。第三,能推断的参数不要暴露给模型,比如当前时间、用户 ID 这类信息,执行层自己就能拿到,没必要让模型填。

还有一个技巧是给参数加示例。在参数说明里附上一个正确填写的例子,模型照着抄的准确率会高很多。比如参数“日期”说明写成“格式如 2024-01-15”,比单纯写“日期字符串”效果好得多。

3.3 执行逻辑:技能背后到底跑的是什么

技能的执行逻辑可以简单也可以复杂,但有几个共性的工程要点必须注意。

首先是超时控制。任何技能执行都要设超时,尤其是涉及网络请求的。模型不会等你,用户更不会。我一般把单个技能的超时设在 10 到 30 秒之间,具体看技能类型。超时后要返回一个明确的错误信息,让模型知道这次调用失败了,可以决定重试还是换方案。

其次是结果截断。有些技能返回的数据量可能非常大,比如读取一个大文件、查询一个长列表。如果原样塞回给模型,轻则浪费 token,重则超出上下文限制直接报错。所以执行层要对结果做截断,比如只返回前 N 个字符或者前 N 条记录,并附上“结果已截断”的提示。

最后是错误处理。技能执行失败是常态,关键是怎么把错误信息传递回模型。我的做法是返回结构化的错误对象,包含错误类型和简短描述,比如“文件不存在”“网络超时”“参数格式错误”。模型看到这些信息后,往往能自己调整策略,比如换个路径重试、或者告诉用户当前无法完成。

3.4 技能注册与发现:让 Agent 知道自己有哪些本事

技能写好了,还得让 Agent 知道它们的存在。这就涉及技能注册机制。最简单的做法是维护一个技能列表,每个技能包含名称、描述、参数定义和执行函数。Agent 启动时加载这个列表,把技能描述拼进系统提示词里,模型就能看到所有可用技能。

但技能一多,全塞进提示词就不现实了。这时候就需要技能发现机制,也就是根据当前任务动态筛选相关技能。常见做法有两种:一种是基于关键词匹配,把用户输入和技能描述做相似度计算,只保留最相关的几个;另一种是用一个轻量的分类模型先做一轮筛选。前者实现简单,后者准确率更高,具体选哪个看你的技能规模和精度要求。

我个人的经验是,技能数量在 20 个以内时,全量塞进提示词完全没问题;超过 20 个再考虑做动态筛选。因为筛选本身也会引入误差,技能不多的时候没必要增加这个复杂度。

4. 实操过程:从零搭一个带技能的 Agent

4.1 环境准备与依赖安装

先把基础环境搭起来。我用的是 Python 3.10 以上版本,主要依赖两个东西:一个大模型的调用 SDK,一个用来做技能执行调度的框架。这里不绑定具体产品,你用任何支持工具调用的模型接口都可以,思路是通用的。

python -m venv agent_env source agent_env/bin/activate pip install requests pydantic

pydantic用来做参数校验,这个后面会讲到为什么重要。如果你用的模型 SDK 有官方包,一并装上。环境准备好之后,建一个项目目录,结构大概是这样:

agent-skills-demo/ ├── skills/ │ ├── __init__.py │ ├── file_skill.py │ └── calc_skill.py ├── executor.py ├── registry.py └── main.py

这个结构把技能定义、执行调度、注册管理、主流程分开,后面加技能只需要往skills目录里丢文件,主流程不用动。

4.2 定义第一个技能:读取文件

我们从最简单的技能开始,把整个流程跑通。先定义一个读取文件的技能,包含描述、参数模型和执行函数。

from pydantic import BaseModel, Field class ReadFileParams(BaseModel): path: str = Field(..., description="要读取的文件路径,如 ./data/report.txt") max_chars: int = Field(2000, description="最多返回的字符数,默认2000") def read_file(path: str, max_chars: int = 2000) -> dict: try: with open(path, "r", encoding="utf-8") as f: content = f.read(max_chars) return {"status": "ok", "content": content} except FileNotFoundError: return {"status": "error", "message": "文件不存在"} except Exception as e: return {"status": "error", "message": str(e)}

注意几个细节。参数用pydantic模型定义,每个字段都带描述,这个描述会进入技能说明,帮助模型填对参数。执行函数里做了异常捕获,任何错误都转成结构化的返回,而不是直接抛出去。max_chars参数给了默认值,模型不填也能跑。

4.3 技能注册表:统一管理所有技能

接下来写注册表,把所有技能集中管理。每个技能注册时提供名称、描述、参数模型和执行函数。

class SkillRegistry: def __init__(self): self.skills = {} def register(self, name, description, params_model, func): self.skills[name] = { "name": name, "description": description, "params_model": params_model, "func": func, } def get_all_descriptions(self): lines = [] for s in self.skills.values(): fields = s["params_model"].model_fields param_desc = ", ".join( f"{k}({v.annotation.__name__}): {v.description}" for k, v in fields.items() ) lines.append(f"- {s['name']}: {s['description']} 参数: {param_desc}") return "\n".join(lines) def execute(self, name, params: dict): skill = self.skills.get(name) if not skill: return {"status": "error", "message": f"未知技能: {name}"} try: validated = skill["params_model"](**params) except Exception as e: return {"status": "error", "message": f"参数错误: {e}"} return skill["func"](**validated.model_dump())

这里的关键是execute方法里的参数校验。模型生成的参数先经过pydantic模型验证,类型不对、缺必填字段都会在这一步被拦下来,返回明确的错误信息。这就是前面说的“执行层兜底”,能挡掉相当一部分模型的小失误。

4.4 把技能接入模型调用循环

最后是主流程,把技能描述拼进提示词,然后进入“模型输出调用请求 → 执行技能 → 结果回传模型 → 模型继续”的循环。

def run_agent(user_input, registry, call_model, max_turns=5): skill_desc = registry.get_all_descriptions() system_prompt = f"""你是一个可以调用技能的助手。 可用技能如下: {skill_desc} 当需要调用技能时,输出 JSON 格式:{{"skill": "技能名", "params": {{...}}}} 如果不需要调用技能,直接回答用户。""" messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_input}, ] for _ in range(max_turns): reply = call_model(messages) action = parse_action(reply) if action is None: return reply result = registry.execute(action["skill"], action["params"]) messages.append({"role": "assistant", "content": reply}) messages.append({"role": "user", "content": f"技能执行结果:{result}"}) return "达到最大轮次,任务未完成"

max_turns这个限制很重要,防止模型陷入无限调用循环。parse_action负责从模型输出里提取 JSON,实际写的时候要做容错,比如模型输出带了多余文字、JSON 格式不标准等情况都要处理。

4.5 参数计算与选择:以“批量处理文件”为例

假设我们要做一个批量处理文件的技能,涉及几个需要权衡的参数。第一个是并发数。如果文件很多,串行处理太慢,但并发太高又可能打满 IO。我的经验值是并发数设在 4 到 8 之间,具体看文件大小和磁盘性能。第二个是单文件大小上限,超过这个值的文件跳过并记录,避免一个大文件卡住整个批次。第三个是失败重试次数,一般设 1 到 2 次,再多意义不大,因为失败往往是文件本身的问题。

这些参数我不建议全部暴露给模型,而是设成技能的默认配置,模型只需要提供“要处理哪个目录”这一个参数。这样既降低了模型填错的概率,又保证了执行的可控性。需要调整的时候改配置就行,不用动提示词。

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

5.1 模型不调用技能,直接编答案

这是最高频的问题。模型明明有技能可用,却选择自己编一个答案。原因通常有三个:技能描述不够明确、系统提示词没有强调“优先使用技能”、或者模型本身能力偏弱。

排查顺序是这样:先看技能描述,是不是写得太笼统,模型没意识到这个技能和当前问题相关;再看系统提示词,有没有明确要求“涉及事实性、操作性任务时必须调用技能”;最后考虑换一个工具调用能力更强的模型。我实测下来,在系统提示词里加一句“不确定的信息不要编造,优先通过技能获取”能明显改善这个问题。

5.2 参数填错或格式不对

模型填错参数太常见了,尤其是日期、路径、数字这类有格式要求的参数。解决办法前面提过:参数描述里给示例、优先用枚举、必填参数控制在三个以内。除此之外,执行层的参数校验是最后一道防线,校验失败时返回的错误信息要具体,比如“日期格式应为 YYYY-MM-DD,你提供的是 2024/1/5”,模型看到这种提示往往能自己纠正。

下面这张表是我整理的常见参数错误和对应处理方式:

错误类型典型表现处理方式
类型错误该填数字填了字符串pydantic 校验拦截,返回类型提示
格式错误日期格式不统一描述里给示例,校验层做格式检查
缺必填项漏填关键参数校验层返回缺失字段名
值超范围数字超出合理区间校验层做范围限制并提示
路径错误文件路径不存在执行层捕获并返回明确错误

5.3 技能调用陷入死循环

模型反复调用同一个技能,或者两个技能来回调用,这是很典型的死循环。根本原因通常是技能返回的结果没有让模型获得新信息,模型以为没成功就再试一次。

解决办法有两个。一是在结果里明确标注状态,成功就返回成功加数据,失败就返回失败加原因,不要让模型猜。二是设置最大调用轮次,前面代码里的max_turns就是干这个的。另外,如果发现某个技能经常导致循环,检查一下它的返回信息是不是太模糊,模型看不懂自然就反复试。

5.4 技能多了之后选择变慢变乱

技能数量上去之后,模型选择准确率会下降,这是必然的。我的处理策略是分层:高频技能放在主列表里全量展示,低频技能收进一个“更多技能”的索引里,模型需要时先查索引再调用。这样主列表保持精简,模型决策负担小,同时又不损失能力覆盖。

还有一个技巧是给技能打标签,按领域分组。比如文件类、网络类、计算类各一组,系统提示词里按组呈现。模型看到分组结构后,定位相关技能的速度会快不少。

5.5 执行结果太长撑爆上下文

读取大文件、查询长列表这类技能,返回结果很容易超出上下文限制。除了前面说的执行层截断,还有一个办法是让技能支持分页,返回结果时带上“还有更多数据”的标记和一个游标,模型需要时可以带着游标再次调用获取下一页。这样既控制了单次返回的大小,又保留了获取完整数据的能力。

注意:截断和分页要配合使用。单纯截断会让模型以为数据就这么多,可能给出错误结论;带上“已截断”标记,模型才知道需要进一步获取。

6. 技能组合与进阶玩法

6.1 让技能之间可以互相调用

单个技能能力有限,真正的威力在于组合。比如“下载文件”技能加上“解析文档”技能加上“生成摘要”技能,就能完成一个完整的资料处理流程。实现上,模型本身就会做这种组合,它调用完一个技能拿到结果,再决定调用下一个。你要做的是确保每个技能的输入输出格式能对接上,比如前一个技能返回文件路径,后一个技能正好接受文件路径作为参数。

6.2 给技能加上“记忆”

有些任务需要跨轮次记住信息,比如用户先说“处理 A 目录”,后面又说“把结果存到 B 目录”。这时候需要一个简单的记忆机制,把关键信息存下来,在后续调用时作为上下文传给模型。最简单的做法是维护一个会话级的键值存储,技能可以往里写,模型也能读到。复杂一点可以用向量存储做语义检索,但对大多数场景来说,键值存储就够了。

6.3 技能的错误恢复策略

技能执行失败后怎么办,这个要提前设计好。我的策略分三级:可重试错误自动重试,比如网络抖动,重试一两次往往就好了;参数类错误返回给模型让它修正;系统性错误直接上报,比如依赖的服务挂了,重试也没用,不如早点告诉用户。把这三类区分开,Agent 的健壮性会好很多。

7. 我踩过的坑和几条实在建议

先说几个我实际踩过的坑。第一个是技能描述写得太技术化,用了很多专业术语,结果模型匹配不上用户的日常表达。后来我把描述改成大白话,匹配率立马上去了。第二个是忘了给技能设超时,有一次一个网络请求技能卡住了,整个 Agent 就僵在那里,用户体验极差。第三个是参数校验做得太松,模型传了个明显不对的路径,执行层没拦住,直接报了个底层异常,错误信息对模型来说完全看不懂,它也没法自我修正。

几条实在建议。技能描述要当成给新人的说明书来写,假设读的人完全不了解你的系统,把该说的都说清楚。执行层永远不要信任模型的输出,该校验校验,该限制限制,这不是不信任模型,而是工程上必须的防御。从少量技能开始,先把两三个技能跑通跑顺,再逐步增加,一上来就搞几十个技能,调试起来会让你怀疑人生。日志要打全,每次技能调用记录输入输出和耗时,出问题的时候这些日志就是你的救命稻草。

最后分享一个小技巧:给每个技能写一个“反例”,也就是明确告诉模型什么情况下不要用这个技能。比如“读取文件”技能的反例是“当用户只是询问文件概念时不要调用”。这个反例写在描述里,能有效减少误调用。我加上反例之后,误调用率大概降了三成,效果很直观。

这套东西跑通之后,你会发现 Agent 的能力边界一下子打开了。原来只能聊天的东西,现在真的能帮你干活了。后面想扩展的话,方向也很多,比如把技能做成可插拔的插件、给技能加上权限分级、做技能的市场化分发等等。但那些都是后话,先把最核心的“定义技能、注册技能、调用技能”这个闭环跑顺,剩下的都是水到渠成的事。

RELATED READING

延伸阅读

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