☰
从零搭建AI Agent技能系统:让模型真正会干活的四层架构
2026/10/14 10:11:16 网站建设 项目流程

你有没有发现一个现象:现在大家都在做“AI Agent”,但绝大多数demo都死在同一个地方——Agent“看起来很聪明,实际上什么都不会”。模型再强,如果它连查个天气、读个文件、调个接口都做不利索,用户用两次就放弃了。问题的根源,恰恰不在模型,而在于Agent到底有没有一套可靠、可扩展、可维护的“技能系统”(agent-skills)。

我在自己的个人项目里从零搭过这么一套东西,踩了不少坑,也沉淀了一些方法论。今天这篇不聊概念,直接聊我实际怎么设计、怎么实现、怎么把一个只有“会聊天”的Agent,变成“真能干活”的Agent。希望给正在做Agent落地、或者被function calling折磨过的朋友一些可直接参考的经验。

1. 为什么“Agent的技能”值得单独做成一套系统

1.1 “聪明”和“能干”是两码事

很多人有个直觉误区:Agent不够好用,是不是因为底层大模型不够聪明?于是换更强的模型、加更长的上下文、调更花哨的prompt。但真把Agent放到实际任务里你会发现,决定体验上限的往往是它“能不能稳定地调用外部能力”——而且这个“稳定”不是靠模型本身能保证的。

我打个比方。一个刚入职的实习生,脑子很灵活(模型很强),但你让他去发邮件,他得先学会用邮件系统;你让他去查数据库,他得先知道库表结构和权限;你让他去操作浏览器,他得知道怎么定位元素、怎么处理弹窗。一个Agent想要“干活”,同样需要一套“业务能力层”,让大模型知道:你现在有哪些能力可以用、每个能力该怎么调用、调用了之后结果怎么处理。

这一层能力,就是“Agent技能”,英文对应agent-skills。它本质上不是一段简单的“函数+注释”,而是把“能力的发现、调用、执行、容错、反馈”全部标准化的一套工程体系。

1.2 技能的本质:一套把不确定性管起来的基础设施

传统软件开发里,函数调用是确定的:代码写好了,参数类型对了,结果就可预期。但Agent场景完全不同——调度方是一个语言模型,它可能理解偏了、参数给错了、甚至选了完全不相干的技能。所以“技能系统”的本质,不是“写几个函数给LLM调”,而是“为模型的不确定性设计一套护栏”。

我自己的理解是这样的:技能系统 = 能力注册表 + 调度协议 + 执行隔离 + 反馈闭环。能力注册表让模型知道“有什么”;调度协议让模型知道“怎么调”;执行隔离保证“出错不炸系统”;反馈闭环让每次调用都在沉淀数据,反哺调度效果。

如果没有这套系统性设计,你会陷入一种很尴尬的状态:demo里技能调用一切正常,一上真实任务就四处漏风。今天超时报错,明天参数格式崩了,后天模型选错技能。这些不是偶发,是必然。因为模型本身是有随机性的,你必须从架构层面把随机性兜住。

1.3 这套系统到底解决了哪几类现实问题

我在实际搭建前后,最直观的感受是三类问题的变化:

第一,能力扩展从“改代码”变成了“加配置”。之前想让Agent多会一个能力,得改主流程、改prompt、改解析逻辑;有了技能系统后,新增一个技能就是注册一个模块的事,主调度逻辑完全不用动。

第二,模型选错技能的频率明显下降。技能描述是结构化维护的,每条技能的“触发条件”“典型场景”“禁用语境”都能写清楚,模型决策的准确率高很多。

第三,错误可排查、可复现。每次技能调用都有标准化的日志和参数快照,出了问题能快速定位是模型理解错了,还是技能执行本身崩了,不再是一团黑盒。

这套系统说白了就是在模型和应用之间,垫一层“可控的中间层”。没有这层,你的Agent就是个只能聊天的玩具;有了这层,它才可能真正接进业务里干活。

2. 技能系统的四层骨架:注册、调度、执行、反馈

从架构角度看,一套经得起实践的技能系统,我会拆成四个层次。每个层次解决一类独立的问题,缺一个,后面都会还债。

2.1 注册层:让Agent知道“自己会什么”

注册层是整个技能系统的基础,它的核心是“技能元数据”。把每个能力封装成一份结构化的描述,交给调度层去读。这份元数据,我一般固定这几个字段:

