ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent-Skills:面向AI系统的可注册、可审计能力调度中枢

Agent-Skills:面向AI系统的可注册、可审计能力调度中枢

1. 项目概述:Agent-Skills 不是插件,而是能力调度中枢

“Agent-Skills”这个词最近在开发者圈子里频繁刷屏,但很多人一搜就懵——它既不是某个具体开源库的官方名称,也不是某家大厂发布的标准产品,而是一个正在快速凝聚共识的技术概念:把大模型调用、工具执行、状态管理、上下文编排这些原本散落在不同脚本、CLI命令、API封装里的能力,统一抽象为可注册、可发现、可组合、可审计的“技能单元”(Skill)。我从去年底开始在多个内部Agent项目里落地这套设计,从最初手写几十个if-else路由函数,到现在用一套轻量框架管理137个生产级Skills(覆盖数据库查询、PDF解析、天气预报、Git操作、飞书消息推送、本地文件搜索等),核心就一句话:Skills不是功能列表,而是能力契约——它定义了“谁能在什么条件下,以什么输入,产出什么结构化输出,并附带什么副作用”。这直接解决了三个高频痛点:一是新同事接手Agent项目时,面对一堆零散脚本完全不知道从哪下手;二是业务方提需求说“加个查订单功能”,开发却要翻三天代码找接口、改路由、补日志、测超时;三是当需要把某个功能同时暴露给Web UI、Slack Slash Command、CLI终端和API网关时,不得不四份重复实现。你不需要懂LLM原理也能用好Skills——就像你不用懂TCP/IP就能用curl发请求一样。它面向的是所有需要让AI系统“动起来”的角色:前端工程师想用/search命令快速验证数据源,后端工程师要确保每个API调用都带trace_id和rate_limit,运维同学需要一眼看清当前激活了哪些技能及其失败率,甚至产品经理都能看懂skills.yaml里写的“该技能需用户登录且权限等级≥2”。接下来我会从设计逻辑、实操细节、避坑经验三个维度,带你把这套能力调度体系真正跑通。

2. 核心设计逻辑:为什么Skills必须脱离LLM生命周期独立存在

2.1 技能的本质是“能力契约”,而非“Prompt模板”

很多团队一开始做Agent时,会把Skills理解成“给大模型写的一段提示词”。比如写个“天气查询”技能,就在system prompt里塞一段:“你是一个天气助手,当用户问天气时,请调用weather_api({city})并返回结果”。这种做法短期内能跑通,但三个月后必然崩溃。原因很现实:Prompt是LLM的输入,而Skills是系统的输出契约。前者决定模型“怎么想”,后者定义系统“能做什么”。我见过最典型的翻车案例是某电商客服Agent——他们把“查物流”、“退换货政策”、“优惠券余额”全写进一个超长prompt里,结果模型经常混淆步骤:用户刚问完快递单号,它突然开始解释退换货流程。根本问题在于,Prompt无法强制约束执行顺序、无法校验参数合法性、无法捕获网络异常、更无法做权限隔离。而Skills的设计起点恰恰相反:它先定义接口契约。比如get_tracking_info这个Skill,它的契约包含:

  • 输入Schema:{"tracking_number": {"type": "string", "minLength": 12, "maxLength": 20}}
  • 执行逻辑:只允许调用指定物流服务商API(不许直连数据库)
  • 输出Schema:{"status": "string", "last_update": "string", "steps": [{"time": "string", "location": "string", "event": "string"}]}
  • 元数据:{"requires_auth": true, "timeout_ms": 8000, "rate_limit": "100/hour"}

这个契约一旦定义,无论后续用Claude、Qwen还是本地部署的DeepSeek,只要调用方传入合规参数,就能得到结构化结果。LLM在这里的角色降级为“技能调度器”——它只负责理解用户意图,然后从Skills Registry里匹配出get_tracking_info,填好参数,触发执行。模型再也不会因为温度值设高了就胡乱编造物流节点。

2.2 CLI与Slash Commands不是终端,而是Skills的标准化接入层

