我们平时聊 Agent聊得最多的是“它能不能自己规划”、“它会不会自己反思”但落到真实项目里我最大的体感是一个只会思考但不会干活的 Agent和一块会聊天的电子屏没什么区别。真正让 Agent 产生价值的是它手里那一堆能落地的技能也就是你给它配的工具集。这个“agent-skills”的核心就是解决“模型如何稳定地调用外部工具完成真实任务”的问题。我最近在做一个内部知识库问答和工单自动化的项目一开始只是简单接了个大模型 API用户问一句答一句效果还算能看。但等我把权限放开让模型自己去查数据库、拉工单状态、创建待办事项之后问题才真正冒出来模型经常选错工具、参数传得乱七八糟、遇到异常直接傻掉。与其说是模型不够聪明不如说是我的技能层设计太粗糙。后来我花了很大精力去重构这一层把自己在“技能定义、注册、调度、容错”上踩过的坑一点点填平才终于做出一个能稳定跑起来的 Agent 系统。这篇文章就是围绕我在这个项目里总结的经验展开的。如果你正准备做一个带工具调用的 Agent或者你已经在做但总觉得 Agent 的“手脚”不听话那这篇内容适合你。我会聊技能层的整体设计思路、技能接口怎么写模型才不容易误用、完整的最小实现代码还有一堆真金白银踩出来的排查技巧。1. 技能库设计先想清楚 Agent 需要哪些能力1.1 为什么单独抽象出“技能层”最早我做 Agent 的时候思路很简单把系统提示词写长一点把所有工具说明都塞进去让模型自己在上下文里找。结果模型总是挑那些“看起来厉害”但不合适的工具而且每次改一个工具描述整个会话的稳定性都会波动。后来我意识到工具和模型之间缺少了一个“管理层”这个管理层就是技能层。类比一下你让一个实习生去干活不会直接把他扔进工具间让他看着一堆设备瞎猜怎么用而是先给他一份操作手册再让带教的师傅告诉他遇到什么情况就翻哪个章节用哪个设备。技能层在 Agent 里承担的就是这份操作手册和带教师傅的角色。它把“模型能做什么”这个问题从模型的自由发挥变成了一个可以被注册、被校验、被监控的结构化流程。这个抽象带来三个直接好处第一是复用性一套技能可以同时给多个 Agent 场景用做客服、做数据分析、做自动化都是一样的注册方式第二是可观测性每一个技能调用都有固定的入口和出口出了问题你很容易定位是模型选错了还是技能执行报错了第三是安全性你可以在技能层做细粒度的权限控制而不是让模型直接拿到整个数据库的连接串。所以我现在做任何 Agent 项目第一件事永远不是调 prompt而是画一张技能的规划表。先明确这个 Agent 需要哪些能力哪些能力可以被归类成同一个技能哪些能力需要多个技能协作。1.2 技能的分类和边界划分技能不是越多越好而是越“边界清晰”越好。我在项目里会把技能分成四大类这个分类方法也是我在一次技术复盘时从团队里一位做后端的同事那里学来的后来就一直沿用技能分类典型场景实现示例风险等级查询类技能获取信息、检索数据查数据库、调外部 API、搜索文档低只读不写操作类技能创建、修改、删除数据创建工单、发送消息、更新状态中影响业务数据控制类技能执行系统指令、调用命令行运行脚本、启动任务、修改配置高影响系统环境编排类技能组合多个子技能完成任务自动生成周报、定时巡检配置中高依赖子技能稳定性这个分类的核心价值不是把技能“归档”而是决定一个技能能否被单独开放。比如查询类技能我几乎不做额外限制因为只读操作最多就是信息不准确不会有破坏性但操作类技能就必须带上二次确认或者干跑模式先返回预览结果让用户确认再真正执行控制类技能我基本不对普通对话开放只有特定授权的情况下才允许调用。边界划分还有一层意思就是技能和技能之间尽量不要有重叠。比如你有一个“查询工单状态”的技能又有一个“获取工单详情”的技能模型就很容易对着同一个需求纠结到底该用哪个。我的原则是一个技能尽量覆盖一类完整场景宁可技能内部多走一步也不要让模型在两个近似技能里做选择。1.3 技能粒度怎么定才平衡技能粒度是我在这个项目里调整最多次的参数。一开始我把技能做得特别细比如“查询工单创建时间”、“查询工单处理人”、“查询工单状态”分开三个技能结果模型经常只调用一个技能拿到部分信息就完事了用户问“这个工单现在什么情况”它回答“工单已创建”而不是去把整个流程状态拉出来。后来我把技能粒度调粗搞了一个“查询工单完整信息”的技能返回完整的工单对象。表面上看信息量变大了模型选工具的难度也降低了但 token 消耗明显上涨一次简单查询可能带上几百个无关字段。这里最理想的平衡点是让技能的输入和输出都尽量贴近“人类表述方式”用户在脑海里想的是“这个工单怎么样了”那你的技能就返回“这个工单的完整摘要”。还有一个策略是给技能设置“必填参数默认值”和“可选上下文注入”。比如“查询工单”这个技能必填参数是工单 ID但很多对话场景里用户根本没有提供 ID只有一句“帮我看看昨天提的那个工单”。这时候你就需要把对话上下文里的时间、用户、工单主题等信息自动提取出来作为技能的补充参数。这个提取动作本身也可以做成一个技能用模型自己去解析比硬编码正则表达式靠谱得多。2. 技能描述与参数设计模型只会按你的描述来“猜”2.1 技能描述就是给模型的“岗位说明书”模型本身是看不到你的技能代码的它只能看到一段 JSON 格式的技能描述里面包含技能名称、功能说明、参数定义。这段描述在模型眼里就是它决定“该不该用这个工具、怎么用这个工具”的全部依据。一旦描述写得模糊模型就会开始脑补脑补出来的参数和逻辑经常让人哭笑不得。我遇到过一个非常典型的例子。当时我注册了一个“发送企业微信通知”的技能描述里写的是“发送通知给指定用户”。看起来没毛病但模型在实际调用的时候经常把 content 参数和 user 参数搞反把“您好您的工单已处理完毕”发给了企业微信的管理员而把管理员的名字当作消息内容发了出去。后面我把描述改成“向企业微信用户发送一条文本消息该用户必须是系统中的有效用户content 是消息的文字内容user 是接收者的用户名”并给每个参数都加了详细的说明和示例误用率才明显降下来。所以我现在对技能描述有四个硬性要求第一第一句话必须是“做什么”而不是“是什么”直接用动作开头第二必须说明适用的业务场景防止模型拿到别的场景里乱用第三必须写明不适用的情况第四参数描述必须给出示例值特别是枚举类型的参数。描述的具体写法我放到了后面的实操部分这里先抛一个结论好的技能描述是那种你拿给一个没做过这个业务的同事看他能根据描述准确判断出什么情况下该用这个技能、不该用这个技能的描述。2.2 参数描述里的隐性陷阱参数描述是另一个容易出问题的地方。很多开发者以为参数定义就是把类型写对、把 required 标上实际上模型对参数的理解完全依赖于描述文字类型只是辅助。我举三个我自己踩过的坑。第一个坑是布尔参数。我原来写“是否需要加急”的时候类型定义是 boolean描述写的是“是否加急”。模型一样会懵它不理解 true 和 false 分别代表什么后果。改成“当该参数为 true 时工单会标记为加急状态并通知相关处理人为 false 时不标记加急”之后错误率下降了一半以上。第二个坑是枚举参数没有给完整枚举值。比如“优先级”这个参数我定义了枚举类型里面有三个值低、中、高。结果模型有时候会传一个“紧急”进去因为它在业务语境里见过这个词。这个问题靠枚举约束其实解决不了因为模型还是会“强行传参数”你要做的是在描述里额外写清楚“优先级枚举值仅限低/中/高其他值无效如果用户说紧急请映射到高”。这是很反直觉但极其有效的技巧。第三个坑是参数之间的依赖关系。比如“发送邮件”技能target 参数如果是“外部联系人”那么 contact_email 就是必填如果是“内部用户”contact_email 可以不传。这类条件逻辑在 JSON Schema 里是能表达的但表达成本很高模型也不一定理解。我的做法是放到技能描述里用一句完整的话写清楚当 target 为 external 时必须提供 contact_email否则调用失败。模型虽然不一定会严格推理但显式写出来能显著提升它的遵守率。2.3 描述写太长会不会增加 token 成本你可以会担心技能描述写得这么详细系统提示词会变得特别长token 成本也跟着涨。这个担心有道理但不值得为此把描述砍短。技能描述这一部分是随请求发送给模型的你在每个会话里都会带上它它确实会占用输入 token。但如果你因为省这点 token 让技能误用率上升你后面排查的成本会远远超过这点 token 费用。我的经验是把技能描述的控制在“详细但不冗余”的区间。一个技能的描述最长不超过 200 字超过的部分说明你的设计有问题可能需要拆分技能或者把部分信息移到参数描述里。另外不要在一个会话里塞太多技能。模型对工具的选择能力会随着工具数量增加而明显下降我的实践是单个会话最多暴露 8 到 10 个技能超过之后宁可把一部分技能收敛成入口型技能让模型先选方向再调具体工具。3. 从注册到调度一次技能调用的完整实现3.1 一次技能调用会经过哪些环节一个标准的技能调用链路从我目前的工程实践来看通常包含这么几步模型收到用户指令后先从自己的工具列表里筛选可能相关的技能然后根据用户意图生成一个结构化的调用请求这个请求会包含技能名称和一组参数请求传递到技能调度层调度层先做参数校验再查找对应的技能实现并在执行前做一次权限检查执行结果会以结构化的文本返回给模型模型再根据结果组织最终的回答。这个链路里最关键也最容易被忽略的一环是“执行结果返回给模型”这一步。很多初次做 Agent 的开发者会默认“调用完技能直接把结果返回给用户就行”但实际不是这样。模型需要看到技能的原始输出才能组织出自然、连贯、有上下文的回答。所以你设计技能时不仅要考虑给用户看的结果还要考虑给模型看的中间结果。比如一个“查询天气”的技能你返回给用户的是“北京今天晴温度 15 到 24 度”。但返回给模型的原始数据应该更结构化包含城市、日期、天气现象、温度区间、风力等级这些字段。模型拿到这些字段之后可以根据用户的具体问题做进一步加工比如“比昨天暖和多啦”。如果你只返回一句自然语言模型就拿不到可用的结构化信息了。这里有一个结构化输出的设计原则技能返回的结果必须同时包含“展示给用户的文本”和“供模型使用的结构化数据”。我会在技能的输出对象里固定一个 result_text 字段和一个 raw_data 字段前者是给人看的后者是给模型进一步推理用的。3.2 一个带技能注册中心的最小实现我先说注册中心。技能注册中心本质上就是一个 Python 字典或者一个数据库表把技能名映射到技能函数和技能描述上。在做最小实现的时候我会用一个装饰器来给技能打标签业务逻辑只关心技能函数本身注册的事情交给装饰器去完成。下面这个例子我定义一个query_work_order技能它接收工单 ID返回工单状态和相关信息。代码是我在项目里用的一个简化版本去掉了鉴权、缓存和复杂异常处理保留了核心链路。import json from typing import Any, Callable, Dict # 技能注册中心技能名 - {函数, 描述schema, 处理器} SKILL_REGISTRY: Dict[str, Dict[str, Any]] {} def register_skill(name: str, description: str, parameters: Dict[str, Any]): def decorator(func: Callable[..., Any]): SKILL_REGISTRY[name] { function: func, description: description, parameters: parameters, } return func return decorator register_skill( namequery_work_order, description( 根据工单ID查询工单的当前状态、处理人和最近更新记录。 当用户询问工单进展、状态或处理详情时使用。 如果用户没有提供工单ID请不要使用该工具。 ), parameters{ type: object, properties: { work_order_id: { type: string, description: 工单编号例如 WO-2024-0001。 } }, required: [work_order_id] } ) def query_work_order(work_order_id: str) - Dict[str, Any]: # 实际业务中这里会查询数据库或者调用内部API mock_db { WO-2024-0001: {status: 处理中, owner: 张三, updated_at: 2024-11-20 10:30}, WO-2024-0002: {status: 已完成, owner: 李四, updated_at: 2024-11-19 16:20}, } if work_order_id not in mock_db: return {result_text: f未找到工单 {work_order_id}, raw_data: None} info mock_db[work_order_id] return { result_text: f工单 {work_order_id} 当前状态{info[status]}处理人{info[owner]}, raw_data: info, }这个注册中心看起来非常简单但它是一个可以在真实项目里直接扩展的骨架。你可以在注册信息里加permission_role字段做权限控制加timeout字段做超时限制加retry_times字段做失败重试。所有技能都会经过同一个注册中心所以这些公共能力你只需要实现一次。3.3 技能调度的核心流程与权限控制技能调度层要做的事情就是把模型生成的工具调用请求解析出来然后在注册中心里找到对应的函数并执行。这里有两个关键点一个是参数解析一个是错误处理。参数解析时模型返回的参数往往是一个 JSON 字符串你需要先做一次json.loads然后做类型校验和必填项检查。我见过不少项目直接把模型的 JSON 参数抛给技能函数执行结果因为参数类型不对导致运行时异常而且异常信息还不友好。我的做法是做一个validate_and_execute包装函数在真正执行技能之前用 JSON Schema 校验器验一遍参数验不过就立刻返回一个可读的错误信息给模型让它重新生成参数。错误处理上最忌讳的是技能崩溃之后把堆栈直接抛给模型。模型看到大段英文 Traceback 基本是懵的它不知道该怎么修正。正确做法是把异常捕获住整理成“技能执行失败 失败原因 请修改参数后重试”的格式再返回给模型。这样模型就知道下一步该怎么做了。这里我放一个简单的调度函数def execute_skill(skill_name: str, arguments: Dict[str, Any]) - Dict[str, Any]: if skill_name not in SKILL_REGISTRY: return {result_text: f未知技能: {skill_name}, raw_data: None} skill SKILL_REGISTRY[skill_name] # 简易参数校验实际项目可以用 jsonschema 库来做 required skill[parameters].get(required, []) missing [p for p in required if p not in arguments] if missing: return { result_text: f参数缺失: {, .join(missing)}请补齐后重试。, raw_data: None, } try: return skill[function](**arguments) except Exception as exc: return { result_text: f技能 {skill_name} 执行出错: {str(exc)}请调整参数后重试。, raw_data: None, }在权限控制上我在实际操作中遵循“默认拒绝”原则。没有明确授权过的技能一律不让模型调用。实现上很简单在注册信息里加一个roles字段调度时检查当前会话的角色是否在roles列表里。列表为空表示所有角色都可调用但这有风险我建议即使空列表也显式声明“all”作为占位防止后续误改。4. 常见问题与排查技巧实录4.1 模型反复调用同一个失败技能这个现象我猜做 Agent 的人都遇到过。你给模型注册了一个技能它调一次报错调两次还是报错第三次也不换个思路依然调用同一个技能只是把参数微调了一下然后继续失败。看起来像是模型“不死心”其实它的本质原因是你的技能描述和参数描述没有给出足够的错误启发信息。打个比方模型就像一个新员工它调用技能失败后会从错误信息里吸取教训但如果错误信息只是“数据库连接失败”这种笼统描述它就不知道该把参数改成什么样子。我的解决方法是双管齐下先保证错误信息是模型可读的明确指出“可能是哪个参数有问题”再在调度层加一个容错保护同一个技能连续失败两次之后就强制不再返回该技能的工具调用结果并在回复里提示用户“这个操作暂时无法完成请尝试其他方式”。这个容错保护看似简单但实际效果非常明显。它避免了模型在一个死循环里空转也节省了大量无谓的 token 消耗。从用户视角看一个会承认“做不到”的 Agent比一个反复失败的 Agent 可靠得多。4.2 技能描述被模型“过度联想”还有一种情况很头疼技能描述里明明只说了查询工单但用户问“工单怎么这么多”模型就调用了查询工单技能返回了一堆所有工单的数据再自作主张总结一句“你的工单很多”。这种“过度联想”本质上是技能描述的召回范围太宽模型只要觉得“用户的话里带工单两个字我就查工单”。我之前处理这类问题时会在描述开头加上触发条件的限制语比如“仅当用户明确要求查询某个或多个工单的具体信息时使用”。这个“仅当”很重要它比“当用户询问工单信息时使用”要明确得多。另外在描述里增加负例也有效比如“如果用户只是表达感受、投诉、反馈而不要求具体数据请勿使用该技能”。还有一个隐蔽的触发点是上下文。模型会基于对话历史里的上下文来猜测当前意图如果上一轮用户在问工单这一轮只是说了句“那然后呢”模型可能就继续调技能了。这种场景不需要改动技能描述你需要做的是在调度层做“上下文意图识别”判断模型是否真的需要调用技能还是只是在延续对话。我现阶段的做法是把技能触发逻辑做成二段式先由分类器判断“本轮对话是否需要调用技能”如果需要再走技能选择。这个分类器可以是一个 prompt 模板也可以是一个微调的小模型关键是省掉了大量无意义的工具调用。4.3 多技能并行调用时的上下文污染当 Agent 开始处理复杂任务时往往需要在一个回合内调用多个技能。比如用户问“帮我总结一下这个季度所有未完成工单的处理情况”模型可能需要先调用“查询工单列表”技能再对每个工单ID调用“查询工单详情”技能。这两步之间模型需要记住一批工单ID但如果对话历史里有大量无关信息模型容易把上下文搞乱。我处理这个问题的方式是在会话层隔离“技能调用上下文”和“对话上下文”。换句话说技能调用的中间结果不回填进正常的对话历史而是放在一个单独的“技能执行记录”里。这样模型在生成技能调用参数时看到的上下文不会被上一次技能返回的长文本干扰。实现上就是在请求模型的时候把系统提示词、对话历史、技能执行记录分别拼接用分隔符隔开并且对技能执行记录做长度限制只保留最近几轮。这个做法的收益是模型在选技能、传参数时注意力会更集中。代价是每次请求的输入 token 会多出一点但换来的稳定性提升是完全值得的。4.4 技能并行调用之间的依赖编排模型在多技能协作时还有一个常见问题它会并行发起多个技能调用请求但这些请求之间存在依赖关系。比如先要查询用户信息拿到用户ID之后才能查询这个用户的工单列表。如果模型一次性把两个请求都发出来第二个请求的参数就会缺失。针对这个问题我在调度层做了一个简单的依赖检测。每个技能在注册时可以声明它“依赖哪些输出字段”。比如“查询用户工单列表”技能依赖一个user_id字段而这个字段可能来自“查询用户信息”的输出。调度层检测到依赖未满足时会先把依赖技能的执行结果缓存起来再执行当前技能。这个机制实现起来不复杂但需要你对每个技能的“输出合同”有清晰定义也就是输出结果里一定包含哪些字段。我甚至会在技能描述里加一句固定话术“如果你需要获取 user_id请先调用 find_user 技能。”模型虽然不会 100% 遵守但加上调度层的依赖校验兜底整体稳定性会提升很多。4.5 技能安全隐患权限边界怎么设都不为过关于技能权限的安全问题我多说几句因为这个坑一旦踩中损失就不是 token 这么简单的了。我最初做 Agent 时为了“省事”让模型直接连了生产环境的数据库结果它在一次测试里把一条测试数据给改掉了。后来我严格按照“最小权限原则”重构了技能层数据库连接只开放只读账号每个技能用单独的 API Key操作类技能默认开启人工确认。这里有一个具体的实现技巧在技能注册信息里加一个confirmation_required字段调度层在执行前判断这个字段如果需要人工确认就先把技能执行结果以“预览”形式返回给用户并带上一个确认按钮或者一段确认文本。用户的确认动作本身可以做成一个新的技能叫confirm_action这样所有需要人工确认的操作都会走同一条路径。从架构上看技能层是 Agent 的“四肢”权限控制就是关节里的“锁”。你可以让模型自由思考、规划但真正的行动必须受到约束。这个约束不是说限制模型的能力而是保护你的业务数据和系统安全防止一个不可控的幻觉把整个生产环境搞崩。4.6 常见问题速查表常见现象可能原因解决方案模型反复调用同一失败技能错误信息没有给出可修正的启发调整错误信息格式加入参数修改建议技能在无关场景被调用技能描述触发条件太宽在描述中加入“仅当...时使用”和负例参数传错类型或枚举值参数描述缺乏示例值为每个参数补充示例值并说明特殊映射规则多技能并行时参数缺失技能之间存在依赖但模型没感知调度层增加依赖检测先执行依赖技能技能执行正常但回答不准确模型拿到的结构化数据不足技能返回结果中加入 raw_data 字段用户抱怨 Agent 像“复读机”模型只是机械调用技能没有整合信息在技能返回中增加 result_text 便于模型理解5. 把技能做得“可插拔”项目迭代的一些体会我做完这一轮重构之后最明显的感觉是Agent 的能力边界不是由模型决定的而是由技能库的丰富程度和稳定程度决定的。模型就像是一个指挥官它能调度的“兵种”越多它能打赢的仗就越多。但反过来如果技能层一塌糊涂再强的模型也会表现得像个无头苍蝇。所以我后续迭代时会把更多精力放在技能库的工程化上。比如给每个技能加监控指标统计它的调用次数、成功率、平均耗时比如给技能做好版本管理因为模型对技能的描述非常敏感你改一个字都可能导致行为变化所以每次改动都要记录再比如做技能的 A/B 测试用同一组用户问题去测试两版技能描述看哪个版本的工具调用准确率更高。这里我还想强调一个容易被忽略的点技能描述不是写一次就完了它是一个需要持续运营的对象。我每个季度都会把用户的真实提问拉出来筛选出那些“模型答得不好”的样本然后去复现、去分析看看是不是技能描述和实际情况不匹配。这个动作比单纯调 prompt 价值大得多因为技能描述直接决定了模型的行为边界。6. 一个我强烈推荐的起步动作如果你现在正准备做 Agent 技能层我建议你先别急着写代码先拿一张纸把你的业务里所有可能需要 Agent 完成的任务列出来然后逐个标记这个任务是查询类、操作类、控制类还是编排类每个任务的输入是什么、输出是什么、错误场景有哪些这个动作看起来和写代码无关但实际上它能帮你省掉大量返工。我做第一个版本的时候就是跳过这个步骤直接上代码结果技能命名混乱、边界重叠、参数不统一后来全部推倒重来。第二次老老实实把这张表做透代码反而写得很顺利。做完这张表之后再按照我上面说的方法去定义技能描述、注册技能、设计调度逻辑你会发现自己搭出来的 Agent 比大多数人用 prompt 硬怼出来的版本稳定得多。你也可以提前准备好几个调试问题集覆盖正常场景、边界场景、异常场景每次改完技能描述都跑一遍防止改一个技能导致另一个技能失灵。这个回归测试的习惯会是你后续维护 Agent 项目时最值得依赖的一道防线。