Agent系统中Skill与Rules的工程化分离实践

1. 这不是术语辨析题,而是Agent系统设计的分水岭

“Agent Skill 和 Rules 有什么区别?”——这个问题在2024年中后期的AI工程实践中,已经从一个初学者的困惑,演变成团队架构师在评审Agent系统方案时必问的“灵魂拷问”。我去年参与三个不同规模的Agent项目:一个面向金融合规审核的内部工具、一个电商客服智能体集群、还有一个嵌入IDE的编程辅助Agent。三支团队在初期都用“Skill”和“Rules”混着写,结果无一例外在第二迭代周期陷入严重瓶颈:行为不可控、调试像盲人摸象、上线后策略漂移。直到我们把这两个概念从代码层、设计层、运维层彻底剥离开,系统才真正进入可演进状态。

核心差异一句话说透: Skill是Agent的“肌肉记忆”,Rules是Agent的“交通法规” 。肌肉记忆决定它 能做什么 (比如调用天气API、解析PDF表格、生成SQL),而交通法规决定它 在什么条件下、以什么方式、对谁、做多少次 (比如“仅当用户身份为VIP且请求含‘加急’关键词时,才调用高成本OCR服务”)。这个区分不是语义游戏,它直接对应到代码结构、测试策略、灰度发布流程甚至SLO指标定义。

你可能在Cursor、Claude Code或Hermes Agent的文档里看到它们被并列罗列,甚至有些框架(比如早期Codex)把Rules硬编码进Skill内部。但实测下来,这种耦合会让系统在三个月后变得无法维护。我见过最典型的案例:某团队把“禁止向未认证用户返回数据库原始错误信息”这条安全规则,直接写死在了数据库查询Skill的catch块里。后来业务要求对内网员工放宽该限制,他们不得不复制整个Skill,改两行代码,再维护两个几乎一样的版本——这就是混淆Skill与Rules的代价。

关键词“Agent”“Skill”“Rules”之所以成为热搜,恰恰说明行业正从“能跑通Demo”阶段,迈入“要长期稳定交付”的深水区。本文不讲抽象定义,只拆解我们在真实项目中如何落地分离:从代码目录结构怎么组织,到Rule引擎选型时为什么放弃YAML硬编码而采用表达式树,再到线上Rule变更如何做到毫秒级生效且不重启Agent进程。所有内容,都来自踩坑后的血泪复盘。

2. Skill的本质:封装确定性能力的最小可执行单元

2.1 Skill不是功能模块,而是带契约的“服务原子”

很多团队把Skill理解成“一个Python函数”或“一个HTTP接口”,这是最大的认知偏差。真正的Skill必须满足三个硬性契约,缺一不可:

  • 输入输出契约 :明确声明支持的参数类型、格式约束、必填项;输出必须有结构化Schema(如JSON Schema),而非字符串拼接。例如一个“提取合同关键条款”的Skill,其输入契约必须规定 document_type: enum["pdf", "docx", "txt"] ,输出契约必须定义 clauses: array[{name: string, content: string, confidence: number}] 。我们曾因忽略 confidence 字段的数值范围定义,在下游做阈值过滤时引发整条流水线误判。

  • 执行边界契约 :声明超时时间、重试策略、失败降级路径。这不是可选项——当Skill调用外部API时,必须预设 timeout_ms=3000 max_retries=2 ,否则Agent主循环可能被单个慢请求拖垮。在电商客服项目中,我们强制所有Skill实现 get_execution_boundaries() 方法,由Agent Runtime统一注入熔断器。

  • 可观测性契约 :每个Skill调用必须产生标准日志事件(含trace_id、skill_name、input_hash、duration_ms、status)和至少一个Prometheus指标(如 skill_invocations_total{skill="extract_clauses",status="success"} )。没有这些,你就永远不知道是Skill本身有问题,还是上游传参错了。