热搜词里反复出现的CLI、slash commands,常被误解为“给Agent加个命令行界面”。实际上,它们是Skills暴露能力的统一协议网关。我们团队的实践是:所有Skills必须通过同一套CLI框架注册,所有Slack/Discord的Slash Command也复用同一套路由。这意味着:

  • codex cli /weather --city=shanghai和/weather city:shanghai在后端走的是完全相同的执行链路
  • 前者由cli模块解析参数并注入argv上下文,后者由slack_adapter提取text字段并映射为kwargs
  • 两者最终都调用SkillExecutor.run("weather", {"city": "shanghai"})

这种设计带来两个关键收益:一是调试成本直线下降。当你发现Slack里天气查询返回空,可以直接在终端执行相同命令,排除是Adapter层问题还是Skill本身问题;二是灰度发布变得极其简单。比如要上线新版天气API,只需在CLI里加个--use-v2flag,让5%的Slack用户流量走新Skill,其余仍走旧版,完全不用改任何前端或IM配置。我们曾用这套机制在4小时内完成一次高危的支付接口升级,全程无用户感知。反观那些为每个渠道单独开发SDK的团队,光是同步三个渠道的错误码映射表就花了两周。

2.3 API不是终点,而是Skills能力的可编程出口

热词里大量出现的API、free api、api调用量,暴露出一个普遍误区:把Skills当成API的包装器。真实情况恰恰相反——Skills是API的治理层。举个例子:某客户要求接入“超稳-q绑在线查询API”,这个API有三个致命缺陷:响应不稳定(30%超时)、返回格式混乱(有时JSON有时HTML)、没有错误码规范(HTTP 200但body里写“查询失败”)。如果直接把它封装成一个API endpoint,前端调用时就得写一堆容错逻辑。而Skills的做法是:把这个API封装成qbind_lookupSkill,在执行层内置三重保障:

  1. 协议转换:自动识别HTML响应并提取其中JSON片段
  2. 熔断机制:连续3次超时则自动切换备用API(我们自建的缓存兜底服务)
  3. 错误归一化:所有异常统一转为{"error_code": "QBIND_001", "message": "实名认证未通过"}

这样,上层API网关暴露给前端的,就是一个稳定、结构清晰、带SLA承诺的endpoint。更重要的是,这个Skill可以被其他Skills复用——比如user_profile_enrich技能在构建用户画像时,会自动调用qbind_lookup获取实名信息,整个过程对调用方完全透明。我们统计过,采用Skills架构后,API网关层的错误率下降62%,而前端SDK的体积减少了40%(因为不再需要嵌入各种API的容错逻辑)。

3. 实操细节拆解:从零搭建Skills Registry的七步法

3.1 第一步:定义Skills目录结构——拒绝扁平化堆放

很多团队第一步就栽在目录管理上。常见错误是把所有Skill脚本扔进/skills文件夹,靠文件名区分功能,结果很快变成weather_v2_fix.py、weather_final.py、weather_new_api.py的迷宫。我们的标准结构强制分层:

skills/ ├── core/ # 系统级基础技能(日志、健康检查、配置加载) │ ├── health_check.py │ └── config_loader.py ├── business/ # 业务领域技能(电商、金融、教育) │ ├── order/ │ │ ├── get_tracking_info.py │ │ └── cancel_order.py │ └── user/ │ ├── get_profile.py │ └── update_preferences.py ├── infra/ # 基础设施技能(数据库、缓存、消息队列) │ ├── db_query.py │ └── redis_cache.py └── utils/ # 工具类技能(文本处理、时间计算、文件解析) ├── pdf_to_text.py └── date_calculator.py

关键设计点在于:每个子目录对应一个明确的权限域和监控域。比如business/order/下的所有Skill共享同一套订单中心API密钥,其调用失败会触发专属告警;而infra/db_query.py的执行耗时超过500ms,会立即触发数据库连接池扩容。这种结构让新人第一天入职就能通过目录树理解系统边界——看到business/user/就知道这是用户中心相关能力,绝不会误改支付相关的Skill。

3.2 第二步:编写Skill契约文件——YAML比Python注释更可靠

Skills的核心契约不能靠代码注释维护。我们强制每个Skill配套一个skill.yaml,例如skills/business/order/get_tracking_info.py对应的skills/business/order/get_tracking_info.yaml:

