ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

技能驱动架构:让智能体从混乱走向可控的工程实践

技能驱动架构:让智能体从混乱走向可控的工程实践 我从去年开始密集接触智能体项目前后帮三个团队搭过 Agent 底座。一个很明显的共性问题大家一开始都把 Agent 当成一个超大的函数来写所有逻辑堆在一个文件里prompt 越加越长工具函数越挂越多到最后没人说得清这个 Agent 到底能干什么、不能干什么。直到我把手头一个内部项目重构成“技能驱动”架构局面才彻底改变。这个项目的名字就叫 agent-skills核心思路只有一句话把智能体的能力拆成一个个可以被声明、注册、编排、观测的技能单元让大模型只负责“选技能、填参数”把“执行”交还给确定性的代码。这篇文章从设计原则讲到工程落地再到上线后踩过的坑完整复盘一遍。适合正在做 Agent 落地的工程师、技术负责人以及准备把智能体接入业务系统的团队参考。1. 为什么我坚持把智能体改造成“技能驱动”架构很多团队问我的第一句话是Agent 框架不是现成的吗LangChain、Semantic Kernel、各类编排框架拖进来就能跑为什么还要自己做一套技能层。我的回答一般是框架能帮你省掉重复代码但省不掉架构决策。你的 Agent 如果只有三个技能用框架自带的能力没有问题当技能超过三十个、由不同团队维护、要按业务线做权限隔离的时候你就必须有一个统一的能力抽象层。agent-skills 就是在这个前提下开始做的。1.1 复盘踩过的坑智能体项目最大的问题不是模型不够聪明先复盘一下我最早做的那个失败版本。当时的需求是做一个内部客服助手能查订单、查物流、处理退换货。我按“万能 Agent”的思路把用户问题直接丢给大模型system prompt 里写了十几条业务规则又给模型挂了七八个函数。demo 演示效果非常好什么问题都能答。一上线就崩了模型把订单号识别错把 0 认成 O然后在订单系统里查到不存在的数据两个技能都需要调用“查询用户身份”的逻辑但一个技能里写死了用户 ID另一个靠模型猜结果权限校验逻辑重复且不一致prompt 越来越长技能描述互相干扰模型开始把“查物流”的功能安到“查订单”技能上。最典型的一个线上事故用户问“我的快递什么时候到”模型正确选中了物流查询技能但参数里把tracking_number和order_id混在一起填传了一个既不是订单号也不是运单号的字符串。由于下游接口做了模糊匹配居然返回了一个陌生人的包裹信息。这个事故给了我两个教训第一模型输出不可作为最终事实参数必须经过严格校验和归一化第二技能的边界必须清晰不能让模型靠猜来区分两个相似的技能。agent-skills 就是在这些事故之后重写的架构。1.2 技能驱动架构的核心思路技能驱动架构的本质是把“Agent 会什么”这件事从隐性的 prompt 中抽出来变成显性的、可管理的代码资产。每个技能是一个独立单元包含三样东西技能的元信息描述告诉模型这个技能何时用、怎么用、参数的 Schema约束模型填参数的结构、以及执行逻辑真正干活的代码不被模型直接触碰。这样一来Agent 的主循环变得很薄接收用户输入 - 结合当前上下文挑选技能 - 生成结构化的调用参数 - 交给技能层执行 - 把结果格式化成自然语言返回。模型的职责被收窄到“决策”这一层具体执行全部落在确定性的代码里。这个架构对我最大的价值是可以量化我能数出当前系统有多少技能、每个技能被调用了多少次、成功率和失败率是多少而不是靠感觉去猜 Agent 好不好用。1.3 agent-skills 的定位和设计原则我给 agent-skills 定了四条设计原则后文的实现都围绕这四条展开技能描述和技能实现分离。业务代码不需要知道模型怎么描述它模型看到的描述由独立文件管理方便算法同学单独调参。一切可观测。每次技能调用都必须留痕包括入参、出参、耗时、token 消耗、异常信息。失败必须显式。技能执行失败时返回结构化错误信息模型可以根据错误信息决定重试、换参数还是向用户坦白而不是生成一段含糊的“系统开小差”。先本地后远端。所有技能先以 Python 函数的形式存在本地跑通之后再封装成微服务避免一上来就搞分布式。有了这四条原则后面的代码和目录设计基本就是水到渠成的事情。2. 技能定义一份让模型和代码都能读懂的契约技能是整个架构的核心资产技能定义的质量直接决定模型调用的准确性。我见过太多团队把技能定义写成一大段说明书式的文字模型读完之后依然不知道什么时候该用这个技能。agent-skills 里技能定义被拆成三层面向人的文档、面向模型的描述、面向代码的 Schema。2.1 三层结构的设计与职责第一层是SKILL.md给人看的。它描述技能的业务背景、前置条件、依赖的权限、可能返回的数据样例。这层内容不进 prompt、不参与模型推理它服务于团队协作新成员通过读这层文档就能理解技能是干什么的。第二层是 skill description给模型看的。它必须克制、精准只说“什么场景下用”和“关键注意点”。我在实践中发现描述写得越短模型选得越准描述里一旦出现“通常”“可能”“一般”这类模糊词模型就会出现选择漂移。一条好的 description 是确定性的陈述句例如“当用户询问订单物流状态并提供订单号时使用”而不是“这个技能可以查询订单的物流信息也可能用于其他相关场景”。第三层是 params schema给代码和模型共同看的。它用 JSON Schema 描述参数结构代码用它做校验模型用它做参数生成引导。Schema 是技能契约的硬边界模型再怎么发挥最终都要落进这个结构里。QUERY_DELIVERY_SCHEMA { type: object, properties: { order_id: { type: string, description: 订单号格式为 OD 开头加 14 位数字例如 OD20250101000123, pattern: ^OD\\d{14}$ }, include_trace: { type: boolean, description: 是否返回完整物流轨迹默认 false只返回当前节点, default: False } }, required: [order_id], additionalProperties: False }2.2 参数 Schema把自由文本变成结构化输入参数 Schema 的重要性怎么强调都不为过。它决定了模型从用户一句自然语言里抽取出什么样的结构化信息。我第一次设计 Schema 时犯的错是把 description 写得太抽象比如“order_id 是订单的唯一标识”模型并不知道这个标识长什么样。后来我养成一个习惯每个字段的 description 里必须包含一个真实示例并且把格式规则写进去例如订单号必须以OD开头、运单号通常是 12 到 15 位数字。一个我强烈建议开启的选项是additionalProperties: False。如果不加这个约束模型有时会自作主张往参数里塞一个未定义的字段比如把user_id混进来。开启了严格模式之后模型生成的参数任何多余字段都会被校验器拒绝错误信息会回传给模型模型会自动修正。根据我的统计开启 strict 模式后参数生成合法率从 76% 提升到了 92%提升非常可观。还有一个小技巧是枚举字段尽量用 enum 而不是自由字符串。比如查询类型query_type如果你写 “string 类型表示查询的是物流还是订单”模型可能会填 “logistics”“delivery”“shipment”“物流” 各种变体。直接用 enum[order, delivery]配合 description 里的中文说明模型基本不会再填错。2.3 技能注册装饰器与入口封装在 Python 实现里我用装饰器把技能定义和实现绑定在一起。这个模式的好处是技能编写者只需要关注函数体不需要理解注册中心的内部逻辑。skill.register( namequery_delivery, description当用户询问订单物流状态并提供订单号时使用返回当前物流节点和预计送达时间, params_schemaQUERY_DELIVERY_SCHEMA ) def query_delivery(params: dict, context: SkillContext) - SkillResult: order_id params[order_id] trace delivery_repo.get_by_order(order_id) if not trace: return SkillResult.fail(codeORDER_NOT_FOUND, message订单不存在或没有物流信息) return SkillResult.ok(data{ current_node: trace.current_node, eta: trace.eta, trace: trace.nodes if params.get(include_trace) else None })这里有个细节值得展开SkillContext 是上下文对象包含用户身份、trace_id、会话状态等系统信息。它由框架注入不允许技能自己从外部拿全局变量这保证了技能的可测试性和可移植性。每个技能在测试时只需要构造一个 DataBuilder 伪造上下文不需要起服务、不需要连数据库。3. 技能注册与发现别再用 if-else 管理你的技能列表技能多了以后最大的工程挑战不是写技能而是管理技能。我在早期版本里用过一个字典把所有技能函数列出来再手写一个 mapping 表。那个维护成本实在太痛了每加一个技能要动三四个文件。agent-skills 改成基于目录约定和装饰器注册的机制之后加一个新技能只需要在skills/目录下新建一个子目录框架启动时自动扫描、自动注册。3.1 目录规范和自动发现我采用的目录结构如下skills/ query_delivery/ SKILL.md schema.json __init__.py query_order/ SKILL.md schema.json __init__.py send_message/ SKILL.md schema.json __init__.py框架启动时遍历skills/目录对每个子目录做三件事读取schema.json和SKILL.md加载技能元信息导入__init__.py模块执行模块内的装饰器注册逻辑校验元信息是否完整名称是否合法、description 是否为空、schema 是否通过 JSON Schema 自校验。自动发现解决了“技能越多越乱”的问题但它也引入了一个隐患隐式加载导致 IDE 跳转困难。为了兼顾可调试性我在注册中心保留了一个list_skills()接口同时启动时会打印一张技能清单包含技能名、加载状态、描述摘要。这样既享受了自动发现的便利也保住了排查问题的手段。3.2 注册时的强校验注册不只是把函数塞进字典里。build 阶段我还会做一次模拟调用测试用 sample params 跑一遍技能函数确保导入路径没问题、依赖注入能解析。这一步在 CI 里执行任何技能加载失败都会让流水线红掉而不是等线上模型调用时才炸。强校验里的一个重要项是参数名冲突检查。技能之间可能共用底层依赖但参数名必须显式声明。我曾经遇到两个技能都定义了user_id但语义完全不同一个指电商平台的用户 ID一个指内部工号。模型看到同名字段会串味。agent-skills 的注册中心会扫描所有技能的参数名遇到同名不同描述的情况直接报错强制开发者区分字段名比如改成platform_user_id和emp_user_id。3.3 注册中心的查询接口注册中心对外暴露两个核心查询接口一个是模型调用时用的match_skills(query)输入用户的原始问题返回候选技能列表另一个是面向业务方的get_skill(name)通过名称精确定位。match_skills的实现在我项目早期是用向量检索后来发现大部分场景下关键词召回加规则过滤就已经足够。真正的语义匹配交给大模型在推理阶段做检索只需要把明显不相关的技能过滤掉缩小候选范围减少 prompt 长度。这样做的收益很直接prompt 里塞的技能描述越少模型的选择准确率越高token 消耗也越低。def match_skills(query: str) - list[SkillSpec]: candidates [] for spec in skill_registry.all(): keywords spec.keywords if any(k in query for k in keywords): candidates.append(spec) return candidates[:MAX_CANDIDATE_COUNT]4. 技能编排不能只会调用单个技能技能注册和发现解决的是“模型选对技能”的问题。但真实业务往往需要多个技能协作才能完成比如收到一个售后请求Agent 需要先查订单、再查物流、再判断是否在退换货窗口期、最后给用户发送一条售后通知。这中间就有编排问题。我在 agent-skills 里把编排分为三种模式顺序编排、条件编排、并行编排。4.1 三种编排模式及落地场景顺序编排是最普遍的模式前一个技能的输出作为后一个技能的输入。在我的项目里主要用在一个跨多个系统的查询场景先通过resolve_user技能拿到用户在各系统的 ID再拿这些 ID 去调query_order和query_delivery。顺序编排的关键是定义好技能间传递的数据结构不能让技能直接从全局 context 里捞数据。条件编排是根据技能执行结果决定下一步走向。比如check_return_eligibility返回eligiblefalse时Agent 应停止后续流程并告知用户原因而不是继续调create_return_order。我在编排引擎里支持了on_success、on_fail和on_judgment三种分支分支条件用简单的规则表达式声明不用写复杂的状态机代码。并行编排用在彼此无依赖的查询场景比如用户同时问“我的订单到哪了”和“这个订单花了多少钱”两个技能可以并行执行。我最早用的是 Python 的asyncio.gather但很快发现在高并发下需要控制并发度否则下游数据库连接池会被打满。后来改成了带信号量的并发池限制最大并行数为 5明显稳了很多。4.2 上下文传递与数据隔离编排最容易出错的地方是上下文传递。我定了一个规矩技能之间不直接传数据统一通过上下文对象传递每条数据都要带来源技能名和写入时间。class SkillContext: def __init__(self, trace_id: str, user: UserContext): self.trace_id trace_id self.user user self._data: dict[str, ScopedValue] {} def put(self, key: str, value, source_skill: str): self._data[key] ScopedValue(valuevalue, sourcesource_skill, tstime.time()) def get(self, key: str): if key not in self._data: return None return self._data[key].value这个设计避免了两个问题一是数据覆盖两个技能写同一个 key 时后写的不一定是对的有了来源字段可以追溯二是脏读编排引擎在分支并行执行时给每个分支提供 context 的快照防止分支 A 修改的数据被分支 B 错误读取。4.3 技能冲突与兜底机制编排还面临一个模型层面的冲突问题模型在一步决策时选择了技能 A但根据业务规则应该选技能 B。比如业务规定用户申请售后时必须先经过智能客服前置沟通不能直接创建售后单。这个规则如果只靠模型自觉一定会翻车。我的做法是在编排层做业务规则前置检查如果技能调用违反了规则编排引擎直接返回RULE_BLOCKED错误码并附带规则说明模型看到这个错误码后会自动调整策略向用户解释并引导到正确流程。这个兜底机制很重要它把“靠模型自觉”变成了“靠引擎强制”。即使模型在某个边缘 case 上选错了引擎也会在最后一道闸门拦住避免向用户展示错误的操作结果。5. 上线后踩过的坑超时、上下文污染和幻觉参数任何架构在落地过程中都会暴露问题agent-skills 也没有例外。这一节我挑三个最有代表性的坑还原它们的排查链路和修复方案给后来者提个醒。5.1 超时链路排查全过程模型和技能执行必须分开设置超时项目上线第二周线上监控开始出现大面积的超时告警。第一反应是技能函数执行太慢但查了链路日志发现query_order的平均延迟只有 120ms不算慢。真正的问题是 Agent 主循环的超时设置我把整个 Agent 调用链路的超时设成了 15 秒其中大模型推理稳稳吃掉 8 到 10 秒留给技能执行的时间只剩 5 秒。如果这轮模型输出恰好选了两个技能并行执行加上下游系统抖动5 秒根本不够。排查过程是这样推进的先看告警集中在哪个环节发现集中在“生成回复”阶段于是打开 trace给模型调用和技能调用分别打了耗时标签对比之后发现技能执行本身健康但整条链路的超时配置太粗放。修复方案很明确——把超时拆成三段模型推理超时、技能执行超时、总时长超时分别设置 12 秒、8 秒和 20 秒。拆完之后超时告警下降了 80%。这个改动听着很小但如果不把“等待模型”和“执行技能”分开看很多超时问题永远定位不到。5.2 上下文污染的根因与修复第二个坑是上下文污染。技能多了以后我把所有技能的 description 都塞进系统提示词里导致上下文长度持续膨胀。随之而来的是模型选择准确率下降特别是技能之间语义相近时模型会出现“抢答”现象把本应选 A 的场景选成 B。根因在于我没有做候选技能的筛选让模型在一二十个技能描述里做选择选择空间太大。修复办法就是我前面提到的match_skills预筛机制先通过关键词把候选技能压缩到 3 到 5 个再让模型做精细选择。压缩后上下文变短模型注意力更集中选择准确率显著回升。我还设置了一个告警如果单次请求的 system prompt 超过 4000 token就触发告警提醒该做技能裁剪了。5.3 大模型幻觉参数的拦截与修正第三个坑是最难对付的因为它的表现不是报错而是“错得一本正经”。模型在生成参数时会编造一个符合格式但实际不存在的订单号或者把日期填成 2024 年 2 月 30 日这种不存在的日期。这类幻觉参数如果不被拦截就会直接打到下游系统造成脏数据。我的处理是双层防线。第一层是参数强校验在进入技能函数前用 jsonschema 校验格式、枚举、范围pattern正则能拦掉大部分格式错误的参数。第二层是业务语义校验比如订单号是否符合当天的单号段、日期是否在合理范围内、金额是否超过订单总额。业务校验规则写在每个技能内部的pre_execute钩子里一旦校验失败返回结构化错误码模型会根据错误信息重新生成参数。经过这两层防线幻觉参数到达下游的比例从最初的 8% 降到了 0.2% 以下。6. 可观测性没有度量就没有优化智能体项目的调试难度远高于普通后端服务因为它有一条“模型输出”的不确定环节复现问题往往是概率性的。如果日志里没有记录模型当时看到了什么、选了哪个技能、填了什么参数几乎不可能定位到根因。所以 agent-skills 从第一天起就把可观测性当成一等公民来做。6.1 技能级日志的规范格式我给每次技能调用定义了统一的结构化日志格式使用 JSON 输出包含以下字段trace_id一次用户请求的全局唯一 IDskill技能名params模型生成的参数字段必须脱敏result_statussuccess / fail / blockedlatency_ms技能执行耗时tokens本次调用的模型 token 消耗数model本次调用使用的大模型版本。为什么要定义统一格式因为跨团队协作时算法要看模型行为后端要看业务数据SRE 要看性能和稳定性。一份统一的日志能支持所有人的需求。我见过很多团队在调智能体问题时日志散落在各种业务系统里格式五花八门最后只能靠人肉对时间线那种效率太低了。6.2 建立技能级评估集除了日志我还维护了一份技能评估集也就是一批“输入 - 期望输出”的测试用例。比如对于query_delivery评估集里有用户直接问“我的快递到哪了”、用户提供订单号“OD20250101000123”、用户提供了格式错误的单号等案例。每次改技能描述、调参数 Schema 后我会用评估集跑一遍回归观察模型选择技能和生成参数的正确率。这个评估集是活文档每次线上出了选择错误的问题我都会把对应 case 加进去作为一个永久回归项。有一次我发现模型总是把“取消订单”误判为“退货申请”就是因为之前没有这些边界 case。加入评估集再调整 description 和参数 Schema 之后这个错误就不再出现了。6.3 运行指标的三个核心信号日志和评估集解决的是“对不对”的问题运行指标解决的是“稳不稳”的问题。我在 dashboard 上重点盯三个指标技能调用成功率、参数生成合法率、端到端平均延迟。这三个指标基本能反映 Agent 系统的整体健康度。成功率下降优先检查下游依赖参数合法率下降优先检查最近是否有技能描述改动延迟爬升优先检查提示词长度和候选技能数量。没有这三个指标任何优化都像是在黑盒里猜。7. 给准备做技能层的团队几点实在建议最后分享一些从项目里沉淀下来的团队协作和演进建议。技能层不是一日建成的也没必要一开始就把所有技能都抽象得干干净净。7.1 先别急着做平台我在项目初期犯过一个错误花了两周时间搭技能管理后台又是审批流、又是可视化编排、又是权限管理结果真正的技能只有三个。后来我把这套后台全部推倒先用代码和目录结构把流程跑通等技能数量上到两位数再重建管理平台。我的建议是最少可用是一份目录规范加一个注册中心足够支撑到三四十个技能不用过早进入平台化。7.2 技能拆分粒度要跟着业务走技能粒度过细比如“查询用户手机号”和“查询用户邮箱”各是一个技能会导致模型选择时注意力分散粒度过粗比如一个“处理售后”技能内部塞了十几步逻辑又回到了万能函数的泥潭。我的经验是按“一个业务动作”来拆分查订单、查物流、创建退款单、发送消息、校验资格。每个技能的输入输出边界清晰且内部逻辑能在一个函数内完成最佳。7.3 让写技能的人参与线上问题追踪技能不是写完上线就完了。agent-skills 项目里我要求每个技能的维护者必须订阅该技能的告警日志第一次被线上调用失败时维护者要在 24 小时内给出原因说明。这个机制倒逼技能描述写得更加严谨因为写得不清楚、参数 Schema 有漏洞最终由自己承担责任。有个团队按这个方式跑了三个月技能一次通过率明显提升模型选择错误的工单量下降了六成。我在实际项目里最深的一个体会是技能层真正解决的是把“大模型不可控”这头大象切成小块每一块都有明确的输入输出、有校验、有日志、有负责人。模型在这个架构里变成了一个有边界的决策器它需要做的判断变少了但判断质量显著提高了。如果你也在为 Agent 的混乱状态头疼不妨先把当前的能力抽象成技能清单看看到底有几项业务动作是重复实现的、哪些边界是模糊的这比追着热门框架跑要实在得多。
返回列表