提示:不要用装饰器或AOP框架动态注入这些契约。我们试过用 @track_skill 装饰器,结果在异步Skill中丢失trace_id。最终方案是让Skill类继承 BaseSkill 抽象基类,所有契约检查在 __init__ execute 方法中硬编码。看似啰嗦,但保证了100%覆盖率。

2.2 Skill的代码结构:为什么必须隔离“能力”与“逻辑”

看一个反面案例——某团队写的“发送邮件”Skill:

# ❌ 危险:Rules逻辑污染Skill本体
class SendEmailSkill:
    def execute(self, to, subject, body):
        # 规则1:VIP用户优先走企业邮箱
        if self.is_vip(to):
            smtp_server = "vip.smtp.company.com"
        else:
            smtp_server = "default.smtp.company.com"
        
        # 规则2:含敏感词的邮件需人工审核
        if self.contains_sensitive_words(body):
            self.queue_for_review(to, subject, body)
            return {"status": "pending_review"}
        
        # 规则3:每日发送上限50封
        if self.get_daily_count(to) >= 50:
            raise RateLimitExceeded()
            
        # 真正的发送逻辑(仅占20行)
        return send_smtp(smtp_server, to, subject, body)

这个Skill看似功能完整,实则埋下三颗雷:

  1. is_vip() 等规则判断依赖外部服务,一旦该服务延迟,整个Skill阻塞;
  2. 规则变更(如新增“白名单域名免审”)必须修改Skill代码,触发全量回归测试;
  3. 无法对“VIP路由”规则单独压测或灰度。

正确做法是剥离出纯能力层:

# ✅ Skill只做一件事:发送邮件
class SendEmailSkill(BaseSkill):
    def __init__(self, smtp_config: dict):
        super().__init__()
        self.smtp_config = smtp_config  # 配置注入,非硬编码
    
    def execute(self, to: str, subject: str, body: str) -> dict:
        # 严格校验输入
        validate_email(to)
        assert len(subject) <= 200
        
        # 执行发送(无任何业务规则)
        result = send_via_smtp(
            server=self.smtp_config["server"],
            port=self.smtp_config["port"],
            auth=self.smtp_config["auth"],
            to=to,
            subject=subject,
            body=body
        )
        return {"status": "sent", "message_id": result.message_id}

# ✅ Rules引擎负责决策:何时调用?用哪个Skill?
# 规则配置(JSON/YAML/DB表均可)
rules = [
    {
        "id": "vip_email_route",
        "condition": "user.vip_level > 2 and request.channel == 'email'",
        "action": {"type": "route_to_skill", "skill": "SendEmailSkill", "config": {"server": "vip.smtp.company.com"}}
    },
    {
        "id": "sensitive_word_review",
        "condition": "contains_any(request.body, ['银行卡', '身份证号'])",
        "action": {"type": "queue_for_review"}
    }
]

注意:Skill的 execute 方法里绝对不能出现 if/else 业务分支。所有分支逻辑必须外移到Rules引擎。这是检验Skill是否合格的黄金标准。

2.3 Skill的生命周期管理:从注册到退役的四阶段

Skill不是写完就扔进仓库的静态资产,它有明确的生命周期,每个阶段都需要配套机制:

阶段 关键动作 我们的实践
注册 Skill元数据入库(名称、版本、契约、作者、SLA承诺) 用Git Hook自动扫描 skills/ 目录下的 skill.yaml ,提交PR时触发CI验证契约完整性
发现 Agent运行时动态加载Skill 不用 importlib 硬导入,而是通过 SkillRegistry 中心注册表,按需加载。避免启动时加载全部Skill导致内存暴涨
调用 Runtime注入上下文(user_id、session_id、trace_id) BaseSkill.execute() 中强制接收 context: dict 参数,禁止Skill自行获取全局变量
退役 标记为deprecated,设置迁移窗口期 当新Skill替代旧版时,在Registry中标记 deprecated_since="2024-06-01" ,并在调用时记录告警日志,30天后自动禁用