name: get_tracking_info description: 查询物流轨迹,支持主流快递公司单号 version: 1.3.2 author: logistics-team@company.com input_schema: tracking_number: type: string min_length: 12 max_length: 20 pattern: ^[A-Z]{2}\d{8,10}$ # 顺丰单号正则 output_schema: status: string last_update: string steps: type: array items: type: object properties: time: string location: string event: string required_permissions: - logistics:read - user:basic_info timeout_ms: 8000 rate_limit: "200/hour" dependencies: - external_api: logistics-provider-v3 - cache: redis-order-tracking

这个YAML文件会被CI/CD流程自动校验:

  • input_schema通过JSON Schema Validator确保参数合法
  • required_permissions与IAM系统实时比对,缺失权限则构建失败
  • dependencies触发服务依赖图谱生成,用于故障影响分析

曾经有个实习生修改了get_tracking_info.py但忘了更新YAML里的version,CI直接报错:“YAML version 1.3.2 ≠ Pythonversion'1.3.3'”。这种强制一致性,比靠Code Review靠谱得多。

3.3 第三步:实现Skill执行器——用装饰器统一拦截所有入口

Skills的执行入口必须收口。我们用Python装饰器实现统一拦截:

from functools import wraps import logging from skills.core.metrics import record_skill_metrics def skill_executor(skill_name: str): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): # 1. 权限校验(从JWT或session中提取) user_perms = get_user_permissions() required = load_skill_yaml(skill_name).get("required_permissions", []) if not set(required).issubset(set(user_perms)): raise PermissionError(f"Missing permissions: {set(required) - set(user_perms)}") # 2. 参数校验(自动匹配YAML中的input_schema) input_schema = load_skill_yaml(skill_name)["input_schema"] validate_input(kwargs, input_schema) # JSON Schema校验 # 3. 执行前埋点 start_time = time.time() trace_id = generate_trace_id() try: result = func(*args, **kwargs) # 4. 成功后记录指标 record_skill_metrics( skill_name=skill_name, status="success", duration_ms=int((time.time() - start_time) * 1000), trace_id=trace_id ) return result except Exception as e: # 5. 异常统一处理 record_skill_metrics( skill_name=skill_name, status="error", error_type=type(e).__name__, trace_id=trace_id ) logging.error(f"Skill {skill_name} failed: {e}", exc_info=True) raise return wrapper return decorator # 使用示例 @skill_executor("get_tracking_info") def get_tracking_info(tracking_number: str) -> dict: # 真正的业务逻辑 return call_logistics_api(tracking_number)

这个装饰器带来的好处是:所有Skills自动获得权限控制、参数校验、性能监控、错误追踪四大能力,且无需修改任何业务代码。当某天需要增加审计日志时,只需在装饰器里加一行log_audit_event(),137个Skill立刻全部生效。

3.4 第四步:构建CLI命令生成器——告别手写argparse

CLI不是简单的argparse拼装。我们用Jinja2模板自动生成:

# cli/templates/skill_command.py.j2 import click from skills.{{ skill_path }} import {{ skill_func_name }} @click.command(name="{{ skill_name }}") {% for param_name, param_def in input_schema.items() %} @click.option( "--{{ param_name }}", required={% if param_def.get('required', False) %}True{% else %}False{% endif %}, help="{{ param_def.get('description', '') }}" ) {% endfor %} def {{ skill_name }}({% for param_name in input_schema.keys() %}{{ param_name }}, {% endfor %}): """{{ description }}""" kwargs = {k: v for k, v in locals().items() if v is not None} result = {{ skill_func_name }}(**kwargs) click.echo(json.dumps(result, ensure_ascii=False, indent=2))

执行python scripts/generate_cli.py后,自动为每个Skill生成对应CLI命令。比如get_tracking_info.yaml会生成:

codex cli /order/get-tracking-info --tracking-number=SF1234567890

关键是:CLI参数名、必填性、帮助文案全部来自YAML契约。当业务方要求“物流查询必须支持城市参数”,只需修改YAML里的input_schema,重新生成CLI,所有渠道(终端、Slack、API)立刻同步更新。我们曾用这个机制在15分钟内响应了一个紧急需求:某银行客户要求所有API调用必须带x-request-id头,只需在YAML模板里加一行@click.pass_context,全局生效。