字段作用示例
name技能唯一标识,小写连字符风格fetch_webpage
description给语言模型看的“使用说明书”获取指定URL的正文内容,适合阅读文章、提取信息
parameters调用参数JSON Schemaurl(string, required), timeout(int, optional)
tags领域标签,用于按场景检索search, web, scrape
category技能分类,便于权限管控system / business / personal
timeout执行超时上限,兜底用15s

这里要专门强调一下description怎么写。很多人把description写成人话,比如“这个函数用来获取网页内容”,但实际上这份描述是给语言模型做决策用的,写得好不好直接决定模型选得准不准。我的经验是必须包含三块信息:这个技能是干什么的、什么场景下优先调用、什么情况下不要调用。比如“获取指定URL的正文内容,适合阅读文章、提取信息”,后面我会加上“仅用于访问公开网页,不要用于登录后才能访问的页面”。你看,当模型知道边界之后,它就不会拿一个技能硬套所有场景。

注册层还要考虑一件事:技能的发现机制不能只是“拿一个列表全塞给模型”。技能少的时候无所谓,技能一旦超过二十个,所有描述都塞进上下文,token爆炸不说,模型反而容易眼花。所以注册层必须配套一套索引能力,按标签、关键词、使用频次做检索,只把最相关的几个候选技能交给调度层。

2.2 调度层:从“塞满”上下文到“按需检索”

调度层就是“Agent大脑和技能双手之间的连接件”。说直白点,它负责两件事:第一,从注册表里挑出当前任务最可能用到的技能;第二,把用户请求和候选技能一起交给大模型,让模型决定调哪个、参数怎么填。

早期的做法很简单:把register层所有技能转换成function calling的schema,一股脑塞进API请求里。技能少的时候确实能用,但技能一多,每次调用光工具描述就占几千token,延迟、成本、准确率一起崩。我后来改成两步走:

第一步是技能预检索。用轻量级方式(关键词匹配或小模型分类)从注册表里筛出5~8个候选技能;第二步再把这几个候选技能的完整描述交给大模型做精细决策。这样做的收益非常直观:同样的请求,token消耗降了一半多,模型选技能的准确率反而提升了,因为候选集里没有干扰项。

调度层另一个容易被忽略的职责是“参数准备”。大模型返回的tool_calls只是“它想怎么调用”,不一定是“能安全执行的调用”。调度层要把模型给的参数再做一遍校验、补默认值、做类型转换,然后把干净的执行上下文交给执行层。

2.3 执行与隔离层:权限边界与防呆设计

执行层是技能真正“干活”的地方,也是最容易出安全事故的地方。我先说几个必须做的防呆设计:

第一,超时兜底。技能外部依赖的服务如果卡住了,Agent的响应会一直等,用户感知就是“死掉了”。所以每个技能必须有独立的超时时间,执行超过阈值直接终止,返回一个“执行超时”的错误结果给模型。

第二,权限隔离。不同技能拥有不同的系统权限。“读文件”的技能不能同时有“写文件”的权限,“操作数据库”的技能必须限定只能访问指定库表。你无法保证模型每次生成的参数都绝对安全,所以必须在执行层做强制隔离,而不是寄希望于模型不犯错。

第三,环境隔离。凡是技能需要操作本地文件系统、执行命令的,一律在沙箱环境跑。我个人的教训是,直接在宿主机上跑文件操作技能,一个路径拼接错误就可能覆盖掉不该动的配置文件。后来所有这类技能一律丢进容器或受限用户环境里执行,出问题最多影响那个沙箱,不会波及主系统。

执行层还有一个重要任务:标准化返回。不管技能内部逻辑多复杂,往外输出一定要统一结构:状态字段(成功/失败/超时)、返回数据、错误信息、耗时。这个统一结构是后续“反馈层”能够做数据沉淀的前提,也是让模型能“看懂”执行结果的关键。

3. 从零搭建一套最小可用的技能系统

理论讲再多,不如直接看代码。下面是我自己在个人项目里实现的一套极简技能系统,完全可运行,也不算复杂,核心就三层:技能类、注册器、调度逻辑。

3.1 技能基类与注册器

先定义技能基类,所有技能都是它的子类,必须实现execute方法,同时声明元数据:

from typing import Dict, Any, Optional import inspect import time class Skill: name: str = "" description: str = "" parameters: Dict[str, Any] = {} tags: list = [] timeout: float = 10.0 def execute(self, **kwargs) -> Dict[str, Any]: raise NotImplementedError def _run(self, **kwargs) -> Dict[str, Any]: start = time.time() try: result = self.execute(**kwargs) return { "status": "success", "data": result, "elapsed": round(time.time() - start, 3), "error": None, } except Exception as e: return { "status": "error", "data": None, "elapsed": round(time.time() - start, 3), "error": f"{type(e).__name__}: {str(e)}" }

