资讯动态

Agent技能系统实战:从提示词泥潭到技能编排架构

发布时间:2026/10/13 23:21:35 来源:尧图企业网站定制
先交代一下背景。我是在给一个客服助手项目做重构的时候第一次认真思考agent-skills这件事的。当时我们维护着几个大型Agent应用Prompt越写越长模型越换越贵效果却越来越飘。同一个业务逻辑换个场景就要复制一份改了A处忘了B处。那段时间我几乎每天都在调Prompt和做稻草人测试直到我把整套思路拆成技能来组织才真正从泥潭里爬出来。这篇文章就把我对Agent技能系统从定义、设计到落地踩坑的全过程写清楚给正在被提示词工程折磨的开发者一个可以照着做的参考。1. 为什么Agent非要有一套技能系统现在不少团队做Agent还是老思路把指令、知识、业务流程全部塞进一个System Prompt再靠模型自己临场发挥。这种模式在小规模Demo里跑得通一旦进入真实业务环境问题会接二连三地冒出来。1.1 单一Prompt模式的三个死穴第一个死穴是上下文膨胀。客服场景里如果Prompt里要放几十条业务规则、几百个商品信息、十几套售后话术每次请求都要把这些内容重新传给模型一次。成本翻倍不说模型处理长文本时注意力分配也会出问题。我见过最夸张的例子有人把整个产品的FAQ全部塞进Prompt结果是模型回答问题的准确率不升反降。第二个死穴是难以演进。业务规则总在变如果你的业务逻辑是写死在Prompt里的那每次改动都要重新跑一遍全量回归测试。更麻烦的是你很难定位是哪一句话导致了行为偏移。Prompt里改个字可能在另一个场景下引发完全不同的输出这种黑盒式的调试体验非常折磨人。第三个死穴是能力无法度量。你把技能都藏在Prompt里就没有办法单独验证某一个能力的准确性。比如订单查询逻辑你只能通过完整对话去测一旦失败根本说不清楚是模型理解错了还是业务逻辑本身错了。技能的思路就是把Agent的能力从Prompt中抽离出来变成一个可以被注册、被检索、被独立验证的单元。打个比方单一Prompt模式像是让一个人同时扮演客服、售后、推荐官、财务等所有角色而技能模式更像是给这个人配了一个工具箱箱子里每件工具都贴着标签他只需要在合适的场景里抽出合适的工具。1.2 技能与函数、API的本质区别很多人会问技能不就是函数调用吗确实有相似之处但存在本质差异。普通函数是给固定程序调用的参数和返回类型在编译期就确定。而技能是给大模型看和选的模型需要根据用户请求和当前上下文自主判断应该调用哪个技能。这也意味着技能描述的质量会直接决定调用准确率。一个函数名叫query_order程序员一眼就懂但模型可能不清楚什么时候该调用它。如果你把描述写成当用户询问订单状态、物流信息、发货进度时调用此技能输入参数为订单号模型才能准确触发。技能还天然带有条件和边界。你可以规定技能只处理某类问题超出范围就拒绝服务这本身就是给Agent划定的安全边界。1.3 技能化之后带来的实际收益我重构那个客服项目后最直观的感受是可维护性大幅提升。以前改一条售后规则要在Prompt里小心寻找多少行旧的文字说明现在只需要修改对应技能里的业务逻辑即可不用碰其他代码也不会影响其他模块的行为。组合性是另一个收益。在技能化之前一个查订单计算退款金额的场景需要写两段逻辑或者干脆写一大串提示词去教模型按顺序操作。技能化之后订单查询技能和退款计算技能是独立的你可以让编排层决定是先查订单再算金额还是在其他场景下直接调用退款技能而不需要查询。还有就是能力可以被单测了。每个技能都可以用一套固定的测试用例去验证输入什么、期望输出什么这是普通提示词工程完全做不到的。2. 技能定义与注册机制的设计既然要建技能系统第一步是定义这套系统的原子单位——一个技能应该包含哪些内容应该以什么格式存储应该怎样被注册到系统中。2.1 一个技能该包含哪些字段我最终沉淀出来的技能描述结构是五个核心字段技能ID、技能名称、功能描述、输入参数Schema、返回输出说明外加一个可选的触发条件。用一个JSON示例来看会更直观。{ skill_id: order/query, name: 订单查询, description: 当用户询问订单状态、物流进度、发货信息、签收情况时调用此技能。适用于已下单但未收货的用户。, input_schema: { type: object, properties: { order_id: { type: string, description: 用户提供的订单编号通常为10位数字字母组合 }, user_id: { type: string, description: 当前会话对应的用户唯一标识 } }, required: [order_id, user_id] }, output_schema: { type: object, properties: { order_status: { type: string }, logistics: { type: array } } }, trigger_hint: 用户消息中应包含类似订单号的信息或者用户明确提到查询订单 }这个JSON描述里的description字段是最容易被低估的。不少团队的工程师在写技能描述时直接复制功能名称效果很差。描述应当是面向模型的写清楚触发时机、适用场景、参数含义甚至写一点反例也没问题。比如订单查询的description里如果加上如果用户尚未下单不要调用此技能就能有效减少误触发。2.2 技能注册的三步流水线技能写好了要让它能被Agent运行时发现就需要一个注册机制。我的方案是三步流水线加载、校验、索引。加载阶段很简单就是扫描你指定的技能目录读取所有JSON文件或Python类。校验阶段至少做三件事一是校验Schema合法性二是检查技能ID是否重复三是检查描述里是否有明显与其他技能冲突的表述。索引阶段是针对描述文本构建向量索引用于后续语义检索这一步需要调用一个Embedding服务把描述文本转成向量。下面是一段技能注册管理的核心代码简化了异常处理但能表达主体的思路。import json from pathlib import Path from typing import Dict, Type class SkillBase: skill_id: str name: str description: str input_schema: Dict {} output_schema: Dict {} def execute(self, **kwargs) - Dict: # 技能的实际执行逻辑由子类覆盖 raise NotImplementedError class SkillRegistry: def __init__(self, skills_dir: Path, embedding_fnNone): self.skills_dir skills_dir self.skills: Dict[str, SkillBase] {} self.embedding_fn embedding_fn self._vector_index [] def load_all(self): for skill_file in self.skills_dir.glob(*.json): data json.loads(skill_file.read_text(encodingutf-8)) skill self._hydrate_skill(data) self._validate_skill(skill) if skill.skill_id in self.skills: raise ValueError(fduplicate skill_id: {skill.skill_id}) self.skills[skill.skill_id] skill def _hydrate_skill(self, data: Dict) - SkillBase: # 把JSON描述转成技能对象 skill SkillBase() skill.skill_id data[skill_id] skill.name data[name] skill.description data[description] skill.input_schema data[input_schema] skill.output_schema data[output_schema] return skill def _validate_skill(self, skill: SkillBase): if not skill.skill_id or not skill.description: raise ValueError(fskill {skill.skill_id} missing required field) def register_skill(self, skill_obj: SkillBase): # 用于直接注册一个类而不是从JSON加载 self.skills[skill_obj.skill_id] skill_obj注册机制的核心逻辑并不复杂真正的复杂度在于后续的检索。当Agent收到一条用户消息它需要从几十上百个技能里选出最合适的几个。如果技能只有二十个以内用规则匹配就够了但到了五十个以上关键词匹配就开始不够用需要引入向量检索。2.3 检索策略规则优先向量兜底我实际使用的检索策略是两级筛选。第一级用规则匹配把包含强信号的技能直接锁定比如用户消息里出现订单号三个字就把订单查询技能加权。第二级再用向量检索召回把用户请求文本和所有技能的description计算相似度召回到Top K个候选。最后把两路结果做加权融合再交给模型做最终选择。有一个容易被忽略的细节是技能数量的控制。召回到Prompt里的技能不是越多越好。每多塞一个技能就多消耗一段token也增加模型选错的概率。我通常控制在3到5个以内。如果召回结果超过这个数说明技能划分粒度出了问题需要合并或者精简描述。3. 从零搭一个可用的技能调度框架理论讲完了直接进入实操。这一部分我会用一个简化但完整的Python框架说明如何搭建技能注册、检索、调度、执行的完整链路。3.1 技能调度器的核心逻辑调度器的作用是接收用户消息结合当前会话上下文从技能注册表里选出合适的技能然后带着参数执行。注意一点调度器本身不需要做太多逻辑推理它只负责候选生成和参数填充最终要不要调用某个技能、怎么组合技能还是要让模型来决策。class AgentRuntime: def __init__(self, registry: SkillRegistry, llm): self.registry registry self.llm llm def dispatch(self, user_message: str, context: Dict) - Dict: # 1. 候选技能召回 candidates self._recall_skills(user_message) # 2. 让模型从候选中做最终选择并抽取参数 selected self._llm_select_skill(user_message, candidates, context) if not selected: return {status: no_skill, reply: 暂时无法处理该请求} skill_id selected[skill_id] params selected[params] # 3. 执行技能 skill self.registry.skills[skill_id] result skill.execute(**params) # 4. 把技能结果交给模型生成最终回复 reply self._llm_generate_reply(user_message, result, context) return {status: ok, result: result, reply: reply}这里比较关键的点在第一行的候选技能召回。如果把几十个技能的描述全丢给模型让它选一方面浪费token另一方面模型在长列表下选择准确率明显下降。所以我倾向先召回再让模型做小范围决策这样效果更可控。_llm_select_skill这步的设计也值得琢磨。传给模型的不只是技能名称列表而是要附带每个技能的一句话描述让模型能判断这个技能适不适合处理当前场景。我用的提示词大致如下你是技能调度引擎。根据用户消息和可用技能列表选出最合适的技能并提取调用参数。 用户消息{user_message} 会话上下文{context} 候选技能 {skill_candidates} 只输出JSON格式例如{skill_id: order/query, params: {order_id: xxx}}不要输出任何解释。关于候选技能的格式我会把技能ID、描述、输入参数Schema都包含进去。模型需要根据Schema来抽取参数所以Schema本身要写得足够准确。3.2 技能执行器标准的入参和出参理解调用技能时有一个很常见的错误就是把LLM返回的参数直接传给业务函数。模型返回的是一个JSON字符串它可能多字段、少字段、甚至类型都不对。在实际项目里我在技能执行器里加了参数校验和类型转换的逻辑。import jsonschema class SkillExecutor: def __init__(self, validator_registry: Dict): # 用一个缓存存已经编译过的schema_validator self._validators {} def validate_and_execute(self, skill: SkillBase, raw_params: Dict): # 先做Schema校验 if skill.skill_id not in self._validators: self._validators[skill.skill_id] jsonschema.Draft7Validator(skill.input_schema) validator self._validators[skill.skill_id] errors list(validator.iter_errors(raw_params)) if errors: # 校验失败时返回一种特殊的fail结果 return {status: param_invalid, errors: [e.message for e in errors]} return skill.execute(**raw_params)还有一个细节技能返回值里应该有一个status字段用来区分正常结果、业务失败、参数校验失败、系统异常。这样外层调度时可以根据status决定是要让模型重新抽取参数还是直接转人工还是给用户一个兜底回复。如果不加这个状态位技能一报错整个Agent可能就直接崩了。3.3 场景落地订单查询与退款建议的组合用一个具体的场景串联起来看会更有体感。假设业务里有两个技能一个order/query查询订单一个refund/estimate估计退款金额。用户发来一条消息我上个月买的那个XX坏了想退款订单号是123456调度器应该怎么处理第一轮召回用户消息里包含订单号和退款这两个技能都会进入候选。但注意refund/estimate技能的输入Schema里需要订单信息所以模型需要先调用order/query拿到订单详情再传给refund/estimate。这就引入了技能编排的概念。我最开始做调度器时只支持单技能调用遇到这种多技能组合场景就傻眼了。后来做了两版改进第一版是简单的顺序编排模型一次性输出一个技能调用链第二版是支持条件分支的编排引擎。前者能满足大部分场景后者才是完整解决方案。class SequentialPlan: def __init__(self, steps: List[Dict]): self.steps steps def execute(self, runtime: AgentRuntime, context: Dict): results {} for step in self.steps: skill_id step[skill_id] params step.get(params, {}) # 允许参数引用之前步骤的结果 for k, v in params.items(): if isinstance(v, str) and v.startswith($): ref_path v[1:] params[k] _resolve_ref(results, ref_path) skill runtime.registry.skills[skill_id] result skill.execute(**params) results[skill_id] result return results这个顺序编排其实很简单甚至不能叫引擎就是按列表顺序执行然后把前一步的结果作为后一步参数的引用。后来我加了条件判断比如订单查询结果显示订单状态是已完成才执行退款估计否则跳过。这段逻辑在我的项目里帮了大忙因为真实业务场景大多不是单纯的A然后B而是A的结果决定要不要走B这条分支。3.4 参数引用与结果缓存的小把戏在做编排时参数引用是最容易出Bug的地方。我的处理方式是在规划时用$前缀引用前序结果执行时一点点把引用替换成实际值。比如第二步的参数写成{order_id: $query_order.order_id}在执行第二步前就把这个引用替换成第一步返回的真实值。另外结果缓存也不能忽略。同一个会话里如果用户反复查询同一个订单你总不能每次都去调后端接口。我在技能执行器里加了一个按(skill_id, params_hash)为key的缓存层。不过要注意缓存时间不能太长订单状态这类数据是会变的一般设五分钟过期。import hashlib import time class CachedExecutor(SkillExecutor): def __init__(self, cache_ttl300): super().__init__({} Documentation) self.cache_ttl cache_ttl self.cache {} def validate_and_execute(self, skill: SkillBase, raw_params: Dict): # 只有幂等的技能才走缓存这个标记在skill描述里预先定义 if getattr(skill, cacheable, False): param_str json.dumps(raw_params, sort_keysTrue) key hashlib.md5(f{skill.skill_id}:{param_str}.encode()).hexdigest() if key in self.cache: cached_at, cached_result self.cache[key] if time.time() - cached_at self.cache_ttl: return cached_result result super().validate_and_execute(skill, raw_params) self.cache[key] (time.time(), result) return result return super().validate_and_execute(skill, raw_params)这里踩过的一个坑是缓存了非幂等的技能。比如退款发起的技能一旦被执行就不允许回滚如果因为缓存让它少执行了一次业务上就是一次事故。所以我在给技能打标记时会特别审慎只有明确可重试的技能才开启缓存。4. 技能系统落地后踩过的那些坑技能系统搭建起来并不算难真正难的是让它长期稳定运行。这一节把我实际遇到的高频问题整理出来附带排查思路希望你能少走弯路。4.1 意图匹配错乱模型总是选错技能症状很典型用户问的是订单状态模型却调用了退款技能。我排查一圈后发现根因往往不是模型笨而是技能描述互相踩踏。例如order/query描述里写了当用户对订单不满时可查询订单详情就可能被模型误判为退款场景。修复方法有两个层面。第一层是描述消歧在技能描述里加上明确反例比如订单查询描述改成仅用于查询订单状态和物流进度不包含退款申请退款请使用refund/apply技能。第二层是引入路由技能当模型对候选技能把握很低时直接走一个专门的澄清技能反问用户到底想办什么业务。我还试着给每个技能加了一个negations字段专门存放不要调用这个技能的场景。执行检索时把这些否定信号也带进Prompt实测下来能把误调用率从大概13%压到5%以内。4.2 上下文污染技能结果把Agent带沟里去了技能调用完以后它返回的结果会被塞到上下文中让模型生成回复。如果技能返回的是一个很大的JSON对象模型的注意力会被无关字段带跑。比如订单查询返回了包含内部备注字段的完整订单对象模型可能就照着备注里的内部术语回复用户造成严重事故。我的解决办法是给技能的输出做面向用户的视图转换技能内部可以返回完整对象但提交给模型生成回复的只是一个精简过的、只保留用户相关字段的视图。这一步是用一个字段白名单过滤实现的。所有技能都定义了一个reply_fields供模型生成最终回复时参考。def build_user_view(raw_result: Dict, reply_fields: List[str]) - Dict: if not reply_fields: return raw_result return {k: raw_result[k] for k in reply_fields if k in raw_result}这个细节极其重要却经常被忽略。很多技能系统Demo跑得好好的上线就各种翻车就是栽在这个地方。4.3 技能幻觉与参数幻觉Agent经常犯的一个毛病是参数幻觉模型构造了订单号去调用技能然后技能返回查询不到订单Agent就回复用户订单不存在。这在真实业务里是硬伤因为极有可能只是模型编了个不存在的订单号。我针对参数幻觉加了两个防御策略。第一个是参数来源校验如果调用参数里的关键信息比如订单号不是从用户消息里抽取出来的而是模型自己硬编的这个参数就被标记为低置信度需要Agent向用户二次确认。第二个是把查询类的技能设计成空结果也返回成功但带特殊状态让Agent清楚地知道订单查到了还是没有从而输入没有找到订单而不是胡说订单已发货。防御逻辑其实不复杂重点在于让模型有明确的、可理解的信号去区分查不到结果和调用出错两种情况。很多模型在技能返回错误时会自动编一个合理的回复来掩饰问题这样的结果非常危险。4.4 技能系统的可观测性技能系统调试起来比普通接口难得多因为中间隔了一层模型。我做的第一个可观测性基建是给每次调度记录一张完整的调用链日志用户消息原文、召回候选列表、模型选中的技能、抽取的参数、技能返回结果、最终生成回复的Prompt。这张日志表里的每一步都对应一个时间戳和tokens消耗。有了这张表排查问题就轻松多了。用户投诉回答不对时看一眼是模型选错了技能还是参数抽错了秒级定位。再配合一个定期回放的评测脚本把历史真实会话拿来跑一遍对比关键行为是否发生变化就能在业务感知到异常前发现回归。这个做法我强烈建议推广它比加再多Prompt防御都有用。5. 从技能管理走向技能生态当技能数量超过两百个以后靠一个团队手写技能描述和实现就变成瓶颈了。这时候要考虑技能生态的问题。我现在观察到一个趋势团队开始把技能定义和技能实现分离。技能定义的JSON描述独立存储运营团队可以像编辑文档一样维护技能描述技能实现则作为后台服务独立部署。这样就能做到描述更新即时生效而无需重新发布整个应用。技能版本的引入也很关键。每个技能除skill_id外还应该带一个version字段注册表里同一个skill_id可以保留多个版本调度时默认使用最高版本同时允许针对特定会话固定旧版本做灰度切换。我遇到过两次技能升级引入回归的情况有了版本机制后回滚就是一次配置修改的事代码不用动。真正往前一步技能的可移植性应该成为核心指标。如果一个团队在一个项目里沉淀出的技能描述换个领域就用不上了那这套系统就没有跨项目复用的价值。好的技能描述应当是插件式的不绑定特定业务状态。比如订单查询技能描述里就不应该写死具体商品类目而是把类目作为参数传给技能实现。最后说一个经验技能分割的粒度不是越细越好。有一个判定标准我很常用——如果一个技能无法用三句话讲清楚它解决什么问题、什么时候不该被调用那它大概率拆分过度了。我在项目里合并过几组过度细分的技能比如把查询退款进度和查询退款金额合并成一个查询退款信息技能误调用率明显下降说明模型在做小范围决策时候选越少准确率越高。末尾分享一个小技巧技能系统的第一个版本不用追求完备先做五六个覆盖核心高频场景的技能跑通调度链路再逐步扩容。我见过不少团队一开始就规划了一百多个技能结果注册完成就发现检索效果一塌糊涂。从小处着手根据实际调用日志持续调整描述和粒度比憋大招稳妥得多。这套技能体系我还会继续迭代下去尤其是编排引擎和评测自动化这两块值得投入更多精力。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