接手Agent项目之后,我最大的感受是:决定一个Agent聪明的上限的,不是模型本身,而是你塞给它的那堆“技能”(agent-skills)设计得好不好。同样的GPT,技能库搭得规整,它就是能干活的数字员工;技能库堆得随意,它就是一本带上下文窗口的百科全书。这篇文章我把自己从零搭技能系统的全过程,包括框架代码、踩坑记录和验收方法,完整写出来,希望能给正在做Agent应用的朋友一点参考。
1. 为什么Agent需要“技能库”而不是更长的提示词
1.1 先给“技能”下一个能落地的定义
在很多项目里,“技能”这个词被用得很虚。我说一下我自己在工程里的定义:Agent技能 = 一段明确的功能描述 + 一套结构化的参数契约(JSON Schema) + 一段真正执行的代码。
把这三样绑成一个模块,注册进一个技能仓库。模型在对话中看到用户的请求,根据技能描述决定“这个任务该调用哪个技能”,然后把参数按照契约填好,交给技能执行体去干活。整个过程有点像你雇了一个实习生,你把工具箱摆在他面前,每个工具贴上标签和使用说明书,他负责判断什么活用什么工具。
这里的关键是:模型不负责执行,只负责“路由”。真正干活的是代码。所以技能设计的本质,是给模型一份高质量的工具清单,让它每一次路由都尽量准确。
1.2 把所有能力写进System Prompt的玩法,天花板很低
我见过很多团队的第一版Agent,就是把所有操作步骤直接写进System Prompt里:
- “当你需要查天气时,调用天气API,参数是城市名。”
- “当你需要写邮件时,按照模板生成,发件箱是xxx。”
- “当你需要算数学时,先把它拆成表达式。”
这种玩法在小规模Demo里确实能跑,但一旦指令超过一定量级,问题立刻暴露。
上下文膨胀:如果20个能力全写在System Prompt里,意味着每一次请求都要把这2000字甚至5000字的指令重新发送一遍。Token成本、首字延迟全都上来了。更麻烦的是,大段指令挤在一起,模型对每条指令的关注权重会被稀释,容易出现“你说了一堆,它只记住了最后一条”的情况。
不可组合:写在提示词里的能力没有边界,没有输入输出契约。比如你有一个“搜索资料”的步骤和一个“写摘要”的步骤,它们之间怎么衔接?没有明确的接口,全靠模型临场发挥,结果就是每次输出都不一样。
不可测试、不可升级:Prompt改了一句话,影响的是全局面,你没法单独验证某一个能力是否正常。而技能不一样,它是一个独立模块,可以单独写单测、单独发布、单独回滚。
所以我的结论很直接:凡是超过3个可调用的外部操作,就应该从Prompt迁移到技能系统里,而不是继续往提示词里堆。
1.3 Tools、Skills、Actions,本质上是一家人
很多人被术语搞晕:OpenAI叫Function Calling,Anthropic叫Tool Use,LangChain叫Tool和Toolkit,业务同事叫“动作”或“插件”。我在实战中把它们当作同一件事来看——本质都是给模型提供一组可调用的外部能力,让模型在对话过程中动态选择并调用。
区别在于工程定位。
| 叫法 | 来源 | 工程定位 |
|---|---|---|
| Function Calling | OpenAI API | 偏底层协议,模型返回结构化的函数名和参数 |
| Tool Use | Anthropic API | 同上,协议细节不同 |
| Tool / Toolkit | LangChain框架 | 框架层面的封装,Toolkit是多个Tool的集合 |
| Skill | 各类开源项目 | 更强调可沉淀、可复用、可组合,带一点“个人能力库”的味道 |
用Skill这个词,其实传达了一种观念:这些能力不是一次性代码,而是Agent长期积累的资产。就像一个人的手艺,会沉淀、会迭代、会互相组合。所以这篇里我用“技能系统”这个叫法,但底层对接的就是各大模型厂商的函数调用协议。
2. 技能系统的四个核心要素
2.1 技能清单:先盘点这个Agent到底会干什么
在设计代码之前,我建议你先做一次“技能盘点”。做法很简单:把你对用户需求的描述拆成动词。
比如做一个会议纪要助手,用户会要求:
- “把这两份文档合并”
- “总结今天的讨论重点”
- “把结论发邮件给相关人”
- “给这周安排一个复盘会议”
- “查一下某个人的日程”
从这些请求里能提出来的动词就是:合并、总结、发邮件、建日程、查日程。每个动词对应一个技能。
很多Agent做砸,不是因为模型不够聪明,而是因为技能清单根本没梳理清楚。要么漏了高频动作,要么把几个动作揉在一个技能里,导致模型不知道该在什么时候调用。我一般会把技能数控制在10个以内,超过10个就要开始考虑分层和分组。
先别急着写代码,拿一张纸把技能清单写下来,每个技能写清楚三句话:
- 这个技能帮用户完成什么目标?
- 它接收什么输入?
- 它返回什么结果?
这三句话,最后会变成技能的描述文本和参数Schema。我在项目里经常说:技能清单整理清楚了,代码只是体力活。
2.2 描述文本:模型靠它做路由,写不好就乱套
技能描述是模型决定“要不要调用”的唯一依据,重要性怎么强调都不为过。但我见过太多人栽在描述上。
坏描述长这样:
获取天气信息,支持各种城市查询。
这个描述说了等于没说。“各种城市”是什么范围?什么条件下调用?跟“查询历史天气”的界限在哪?模型只好猜,一猜就乱。
好描述长这样:
获取指定城市当前天气。当用户询问“今天天气如何”“会不会下雨”“适合穿什么”等与当前气象相关的问题时调用;只支持中国县级以上城市名称,历史天气与空气质量查询不要调用此技能,请使用historical_weather技能;参数city必须是标准城市名,例如“北京”“上海”,不能带“市”字和标点。
核心要点:
用“当……时调用”写明触发条件,让模型有明确的判断依据。
用“不要调用”写清排除边界,很多模型出错就是因为边界模糊,导致两个技能之间抢活。
在参数描述里带上格式约束,比如“必须是数字,不要带单位”“必须是YYYY-MM-DD格式”,可以大幅降低参数错误率。
一个经验数据:在我自己的项目里,把技能描述从一句话扩写成一整段之后,工具调用准确率从67%提升到了91%,模型选错工具的情况几乎消失。
2.3 参数Schema:契约设计决定调用成功率
技能参数遵循JSON Schema。给模型的函数调用接口,其实就是在传递这个契约。我一般会严格做到以下几点:
类型必须严格。是数字就写integer,是布尔就写boolean,不要因为字段名看起来像字符串就随便写。模型会严格遵守Schema里的类型声明,你写string,它就把数字当字符串传进来。
必填参数写清楚。只把真正绕不开的参数放进required数组。能缺省的一律给默认值,参数越多,模型填错的概率越大。
命名习惯向自然语言靠拢。用file_path而不是fp,用start_date而不是sd。模型对自然语言命名的理解能力好得多。
一个典型Schema示例:
{ "type": "object", "properties": { "city": { "type": "string", "description": "标准城市名,例如北京、上海,不要带'市'字" }, "days": { "type": "integer", "description": "查询未来几天的天气,必须是正整数" } }, "required": ["city"], "additionalProperties": false }这里有个细节:additionalProperties: false很多人会加,但我实际试下来,在一些模型上加了反而容易让模型因为多填了参数而报错。如果你发现调用失败率偏高,可以先去掉这个字段试试。
2.4 执行体:返回值怎么写才不会污染上下文
技能执行体的返回值,会被直接拼回对话历史,作为工具调用结果给模型看。所以返回值的设计直接决定两件事:模型能不能基于结果继续思考,以及上下文会不会被撑爆。
我总结的三个原则:
只返回结果,不返回日志流。技能执行过程中的调试日志、打印信息,一律用logging输出到控制台,不要混进返回值。
结构化返回。统一返回JSON,比如{"status": "ok", "data": {...}},或者出错时返回{"status": "error", "message": "..."}。模型解析JSON的能力很强,结构化的返回能让它快速提取关键信息。
大结果做截断或摘要。一个技能查数据库返回了50行记录,直接全量塞回对话,几轮下来上下文就爆了。正确做法是在执行体里先做截断,比如只返回前5条加一个"total": 50的提示。模型如果需要更多,它会自己再想办法,或者你再设计一个“翻页”技能。
这四条要素合起来,就是一个技能的完整生命周期:描述负责被看到,Schema负责被理解,执行体负责把事干成,返回值负责让整个对话继续下去。
3. 从零实现一个最小技能注册框架
3.1 目录结构的第一版设计
我自己搭技能系统的目录是这么组织的:
agent/ ├── skills/ │ ├── __init__.py │ ├── registry.py │ ├── weather.py │ ├── calendar.py │ └── email.py └── agent.pyregistry.py放注册中心和Skill数据结构。每个技能文件放一组相关技能,比如所有天气相关的放一个文件里,所有日历相关的放另一个文件。这样每个文件职责清晰,也能单独测试。
有人喜欢把所有技能塞进一个文件,几十个函数堆在一起,说实话也能跑,但一旦某个技能出问题,排查起来特别痛苦。分文件的好处是:边界就是文件边界,改一个不会碰坏另一个。
3.2 Skill数据结构和注册中心
先定义Skill这个数据结构:
# skills/registry.py from dataclasses import dataclass, field from typing import Any, Callable, Dict, List import json @dataclass class Skill: name: str description: str parameters: Dict[str, Any] handler: Callable tags: List[str] = field(default_factory=list) def to_openai_tool(self) -> Dict[str, Any]: return { "type": "function", "function": { "name": self.name, "description": self.description, "parameters": self.parameters, }, } class SkillRegistry: def __init__(self): self._skills: Dict[str, Skill] = {} def register(self, skill: Skill) -> None: if skill.name in self._skills: raise ValueError(f"duplicated skill name: {skill.name}") self._skills[skill.name] = skill def resolve(self, name: str) -> Skill: skill = self._skills.get(name) if not skill: raise KeyError(f"skill not found: {name}") return skill def tool_specs(self) -> List[Dict[str, Any]]: return [s.to_openai_tool() for s in self._skills.values()]这个注册中心做两件事:第一,把技能对象保存下来;第二,统一生成给模型看的工具规范列表。duplicated skill name报错是我刻意加的,因为技能重名是最危险的错误之一,模型可能因此调错功能。
3.3 用装饰器把函数变成技能
注册技能的调用方式,我喜欢用装饰器。在函数定义上一标注,这个函数就变成技能了:
# skills/weather.py from skills.registry import registry from skills.decorator import skill @skill( name="get_current_weather", description="获取指定城市当前天气...", parameters={ "type": "object", "properties": { "city": {"type": "string", "description": "标准城市名,例如北京、上海"}, }, "required": ["city"], }, ) def get_current_weather(city: str): # 这里写实际的API调用逻辑 return {"status": "ok", "data": {"city": city, "weather": "晴", "temp": 26}}装饰器内部做的工作就是把函数包成Skill对象,然后注册进全局注册中心:
# skills/decorator.py from functools import wraps def skill(name: str = None, description: str = None, parameters: Dict[str, Any] = None, tags: List[str] = None): def decorator(fn): skill_name = name or fn.__name__ registry.register( Skill( name=skill_name, description=description or fn.__doc__ or skill_name, parameters=parameters or {"type": "object", "properties": {}}, handler=fn, tags=tags or [], ) ) return fn return decorator实际项目里我建议参数和描述都显式写,不要依赖docstring解析。虽然有一些框架能从docstring自动生成Schema,听起来很省事,但在复杂参数类型上错误率不低,而且docstring写得不规范时,生成的Schema完全不可控。省的那点功夫,后面填坑时全得还回来。
3.4 接入模型调用循环
注册好了技能,接下来把它接到模型调用循环里。这里以OpenAI兼容的接口为例,核心循环是这样的:
def run_agent(user_input: str, registry: SkillRegistry, max_rounds: int = 8): messages = [{"role": "user", "content": user_input}] for _ in range(max_rounds): resp = client.chat.completions.create( model="gpt-4o", messages=messages, tools=registry.tool_specs(), tool_choice="auto", ) assistant_msg = resp.choices[0].message messages.append(assistant_msg) # 模型没有发起工具调用,说明它认为任务已完成 if not assistant_msg.tool_calls: return assistant_msg.content # 遍历模型返回的工具调用 for tc in assistant_msg.tool_calls: try: arguments = json.loads(tc.function.arguments) skill = registry.resolve(tc.function.name) result = skill.handler(**arguments) output = json.dumps(result, ensure_ascii=False) except Exception as e: output = json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False) # 把工具结果拼回对话历史 messages.append({ "role": "tool", "tool_call_id": tc.id, "content": output, }) return {"error": "exceed max rounds", "messages": messages}这个循环的思路很直白:模型发起工具调用,我解析调用请求、执行对应技能,把结果拼回历史,让模型基于结果继续。一直到模型不再发起调用,输出最终回复。
有两个点值得展开说。
JSON解析失败的处理。模型偶尔会返回格式不完整的arguments,json.loads会抛异常。我这里的兜底是把错误信息拼回对话,模型看到错误后会自己纠正参数重新发起调用,实测大部分情况它能自己修好。
最大轮数限制。max_rounds=8是防死循环用的。没有这个限制,模型可能在遇到反复报错时无限循环下去。协作场景下8轮一般够用,但如果你的Agent涉及长链路分析,可以适当调大。
4. 多技能协作:串一条链路,还是并行分发
4.1 一个具体的协作场景
单技能调用比较容易,但真实业务里Agent经常需要连续调用多个技能。我以“整理会议纪要并发送邮件”为例。
用户请求:“把今天上午的产品评审会记录整理成摘要,发给周总。”
这个任务至少涉及四个能力:
- 读取原始会议记录(get_meeting_notes)
- 生成结构化摘要(summarize_text)
- 查询收件人邮箱(search_contact)
- 发送邮件(send_email)
模型在这种场景下的决策链路是怎样的?
第一轮:模型判断需要先取到会议记录,调用get_meeting_notes,参数为日期“今天上午”。
第二轮:拿到记录内容后,调用summarize_text对内容做摘要。
第三轮:需要给周总发邮件,调用search_contact确认邮箱地址。
第四轮:调用send_email,把摘要内容和邮箱地址填入参数。
第五轮:收到发送成功的结果后,模型认为任务完成,输出“已发送”的最终回复。
这个流程串起来,就是Agent的“多技能协作”能力。
4.2 串行编排的隐藏设计点:上下文里要留得住中间结果
串行链路跑起来之后,我发现一个问题:有些模型会把中间结果“弄丢”。比如第二轮拿到摘要后,第三轮它需要同时知道“摘要内容”和“周总邮箱”,但如果模型只记住了后一次调用的结果,前面的信息就可能被忽略。
解决办法有两个。
一是要求技能返回值尽量自包含。比如search_contact的返回值直接是{"name": "周总", "email": "zhou@example.com"},这样后续调用可以复用。
二是在技能描述里写明“在调用本技能之前,你需要先从之前的工具结果中提取关键信息”。比如send_email的描述里可以写:当你调用本技能时,请确保content参数来源于前面工具的摘要结果,收件人参数来源于search_contact的返回结果。
这种方法本质上是在给模型的注意力“划重点”。
4.3 并行分发:一轮多个tool_calls怎么办
有些模型API不只支持一个工具调用,它们会在同一轮返回多个tool_calls,让框架并发执行。这比串行高效,尤其是几个技能互不依赖时,比如:
模型判断同时需要“查天气”和“查当前时间”,它可能在一轮里返回两个tool_calls。如果是串行执行,那就白白多花一轮模型推理时间。如果框架支持并发,两个技能同一时间执行,省一半时间。
处理并发的方式很简单,用线程池:
from concurrent.futures import ThreadPoolExecutor def dispatch_tool_calls(tool_calls, registry, messages): def _run_one(tc): skill = registry.resolve(tc.function.name) args = json.loads(tc.function.arguments) return tc.id, skill.handler(**args) with ThreadPoolExecutor(max_workers=len(tool_calls)) as pool: results = list(pool.map(_run_one, tool_calls)) for tc_id, result in results: messages.append({ "role": "tool", "tool_call_id": tc_id, "content": json.dumps(result, ensure_ascii=False), })注意并发不是随便用的。如果两个技能操作同一个文件,或共享同一个写状态,并发反而会引入竞态问题。我一般只对纯查询类、无副作用的技能开并发,涉及写操作的一律串行。
4.4 什么时候该引入规划器
技能少的时候,模型直接路由就够了。但当技能数量到十几个,模型偶尔会漏掉关键步骤,或者顺序搞错。这时候可以考虑引入一个“规划器”:让模型先不执行,而是把用户目标拆解成一步步的计划,然后再逐步骤执行。
规划器的实现方式很多,有人直接用单独一次LLM调用生成计划,有人用ReAct框架让模型边想边做。我的看法是:先用技能分组解决80%的复杂度,不要一上来就上规划器。
比如,与其让模型在20个技能里挑,不如把技能按领域分组:会议类、邮件类、文档类。模型先判断用户需要对哪个领域操作,再在领域内挑选具体技能。这本质上就是给模型做了一个“先粗后细”的路由逻辑,比全量技能硬搜索效果好得多。
5. 我实测三个月踩到的坑,以及防御方案
5.1 参数被模型当成字符串传进来
这是最频繁的坑。我设计了一个查询“未来N天天气”的技能,Schema里写明days是integer,但模型经常返回"days": "3"——带了引号的字符串。
原因在于模型本身训练时接触了大量文本化的数字,它不擅长把抽象的数字类型匹配到自己的输出上。
防御方案:执行体里做一次类型清洗。不能只依赖Schema,要自己做兼容:
def _coerce_int(value): if isinstance(value, int): return value if isinstance(value, str): return int(value.strip()) raise ValueError(f"cannot coerce {value} to int")在技能入口统一做参数校验,不合法就抛错,错误信息会传回给模型,模型会自己修正。一套参数清洗工具函数,基本上能干掉这类错误的三分之二。
5.2 技能描述写得太“万能”,技能之间互相抢活
我早期给一个“查询客户信息”的技能写的是:查询客户库,支持按名称、电话、邮箱搜索客户。
听起来没毛病,但实际运行中发现,用户问“帮我找一下王总的公司”,这个技能也跳出来了,而“王总的公司”其实应该走“查询公司信息”的技能。两个技能同时被调用,或者错的那个被调用,结果就乱了。
后来我把描述改成:
查询客户库中的个人联系人信息。当用户提供联系人姓名、手机号、邮箱并要求查找联系方式时调用。如果你认为用户问的是客户所在的公司,使用query_company技能,不要调用本技能。
加了“不要调用”的边界说明之后,抢活现象明显减少。技能描述不仅要告诉模型技能能做什么,还要告诉模型它不该做什么。
5.3 技能返回结果把上下文撑爆
有个技能是“读取本地文件”,我把整个文件内容原样返回了。一个500KB的CSV直接塞进对话,一轮下来上下文窗口就满了,模型后面的所有操作都变得迟钝甚至直接报错。
现在我处理大结果的方法:
- 只返回前N行,加一个
"total": X字段; - 需要全部内容时,另外设计一个
get_file_preview和get_file_page技能做分批读取; - 如果后续模型需要的只是统计信息,直接在技能里算好,返回结论而不是原始数据。
原则一样:传给模型的任何东西都要小,反正模型记不住太长,而你还浪费Token钱。
5.4 死循环:模型反复调用同一个技能
遇到过最魔幻的一次:模型在调用“查询天气”时一直传错城市名,每次都被天气API报错弹回。然后它继续用同样错误的参数重试,整整转了7轮,直到我设置的max_rounds=8才停下来,而最终回复还是一团乱麻。
防御方案除了max_rounds,还可以做“重复熔断”:如果同一技能在同一轮对话里连续失败超过3次,直接终止该技能调用,并把综合错误信息返回给模型,让它换个策略。
if last_rounds.count((skill_name, "error")) >= 3: return {"error": "该技能连续失败,请更换思路或告知用户"}用了熔断之后,这种“死脑筋循环”基本绝迹。
5.5 技能内部异常处理放错了层级
最开始我的技能函数里出了任何异常,整个Agent循环就崩了。后来我发现异常不能直接往上抛,应该把它变成一种“可恢复的错误消息”返回给模型,因为模型很擅长从错误信息里自己找原因。
正确做法:技能内部预期内的错误,返回{"status": "error", "message": "..."};预期外的异常,在框架层捕获后同样包装成错误消息回传,而不是直接中断。
try: result = skill.handler(**arguments) except Exception as e: result = {"status": "error", "message": f"技能执行异常: {e}"}这样模型能读到错误信息,并基于它做下一轮决策——重试、换参数、换技能,都行。这比直接崩掉体验好一个量级。
5.6 技能边界模糊:功能重叠的代价
最后一个是设计层面的坑。有一段时间我做了一个“邮件摘要”技能,又做了一个“文档摘要”技能,同时还留着一个“通用文本摘要”技能。三个技能的描述里都有“总结”“提炼要点”这些词,模型经常随机选一个。
我后来痛下决心合并:把“邮件摘要”和“文档摘要”全部重构成“文本摘要”技能,接收source_type参数来区分场景。技能数量减少了,调用准确率反而上来了。
技能不是越多越好。功能重叠的技能会让模型陷入选择困难,宁可少而精,也不要多而杂。这是我在这个项目里最深的体会之一。
6. 技能上线前的测试与验收
6.1 黑盒回归测试:固定问题集是底线
技能系统最容易出现的问题是“改了A技能,B技能的调用率掉了”,因为描述文本之间会互相干扰。所以我在项目里维护了一份固定的回归问题集,大概40个问题,覆盖所有核心技能和典型边界场景。
每次调整任何技能之后,都会用同一模型版本重跑一遍,记录三个指标:
| 指标 | 说明 |
|---|---|
| 工具选择准确率 | 该调对的技能是否调对 |
| 参数成型率 | 参数是否完整、类型是否合法 |
| 回答完整率 | 最终回复是否解决了用户问题 |
不记不知道,一旦开始记,你会发现很多“感觉没问题”的改动,实际让准确率掉了5个百分点。回归测试是Agent工程质量的地基,不过这个问题集需要持续维护,每次线上出现新错误case,都往里加一条。
6.2 技能单元测试:不和模型耦合
技能执行体本身是纯函数,完全可以脱离模型做单元测试。我会给每个技能写几组常规输入、边界输入、错误输入,检查返回结构是否符合约定。
边界输入最重要,比如空字符串、超长文本、非法日期。因为模型调用时常常会生成一些“半对半错”的参数,执行体能优雅处理这些输入的话,整个系统的稳定性会上一个台阶。
6.3 技能版本管理与线上观测
技能上线后,建议做两件事。
日志观测。每条工具调用都要记录:技能名称、发起时间、模型传入的参数、执行结果、结果大小、消耗轮次。这样出问题时能快速回放整个决策过程,定位是模型选错还是执行体报错。日志里能反映出来的信号很直观,比如某个技能的调用成功率突然下降,很可能就是你改了它的描述文本,边界写得不清楚了。
技能版本化。用skills/weather.py这种文件加一个简单的版本号__version__ = "1.2.0"就能实现基本控制。频繁改动的技能建议记录变更日志,因为技能描述文本的修改对模型路由影响很大,不是你改完代码就万事大吉了。
最后再分享一个我自己一直坚持的习惯:新技能上线前,我会在固定的对话开场白里主动加一句“你可以使用以下技能:……”让模型先知道自己的能力范围,同时也能在初轮就暴露技能识别问题。技能开发是一个持续迭代的过程,先跑通一个最小循环,再慢慢往里面加能力,比一开始憋一个大而全的技能库要稳得多。好的技能系统,是长出来的,不是一次性设计出来的。