然后写一个全局技能注册器,支持按名字、标签、关键词查找:

class SkillRegistry: registry = {} tag_index = {} @classmethod def register(cls, skill_cls): skill = skill_cls() cls.registry[skill.name] = skill for tag in skill.tags: cls.tag_index.setdefault(tag, []).append(skill.name) return skill_cls @classmethod def list_skills(cls): return list(cls.registry.keys()) @classmethod def get(cls, name): return cls.registry.get(name) @classmethod def search(cls, query: str, top_k: int = 5): query = query.lower() scored = [] for skill in cls.registry.values(): score = 0 if query in skill.name.lower(): score += 3 if query in skill.description.lower(): score += 2 for tag in skill.tags: if query in tag.lower(): score += 1 scored.append((score, skill)) scored.sort(key=lambda x: -x[0]) return [s for _, s in scored[:top_k]]

注册一个技能就变得非常简单了,只要继承Skill、填好元数据、实现execute、打上注册装饰器:

@SkillRegistry.register class GetTimeSkill(Skill): name = "get_current_time" description = "获取当前系统时间,适合回答今天日期、现在是几点等时间查询" parameters = { "type": "object", "properties": {}, "required": [] } tags = ["time", "basic"] timeout = 3.0 def execute(self, **kwargs): from datetime import datetime return {"datetime": datetime.now().isoformat()}

3.2 让语言模型学会“使用”技能

有了注册表,下一步就是把候选技能转成大模型能理解的function schema。这个转换逻辑是一次性的,直接遍历技能对象即可:

def skill_to_function_schema(skill: Skill) -> Dict[str, Any]: return { "type": "function", "function": { "name": skill.name, "description": skill.description, "parameters": skill.parameters, } }

然后再写调度逻辑:给定用户消息,先做技能检索,再用大模型的function calling能力决策调用哪个技能、传什么参数:

from openai import OpenAI client = OpenAI() # 假设已有客户端 def agent_run(user_message: str, registry: SkillRegistry): candidates = registry.search(user_message, top_k=5) tools = [skill_to_function_schema(s) for s in candidates] response = client.chat.completions.create( model="gpt-4o-mini", # 仅示例,可按需替换 messages=[{"role": "user", "content": user_message}], tools=tools, tool_choice="auto" ) msg = response.choices[0].message if msg.tool_calls: # 执行模型选择的技能 results = [] for tc in msg.tool_calls: skill = registry.get(tc.function.name) if not skill: results.append({"error": f"技能 {tc.function.name} 不存在"}) continue import json args = json.loads(tc.function.arguments) results.append(skill._run(**args)) # 把执行结果回传给模型,生成最终回答 messages = [ {"role": "user", "content": user_message}, msg.to_dict(), { "role": "tool", "tool_call_id": tc.id, "content": json.dumps(results, ensure_ascii=False), } ] final = client.chat.completions.create( model="gpt-4o-mini", messages=messages ) return final.choices[0].message.content return msg.content

这个流程是function calling的标准范式:先让模型选工具,执行完把结果回灌,再让模型基于结果组织自然语言回答。核心就是“把技能作为工具暴露给模型”,让模型只负责决策,执行完全交给代码。

3.3 服务化封装与全链路日志

技能系统最终要作为服务对外提供,我会在外面包一层轻量HTTP接口,方便外部系统统一调用,也方便在入口和出口都打日志:

from fastapi import FastAPI, Request import logging import uuid from contextvars import ContextVar app = FastAPI() request_id_var = ContextVar("request_id", default="unknown") logger = logging.getLogger("skill_agent") logging.basicConfig(level=logging.INFO) @app.middleware("http") async def add_request_id(request: Request, call_next): request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())) token = request_id_var.set(request_id) logger.info(f"[{request_id}] request start: {request.url.path}") response = await call_next(request) logger.info(f"[{request_id}] request end: status={response.status_code}") request_id_var.reset(token) return response @app.post("/v1/agent/run") async def run_agent(payload: dict): request_id = request_id_var.get() user_message = payload.get("message", "") try: result = agent_run(user_message, SkillRegistry) logger.info(f"[{request_id}] agent response: {result[:100]}") return {"data": result} except Exception as e: logger.error(f"[{request_id}] agent error: {e}") return {"error": str(e)}, 500

