Agent 项目总在"就差最后一步"时崩掉?如果你带过稍微复杂一点的 Agent 项目,大概率见过这样的现场:模型已经正确理解用户意图,也知道该调用哪个工具,结果因为工具描述写得含糊、参数传错、超时没人管,最后返回一堆莫名其妙的内容。我前前后后折腾过好几个 Agent 项目,最大的感触是:决定 Agent 能力上限的往往不是模型本身,而是一套技能系统的设计水平。这里说的技能,不是让模型变大,而是给模型一套结构化的、可复用的、能稳定调用的外部能力模块。这篇文章围绕 agent-skills 这个方向,分享我在技能定义、注册、规划、调用和异常处理上的完整思路与踩坑记录,适合正在搭建多工具 Agent 的开发者,特别是被"功能一堆但不听话"折磨过的朋友。
1. 为什么 Agent 不能只有一堆函数:技能系统要解决的根本问题
我先说一个反直觉的观察:把同样的函数直接塞给大模型,和把这些函数封装成技能再交给大模型,效果差异非常明显。前者像给新人一堆工具不教用法,后者像附了一张带示例的说明书。
1.1 混乱的工具调用现场
我之前做过一个项目,为了省事把所有外部接口直接暴露给模型,每个接口就是一个 Python 函数,文件名和函数名就是模型唯一能看到的线索。结果就是灾难现场:模型为了查天气去调一个名为process_data的函数,因为那是它见过的所有函数里参数最像"能传城市名"的一个;为了发邮件去调用save_document,还传了一堆格式完全错误的参数。模型不是笨,是它真的不知道这些函数内部发生了什么,函数名和签名提供的信号太弱了。
这类问题的根源在于:大模型通过自然语言理解世界,它并不具备"阅读源码"的能力,只能依赖你提供给它的文本描述来做出决策。如果你给它的决策依据是残缺的、模糊的,那它的行为必然不可控。换个角度理解,你用requests调用别人的 API 时,会先看 OpenAPI 文档而不是直接翻源码,模型也是一样的。
另外还有一层更实际的问题:当工具数量超过十个以后,模型在上下文中"找到合适工具"的难度会直线上升。不是你描述的字段变少了,而是模型在十几页工具列表里做选择时,注意力会被高相似度的描述干扰。工具越相近,模型的选错率越高,这是基于 Transformer 注意力机制天然的特性。
1.2 技能与工具的区别
我理解的技能系统核心在于:工具是"能做什么",技能是"什么时候用、怎么用、用了之后怎么处理结果"的完整闭环。同一个底层函数,可以被封装成不同视角的技能——比如底层的execute_sql函数,既能封装成"查询订单"技能,也能封装成"统计用户分布"技能,后者只是决定了默认的查询模板和返回结果的格式化方式。
一个合格的技能定义至少包含五个维度:
- 技能名称:短、唯一、语义清晰,模型能直接理解
- 技能描述:说明这个技能解决什么问题、在什么场景下该调用、不该调用什么
- 参数定义:结构化 schema,包括类型、必填、枚举、约束、依赖关系
- 触发建议:一些典型的用户表达方式示例,帮助模型做意图匹配
- 输出处理:返回值如何被解析、是否需要展示给用户、是否需要二次调用
我见过很多人把技能系统简单理解成"加了 docstring 的函数",这是最大的误解。docstring 给开发者看的,技能描述给模型看的,两者的信息组织逻辑完全不同。给模型看的描述要直白地写清"什么时候用",而不是"这个函数内部做了什么"。
实操体会:技能描述里多写一句"当用户提出修改类请求时,不要使用此技能",比任何参数约束都能有效减少模型误调用。这种负向引导极其有效。
2. 技能接口的契约设计:让模型和人读同一本说明书
技能设计里最容易被低估的是契约。一旦技能数量增多,接口的一致性直接决定模型的调用成功率。我现在的做法是把每个技能当作一个小型 API 去设计,而不是一个普通函数。
2.1 一份好技能描述的两个世界
写技能描述的时候,我建议脑子里同时装两套读者:一套是大模型,它需要靠这份描述来做决策;另一套是团队新同事,他需要靠这份描述来理解系统的能力边界。一个好的描述应该让两边都满意。
我常用的描述模板这样组织:
技能:search_knowledge_base 用途:在内部知识库中检索与用户问题相关的文档片段。 适用场景:用户询问产品功能、操作流程、FAQ及政策条款; 用户问题涉及具体业务名词的释义。 禁用场景:用户仅进行寒暄、闲聊、追问上文已给出的答案; 问题明确指向外部实时信息(如新闻、天气)。 关键参数: - query: 检索关键词,建议从用户原话中提取核心实体。 - top_k: 返回条数,默认5,最大10。 输出格式:JSON数组包含 [ { content, score, source } ]。 额外说明:如果搜索结果score低于0.5,请明确告知用户“没有检索到精确答案”,不要编造。仔细看,这个描述几乎没有一个字在讲"函数内部怎么实现",全部信息都在帮模型建立"何时用、怎么传参、如何解读输出"的决策链路。
2.2 JSON Schema 参数约束:该管的管住,不该管的放开
参数定义我强烈推荐用 JSON Schema。原因有两个:一是 LLM 函数调用结构天然偏爱 JSON,各家推理框架对结构化输出的支持也是这个标准;二是 Schema 本身能表达循环依赖、条件约束,能力上限足够高。
es 一个实际的例子——查询订单这个技能,My 参数 Schema:
{ "type": "object", "properties": { "order_id": { "type": "string", "description": "订单编号,完整匹配" }, "customer_id": { "type": "string", "description": "客户编号,与订单编号二选一" }, "expand_items": { "type": "boolean", "description": "是否需要返回订单明细" } }, "oneOf": [ { "required": ["order_id"] }, { "required": ["customer_id"] } ], "additionalProperties": false }这个 schema 的精髓在oneOf约束:模型必须二选一,不能两个都传或都不传。你可能会问,直接在代码里判断不也一样吗?区别在于,把约束放在 schema 层面,模型在生成参数阶段就会被引导,而不是先生成错误 JSON 再被你的校验逻辑清洗。前者是引导,后者是救火,两者成本天差地别。
另一个我踩过的坑是additionalProperties: false一定要显式声明。如果不写,模型很可能会往参数里塞一些你的函数根本没定义过的字段,导致 Pydantic 校验直接抛异常。这种幻觉参数特别常见,而且不可控,只能靠严格校验挡下来。
2.3 技能命名与颗粒度的经验法则
命名这件事看着琐碎,实则影响重大。我发现用动词_业务对象的结构最稳,比如fetch_inventory、create_ticket、cancel_subscription。动词指明了行为,业务对象限定了领域,模型在短列表里扫一眼就能定位。再加上描述里点明场景,误调用率能控制在很低水平。
颗粒度方面,我个人的经验是"一个技能完成一个用户可以感知的目标"。比如"查询天气"是一个技能,不要拆成"获取经纬度"+"调用天气 API"+"生成天气文案"三个技能。拆得太细,模型的多步串联能力会被白白消耗,而且每一步都可能出错。反过来,如果某个技能内部逻辑太重、参数超过八个,也要考虑拆分——这种复杂度会让模型的参数生成准确率明显下降。平衡点大概在"用户一句自然语言能完整描述任务"这个水平。
3. 技能注册与管理系统:从装饰器到可插拔架构
定义好单个技能之后,接下来是管理一大批技能的问题。尤其是维护五十个以上的技能时,没有一套统一的注册与发现机制,整个系统就是一座屎山。
3.1 轻量注册表实现
我一开始直接用装饰器注册,简单粗暴:
# skills/registry.py from typing import Callable, Dict, Any import inspect SKILL_REGISTRY: Dict[str, Dict[str, Any]] = {} def skill(name: str, description: str, schema: dict): def decorator(func: Callable): SKILL_REGISTRY[name] = { "name": name, "description": description, "parameters": schema, "function": func, "signature": inspect.signature(func), } return func return decorator使用侧也很直观,开发一个新技能只需要在同一目录下新建文件:
# skills/weather.py from .registry import skill @skill( name="fetch_weather", description="获取指定城市的实时天气与未来三天预报,适用场景:用户询问天气、穿衣建议、出行计划。", schema={...} ) def fetch_weather(city: str, days: int = 3) -> dict: # 这里调用真实天气 API return {"city": city, "forecast": [...]}装饰器方式最大的好处是解耦:新增技能不需要改任何注册中心代码,业务逻辑和工具能力互相独立。我在项目里还用了一个load_skills()函数,在启动阶段扫描特定包目录下的所有模块,自动执行注册。这样加一个新技能 = 新建一个文件,零配置。
3.2 技能加载与依赖关系管理的两个坑
第一只坑:技能内部依赖的外部服务配置不能写在代码里。我在某个项目里把 API Key 直接写在技能模块顶层,结果技能一旦被 import,所有环境配置全耦合在一块。正确做法是技能通过 context 对象获取外部依赖,context 在请求开始时注入:
@skill(name="create_ticket", description="...", schema={...}) def create_ticket(ctx: Context, title: str, priority: str) -> dict: client = ctx.get_service_client("ticket_system") return client.create(title=title, priority=priority)Context 对象的好处是可以实现依赖的按需加载。假设系统同时接入了工单系统、CRM、内部 IM,某个请求只用到其中两个技能,那么另外几个外部服务的连接根本不会建立。
第二只坑:技能的依赖关系缺失。A 技能执行后需要 B 技能的结果作为前置条件,这种依赖要在描述里显式写清楚。比如"query_weather"和"make_travel_advice"之间天然有先后关系,模型如果先调用后者,拿不到天气数据必然失败。解决方式有两种,要么在技能描述中写"建议先调用 fetch_weather 获取天气数据后再调用本技能",要么干脆把组合逻辑封装成一个新的融合技能,彻底避免模型自由规划。技能多到一定程度,融合技能的收益会越来越明显。
4. 规划器:如何让大模型做出正确的工具选择
技能系统里最核心的运行环节就是规划——也就是让模型从技能清单里选出合适的技能并确定调用顺序。这一步做得不好,技能定义得再好也没用,模型压根就"看不上"你的技能。
4.1 提示词中的技能清单构建
早期我直接把所有技能的 name 和 description 拼起来塞进 system prompt。技能少还好,技能超过三十个以后,模型的工具选择准确度肉眼可见地下降。后来我改成分组展示:按业务领域把技能分为"信息查询""数据修改""外部操作"等组,每组给出简短标题和列表。
分组的商业逻辑是给模型搭建注意力聚焦的路径:用户说"帮我查一下订单状态",模型先感知到"信息查询"这个分组,再在这个分组内部锁定具体技能。分组减少了候选集,注意力的竞争少了,准确率自然上去了。可以在注册表里给每个技能打上一个category字段,然后在构建提示词时按 category 聚合。
4.2 少样本示例的必要性
另一个容易忽略的因素是模型对技能的调用格式并不天然熟悉,哪怕是 GPT 这样的大模型也经常在首次调用时犯低级错误:漏掉必填参数、把文本直接当作参数值、在不需要调用技能时强行调用。能在一定程度上解决这个问题的不是修改描述,而是给几个完整调用示例。
我一般每个技能至少配一条示例,放在描述的最前面,格式类似:"当用户说'帮我看看明天的天气'时,调用 fetch_weather,参数为 { city: '北京', days: 3 }。" 几个好的少样本示例,比反复强调"请按照规则调用"管用得多。而且示例最好覆盖"不调用技能"的情况——也就是说,如果用户问题无法被任何技能解决,应该直接回复原话而不是硬凑一个技能。
4.3 自动让模型记住历史调用的结果
规划器还有一个经常被遗漏的环节:上下文结果传递。Agent 出现"反复调用同一个技能拿相同结果"的浪费行为,往往是因为模型忘了自己刚才已经调用过一次。解决方式是在历史消息中保留结构化的 tool_call 记录,并在生成下一轮决策时让模型能看到前面技能的返回摘要。
我给规划器的输入格式做了一个小改进:不仅在 system prompt 中列出技能清单,还会在每轮对话的动态提示里追加"最近已调用技能摘要"。这段摘要由引擎自动清理生成,在大模型开始新一轮规划前就注入。实测效果很好,重复调用大幅减少,token 消耗也降下来了。
5. 运行时的三座大山:超时、幻觉、失败回退
技能定义好了,规划也顺利了,模型终于调用了正确的技能。但真正的磨难才刚开始——运行时的异常处理是 Agent 项目最容易翻车的地带。技能调用失败不能只把堆栈抛给用户,模型需要一个可感知的、结构化的失败信号,自主决定下一步动作。
5.1 技能超时兜底
我在第一版技能系统里完全没考虑超时问题:某个 API 突然变慢,模型傻等 30 秒,用户等了 40 秒后直接关掉了对话。后来我强制给每个技能的执行包了一层超时控制:
# runtime/timeout.py import asyncio async def with_timeout(coro, seconds: float): try: return await asyncio.wait_for(coro, timeout=seconds) except asyncio.TimeoutError: return {"status": "error", "error": "timeout", "message": "服务响应超时,请稍后再试"}超时的秒数因技能而异:简单的内存查询可以给 3 秒,涉及外部 HTTP 调用的至少给 10 秒。这个配置我放在技能注册表的元数据里,而不是写死在代码中。模型在规划时看到超时时间也是有好处的:它会对"快技能"和"慢技能"有概念,从而在多个可选技能中倾向于快速返回结果的方案。
5.2 幻觉参数:一面拦截一面引导
幻觉参数是 Agent 运行时最像"薛定谔的猫"的问题:同一个提示词下,模型有时正常,有时传一个完全不在 schema 里的字段。整改之前,我靠 Pydantic 的model_config = {"extra": "forbid"}拦截异常参数。这个能兜底,但会让很努力但做错了的请求整个被丢弃。
后来我调整了一下方案:参数校验失败后,不直接返回失败,而是进入一个小的自动修复通道——把模型原始返回的 JSON 与 schema 做对比,将多余的字段剔除,将缺失的必填项设置为默认值。可以把这个逻辑当成一个"宽松模式":先试宽松解析,不通过再走严格报错。这个路径能显著提升成功率,代价只是几十行解析代码。
5.3 失败回退与自动重试矩阵
技能失败种类很多,绝不能一刀切全都重试。我总结了一张适用的重试矩阵:
| 错误场景 | 是否重试 | 重试策略 |
|---|---|---|
| 网络超时 / 连接重置 | 是 | 指数退避重试2次,间隔1s和3s |
| 参数校验失败 | 否 | 立即返回模型,提示修正参数 |
| 外部服务返回500 | 是 | 重试1次,间隔5s |
| 数据未找到 | 否 | 正常返回空结果,由模型向用户解释 |
| 技能内部异常 | 否 | 返回错误码,并给出友好描述 |
重试逻辑的核心思想是:只重试"状态不明确"的失败,不重试"确定失败"的请求。网络超时可能是服务端已经处理完但响应丢了,重试有实际意义;但参数不行的情况下重试一百次结果还是一样,白白浪费时间和 token。
5.4 结构化输出的容错策略
模型调用技能后生成的输出处理逻辑也值得重视。如果你要求模型"按 JSON 返回结果",它偶尔会在 JSON 前后加几条解释性的自然语言,导致解析失败。我后来改用正则先提取第一个{到最后一个}之间的内容,再走 JSON 解析;实在解析不出来,就单独让模型重新总结一次"只用 JSON"。
另外,技能返回的原始数据如果格式复杂,我会在技能内部做一次"面向用户的摘要"层。这个摘要层不是给模型看的,是给用户看的。一边是保持原始数据完整供后续技能调用,一边是让用户得到一份可读的结果文本,两者分开后系统会干净很多。
6. 技能系统的效果度量与迭代策略
技能系统不是做完就能用的,它需要像软件一样持续维护。我在这上面吃过亏:辛苦封装了一堆新技能,上线后没任何评估,根本不知道哪些技能真的被模型选中过,哪些一直吃灰。后来我建立了一套简单的度量机制,让每一个技能的生死有数据支撑。
6.1 两个关键的度量指标
第一个指标是技能召回率:在所有应该调用此技能的场景里,模型正确选择了这个技能的比例。这个指标比模型最终的回复质量更本质,因为它直接刻画了"模型知不知道有这个工具"。我做了一个标注集,人工标注一百条测试问题对应的正确技能序列,跑完离线评测后计算每次规划是否命中了正确技能集合。
第二个指标是技能解析正确率:在模型选对了技能的前提下,生成的参数能否通过 schema 校验、执行后返回是否成功。这两个指标放一起能清晰定位问题:召回率低说明提示词和描述出了问题,解析正确率低说明 schema 设计有问题,两者都高才能真正说这个技能的接入是健康的。
6.2 失败日志驱动的改进闭环
每次调用技能失败时的完整上下文都值得留痕。我会记录模型当时的完整规划链路、传入参数、校验抛出的异常信息、最终回退决策,形成一个失败语料库。每周花一个小时翻一遍这个语料库,能发现大量提示词层面的共性问题,比如:
- 某技能描述里"建议先调用其他技能"的措辞不够明确,模型 40% 的情况下跳过了前置调用
- 某参数
format的可选值没写齐,模型选了枚举以外的值导致校验失败 - 某技能在描述里同时涵盖了太多场景,模型一遇到差不多的请求就选它,但执行结果的正确率只有一半
这些问题的修复动作大多很简单,改几行描述、调整一下 schema 约束、或换一个更精确的例子,却能让整个系统的整体成功率获得肉眼可见的提升。这也是为什么我一直强调"技能系统要持续迭代",而不是写完代码就撒手不管——模型的行为可以被提示词和 schema 雕刻,但前提是你得先看见它的错误。
另一个日常会被忽略的点是技能版本的灰度。我在某个项目里因为直接改了一个热门技能的描述,导致模型在随后一段时间里调用行为变来变去,用户感知非常明显。后面所有技能描述和 schema 的修改都会走"复制技能为新版本,灰度一段对话,对比在线指标后再切换"的路径。这确实多了一些维护成本,但换来的是系统行为的稳定。
结束语
坦白说,Agent 技能系统的设计思路,本质上就是你给一个非常聪明但非常没有常识的实习生写一份详细到夸张的操作手册。手册清晰,他的产出就基本可控;手册含糊,再好的人才也会把事情搞砸。多花一个小时完善技能的定义、梳理注册结构、设计失败回退,比在大模型层面调十轮提示词更值。我在多次重构之后最深的体会是:Agent 的下限由模型决定,但上限由技能系统的工程质量决定。这套方法论是我自己趟出来的,不同项目侧重点不同,但思路完全通用。如果你也在做类似的 Agent 项目,不妨从审视你的第一个技能定义开始。