我们曾因跳过“退役”阶段,导致一个已废弃的 LegacyPDFParserSkill 在生产环境静默运行半年,其不兼容新版PDF库的bug引发大量解析失败,却因日志被淹没而未被发现。现在所有Skill注册时必须填写 deprecation_policy 字段,这是硬性准入门槛。

3. Rules的本质:定义Agent行为边界的动态策略系统

3.1 Rules不是配置文件,而是可执行的“行为合约”

把Rules当成YAML配置是另一个常见误区。真正的Rules系统必须满足:

  • 可计算性 :每条Rule必须能被编译成可执行的表达式树(AST),而非字符串模板。例如 user.balance > 10000 and request.intent == "loan" 必须能转为AST节点,才能被高效求值。
  • 可组合性 :多条Rule能按优先级、作用域、条件交集等方式组合。比如“VIP用户享受免密支付”和“单笔交易超5万需二次验证”必须能同时生效,且后者优先级更高。
  • 可追溯性 :每次Rule匹配必须记录完整的决策链(which rule matched, why condition passed, what action triggered),用于审计和问题定位。

我们弃用过三种Rule方案:

  • 纯YAML配置 :简单但无法处理复杂条件(如嵌套 and/or/not 、函数调用),且每次变更需重启Agent;
  • 数据库规则表 :灵活但查询性能差,高并发下成为瓶颈;
  • 硬编码在Service层 :完全违背开闭原则。

最终选择自研轻量级Rule引擎,核心是将Rule编译为字节码:

# Rule定义(DSL)
rule "high_value_user_discount"
    when user.vip_level >= 3 and cart.total_amount > 5000
    then apply_discount(0.15)
    priority 100

# 编译后字节码(简化示意)
[
    LOAD_ATTR "user.vip_level",
    CONST 3,
    COMPARE_OP ">=",
    LOAD_ATTR "cart.total_amount",
    CONST 5000,
    COMPARE_OP ">",
    AND,
    JUMP_IF_FALSE 10,
    CALL_FUNCTION "apply_discount" [0.15],
    ...
]

这套字节码在Agent启动时编译一次,后续匹配速度比解释执行快8倍,且支持热更新——上传新Rule DSL,引擎自动重新编译字节码并替换,毫秒级生效。

3.2 Rules的四大核心维度:为什么必须立体化设计

Rules绝非单一维度的“if-else”,它需要四个正交维度协同定义行为边界:

维度 说明 实例 我们的实践
作用域(Scope) Rule生效的上下文范围 global (全系统)、 agent:customer_service (特定Agent)、 user:12345 (单用户) 用Redis Hash存储Scope映射, scope:user:12345 指向该用户的专属Rule列表,支持AB测试
优先级(Priority) 冲突时的裁决顺序 数值越大越优先, priority 100 覆盖 priority 50 优先级不设上限,但强制要求每条Rule注明 conflict_with 字段,明确声明与哪些Rule可能冲突
时效性(Validity) 生效/失效时间窗口 valid_from: "2024-06-01T00:00:00Z", valid_until: "2024-06-30T23:59:59Z" 时间判断用UTC,避免时区混乱;引擎每分钟检查一次Rule时效性,自动归档过期Rule
可观测性(Traceability) 决策过程可审计 记录 rule_id , matched_at , input_snapshot , decision_path 所有Rule匹配日志接入ELK,用KQL可查“过去1小时所有被触发的VIP相关Rule”

提示:不要在Rule条件中写 now() > '2024-06-01' 这类硬编码时间。正确做法是Rule引擎在执行时注入 context: {current_time: datetime.utcnow()} ,条件写成 context.current_time > rule.valid_from 。这样既保证时间一致性,又便于单元测试(可注入任意测试时间)。

3.3 Rules的实战陷阱:三条血泪教训

陷阱一:规则爆炸(Rule Explosion)

当Rule数量超过200条,维护成本指数级上升。我们曾在一个客服Agent中积累387条Rule,导致:

  • 新增一条“节假日问候语”Rule,需手动检查是否与现有127条“用户情绪识别”Rule冲突;
  • 每次发布前需全量回归测试,耗时47分钟。

