首页 / 资讯中心 / 文章详情

Agent技能层设计实战:从模型思考到工具执行的能力闭环

Agent技能层设计实战:从模型思考到工具执行的能力闭环 ★ FEATURED ARTICLE
代码库拆空重写的那天晚上我终于想明白一个问题同样一个基础模型在别人手里能完成从需求到行动的完整闭环在我手里却只会输出大段大段漂亮的建议文本。差别根本不在模型本身而在于模型外面有没有包一整套能真正操作世界的“手脚”。这层手脚就是 agent-skills也就是常说的技能层。本文想围绕它展开聊聊整理一套我在真实业务里把 Agent 从 Demo 推向落地时反复打磨出的设计方法和实操经验适合正在被“Agent 看着聪明但干不了活”困扰的开发者也适合想给业务系统接入智能体能力但不知道从哪里下手的同学。1. agent-skills 到底在解决什么问题模型负责思考技能负责移动世界1.1 先把误区说破Agent 不是更聪明的聊天窗口我第一次做智能体接入的时候犯过一个特别幼稚的错误。我天真地以为把业务系统里所有相关规则都写进一段超长系统提示词模型就能“自己搞定一切”。结果很真实它确实能聊能把用户问题复述得头头是道能给出听起来非常专业的方案但一旦需要它真正去查数据库、改订单状态、触发一条审批流、把结果写回系统它就彻底卡住了。原因其实特别简单模型本质上是一个文本推理引擎它的输入输出都只是文本它不能直接修改数据库不能直接发请求不能直接操作任何外部系统。所有“动手改变世界”的能力都必须由外部工具提供而这个外部工具的接入层就是 agent-skills 要管的事情。很多产品经理会把 Agent 想象成“更聪明的对话机器人”但做技术的人必须把概念掰开对话机器人解决的是“理解与表达”Agent 解决的却是“理解、决策与执行”的闭环。用户提出需求之后模型负责拆解目标、制订步骤、决定调用哪个技能技能负责真实地执行动作最后把执行结果反馈给模型继续规划。没有技能层的 Agent 只是高级文案生成器它永远不会对业务产生真正的改变。想清楚这一点之后我再也不纠结“要换一个多强的模型”了。模型榜单上的分数当然重要但一个组织真正稀缺的往往是把复杂业务流程拆成清晰可调、稳定可靠、模型一眼就能看懂怎么用的技能库的能力。后者才是 agent-skills 要解决的核心问题。1.2 技能拆解的本质从“函数”到“可被模型理解的能力单元”有人可能会问那我直接在代码里封装一个工具函数然后让模型去调用不就行了吗这个问题我自己也问过答案是不完全行。面向程序员的工具函数和面向模型的技能听起来是一回事实际上是两个层面的东西。函数是写给编译器和其他程序员看的重要的是类型签名、接口契约、边界条件。技能是写给模型“读”的模型只能通过一段自然语言描述、一套参数声明和返回结构来决定“现在该不该用它、怎么用、结果怎么理解”。换句话说函数的选择发生在编码期由程序员决定技能的选择发生在运行期由模型在每一轮推理中自主决定。这个差异带来了一系列连锁的设计约束。技能的 description 不能只是“查询数据”而要写清楚“什么时候触发、默认行为是什么、边界是什么”技能的参数不能只给一个宽松的字符串而要尽量结构化让模型按着格式填技能的返回值不能只让程序员看懂还要让模型在下一轮推理时能轻松接手。我把普通函数和 Agent 技能的区别整理成了下面这张表方便对照对比维度普通工具函数Agent 技能主要消费者程序员、编译器大模型通过文本理解接口描述方式类型签名、代码注释自然语言描述 参数声明选择时机编译期或编码期运行时模型自主决策失败处理方式抛异常、由调用方捕获返回结构化错误并给出修复线索可观测性要求日志足够需要记录模型的选择、参数与执行链路方便回归版本关注点接口兼容性描述变更会被模型感知等于行为变更这张表里的每一行后面都会展开成具体的设计原则。记住一个核心判断agent-skills 不是简单把函数列表喂给模型而是要站在“模型是个只会读文本的推理者”这个设定上把能力包装成它一眼就能看懂的形态。2. 技能的最小可用形态名字、描述、参数、返回值四要素拆解一个技能的最小可用形态至少包含四个要素名字、描述、参数声明和返回值约定。这里头每个都有讲究任何一个偷懒都会在后续实际运行中付出代价。2.1 描述写不好模型等于盲人摸象技能描述是模型选择技能时最重要的依据没有之一。我在早期项目里写过一个查询销售数据的技能描述只写了“Query sales data”五个词。测试的时候模型在大多数场景下根本不会主动去调它偶尔调用了参数也填得乱七八糟。后来我把它改成了一段更完整的描述“当用户询问销售额、订单量、客单价、退款金额等销售指标时使用支持按时间范围与商品类目过滤若用户未指定时间范围默认返回最近 30 天数据若用户只问了某个类目则把类目名填入 category 参数。”改完之后整个行为立刻不一样了命中率肉眼可见地上升。这件事让我意识到模型根本没有办法“打开函数源码”去理解你的真实意图它只能靠描述来盲选。描述里至少要覆盖四个部分触发场景这个技能在什么业务问题下被使用业务语义输入和输出分别对应什么业务概念默认行为当用户没说清楚时技能会做什么禁止事项什么情况下不应该调用它或者它明确不支持什么。写描述的时候可以用一个简单的模板自查这个句子能不能让一个不了解系统的新人在完全不看代码的情况下正确判断“要不要用、怎么用、用了会怎么样”。如果能这个描述基本合格如果不能哪怕代码实现再完美模型也会用错。2.2 参数 Schema宁可严格不要宽松参数声明直接决定了模型“能填出什么样的参数”。我见过不少项目图省事给技能声明一个极宽松的参数结构比如一个巨大的自由字符串让模型“把用户需求原样填进去”。结果就是模型真的会把一整句话塞进去比如“帮我查一下这个月在北京的餐饮门店里评价比较高的前五家的营业额”而你的解析代码根本没能力处理这种混沌输入。正确做法是尽量把参数拆细用结构化的 JSON Schema 严格声明每个字段。以查询餐饮门店营业额为例{ type: object, properties: { city: { type: string, description: 城市名称例如某城市, enum: [城市A, 城市B, 城市C] }, start_date: { type: string, format: date, description: 查询开始日期YYYY-MM-DD }, end_date: { type: string, format: date, description: 查询结束日期YYYY-MM-DD }, sort_by: { type: string, enum: [revenue, rating], default: revenue } }, required: [start_date, end_date] }这套声明的价值在于模型看到 enum 和 required 之后会主动把参数往合理范围收敛。像 city 这种业务上限定死的取值就必须用 enum 锁住日期默认必须传否则技能无法执行就放进 required。宁可让模型多问一句“用户没提供城市怎么办”也不要让它编造一个系统完全不认识的城市名。参数 Schema 还是技能自动化测试的基础。后面讲回归测试时你可以直接依据 Schema 生成边界测试用例这比对着函数签名手写用例要省太多事。2.3 返回值约定给模型看结构而不是看一篇作文技能执行完毕以后返回值会被原样塞进模型的下文上下文参与它的下一轮推理。这一环最容易被低估很多人在本地测试时发现函数返回值人类读着没问题就上线了。但模型不是人类它不会像程序员一样“扫一眼”知道哪个字段是业务结果哪个字段只是调试信息。它需要的是高度结构化的返回最好一眼就能看出状态和数据。我习惯给所有技能统一返回格式形如{ status: ok, code: 0, data: {...}, msg: }出错时则返回{ status: error, code: 40001, data: null, msg: user_not_found请检查 user_id 是否传入或该用户是否已注销 }这套约定的关键不在字段名而在三点状态必须清晰、业务数据和状态分离、错误信息必须带修复线索。真实场景里我踩过一个大坑某个技能校验参数时失败我直接返回“查询失败”。模型拿到这四个字之后完全不知道该向用户怎么解释只能硬着头皮说“系统出了点问题”。后来我把错误信息改成“参数 start_date 缺失当前时间为 2025-03-01请向用户确认查询起止日期”模型立刻就能生成一句得体的追问。所以返回值不是“给自己程序看的”而是“给模型下一轮推理看的”。你写在错误信息里的每一个修复线索都是模型下一步行动的路标。3. 调度引擎的运行范式ReAct 循环与 Tool Calling这两条路我都跑通了技能定义好后还要有一个引擎来决定“什么时候调、调几次、怎么把结果塞回去”。这一层就是调度范式。我实际用过的有两种各有各的适用场景。3.1 我用得最多的 ReAct 风格循环思考、行动、观察三步走所谓 ReAct就是把模型的每一步输出看成“思考Reasoning”和“行动Action”的交替然后把行动结果作为“观察Observation”再交还给模型。听起来玄乎实现起来就是一个循环def run_agent_with_react(user_input, skill_names, max_steps8): messages [{role: user, content: user_input}] for step in range(max_steps): response call_model(messagesmessages, tools[SKILLS[n] for n in skill_names]) msg response.choices[0].message messages.append(msg) if not msg.tool_calls: return msg.content for call in msg.tool_calls: skill_info SKILLS[call.function.name] args json.loads(call.function.arguments) result safe_call_skill(skill_info, args) messages.append({ role: tool, tool_call_id: call.id, content: json.dumps(result, ensure_asciiFalse), })这个循环的精髓是模型每一步都有机会重新审视“已经拿到的结果”从而动态调整下一步计划。任务路径越开放、分支越多这种自由探索的范式越有优势。比如用户问“帮我查一下这个季度销售额下降最厉害的是哪个区域然后给出可能原因”模型可能会先调一个查询技能拉整体数据再调一个分析技能算同比再查某几个区域的历史明细。每一步都依赖上一步的实际结果这是固定流程很难预判的。ReAct 的代价是如果技能库特别大模型容易在一堆候选里挑花眼甚至在同一件事上来回徘徊白白消耗时间和 token。所以它更适合技能数量可控、任务目标偏向开放探索的场景。3.2 更可控的 Tool Calling 模式一次声明整段执行减少模型绕圈子随着模型接口的进化现在主流方案是结构化 Tool Calling也叫工具调用或函数调用。调用者在请求里显式声明可用工具列表模型在生成文本的同时可以结构化地返回“我要调用哪些函数、参数是什么”。你在后端接收后执行把结果传回去模型再继续。相比自由 ReActTool Calling 最大的优势是路径更收敛。模型会倾向于先把能够并行完成的调用一次性返回比如“查询 A 区域销售额”和“查询 B 区域销售额”这两个互不依赖的调用可以在同一轮里返回后端并发执行效率高、token 省。但 Tool Calling 也不是万能的。任务需要很多连续判断时它往往表现为“一次想调多个工具但后一个工具的参数依赖前一个工具的结果”这种情况模型会先只调第一个等观察结果再继续。设计时一定要接受这种多轮交互不要指望一个回合解决所有问题。3.3 两种方式怎么选一张对照表直接给结论判断维度ReAct 风格循环Tool Calling 模式任务路径开放、动态、多分支相对固定、可枚举技能库规模小到中等中等到较大对实时反馈的依赖强需要边探路边走弱多数步骤可预先规划token 开销通常更高通常更低可控性偏弱模型自由的舞台大偏强边界更清晰调试难易较难步骤随机性强相对容易调用序列可录我的个人经验是如果业务场景里有固定的“流程骨架”优先用 Tool Calling 把骨架钉住把自由度只留给少数几个真正需要模型即兴发挥的环节如果业务本身就是一个接连不断的开放性任务比如“帮我分析这堆报表再制定下个月计划”则用 ReAct 风格更不容易把模型困死在预设路径里。4. 从零写一个最小可运行的技能调度器注册表、主循环与一次完整调用说了一堆概念还是动手写一个最小可运行的调度器更有意义。下面这套实现非常轻量适合作为自己项目里技能层的雏形。4.1 技能注册表装饰器方案最顺手我见过有的团队把技能配置放在一个大 JSON 文件里再由框架动态加载。那样做当然也可以但调试跳转很不方便。我更推荐直接用装饰器把“技能定义”和“函数实现”写在一起一眼就能看到全貌# skills/registry.py SKILLS {} def skill(name: str, description: str, parameters: dict): def decorator(func): SKILLS[name] { name: name, description: description, parameters: parameters, func: func, } return func return decorator然后业务代码里这样写skill( namelist_tickets_by_date, description查询模拟项目X内用户提交工单的数量支持按日期区间过滤若用户未指定日期默认返回最近7天。, parameters{ type: object, properties: { start_date: {type: string, format: date, description: 开始日期 YYYY-MM-DD}, end_date: {type: string, format: date, description: 结束日期 YYYY-MM-DD}, }, required: [start_date, end_date], }, ) def list_tickets_by_date(start_date: str, end_date: str) - dict: rows query_ticket_counts(start_date, end_date) # 这里接你的数据层 return {status: ok, code: 0, data: rows}这个方案的优点有三个第一注册集中在一个全局字典里调度器遍历时非常方便第二技能描述紧跟函数实现改实现的时候顺带会想到改描述降低“代码和描述漂移”的风险第三新增技能只需要在任意模块里写一个装饰器函数团队协作时几乎不会有合并冲突。4.2 调度主循环把模型返回变成真实动作有了注册表主循环的骨架就显得很清晰了。核心逻辑就是上面 3.1 小节里那个循环但要补一个安全细节千万不要直接func(**args)要加一层参数校验和异常捕获。原因很简单模型的参数是生成出来的什么畸形值都可能出现如果它传了一个end_datetomorrow你的解析层首先会炸。一个更稳的做法是先用 Schema 校验再调用def safe_call_skill(skill_info, args): try: # 这里用 jsonschema 校验模型生成的参数 validate(instanceargs, schemaskill_info[parameters]) return skill_info[func](**args) except Exception as exc: return { status: error, code: 10001, data: None, msg: f技能参数校验失败{str(exc)}请检查参数格式, }校验这一层非常重要它把“模型胡诌参数”和“业务代码真的处理不了”这两件事隔离开。错误信息里要写明是参数校验失败这样模型看到之后会老老实实重新生成参数而不是把锅甩给用户。4.3 用“模拟项目X”完整跑一遍一次真实的调用链路为了把整条链路说清楚我举一个非常具体的模拟场景。假设模拟项目X是一个内部工单系统Agent 接入了两个技能list_tickets_by_date和summarize_by_day。用户提问“帮我按天统计一下上个星期提交的工单数量。”第一轮调度器把用户问题和技能列表发给模型。模型分析后返回一个结构化工具调用{ tool_calls: [ { id: call_001, function: { name: list_tickets_by_date, arguments: {\start_date\:\2025-02-10\,\end_date\:\2025-02-16\} } } ] }第二轮调度器解析参数调用list_tickets_by_date拿到原始工单数据返回体塞回上下文。模型发现这些数据还需要按天聚合展示于是调用第二个技能{ tool_calls: [ { id: call_002, function: { name: summarize_by_day, arguments: {\rows\: [{\date\:\2025-02-10\,\count\:3},{\date\:\2025-02-11\,\count\:5}]} } } ] }第三轮调度器执行summarize_by_day模型拿到聚合结果认为任务目标已达成不再返回工具调用而是生成一段自然语言回答给用户“2025-02-10 至 2025-02-16 期间模拟项目X 日均提交工单 4 单其中 2025-02-13 最高为 12 单。”整个过程里调度器本身不干预模型的决策只负责三件事把技能描述准确传进去、把执行结果安全拿回来、把错误信息原样交给模型。架构上越“笨”越好越透明越好。5. 技能库的演进与治理技能越加越多真正的麻烦才刚刚开始技能从一两个加到几十个之后真正的工程问题才会浮出水面。5.1 技能会过时版本与兼容层的设计业务系统永远在变技能背后的接口也永远在变。最容易踩的坑是技能参数变了但旧对话还在恢复模型还在按旧描述生成参数结果全部调用失败。我的处理方式很朴素技能表里加一个version字段每次行为有破坏性变更就递增版本号。同时让调度器把“技能版本”和用户会话绑定恢复历史会话时使用会话当时注册表的快照去解析工具调用而不是用最新注册表。这样旧会话不会因为技能库升级而集体失效。快照意味着要把注册表序列化保存比如存成 JSON放在会话记录里。代价是存储会稍微变大但换来的是极强的回溯能力。为了排查“为什么这个用户三天前的会话出错了”你甚至可以直接拿着快照复现当时的调用链路。5.2 回归集每次改动技能库都要跑一遍“家底”技能多了以后最怕的不是改错一个函数而是“改了一个技能的描述结果另一个技能的调用频率异常下降”。原因是模型选择技能完全受文本描述影响你只是把某个技能的描述写得更有吸引力了模型就可能不再选另一个其实更合适的技能。这就引出回归集的概念。我建议每个有 Agent 业务的项目都维护一批固定的测试任务可以放在一个 JSON 文件里[ { input: 帮我查一下上周销售总额, expect_skills: [sum_sales_by_range] }, { input: 给用户张三发送一条本月账单提醒, expect_skills: [query_user, send_message] } ]每次技能库有任何改动就把这些任务全部跑一遍检查两点是否选到了预期技能以及调用参数是否合理。这一套跑完基本能拦住 80% 的“描述漂移”问题而且它特别适合接入 CI提交代码后自动跑。5.3 技能漂移描述被随手改一下行为就跑偏很多人对描述变更不敏感觉得只是改几个字不涉及代码。但描述是模型的“玩法说明书”你改了描述就等于改了模型的行为策略。比如原来某个技能描述里写“若用户未指定日期默认返回最近7天”某天你改成“默认返回最近30天”。这个改动对函数实现毫无影响但模型以后在用户没提日期时生成的参数会从近 7 天变成近 30 天用户感知到的结果完全不同。因此描述变更必须当作一次发布来对待走同样的测试和评审流程不能随手一改就上线。6. 真实场景踩坑实录并发副作用、错误描述与提示词膨胀最后这部分我把自己踩过的一些坑集中说一下都是常规文档里不太会写、但真实环境里特别影响稳定性的东西。6.1 并发调用与共享状态技能内部必须无状态当你的 Agent 开始服务多个用户时技能函数会被并发执行。如果技能内部意外地引用了某个全局变量或者更隐蔽地多个调用共享了同一个缓存对象那轻则数据串味重则直接把内存搞炸。教训来自于我自己写的一个缓存技能为了“提升性能”我在技能内部放了一个全局字典做缓存。结果两个用户几乎同时发起相似查询第一个用户的查询条件还没执行完就被第二个用户的条件覆盖了返回给双方的都是彼此混淆的数据。从那以后我规定技能内部一律无状态所有数据要么来自参数要么来自函数内部临时变量需要缓存时缓存对象必须用请求 ID 做 key并保证读写隔离。这个约束要写进团队的代码规范里而不是靠临场发挥。因为技能是无状态的调度器才有可能安全地并发执行多个调用。6.2 错误返回别让模型猜谜把“修复线索”写进错误文本前面提到过错误信息必须带修复线索。这里我再展开一个真实例子某个技能返回了“permission denied”模型完全不理解这个权限是什么、谁该去改权限。于是它只能把错误原样翻译给用户用户以为是自己密码错了跑去改密码折腾半天。正确做法是让技能层在错误信息里写明“哪个用户对哪个资源缺什么权限应该找哪个角色授权”。比如{ status: error, code: 43000, data: None, msg: 用户 ID 10086 对门店 revenue_detail 资源无查询权限请先完成门店管理员角色授权后再重试。 }模型拿到这个信息就能生成更准确的提示甚至主动建议用户联系管理员。错误信息写得越具体模型下一步的决策就越可能正确。6.3 重试与重复执行副作用需要幂等保护模型在复杂的多轮对话里完全有可能因为网络问题重试同一个请求或者因为自己绕圈子重复调用同一个有副作用的技能。如果技能是“发送短信”或者“创建一个订单”重复执行一次就是一次灾难。我的作法是所有有副作用的技能都强制接受一个request_id参数在执行前先查重。如果这个 request_id 之前已经处理过直接返回上次的结果不再执行第二次。这一条的成本极低但能省掉大量生产事故。6.4 提示词膨胀技能描述别一股脑全塞进系统提示技能从十几个涨到几十个之后如果每个技能都写一段三五百字的描述这些描述全部塞进系统提示token 消耗会非常吓人还会稀释模型对核心技能的注意力。我的解决思路是分级路由先用一个轻量级的模型或规则根据用户输入判断候选技能范围只把相关的技能描述放进接下来的调用请求里。比如用户问的是“订单问题”那我只把订单域的三五个技能发给主模型而不是把全公司一百多个技能全部摆上去。这既省 token又提升命中率。顺带提一个细节技能描述第一句话最好写成“一句话总括”方便模型在长列表里快速定位。如果模型需要更细的信息可以在描述里指出“更完整的说明见 help 字段”但没有必要把一整套帮助文档塞进工具定义里。回到开头那个问题。如果你问我Agent 项目的成败最取决于哪一环我会毫不犹豫说技能层。模型可以随时换prompt 可以反复调但一套结构清晰、描述精准、无状态、带版本、可回归的技能库需要长期打磨也是整个系统里最值得投入的部分。我自己现在每新增一个技能都会顺手写一小段“为什么需要这个技能、预期被哪种问题触发”三个月后回头看这些记录比大部分设计文档都有用。希望这篇围绕 agent-skills 的经验整理能让你少走几步我曾经走过的弯路。
阅读完成 · 觉得有帮助?
咨询建站