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( namefetch_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(namecreate_ticket, description..., schema{...}) def create_ticket(ctx: Context, title: str, priority: str) - dict: client ctx.get_service_client(ticket_system) return client.create(titletitle, prioritypriority)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, timeoutseconds) 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 项目不妨从审视你的第一个技能定义开始。