解法:引入Rule分组与继承

# 定义基础组(base_rules)
base_rules:
  - id: "greet_new_user"
    when: "user.first_login == true"
    then: "send_message('欢迎加入!')"

# 继承并扩展(group: vip_rules)
vip_rules:
  extends: base_rules
  rules:
    - id: "vip_greet"
      when: "user.vip_level >= 3"
      then: "send_message('尊贵的VIP,您好!')"

所有Agent实例只加载自己所属的Rule组(如 customer_service_vip ),基础组自动继承,避免重复定义。

陷阱二:条件歧义(Ambiguous Condition)

user.age > 18 看似清晰,但当 user.age 是字符串 "18" 或空值 None 时,行为不可预测。

解法:强制类型安全与空值处理

  • Rule DSL中所有字段访问必须声明类型: user.age:int > 18
  • 引擎内置空值安全操作符: user.profile?.address?.city == "Shanghai" ?. 表示安全导航);
  • 单元测试强制覆盖 null "" [] 等边界值。
陷阱三:动作副作用(Action Side Effect)

then: "send_notification('order_confirmed')" 看似无害,但如果该通知服务宕机,整个Rule执行失败,可能导致Agent卡死。

解法:动作解耦与异步化

  • 所有 then 动作只生成标准化事件(如 {"type": "notification.send", "payload": {...}} );
  • 事件由独立的Event Bus分发,通知服务作为消费者异步处理;
  • Rule引擎只关心事件是否成功发出,不等待执行结果。

4. Skill与Rules的协同机制:让Agent真正“懂规矩地干活”

4.1 协同不是调用关系,而是契约驱动的事件流

Skill和Rules之间不存在直接调用。它们通过标准化事件总线(Event Bus)松耦合协作,形成清晰的数据流:

User Request 
    ↓ (Agent Runtime解析)
Intent Detection → [Event: intent_detected {intent: "book_flight", entities: {...}}]
    ↓
Rules Engine 匹配 → [Rule: "flight_booking_flow" triggers]
    ↓ (生成执行计划)
Execution Planner → [Plan: [{"skill": "SearchFlightsSkill", "input": {...}}, {"skill": "ValidatePassportSkill", "input": {...}}]]
    ↓
Skill Executor → 并行调用Skills,捕获结果
    ↓
Rules Engine 再匹配 → [根据Skill结果触发后续Rule,如"passport_valid == false" → "show_passport_help"]
    ↓
Response Builder → 合成最终响应

这个流程的关键在于: Rules不决定“用哪个Skill”,而是决定“在什么条件下生成什么执行计划” 。执行计划本身是数据结构,Skill Executor只负责按计划执行,不关心计划为何生成。

我们曾用消息队列(Kafka)实现此总线,但因消息堆积导致延迟。现改用内存级Event Bus(基于Rust编写的 event-bus crate),吞吐量提升12倍,P99延迟<5ms。

4.2 如何设计一条高质量Rule:从需求到上线的六步法

以“防止客服Agent泄露用户手机号”为例,展示完整落地流程:

步骤1:需求抽象
原始需求:“客服回复中不能出现用户手机号”。抽象为Rule目标: 拦截所有包含手机号的响应内容

步骤2:定义作用域与优先级

  • Scope: agent:customer_service (仅客服Agent)
  • Priority: 999 (最高优先级,确保在响应生成后立即拦截)

步骤3:编写条件表达式

// 使用正则+语义识别双保险
response.content matches /1[3-9]\d{9}/ 
or response.content contains any(user.phone_numbers)

步骤4:定义安全动作

then mask_phone_numbers(response.content)
// mask_phone_numbers是预置安全Skill,将手机号替换为**** **** ****

步骤5:本地验证

  • 准备测试用例: "您的订单号12345,手机号13812345678已确认" → 应返回 "您的订单号12345,手机号**** **** ****已确认"
  • 用Rule引擎CLI工具验证: rule-engine test --rule-id phone_mask --input test.json