3.5 第五步:实现Slash Command适配器——Slack/Discord共用同一套路由

Slack和Discord的Slash Command协议差异很大,但Skills层应该无感。我们的适配器设计如下:

# adapters/slack_adapter.py class SlackAdapter: def handle_request(self, payload: dict) -> dict: # Slack payload示例:{"command":"/weather", "text":"city:shanghai"} command = payload["command"].lstrip("/") text = payload["text"] # 统一解析为kwargs kwargs = self._parse_slash_text(text) # "city:shanghai" → {"city": "shanghai"} # 调用Skills Registry result = SkillRegistry.execute(command, kwargs) return { "response_type": "in_channel", "text": self._format_result(result) } # adapters/discord_adapter.py class DiscordAdapter: def handle_request(self, interaction: dict) -> dict: # Discord payload示例:{"data": {"name": "weather", "options": [{"name": "city", "value": "shanghai"}]}} command = interaction["data"]["name"] options = interaction["data"].get("options", []) kwargs = {opt["name"]: opt["value"] for opt in options} result = SkillRegistry.execute(command, kwargs) return { "type": 4, "data": {"content": self._format_result(result)} }

核心思想是:所有Adapter只做协议转换,绝不碰业务逻辑。SkillRegistry.execute()是唯一入口,它根据command字符串从注册表里找到对应Skill,传入kwargs执行。这样当Discord新增一个/pdf-to-text命令时,后端只需在YAML里定义新Skill,Adapter自动识别——因为execute("pdf-to-text", {...})的调用方式完全一致。

3.6 第六步:设计Skills Registry——用插件式加载替代硬编码

Registry不是字典,而是可热加载的插件中心。启动时扫描目录:

class SkillRegistry: _registry = {} @classmethod def load_all_skills(cls): for skill_path in Path("skills").rglob("*.py"): if skill_path.name.startswith("_") or skill_path.name == "__init__.py": continue # 从文件路径推导skill_name: skills/business/order/get_tracking_info.py → order/get_tracking_info relative_path = skill_path.relative_to("skills") skill_name = str(relative_path).replace(".py", "").replace("/", "-") # 动态导入模块 spec = importlib.util.spec_from_file_location(skill_name, skill_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) # 查找被@skill_executor装饰的函数 for attr_name in dir(module): attr = getattr(module, attr_name) if hasattr(attr, '_is_skill') and callable(attr): cls._registry[skill_name] = attr break @classmethod def execute(cls, skill_name: str, kwargs: dict): if skill_name not in cls._registry: raise ValueError(f"Skill '{skill_name}' not found") return cls._registry[skill_name](**kwargs)

这个设计让Skills真正成为“可插拔模块”。测试环境可以只加载core/和utils/目录,生产环境才加载全部。某次安全审计要求禁用所有infra/db_query.py技能,我们只需在CI里加一行rm -rf skills/infra/db_query.*,重启服务即生效,无需改任何代码。

3.7 第七步:实现API网关层——Skills即OpenAPI endpoint

最后一步,把Skills暴露为RESTful API。我们用FastAPI自动生成OpenAPI文档:

