1. 为什么我要做一套 Agent 技能体系:从一次失败的项目复盘说起
1.1 现象:模型会聊天,但不会干活的尴尬期
去年我在做一个企业内部的知识库问答 Agent,最开始方案很朴素:把文档切好片、做向量召回、塞给大模型生成回答。跑通 demo 只花了两周,效果看着还不错,领导挺满意。但一到真实业务场景就露馅了——用户问"帮我查一下上个月的支出报表",Agent 确实能从知识库找到报表模板,但它不会真的去财务系统拉数据,也不会按模板生成 PDF,更不会通过邮件发给指定人。
换句话说,Agent 只会"说",不会"做"。
这个阶段我踩过一个大坑:尝试把所有工具调用逻辑全部写进 system prompt,让模型自己判断什么时候调什么工具。结果就是 prompt 越写越长,模型越来越"选择困难",经常在前三步就晕头转向。后来我意识到,问题压根不在 prompt 工程,而在于整个 Agent 的架构设计里缺少一个"技能层"——模型需要的不只是"工具清单",而是一套能被理解、能被调度、能被稳定执行的"技能体系"。
1.2 本质:技能不是模型自带的,是需要工程化的
什么是 Agent 的技能?我当时的理解比较浅,以为就是注册几个函数、定义好参数,让模型调用就行了。但真正做过之后才发现,技能应该是一个完整的能力单元,它至少包含四件事:
- 可被模型正确理解的调用意图,即这个技能是干什么的、什么时候该用;
- 规范化的输入输出接口,模型得知道要传什么参数、能得到什么结果;
- 可执行的业务逻辑,这层可以是代码、脚本、API 调用链,甚至人工审批环节;
- 异常处理机制,技能执行失败了怎么办,怎么回退、怎么告诉模型。
所以 "agent-skills" 这个项目本质上不是某个炫酷的大模型应用,而是一套解决"模型如何稳定调用外部能力"的工程方法论。我一开始用 OpenAI Function Calling 做基础,后来切换到本地开源模型(Qwen、GLM 这些),发现不同模型对工具调用的理解差异很大,这就倒逼我把技能定义做得更通用、更严谨。
这个项目的目标读者,是完全不满足于"做个聊天机器人"的开发者。如果你正准备让 Agent 干点实际工作,比如自动处理工单、操作内部系统、跑数据分析流程,那这套技能体系的思路应该能帮到你。
2. 技能体系的三个核心设计:定义、注册、编排
2.1 技能描述:模型能否正确触发,全看这一步
先说技能描述的作用。用过 Function Calling 的朋友都知道,每个函数要写一个 description,模型的触发准确率很大程度上就压在这段描述上。但在我实际测试中,单一函数描述远远不够,特别是技能数量超过 20 个之后,模型经常把相似技能搞混。
我的做法是把技能描述拆成五个独立字段:
- name:技能唯一标识,全局不可重复;
- description:一句话说明技能功能,注意这句话要站在"模型视角"写,是描述这个技能能帮模型完成什么目标,而不是给人类看的文档;
- when_to_use:显式告诉模型什么场景下应该调用,甚至可以明确写"不要在 XX 场景使用";
- input_schema:输入参数定义,每个参数都要写明类型、是否必填、含义、枚举值;
- output_format:输出结构定义,让模型知道拿到结果后怎么接。
举个例子,一个"创建知识库文档"的技能描述,我一开始写得特别简短,"Create a document in knowledge base"。结果模型在用户问"帮我把这段内容存进资料库"时,死活不触发这个技能,反而去调"搜索知识库"。改成更完整的描述后,触发率从 67% 涨到了 94%。
提示:when_to_use 这个字段是我的经验优化,效果非常明显。大模型看长文本时会做注意力分配,明确的正反面示例能让它更快锁定技能。
2.2 技能注册中心:告别一把梭,引入可管理性
有了技能定义,接下来要考虑的是技能从哪来、怎么加载、怎么更新。我第一次做的时候,把所有技能写在一个 Python 文件里,函数直接注册。技能少没问题,一旦技能多起来,每次修改都要发版重部署,代价太高。
我参考微服务注册中心的想法,做了一个轻量的技能注册表。所有技能通过 JSON 描述文件注册,每个技能对应一个目录,里面包含 skill.yaml(元信息)、main.py(执行逻辑)、schema.json(参数定义)、tests.py(自测脚本)。技能引擎启动时扫描全部技能目录,加载到内存里形成一个可查询的技能索引。
技能注册中心的作用不只是管理文件,它还为 Agent 提供两个关键能力:
- 通过语义检索筛选候选技能,模型不需要看到全部技能,只加载与当前任务最相关的 5 到 10 个,大幅节省上下文空间;
- 支持技能版本管理,灰度发布时可以先让一小部分流量走新版本技能,稳定后再全量切换。
技能注册中心的实现并不复杂,但要做好约定。我的约定是:技能目录名必须等于技能 name,不允许嵌套,所有路径都用相对路径。这些约定看着死板,在实际协作中却省了很多沟通成本。
2.3 技能编排:单技能是零件,多技能组合才是产品
单一的技能只能完成原子操作,真正让 Agent 变得有用的,是把多个技能按一定的逻辑编排起来。这里我不建议用传统的工作流引擎去做,太重了,Agent 场景天然需要动态决策,你没法提前把所有流程固化下来。
我采用的编排方式,是基于模型的"计划-执行-反思"循环:
- Plan:模型读取用户请求,结合候选技能列表,生成一个执行计划;
- Execute:依次执行计划中的技能,每一步执行前先让模型确认参数;
- Observe:将技能执行结果返回给模型,由模型判断结果是否符合预期;
- Reflect:如果结果不对,模型要决定是重试、换技能,还是回退到让用户澄清。
这个循环里最关键的,是执行计划的表达方式。我用自然语言加参数列表描述计划,而不是强 JSON 格式,原因是模型生成自然语言更稳定,JSON 经常出现格式错误。用户提问"帮我把库存低于 10 件的商品列出来,并生成告警邮件",Agent 生成的执行计划是:
- 调用"query_inventory"技能,参数为 threshold=10;
- 调用"generate_alert_email"技能,参数为商品列表;
- 调用"send_email"技能,参数为收件人和邮件内容。
每个步骤执行完,模型都会拿到真实结果并决定下一步。这套编排机制看起来很朴素,但它的优势在于,每个技能只需关注自己那部分逻辑,复杂度被隔离在技能内部,模型反而更容易做出正确的调度决策。
3. 实操:从零手写一套轻量 agent-skills 技能加载与执行框架
3.1 环境与架构选型
我知道很多朋友喜欢用现成的 Agent 框架,比如 LangChain、AutoGen。但如果你要深度定制技能体系,我建议至少自己写一层,原因后面会讲。这里我选择的是 Python 3.10 + FastAPI,主要考虑是生态成熟、写起来快,而且和现有业务系统集成方便。
先看项目的目录结构:
agent-skills/ ├── skills/ # 技能目录 │ ├── query_inventory/ │ │ ├── skill.yaml │ │ ├── schema.json │ │ ├── main.py │ │ └── tests.py │ ├── send_email/ │ │ ├── skill.yaml │ │ ├── schema.json │ │ ├── main.py │ │ └── tests.py │ └── ... ├── registry.py # 技能注册与加载 ├── executor.py # 技能执行器 ├── scheduler.py # 技能编排(计划-执行-反思) └── api.py # Agent 服务入口关键依赖只有两个:PyYAML(解析技能元信息)和 FastAPI(提供 HTTP 接口)。没有引入重量级框架,保持核心逻辑可控,这是我吃过 LangChain 版本升级的亏之后的教训——框架封得太死,底层一升级你的代码就要跟着改。
3.2 定义好第一个技能:query_inventory
技能元信息用 YAML 写,简单清晰。我先写 skill.yaml:
name: query_inventory description: 查询商品库存数量,支持按商品 ID 或按库存阈值筛选 when_to_use: 当用户需要了解商品库存情况、库存不足告警、补货决策时使用 when_not_to_use: 当用户询问订单物流状态时不要使用 version: 1.2.0再看 schema.json,定义输入参数:
{ "type": "object", "properties": { "product_id": { "type": "string", "description": "商品唯一ID,支持批量,多值用逗号分隔" }, "threshold": { "type": "integer", "description": "库存阈值,只返回库存低于该数值的商品" } }, "oneOf": [ {"required": ["product_id"]}, {"required": ["threshold"]} ] }这里注意一个细节:我没有把 product_id 设置成必填,因为阈值查询场景根本不传它。但如果不做 oneOf 约束,模型可能会两个参数都传,产生歧义。所以在 schema 里写清楚逻辑约束,比依赖模型自己理解要可靠得多。
main.py 是技能的执行逻辑。以伪代码演示:
import json from .db import get_inventory def run(ctx, params): """执行库存查询。 ctx 中携带了必要的上下文信息,如当前用户、租户ID、日志句柄等。 params 是经过 schema 校验后的输入参数。 """ threshold = params.get("threshold") product_id = params.get("product_id") if product_id: data = get_inventory(product_ids=product_id.split(",")) elif threshold is not None: data = get_inventory(threshold=threshold, low_stock=True) else: raise ValueError("product_id 与 threshold 不能同时为空") # 返回结构化结果,方便模型读取 return { "status": "success", "items": data, "summary": f"共查询到 {len(data)} 条库存记录" }技能执行器的逻辑是"先校验 schema,再执行业务代码"。我先调用 jsonschema 校验参数,校验不通过直接返回语义化的错误,给模型提供修正参数的机会,而不是抛出 Python 堆栈。这一步相当关键,因为模型生成的参数本来就容易出错,如果错误信息人类都读不懂,模型更读不懂。
3.3 写好注册中心,让技能"即插即用"
registry.py 的任务是启动时扫描所有技能目录,把元信息和执行函数加载到内存。核心逻辑如下:
import yaml import json import importlib.util from pathlib import Path SKILLS_ROOT = Path(__file__).parent / "skills" class SkillRegistry: def __init__(self): self._skills = {} def load_all(self): for skill_dir in SKILLS_ROOT.iterdir(): if not skill_dir.is_dir(): continue self._load_skill(skill_dir) print(f"已加载 {len(self._skills)} 个技能") def _load_skill(self, skill_dir: Path): # 读取元信息和 schema meta = yaml.safe_load( (skill_dir / "skill.yaml").read_text(encoding="utf-8") ) schema = json.loads( (skill_dir / "schema.json").read_text(encoding="utf-8") ) # 动态加载 main.py 中的 run 函数 spec = importlib.util.spec_from_file_location( f"skills.{meta['name']}.main", skill_dir / "main.py", ) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) self._skills[meta["name"]] = { "meta": meta, "schema": schema, "run": module.run, } def list_skills(self, query: str = None, top_k: int = 10): """ 这里可以用 embedding 检索候选技能,也可以简化为关键词匹配。 我的实现是接了一个本地 embedding 模型,对 query 与技能描述做余弦相似度排序。 """ # 省略 embedding 细节 return ranked_skill_names这个注册中心的重点不是加载本身,而是它承担了"技能候选筛选"的职能。原始系统把所有技能描述都硬塞给模型,上下文炸裂;现在系统只在每个 Agent 请求进来时,从注册中心召回 5 到 10 个候选技能,再把这些技能的描述拼接给模型。这一步改动,让模型的工具选择准确率提高了近 20 个百分点。
关于动态加载,用的 importlib 方案在开发期方便,改代码不用重启服务,但生产环境建议改成更稳妥的加载方式,因为 importlib 一旦加载同一个模块名容易产生缓存污染,我在 4.2 小节会讲这个坑。
3.4 执行器与编排循环:让模型按计划干活
executor.py 的核心是一个 execute 函数,负责安全地运行技能并捕获异常:
import inspect import traceback class SkillExecutor: def __init__(self, registry: SkillRegistry): self.registry = registry def execute(self, skill_name: str, params: dict, context: dict): skill = self.registry.get_skill(skill_name) if not skill: return { "status": "error", "error_type": "skill_not_found", "message": f"技能 {skill_name} 不存在,请检查技能名称" } # 参数校验 import jsonschema try: jsonschema.validate(params, skill["schema"]) except jsonschema.ValidationError as e: return { "status": "error", "error_type": "invalid_params", "message": f"参数校验失败: {e.message},请修正参数后重试" } # 执行技能 try: result = skill["run"](context, params) return {"status": "success", "result": result} except Exception as e: traceback.print_exc() return { "status": "error", "error_type": "execution_failed", "message": f"技能执行出错: {str(e)}" }然后到 scheduler.py,这是整个 agent 的大脑。它把"计划-执行-反思"循环落地。下面是一段关键流程伪代码:
def run_agent_task(user_request: str, context: dict): # 1. 技能候选筛选 candidate_skills = registry.list_skills(user_request, top_k=8) system_prompt = build_system_prompt(candidate_skills) # 2. 让模型生成执行计划 plan = llm.chat( system_prompt, f"请根据用户请求生成执行计划。\n用户请求: {user_request}" ) # 3. 循环执行计划中的每个步骤 for step in parse_plan(plan): skill_name = step["skill"] params = step["params"] result = executor.execute(skill_name, params, context) if result["status"] == "error": # 4. 反思:把错误信息反馈给模型,让它决定下一步 correction = llm.chat( system_prompt, f"步骤 '{skill_name}' 执行失败,错误信息: {result['message']}。" f"请给出修正后的参数,或选择其他技能,或要求用户补充信息。" ) if correction["action"] == "retry": params = correction["params"] result = executor.execute(skill_name, params, context) elif correction["action"] == "ask_user": return {"need_user_input": correction["question"]} else: return {"status": "failed", "reason": result["message"]} context["last_result"] = result["result"] return {"status": "success", "final_result": context["last_result"]}表面看,这只是一个 while 循环加两次模型调用,但真正的难点在于"步骤解析"。模型生成的执行计划是自然语言,你需要把它转成结构化的步骤对象。我踩过的坑是直接要求模型输出 JSON,结果 10 次里有 2 次 JSON 格式错误。后来换成"先输出自然语言计划,再单独用一次模型调用把计划转成 JSON",准确率稳定在 99% 以上。多一次调用,换来的是稳定,很划算。
3.5 把 Agent 服务暴露出去,并在真实业务里跑通
最后写 api.py,把整个流程封成一个 HTTP 接口。这里我只暴露一个简单的 POST 接口,接收用户消息和上下文,返回 Agent 的处理结果:
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI(title="Agent Skills Service") class AgentRequest(BaseModel): message: str user_id: str department: str = "general" class AgentResponse(BaseModel): status: str data: dict = None error: str = None @app.post("/agent/run", response_model=AgentResponse) async def run_agent(req: AgentRequest): try: context = { "user_id": req.user_id, "department": req.department, } result = run_agent_task(req.message, context) return AgentResponse(status="success", data=result) except Exception as e: return AgentResponse(status="error", error=str(e))到这里,一个最小可用的 agent-skills 框架就落地了。你可以把技能目录新增一个 skill,重启服务(开发环境甚至可以热加载),然后在接口层测试整个链路。
我在真实业务里接入了两个技能:"query_inventory" 和 "generate_alert_email"。用户发一句"看看哪些商品库存低于 20 件,发封告警邮件给采购组",Agent 会经历这样的完整链路:召回技能、生成计划、执行查询、生成邮件内容、调用邮件技能发送、向用户确认。整个链路耗时 8 到 15 秒,其中主要时间花在两次模型推理上,技能本身执行不超过 200 毫秒。
4. 技能开发中的典型坑与排查技巧
4.1 技能触发率低?先检查描述里的"场景信号"
我见过很多开发者写完技能描述后发现模型死活不调用,第一反应是模型不行,或者 prompt 不够长。实际上问题往往出在描述本身。技能描述里的关键词会直接影响模型对该技能"何时使用"的判断。比如 query_inventory 的描述若只写"查询库存",用户在说"帮我看看哪些商品快断货了"时,模型可能就联想不到这个技能。
我的排查方法是让大模型自己生成 20 个可能的用户提问,再用这些提问去验证技能触发准确率,低于 90% 就返回去改描述。改描述时注意加场景化关键词,比如"库存不足、缺货、补货、断货、库存告警、安全库存",这些词都能帮助模型建立关联。
| 触发率低的原因 | 排查方向 | 解决方案 |
|---|---|---|
| 描述太短,缺少场景词 | 统计模型未触发时的用户提问 | 补充 when_to_use 与典型场景词 |
| 多个技能描述语义重叠 | 两两计算描述相似度 | 明确边界词,一个技能负责一个领域 |
| 技能数量过多导致选择困难 | 查看模型实际接收到的完整技能描述 | 引入注册中心按语义召回候选技能 |
4.2 技能参数总是传错?用 schema 约束代替 prompt 说教
模型生成参数出错是高发现象。一个典型的场景是用户说"查一下商品 ABC 的库存",模型可能会自作聪明地补一个 threshold=0,或者把商品 ID 写成商品名称。如果我靠 prompt 写"请严格按照参数定义传参",效果几乎为零。
正确做法是在 schema 里尽可能把约束写死:
- 参数类型严格指定,能用 enum 就用 enum;
- 参数格式写明正则、长度、取值范围,比如商品 ID 统一 8 位字符;
- 必要的时候,在执行器里做参数值映射,把模型输出的"商品名称"映射成系统内部的"商品 ID",这个映射逻辑可以做成一个独立技能,叫"entity_resolution"。
另外,我在编码过程中发现 importlib 动态加载的技能模块在多次 reload 时会保留旧的全局变量,导致状态脏读。解决方式是在注册中心里维护一个模块名到文件路径的映射,每次加载前先清理 sys.modules 里对应的旧模块。这些细节不处理,线上会出现各种"灵异事件"。
4.3 技能执行链路太长,上下文塞爆了怎么办
技能一多,每次执行的数据都是动态的,几次迭代下来,上下文很容易膨胀。我有一次跑一个五步的编排任务,到了第四步模型就开始"忘记"最初的目标,生成的内容开始答非所问。排查后发现上下文里塞满了前三步返回的 JSON 数据。
解决办法是上下文压缩,也叫状态摘要。每执行完一个技能,我会让模型用一句话总结"这步完成后的关键信息",然后把完整的返回结果移出上下文,只保留摘要。比如 query_inventory 返回了 50 条商品记录,摘要就是"共查询到 50 条记录,其中 15 条低于安全库存阈值,最低库存为 A001 商品仅剩 3 件"。后续步骤只需要这个摘要就足以继续决策。
这一招让我的会话上下文消耗减少了约 70%,模型在长链路任务中的稳定性也明显改善。如果你用的模型上下文窗口偏小,这个方法几乎是必选项。
4.4 技能失败后的"自我修复"能力怎么给
技能执行不可能永远成功,网络抖动、权限不足、业务数据异常都会导致失败。最初我的做法是失败就直接返回错误,让用户重新描述。后来我发现,给模型一个"反思重试"的机会,能把很多临时性问题自动化解决。
具体做法是在 executor 返回错误时,把错误类型和错误信息喂给模型,让它做三类决策:
- 若是参数错误,提供修正后的参数重试;
- 若是技能内部业务规则错误,换一个技能尝试;
- 若是权限或数据问题,立刻转向向用户说明原因,而不是无限重试。
这里的难点是防死循环。我加了一个最大重试次数限制(默认 2 次),超过次数就终止,并向用户道歉并要求补充信息。有一次我测试发送邮件技能,模拟 SMTP 服务不可用,模型第一次重试仍失败,第二次就正确地转向了"请稍后再试"的应答,这个表现已经足够好。
5. 进阶扩展:从单 Agent 到多技能协作体系
5.1 技能之间的依赖关系与共享上下文
当技能体系超过 30 个之后,技能之间会产生隐式依赖。比如"生成销售日报"技能内部依赖"查询订单数据"技能的输出。如果每个技能都独立接收参数、返回结果,那 Agent 在执行日报任务时,就得先手动调查询,再把结果作为参数传给生成日报,编排逻辑会越写越复杂。
我的解法是引入一个轻量的 context store,技能之间通过共享上下文交换数据。技能执行完后,可以把结果里的关键数据写入 context,比如context.set("inventory_alert_list", data)。后面无论哪个技能要这份数据,都通过context.get("inventory_alert_list")读取。这样 Agent 编排时的参数传递就大幅简化,不再需要把上一轮的完整结果复制到下一轮。
共享上下文也有风险。数据过期是最常见的,比如用户先查了库存,5 分钟后又查销售数据,前者的库存结果已经不新鲜了。所以我给每个 context 变量加了时间戳和来源技能标识,使用方可以判断数据是否过期。如果模型拿到的数据太旧,它会主动重新调用源头技能刷新。
5.2 技能评估:不回归测试,你永远不知道改坏了什么
技能体系做大了以后,最怕的就是"改了一个技能,别的技能触发率掉下去了"。每个技能的定义、描述、schema 都像参数一样在影响模型的行为,必须有一套回归评估机制。
我做了一个简单的评估集:收集了 200 条真实用户提问,为每条标注了最佳技能调用序列和期望输出。每次对技能定义做任何改动,就跑一遍评估集,统计三个指标:
- 技能触发准确率:正确的技能有没有被选中;
- 参数传递正确率:选中技能后参数生成是否正确;
- 最终任务成功率:Agent 是否在 N 步内完成用户请求。
这套回归机制救过我一次。有一次我给 send_email 技能加了一个"紧急邮件"参数,结果评估集显示 trigger accuracy 从 92% 掉到了 85%,一查发现是一个技能描述里的关键词跟另一个技能冲突了。如果不做回归,这个问题上线后用户会大面积反馈"发邮件功能咋不灵了"。
5.3 接入更多模型和本地化部署时的注意点
agent-skills 的方法论不绑定任何特定模型。我在 OpenAI 上验证过后,也切换到了 Qwen、GLM、DeepSeek 等模型。不同模型对 Function Calling 的支持能力差别很大,我总结了几条经验:
- 强模型(GPT-4、Claude)可以直接用原生 function calling,技能描述可以写得简洁一些;
- 中等模型(GPT-3.5、部分开源 7B 模型)对函数调用的遵从度不够稳定,需要把技能调度改成"模型生成自然语言计划 + 解析器转结构化"的方式,不要依赖模型直接输出结构化 JSON;
- 本地部署模型时,技能描述越短越好,因为小模型的注意力机制对超长上下文的处理能力有限,冗长的技能清单会直接拖低准确性。
如果你有可复用且已调优的开源模型,这套技能体系也支持"内置技能 + 外部 llm 网关"的混合架构。模型的差异在技能调度层被抽象隔离了,主要影响的是调度准确率和延迟,不影响技能本身的业务逻辑。
写在最后:我跑完整个项目的真实体会
agent-skills 这套体系做到今天,最大的收获不是代码量,而是想明白了一件事:Agent 的真正瓶颈往往不是模型智商,而是工程能力。给模型一个定义清晰、边界明确、容错可靠的技能层,比单纯换一个更大的模型更有效。我见过太多项目卡在"demo 惊艳、上线翻车",本质都是把复杂的真实世界逻辑一股脑塞给模型,而没有做工程化拆解。
如果你正在折腾 Agent 方向的实践,我的建议是:不要一上来就追新框架,先把手上的一个真实业务场景拆成 3 到 5 个技能,用本文这套思路实现一遍,你会比看十篇论文收获更大。
最后分享一个小技巧:技能描述别急着一次写完美,跑完一轮真实数据之后再迭代。你会发现用户的实际问法和你想的完全不一样,这些真实样本才是优化技能定义的黄金素材。