步骤6:灰度发布与监控

  • 先对1%客服会话启用,监控 rule_matched_total{rule="phone_mask"} 指标;
  • 设置告警:若匹配率>5%,说明误伤正常文本,需优化正则;
  • 72小时无异常后,全量发布。

注意:Rule上线后,必须同步更新对应Skill的文档。例如 mask_phone_numbers Skill的README中,要注明“此Skill被Rule 'phone_mask' 调用,用于GDPR合规”。

4.3 故障排查全景图:当Agent行为异常时,如何快速定位是Skill还是Rules的问题

我们建立了一套标准化排查流程,所有SRE都必须按此执行:

现象 第一步检查 第二步检查 根本原因示例
Agent完全无响应 agent_runtime 日志中的 execution_timeout 检查Skill的 timeout_ms 设置是否过小 SearchFlightsSkill 超时设为100ms,但实际API平均耗时800ms
Agent返回错误结果(如错别字) skill_invocations_total{status="error"} 指标 检查Skill的输入契约是否被违反 上游传入 city_name: null ,但Skill未校验必填字段
Agent行为不符合预期(如该优惠没生效) rule_matched_total{rule="vip_discount"} 是否为0 检查Rule条件中的 user.vip_level 字段是否在上下文中存在 用户Profile服务故障, user.vip_level 未注入到context
Agent响应延迟高 rule_evaluation_duration_seconds P95 检查Rule条件是否含高开销操作(如正则回溯) response.content matches /.*password.*/i 导致O(n²)回溯

最关键的证据链是 Trace ID贯穿全程 :从用户请求开始,到Rules匹配、Skill调用、响应生成,所有日志共享同一 trace_id 。用Jaeger查看完整链路,一眼就能看出卡点在哪一层。

5. 工程落地 checklist:你的Agent系统达标了吗?

最后分享一份我们在所有Agent项目上线前强制执行的Checklist。少一项,就不允许发布:

  • [ ] Skill层面

    • 所有Skill继承 BaseSkill ,且 execute() 方法无 if/else 业务逻辑
    • 每个Skill的 skill.yaml 包含完整契约: input_schema output_schema timeout_ms retries
    • Skill代码中无硬编码配置(如API Key、Endpoint),全部通过 config.py 或环境变量注入
  • [ ] Rules层面

    • 所有Rule存于 rules/ 目录,按 scope/ 分组(如 rules/global/ , rules/agent_sales/
    • 每条Rule DSL包含 id when then priority valid_from 字段,无缺失
    • Rule条件中无硬编码时间、无未声明的变量访问(如 user.age 必须写为 user.age:int
  • [ ] 协同层面

    • Agent Runtime中 ExecutionPlanner 模块与 RulesEngine 模块完全解耦,通过 EventBus 通信
    • 所有Skill调用日志包含 trace_id rule_id (若由Rule触发)
    • rule_matched_total skill_invocations_total 指标已接入Grafana看板
  • [ ] 运维层面

    • Rule热更新API已上线,支持 POST /api/v1/rules/hot-reload 上传新Rule DSL
    • Skill Registry提供 GET /skills/active 接口,返回所有可用Skill列表及健康状态
    • 建立Rule变更审批流:GitHub PR → 自动CI验证 → SRE人工审核 → 生产环境发布

这份Checklist不是纸面文章。我们把它做成了自动化脚本,每次CI构建时运行 ./check-agent-health.sh ,任何一项失败即阻断发布。上线半年来,因Skill/Rules问题导致的P1事故为零。

我在实际项目中发现,团队对Skill和Rules的区分意识,往往比技术选型更重要。一个坚持“Skill只干活、Rules管规矩”的团队,即使用最简陋的YAML Rule引擎,也能做出稳定可靠的Agent;而一个混淆二者边界的团队,哪怕用上最先进的LLM推理框架,也会在三个月后陷入维护泥潭。真正的工程能力,不在炫技,而在对边界感的敬畏。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值