from fastapi import FastAPI, Depends, HTTPException from skills.registry import SkillRegistry app = FastAPI(title="Skills API Gateway") @app.post("/skills/{skill_name}") async def execute_skill( skill_name: str, payload: dict, current_user: User = Depends(get_current_user) # JWT鉴权 ): try: # 自动注入用户上下文到kwargs payload["user_id"] = current_user.id result = SkillRegistry.execute(skill_name, payload) return {"status": "success", "data": result} except PermissionError as e: raise HTTPException(status_code=403, detail=str(e)) except ValidationError as e: raise HTTPException(status_code=400, detail=f"Invalid input: {e}") except Exception as e: raise HTTPException(status_code=500, detail="Execution failed") # 自动生成OpenAPI文档 # GET /openapi.json 返回标准OpenAPI 3.0规范 # Swagger UI自动渲染所有Skills的endpoint

关键创新点在于:每个Skill的OpenAPI Schema直接从YAML契约生成。get_tracking_info.yaml里的input_schema和output_schema,经pydantic模型转换后,自动成为FastAPI的RequestModel和ResponseModel。前端开发者打开/docs,就能看到每个Skill的完整API文档、示例请求、错误码说明,连curl命令都自动生成。我们曾让一个没接触过Skills的前端实习生,花20分钟就调通了5个不同领域的API,全程没看一行后端代码。

4. 实操过程详解:以“PDF文本提取”Skill为例的全流程实现

4.1 需求分析与契约定义——先写YAML再写代码

业务方需求:“用户上传PDF,返回纯文本内容,支持中文,10MB以内”。这不是一句模糊描述,而是要转化为可执行契约。我们创建skills/utils/pdf_to_text.yaml:

name: pdf-to-text description: 将PDF文件转换为纯文本,保留中文字符和段落结构 version: 1.0.0 author: docs-team@company.com input_schema: file_url: type: string format: uri description: PDF文件的可公开访问URL(如S3 presigned URL) page_range: type: array items: type: integer minimum: 1 min_items: 1 max_items: 100 description: 要提取的页码数组,如[1,3,5],空数组表示全部页面 output_schema: text: string page_count: integer extracted_pages: array warnings: array required_permissions: - storage:read timeout_ms: 30000 rate_limit: "50/hour" dependencies: - service: pdf-parser-v2 - cache: redis-pdf-cache

注意几个设计细节:

  • file_url要求URI格式,强制使用预签名URL而非直接传文件,规避服务端文件上传风险
  • page_range用数组而非字符串(如"1-5"),避免解析歧义
  • warnings字段预留扩展空间,未来可返回“字体缺失”、“扫描件OCR精度低”等提示

4.2 Skill实现——专注业务逻辑,不碰基础设施

skills/utils/pdf_to_text.py只做一件事:调用PDF解析服务并清洗结果:

import requests import json from skills.core.metrics import record_skill_metrics from skills.core.cache import get_cache_client @skill_executor("pdf-to-text") def pdf_to_text(file_url: str, page_range: list = None) -> dict: # 1. 缓存键生成:基于file_url和page_range哈希 cache_key = f"pdf:{hashlib.md5((file_url + str(page_range)).encode()).hexdigest()}" cache_client = get_cache_client() # 2. 先查缓存 cached = cache_client.get(cache_key) if cached: record_skill_metrics("pdf-to-text", "cache_hit", 0) return json.loads(cached) # 3. 调用PDF解析服务(内部HTTP API) try: response = requests.post( "http://pdf-parser-service:8000/extract", json={"url": file_url, "pages": page_range}, timeout=25 # 留5秒给Skill执行器做收尾 ) response.raise_for_status() result = response.json() except requests.exceptions.Timeout: raise TimeoutError("PDF parsing service timeout") except requests.exceptions.RequestException as e: raise ConnectionError(f"Failed to call PDF service: {e}") # 4. 清洗文本:移除多余空白、合并软换行 cleaned_text = re.sub(r'\s+', ' ', result.get("text", "")).strip() # 5. 写入缓存(TTL 24小时) cache_client.setex(cache_key, 86400, json.dumps({ "text": cleaned_text, "page_count": result.get("page_count", 0), "extracted_pages": result.get("extracted_pages", []), "warnings": result.get("warnings", []) })) return { "text": cleaned_text, "page_count": result.get("page_count", 0), "extracted_pages": result.get("extracted_pages", []), "warnings": result.get("warnings", []) }

这段代码里没有任何CLI、Slack或API相关的胶水代码——它只关心“如何把PDF变成干净文本”。所有协议适配、错误包装、监控埋点都由@skill_executor装饰器完成。

4.3 CLI命令生成与测试——三行命令验证全流程

运行python scripts/generate_cli.py后,自动生成codex cli /utils/pdf-to-text命令。测试流程:

# 1. 生成测试PDF(用wkhtmltopdf快速创建) echo "<h1>测试文档</h1><p>中文内容测试:你好世界</p>" > test.html wkhtmltopdf test.html test.pdf # 2. 上传到S3并生成预签名URL(假设已有AWS CLI配置) aws s3 cp test.pdf s3://my-bucket/test.pdf aws s3 presign s3://my-bucket/test.pdf --expires-in 3600 # 3. 调用CLI(URL替换为实际生成的presign URL) codex cli /utils/pdf-to-text \ --file-url="https://my-bucket.s3.amazonaws.com/test.pdf?X-Amz-Expires=3600&..." \ --page-range="[1]" # 输出: { "text": "测试文档 中文内容测试:你好世界", "page_count": 1, "extracted_pages": [1], "warnings": [] }

整个过程无需启动任何Web服务,纯命令行验证。当CLI能跑通,意味着Slack/pdf-to-text file_url:xxx和APIPOST /skills/pdf-to-text也必然可用——因为它们共享同一套执行逻辑。

4.4 Slash Command集成——Slack里一键调用

在Slack App配置中,设置Slash Command/pdf-to-text,指向我们的Webhook地址。Adapter收到请求后:

# Slack payload示例 { "command": "/pdf-to-text", "text": "file_url:https://xxx.s3.amazonaws.com/test.pdf?... page_range:[1]" } # Adapter解析后调用 SkillRegistry.execute("pdf-to-text", { "file_url": "https://xxx.s3.amazonaws.com/test.pdf?...", "page_range": [1] })

用户在Slack里输入/pdf-to-text file_url:https://xxx.pdf page_range:[1,2],几秒后收到格式化消息:

✅ PDF文本提取成功
页数: 2页
提取内容:
测试文档
中文内容测试:你好世界

⚠️ 提示:第2页含扫描图片,已跳过OCR(如需处理请启用高级模式)

这个消息格式由SlackAdapter._format_result()统一控制,与CLI输出的JSON结构完全解耦。

4.5 API网关暴露——前端直接调用

前端调用POST /skills/pdf-to-text:

curl -X POST "https://api.company.com/skills/pdf-to-text" \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "file_url": "https://xxx.s3.amazonaws.com/test.pdf?...", "page_range": [1] }'