这里为什么要重点提日志?因为Agent场景的排错和传统接口排错完全不同:传统接口是输入确定、输出确定,错了看堆栈就行;Agent场景里模型选型、参数生成、技能执行、结果回灌,每个环节都可能出错。没有全链路日志和request_id串联,你根本没法判断问题出在哪个环节。我实测中,有了日志后排查时间缩短了80%以上。

4. 接真实任务后我踩过的五个坑

能跑通的demo和能稳定服务的系统完全是两码事。这一节分享我在真实使用过程中踩过的、每个都能让Agent当场翻车的坑。

4.1 上下文污染:技能描述写太长,模型反而变笨

第一个坑是我在技能数量不多时遇到的,当时我把每个技能描述都写得特别详细,甚至把源码里的实现逻辑都写进description,心想“让模型更懂这个技能”。结果实测发现,技能一旦超过十个,整个工具列表占掉的token比重就已经很夸张了,而且模型决策准确率显著下降——它会被好几个相似描述干扰。

后来我把技能描述全部精简成“人说人话”的版本:一句话说功能,一句话说适用场景,一句话说边界条件。同时配上了技能检索,只把最相关的5个技能完整描述发给模型。效果立竿见影:token消耗降了约40%,准确率反而提升。

这里的关键认知是:技能描述不是文档,是给模型的“决策信号”。写太多无关细节,等于给模型增加噪声。

4.2 外部依赖超时:技能比人还“卡”

有一次我给Agent加了个“查询数据库”的技能,本地测试一切正常。放到线上后,数据库连接池某个时刻被慢查询占满,新的技能调用全都卡在等待连接上。结果就是,用户问了一个本来只需要1秒回答的问题,Agent等了足足15秒才回复,体验惨不忍睹。

这次事故让我彻底意识到:技能执行过程中,外部依赖的不可控性必须被系统兜住。后来我做了三层防护:第一,每个技能设置独立超时;第二,所有外部调用都走带超时的session;第三,超时后的错误信息要能直接回传给模型,让模型告诉用户“当前查询超时”,而不是一直转圈。

import httpx timeout = httpx.Timeout(5.0, connect=3.0) client = httpx.Client(timeout=timeout)

这种“让错误可感知、可反馈”的设计,比“让系统永远不报错”更现实。

4.3 参数幻觉:LLM生成的调用参数不能直接执行

大模型在生成tool_calls时,有概率出现参数幻觉:比如技能明明只定义了一个url字段,模型却额外塞了一个retries参数;或者技能要求timeout是整数,模型传了个字符串。

第一次遇到时我以为是偶发,后来统计了一下,在比较复杂的对话上下文里,参数格式出错的概率能到5%~10%。所以我在调度层加了一道严格的schema校验,凡是模型返回的参数,先做类型校验和字段白名单过滤,然后再传给技能执行:

import jsonschema def validate_arguments(skill: Skill, args: dict) -> dict: schema = skill.parameters # 剔除schema里不存在的字段 allowed = set(schema.get("properties", {}).keys()) clean_args = {k: v for k, v in args.items() if k in allowed} jsonschema.validate(instance=clean_args, schema=schema) return clean_args

校验失败的参数,我会直接返回“参数错误”给模型,不尝试重建或推测。宁可让用户重说一遍,也不执行一个带着幻觉参数的调用。

4.4 环境隔离问题:一次误操作差点清掉配置

这是我个人项目里经历的最险的一次事故。我写了一个“读取项目文件”的技能,用于帮Agent查看目录结构。但当时偷懒,允许传绝对路径,没做目录白名单限制。某次测试时模型生成了一个非常离谱的路径,差点把项目根目录的配置文件当作临时文件删掉。还好那行代码写的是读取,不是删除,不然得重写整个配置。

从那以后,凡是文件读写类技能,我一律加了“路径规范化”和“根目录限制”两层保护。技能可以传相对路径,最终执行前把绝对路径join到指定的工作目录内,如果越界直接拒绝:

from pathlib import Path WORKDIR = Path("/srv/agent_workspace").resolve() def safe_resolve(raw_path: str) -> Path: target = (WORKDIR / raw_path).resolve() if not target.is_relative_to(WORKDIR): raise PermissionError(f"路径越界: {raw_path}") return target

环境隔离不是“防小人”,而是“防概率”。模型不会故意做坏事,但它可能在特定上下文里生成完全出乎你意料的参数。

4.5 语义冲突:模型为什么总是调错技能