响应自动符合OpenAPI规范:

{ "status": "success", "data": { "text": "测试文档 中文内容测试:你好世界", "page_count": 1, "extracted_pages": [1], "warnings": [] } }

Swagger UI里能看到完整的请求体定义、响应示例、错误码说明,前端工程师直接复制粘贴就能集成。

4.6 监控与告警——用Metrics驱动运维

Skills Registry自动上报指标到Prometheus:

MetricLabels说明
skill_execution_duration_secondsskill="pdf-to-text",status="success"执行耗时(直方图)
skill_execution_totalskill="pdf-to-text",status="error"错误次数(计数器)
skill_cache_hit_ratioskill="pdf-to-text"缓存命中率

Grafana看板配置告警规则:

  • rate(skill_execution_duration_seconds_sum{skill="pdf-to-text"}[5m]) / rate(skill_execution_duration_seconds_count{skill="pdf-to-text"}[5m]) > 10→ 平均耗时超10秒
  • rate(skill_execution_total{skill="pdf-to-text",status="error"}[5m]) > 0.1→ 每分钟错误率超10%

某次PDF解析服务升级后,我们通过告警发现pdf-to-text错误率突增,排查发现是新版本返回了null而非空字符串,导致前端JSON解析失败。修复只需在Skill里加一行result.get("text", ""),5分钟内热更新,无需重启服务。

4.7 权限与审计——谁在什么时候调用了什么

所有Skills执行都记录审计日志到Elasticsearch:

{ "timestamp": "2024-06-15T10:23:45.123Z", "skill": "pdf-to-text", "user_id": "usr_abc123", "ip_address": "203.0.113.45", "input": {"file_url": "https://xxx.s3.amazonaws.com/test.pdf", "page_range": [1]}, "output_size_bytes": 1245, "duration_ms": 2341, "status": "success" }

安全团队可以用KQL查询:“过去24小时,用户usr_abc123调用pdf-to-text超过100次”,或“所有访问S3私有bucket的技能调用”。这种细粒度审计,是传统API网关无法提供的——因为网关只看到POST /skills/pdf-to-text,而Skills Registry知道具体参数和执行结果。

5. 常见问题与实战避坑指南

5.1 “Skills太多管不过来”——用分层注册解决