技能数量多了之后还会遇到一类“选型打架”问题:两个技能描述有重叠,模型总是选错。印象最深的是“网页内容抓取”和“搜索引擎结果获取”,两个技能都涉及“打开网页拿信息”,在部分请求下模型完全分不清该用哪一个。

我的解法是在每个技能描述末尾固定加一个“边界与失败场景”段落,专门写清楚“什么情况下不要调用本技能”。比如网页内容抓取技能写“仅用于直接通过URL访问具体页面内容,不适用于需要搜索关键词找链接的场景”;搜索引擎技能写“当用户给出明确的URL时不要调用本技能”。加上这段之后,混淆率显著下降。

这个细节很多教程不会提。大家只觉得“描述写得详细就好”,但真正有效的是“把不适用于什么情况写清楚”,负面约束对模型决策的帮助比正面描述大得多。

5. 从“能跑”到“好用”:技能体系的评估与迭代

技能系统不是搭完就完了,它是一个需要持续迭代的系统。怎么评估好坏、怎么持续改进,是最后一个重要话题。

5.1 评测:先建一个属于你自己的回归集

我给技能系统专门建了一个“回归评测集”,里面收集了过去几个月真实用户的高频请求,以及每个请求“期望模型调用的技能”和“期望产出的答案”。每次改动技能描述、新增技能、或者调整调度逻辑,就在这个集合上跑一遍,看有多少请求的行为发生了变化。

我用的核心指标有这几个:

指标计算方式我的目标值
工具选择准确率模型选中的技能是否等于标注的期望技能≥95%
执行成功率技能执行状态为success的比例≥98%
平均决策耗时从请求到技能选定的时间≤1.5s
Token增量全程调用比纯对话多花的token数≤40%
人工纠错率用户对Agent回答进行纠错/追问的比例≤10%

这里要提醒一点:回归集不需要多,但必须有代表性。我第一批只收集了50个样本,覆盖高频场景和困难场景,就已经能帮我抓出大量回归问题。尤其是调整技能描述时,没有回归集的辅助,真的就是盲人摸象。

5.2 反馈回路:把用户的纠错变成技能优化素材

Agent上线后最大的反馈来源是用户行为。用户改提示、追问、否定回答、表达不满,这些都是信号。我建了一个“反馈采集”的轻量机制:每次Agent回答后,前端回传一个用户是否满意的标记;不满意时回传用户的补充说明。把这些样本定期导出,人工标注“模型哪里选错了”,再反馈到技能描述的调整里。

这形成了一个很简单的闭环:真实使用暴露错误 -> 采集并标注 -> 修改技能元数据或调度策略 -> 重新跑回归集验证 -> 上线观察。这个循环跑上两三个月后,模型和技能系统的匹配度会越来越高。我自己的项目从第一版到现在,工具选择准确率从大概85%提升到了97%以上,靠的就是这个反馈回路。

5.3 技能组合与多Agent协作:下一步我在探索的方向

技能系统稳定之后,自然会长出一些更高级的玩法。比如“技能组合”:把“搜索网页”和“提炼摘要”两个技能串起来,形成一个新的复合技能“搜索并总结”。这就不只是一个工具注册的问题,还会涉及状态管理、中间结果传递,实际上是一个小型的Agent编排。

另外一个方向是“跨Agent技能复用”。如果多个Agent共享一套技能注册中心,那么不同Agent可以按需加载不同技能集合,而不是每个Agent都维护一套重复的打法。技能注册表从一个Agent的内部模块,演进成一种“能力市场”,这大概就是agent-skills真正成熟的形态。

我现在正在尝试的技能组合,核心思路是给注册表里的技能增加“依赖链”概念:一个复合技能可以声明自己依赖哪些原子技能。调度层在执行复合技能时会自动编排前缀顺序,这样既保持每个技能的可测试性,又能向外提供更高阶的能力。

最后分享一个我个人的小技巧

收个尾。如果你正准备开始给Agent搭技能系统,我最大的建议是:别一开始就追求大而全。先把你日常重复率最高的手工操作变成技能,比如查天气、读网站、算时间、记笔记,加五六个用到顺手了,再去考虑复杂编排、长流程任务。

还有一个特别管用的经验:每个技能描述的最后一行固定写“边界与失败场景”。这句话写起来特别不起眼,但实际效果比我试过的任何高级prompt技巧都好——它能显著降低模型误选率。至少在我自己的项目里,加了这一行之后,选型准确率有肉眼可见的提升。你写技能的时候可以试试,有惊喜。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询