问题:团队规模扩大后,Skills数量突破200个,新人找不到该用哪个技能。
解决方案:强制分层注册+智能发现。
我们在Registry里增加category字段:

# skills/business/order/get_tracking_info.yaml category: "logistics" tags: ["real-time", "third-party-api"]

CLI增加find命令:

# 按类别查找 codex cli find --category logistics # 按标签查找 codex cli find --tag "third-party-api" # 模糊搜索 codex cli find "track"

输出自动按热度排序(基于调用频次),并显示每个Skill的description和version。我们还做了个小技巧:在skills/core/health_check.py里加入list_all_skills(),当执行codex cli /health时,返回所有Skills的简明清单(名称、版本、最后更新时间),方便快速浏览。

5.2 “API调用失败但不知道原因”——结构化错误码体系

问题:deepseek api如何调用这类热搜词背后,是开发者面对400 this model's maximum context length is 1048576 tokens这种错误时的无助。
解决方案:Skills层统一错误归一化。
所有外部API错误,必须转换为标准错误码:

# skills/infra/llm_call.py try: response = llm_client.chat.completions.create(...) except openai.BadRequestError as e: if "maximum context length" in str(e): raise SkillError("LLM_CONTEXT_TOO_LONG", f"Input exceeds {e.context_length} token limit") else: raise SkillError("LLM_BAD_REQUEST", str(e)) except openai.AuthenticationError: raise SkillError("LLM_AUTH_FAILED", "Invalid API key")

前端收到的永远是:

{ "error_code": "LLM_CONTEXT_TOO_LONG", "message": "Input exceeds 1048576 token limit", "suggestion": "请缩短输入文本或选择更高上下文窗口的模型" }

这个suggestion字段来自错误码映射表,运维可以随时更新,无需发版。我们统计过,采用结构化错误后,前端错误处理代码减少70%,用户投诉率下降45%。

5.3 “本地开发环境跑不通”——用Docker Compose模拟生产

问题:node安装codex cli很慢、permission denied while trying to connect to the docker api这类问题,本质是本地环境与生产不一致。
解决方案:为Skills提供最小化Docker环境。
docker-compose.skills.yml:

version: '3.8' services: skills-core: build: . volumes: - ./skills:/app/skills - ~/.aws:/root/.aws:ro # 共享AWS凭证 environment: - SKILLS_ENV=dev - REDIS_URL=redis://redis:6379 depends_on: - redis redis: image: redis:7-alpine ports: ["6379:6379"]

开发者只需docker-compose -f docker-compose.skills.yml up,就能获得与生产一致的Redis、权限模型、网络配置。CLI命令codex cli /utils/pdf-to-text在容器内执行,效果和线上完全一致。我们甚至把CI的测试环境也用同一份Compose文件,彻底消灭“在我机器上是好的”这类问题。

5.4 “技能之间互相调用太复杂”——用Context传递隐式参数

问题:user_profile_enrich技能需要调用qbind_lookup和weather两个技能,手动传token、超时设置太繁琐。
解决方案:Skills Context透传。
在SkillExecutor里注入context对象:

@skill_executor("user_profile_enrich") def user_profile_enrich(user_id: str, context: SkillContext) -> dict: # context自动携带:current_user, request_id, timeout_ms, auth_token qbind_data = SkillRegistry.execute("qbind_lookup", {"id": user_id}, context=context) weather_data = SkillRegistry.execute("weather", {"city": qbind_data["city"]}, context=context) return {**qbind_data, "weather": weather_data}

context对象在装饰器里自动创建,包含所有必要元数据。这样Skills之间调用,就像函数调用一样自然,无需手动传递认证信息或超时设置。

5.5 “热更新技能导致服务中断”——用原子化加载保证可用性

问题:本轮运行失败llm-deepseek: no api key for provider route "deepseek-official"这类错误,常因热更新时Skill加载失败。
解决方案:双缓冲加载机制。
Registry维护两个字典:_active_registry和_pending_registry。热更新时:

def hot_reload_skill(skill_name: str): # 1. 在_pending_registry里加载新Skill new_skill = load_skill_module(skill_name) _pending_registry[skill_name] = new_skill # 2. 原子化切换 _active
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进