1. 项目概述:一个被严重低估的“技能容器”概念
“agent-skills”这个词组最近在技术社区和开发者讨论中频繁出现,但它既不是某个具体开源库的官方命名,也不是某家大厂刚发布的API产品线。它本质上是一个架构级隐喻——描述一类正在快速演进的、专为智能体(Agent)设计的可插拔、可组合、可验证的功能单元。我第一次在某高校实验室的内部技术分享会上听到这个词时,主讲人用一句话点破了本质:“我们不再写‘函数’,而是定义‘技能’;函数服务于代码流程,技能服务于目标达成。”这句话让我记了整整三个月。
简单说,“agent-skills”解决的是当前智能体开发中最痛的一个问题:能力碎片化与复用断层。你可能已经用LangChain搭过一个能查天气、能读邮件、还能生成周报的Agent,但当你想把“查天气”这个能力迁移到另一个金融分析Agent里时,大概率要重写HTTP请求逻辑、重配API密钥、重写错误处理——因为那个“查天气”功能是嵌在旧Agent的prompt里、硬编码在某个chain里,甚至混在一堆日志打印语句中。它不是技能,只是临时拼凑的代码块。
而真正的“agent-skill”,必须满足三个硬性条件:有明确定义的输入/输出契约、有独立的执行上下文、有可脱离宿主Agent运行的验证路径。它不依赖特定框架,不绑定特定LLM,甚至不强制要求联网——一个纯本地运行的PDF摘要技能,只要接口清晰、行为稳定、结果可测,就是合格的agent-skill。
适合谁看?如果你正卡在以下任一阶段,这篇内容就是为你写的:
- 正在用AutoGen或CrewAI搭建多Agent协作系统,但发现Agent之间传参混乱、错误难以定位;
- 已经写了十几个工具函数,却不敢打包成公共模块,怕别人调用出错;
- 在做RAG应用,每次加一个新数据源就要改三处代码、调四次prompt、测五轮边界case;
- 或者你只是个刚接触Agent概念的开发者,听到“function calling”“tool use”“retrieval-augmented action”这些词一头雾水,想知道底层到底该组织成什么样子。
这不是一篇讲“怎么调用OpenAI API”的入门指南,而是一份从零开始构建技能体系的实操手册。接下来我会拆解:为什么必须用“技能”而非“函数”来思考Agent能力;一个真实可用的skill长什么样、怎么定义它的契约;如何让skill在不同Agent框架间无缝迁移;以及最关键的——我在三个实际项目中踩过的坑,比如“为什么同一个skill在本地测试100%通过,上线后失败率突然飙升到37%”。
2. 核心设计逻辑:从“函数思维”到“技能契约思维”的范式转移
2.1 为什么传统函数模型在Agent场景下必然失效?
先看一个典型反例。假设你要实现一个“获取用户最近三笔订单”的技能。用传统函数思维,你可能会这样写:
def get_recent_orders(user_id: str) -> List[Dict]: # 1. 连接数据库 conn = get_db_connection() # 2. 执行SQL查询 cursor = conn.cursor() cursor.execute("SELECT * FROM orders WHERE user_id = %s ORDER BY created_at DESC LIMIT 3", (user_id,)) # 3. 处理结果 rows = cursor.fetchall() return [{"id": r[0], "amount": float(r[1]), "status": r[2]} for r in rows]这段代码在单体服务里跑得飞快,但一旦放进Agent工作流,立刻暴露四个致命缺陷:
隐式依赖不可控:
get_db_connection()这个函数背后可能依赖环境变量DB_URL,而Agent运行环境(如Docker容器、Serverless函数)未必预置该变量。更糟的是,它还可能依赖全局连接池状态,当多个Agent并发调用时,连接泄漏风险陡增。错误处理无契约:
cursor.execute()可能抛出OperationalError(数据库宕机)、DataError(SQL注入攻击)、TimeoutError(慢查询),但函数签名里没声明任何异常类型。Agent框架无法根据错误类型决定是重试、降级还是终止整个任务流。输入输出模糊:
user_id: str看似明确,但实际业务中user_id可能是UUID、手机号、邮箱,甚至加密token。函数没声明校验规则,Agent传入非法值时,错误发生在数据库层,调试成本极高。不可验证性:你无法在不启动完整Agent的情况下,单独测试这个函数是否符合预期。它必须和数据库、网络、日志系统一起启动,测试周期长达数秒,根本无法集成进CI/CD流水线。
提示:所有在Agent中直接使用的函数,如果不能在500ms内完成、不声明全部可能错误、不提供输入校验规则,都不具备成为skill的资格。
2.2 技能契约(Skill Contract)的三大支柱
一个真正可复用的agent-skill,必须围绕“契约”构建。这个契约不是法律文件,而是由三部分组成的机器可读协议:
第一支柱:结构化输入规范(Input Schema)
不再是user_id: str,而是明确定义字段名、类型、约束、示例、描述。我们采用JSON Schema标准,因为它被LangChain、LlamaIndex、AutoGen等主流框架原生支持:
{ "type": "object", "properties": { "user_id": { "type": "string", "minLength": 12, "maxLength": 36, "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$|^\\d{11}$", "description": "用户唯一标识符,支持UUID格式或11位手机号", "examples": ["a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", "13800138000"] }, "max_count": { "type": "integer", "minimum": 1, "maximum": 10, "default": 3, "description": "最多返回的订单数量" } }, "required": ["user_id"] }这个schema的价值在于:
- Agent框架可自动生成参数校验逻辑,非法输入在进入skill前就被拦截;
- LLM可基于schema生成符合要求的参数字典,避免因字符串拼接导致的JSON解析失败;
- 前端可据此生成表单控件(如手机号输入框自动添加11位限制);
- 文档可全自动提取,无需人工维护。
第二支柱:确定性输出契约(Output Contract)
输出不能是“返回一个列表”,而必须是带版本号的结构化响应。我们约定所有skill输出统一为:
{ "version": "1.0.0", "status": "success" | "error" | "partial", "data": { /* 业务数据 */ }, "metadata": { "execution_time_ms": 127.4, "cache_hit": true, "source": "postgres_v2" } }其中status字段是关键:
success:所有预期数据完整返回;error:发生不可恢复错误(如数据库连接失败),data字段为空,metadata中必须包含error_code和error_message;partial:部分数据缺失但主体可用(如订单金额为空),data中缺失字段设为null,并用metadata.warnings说明原因。
这种设计让Agent能做出智能决策:遇到error可触发告警并切换备用技能;遇到partial可向用户提示“部分信息暂不可用”,而非直接崩溃。
第三支柱:可验证执行环境(Verifiable Execution Context)
每个skill必须声明其运行所需的最小环境:
| 环境维度 | 声明方式 | 示例 |
|---|---|---|
| 网络访问 | `network: "none" | "outbound" |
| 存储权限 | `storage: "read" | "write" |
| 计算资源 | `cpu: "light" | "heavy",memory_mb: 128` |
| 敏感操作 | `sensitive: true | false` |
这个声明不是摆设。我们在CI流水线中部署了一个轻量沙箱,会根据声明自动配置Docker容器:
- 声明
network: "none"的skill,容器启动时禁用所有网络接口; - 声明
storage: "read"的skill,只挂载数据库只读副本; - 声明
sensitive: true的skill,强制启用内存加密和审计日志。
注意:技能契约不是越严格越好。我们曾为一个“生成随机密码”的skill过度声明
cpu: "heavy",结果CI沙箱给它分配了2核CPU,而实际执行只需0.5ms。这导致资源浪费和调度延迟。经验是:先按直觉声明,再用压测数据反向修正。
2.3 为什么必须放弃“框架绑定”,拥抱“契约优先”?
很多团队一开始会问:“既然LangChain有Tool类,AutoGen有FunctionCalling,为什么不直接用?”答案很现实:框架迭代太快,契约必须比框架更稳定。
我们做过一个统计:过去18个月,LangChain的BaseTool接口变更了7次,其中3次是破坏性更新(如run()方法签名从run(input: str)改为run(**kwargs));AutoGen的register_function在v0.2.0和v0.3.0之间,参数传递机制从字典变为Pydantic模型。每次升级,所有已开发的工具都要重写适配层。
而技能契约是稳定的。我们定义的JSON Schema、输出结构、环境声明,在三年内没有一次变更。当LangChain升级时,我们只需更新一个薄薄的适配器:
# langchain_adapter.py from langchain.tools import BaseTool from pydantic import BaseModel, Field from typing import Any, Dict, Optional class OrderSkillAdapter(BaseTool): name = "get_recent_orders" description = "获取用户最近订单,支持UUID或手机号查询" def _run(self, **kwargs: Any) -> str: # 1. 将kwargs映射到skill输入schema validated_input = validate_against_schema(kwargs, ORDER_SCHEMA) # 2. 调用核心skill逻辑(完全独立于LangChain) result = core_order_skill.execute(validated_input) # 3. 将skill输出转换为LangChain期望的字符串 return json.dumps(result, ensure_ascii=False)这个适配器只有23行代码,且可复用。当AutoGen升级时,我们写另一个适配器,核心skill逻辑一行不动。这就是契约优先带来的长期价值:你投资的是能力本身,而不是某个框架的语法糖。
3. 实操细节:从零构建一个生产级订单查询技能
3.1 技能目录结构与文件职责划分
一个完整的agent-skill不是单个Python文件,而是一个有明确边界的模块。我们采用如下标准化目录结构(以order-query技能为例):
skills/ └── order-query/ ├── __init__.py # 技能入口,暴露execute()函数 ├── schema.json # 输入JSON Schema(机器可读契约) ├── contract.md # 人类可读的契约文档(含示例、限制、SLA) ├── core.py # 核心业务逻辑(无框架依赖) ├── adapter/ # 框架适配器目录 │ ├── langchain.py # LangChain适配器 │ ├── autogen.py # AutoGen适配器 │ └── fastapi.py # FastAPI HTTP适配器 ├── test/ # 独立测试套件 │ ├── unit_test.py # 单元测试(mock所有外部依赖) │ ├── integration_test.py # 集成测试(连接真实数据库) │ └── contract_test.py # 契约合规性测试(验证输入/输出结构) └── docker/ # 运行时环境定义 ├── Dockerfile # 多阶段构建,生产镜像<50MB └── entrypoint.sh # 启动脚本,支持环境变量覆盖这个结构的关键在于分层隔离:
core.py是技能的心脏,只包含纯业务逻辑,不导入任何框架代码;adapter/是皮肤,负责把心脏接入不同生态;test/是免疫系统,确保心脏健康且皮肤适配正确;docker/是器官培养皿,保证心脏在任何环境都能稳定跳动。
实操心得:新手常犯的错误是把数据库连接逻辑写在
core.py里。正确做法是core.py只接收一个db_client参数(类型为抽象接口),具体实现由调用方注入。这样单元测试时可传入内存数据库mock,集成测试时传入真实PostgreSQL客户端,彻底解耦。
3.2 核心逻辑实现:如何写出“可预测”的业务代码
core.py是技能最核心的部分,也是最容易写出“不可靠”代码的地方。我们坚持三条铁律:
铁律一:输入即校验,拒绝任何“信任传入参数”的侥幸心理
即使上游Agent已做过校验,core.py仍要执行完整校验。因为技能可能被其他系统直接调用(如运维脚本、监控告警),不能假设调用方永远可靠。
# core.py from jsonschema import validate, ValidationError import logging # 从schema.json加载schema(缓存避免重复IO) SCHEMA = load_json_schema("schema.json") def execute(input_data: Dict[str, Any], db_client: DatabaseClient) -> Dict[str, Any]: try: # 第一步:严格校验输入 validate(instance=input_data, schema=SCHEMA) except ValidationError as e: return { "version": "1.0.0", "status": "error", "data": None, "metadata": { "error_code": "INVALID_INPUT", "error_message": f"输入校验失败: {e.message}", "validation_path": list(e.absolute_path) } } # 第二步:参数归一化(将手机号转为标准格式) user_id = normalize_user_id(input_data["user_id"]) max_count = input_data.get("max_count", 3) # 第三步:执行业务逻辑(此处才是真正的数据库查询) try: orders = db_client.query_recent_orders(user_id, max_count) # 对orders进行后处理:金额转为Decimal,时间转为ISO格式 normalized_orders = [normalize_order(o) for o in orders] return { "version": "1.0.0", "status": "success", "data": {"orders": normalized_orders}, "metadata": {"execution_time_ms": 127.4} } except DatabaseConnectionError as e: return { "version": "1.0.0", "status": "error", "data": None, "metadata": { "error_code": "DB_CONNECTION_FAILED", "error_message": str(e) } } except Exception as e: # 捕获所有未预期异常,防止技能崩溃 logging.error(f"Order skill unhandled error: {e}", exc_info=True) return { "version": "1.0.0", "status": "error", "data": None, "metadata": { "error_code": "UNEXPECTED_ERROR", "error_message": "技能内部错误,请联系管理员" } }铁律二:输出即承诺,所有字段必须有明确来源和默认值data字段中的每个键,都必须在代码中有显式赋值。绝不允许data = orders这种模糊赋值。我们要求normalize_order()函数必须返回一个严格定义的字典:
from decimal import Decimal from datetime import datetime def normalize_order(raw_order: Dict) -> Dict: return { "id": str(raw_order["id"]), # 强制转字符串,避免int溢出 "amount": float(Decimal(str(raw_order["amount"]))), # 防止浮点精度丢失 "status": raw_order["status"].upper(), # 统一大小写 "created_at": raw_order["created_at"].isoformat() if isinstance(raw_order["created_at"], datetime) else raw_order["created_at"] }铁律三:错误即信号,每个错误码必须对应可操作的修复路径error_code不是随便起的。我们维护一份全局错误码表,每个码都关联一个“修复建议”:
| 错误码 | 触发条件 | 修复建议 |
|---|---|---|
INVALID_INPUT | JSON Schema校验失败 | 检查调用方传入的参数格式,参考contract.md示例 |
DB_CONNECTION_FAILED | 数据库连接超时/认证失败 | 检查数据库服务状态、网络连通性、凭据有效性 |
QUERY_TIMEOUT | SQL查询超过5秒 | 优化SQL索引,或降低max_count参数值 |
RATE_LIMIT_EXCEEDED | 调用频率超限 | 在调用方增加退避重试逻辑 |
这份表不仅用于开发,还直接嵌入到Agent的错误处理链中。当Agent收到DB_CONNECTION_FAILED时,会自动发送告警到运维群,并尝试切换到备用数据库;收到QUERY_TIMEOUT时,则自动将max_count减半后重试。
3.3 测试策略:为什么90%的技能故障源于测试盲区
很多团队的测试只停留在“能跑通就行”,结果上线后问题百出。我们为order-query技能设计了三层测试,每层解决不同维度的风险:
第一层:单元测试(unit_test.py)——验证逻辑正确性
目标:100%覆盖所有代码分支,执行时间<100ms。
关键技巧:用unittest.mock完全隔离外部依赖。数据库客户端被mock为一个返回预设数据的函数:
# unit_test.py import unittest from unittest.mock import Mock, patch from skills.order_query.core import execute class TestOrderQueryCore(unittest.TestCase): def test_valid_uuid_input_returns_success(self): # Arrange mock_db = Mock() mock_db.query_recent_orders.return_value = [ {"id": 1, "amount": "99.99", "status": "paid", "created_at": "2023-01-01T10:00:00Z"} ] # Act result = execute({"user_id": "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8"}, mock_db) # Assert self.assertEqual(result["status"], "success") self.assertEqual(len(result["data"]["orders"]), 1) self.assertEqual(result["data"]["orders"][0]["amount"], 99.99) # 验证float转换 def test_invalid_phone_number_returns_error(self): # Arrange mock_db = Mock() # Act result = execute({"user_id": "123"}, mock_db) # 仅3位,不符合schema # Assert self.assertEqual(result["status"], "error") self.assertEqual(result["metadata"]["error_code"], "INVALID_INPUT")第二层:契约测试(contract_test.py)——验证接口合规性
目标:确保输入/输出结构100%符合schema声明,执行时间<50ms。
这是最容易被忽略的一层。我们用jsonschema库直接验证输出:
# contract_test.py import jsonschema from skills.order_query.core import execute def test_output_conforms_to_contract(): # 使用合法输入调用 result = execute({"user_id": "13800138000"}, mock_db()) # 加载输出schema(我们为输出也定义了schema) output_schema = load_json_schema("output_schema.json") # 验证result是否符合输出schema try: jsonschema.validate(instance=result, schema=output_schema) except jsonschema.ValidationError as e: raise AssertionError(f"Output does not conform to contract: {e}")第三层:集成测试(integration_test.py)——验证真实环境行为
目标:在真实数据库上运行,验证性能、事务一致性、错误恢复能力。
关键配置:使用专用测试数据库,每次测试前清空数据,插入预设测试数据:
# integration_test.py import pytest from skills.order_query.core import execute from skills.order_query.db import PostgreSQLClient @pytest.fixture def test_db(): # 创建测试数据库连接 client = PostgreSQLClient( host="localhost", port=5432, database="test_orders", user="test_user", password="test_pass" ) # 清空并初始化测试数据 client.reset_test_data() yield client client.close() def test_query_with_real_database(test_db): # Arrange: 插入3条测试订单 test_db.insert_test_orders([ {"user_id": "13800138000", "amount": 199.99, "status": "paid"}, {"user_id": "13800138000", "amount": 299.99, "status": "shipped"}, {"user_id": "13800138000", "amount": 399.99, "status": "delivered"} ]) # Act result = execute({"user_id": "13800138000", "max_count": 2}, test_db) # Assert: 验证返回2条,且按时间倒序 assert result["status"] == "success" assert len(result["data"]["orders"]) == 2 assert result["data"]["orders"][0]["status"] == "delivered" # 最新订单在前常见问题:为什么不用SQLite做集成测试?因为SQLite和PostgreSQL在事务隔离级别、JSON函数支持、并发锁机制上有本质差异。我们曾用SQLite测试通过的技能,在PostgreSQL上因
SELECT ... FOR UPDATE锁竞争导致死锁。教训是:集成测试必须用生产环境同款数据库。
4. 生产部署与跨框架迁移实战
4.1 Docker化:如何构建一个<50MB的技能容器
技能容器不是简单的“Python + 代码”打包,而是要精确控制运行时环境。我们的Dockerfile采用多阶段构建:
# 构建阶段:编译依赖,安装编译型包 FROM python:3.11-slim AS builder WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --user -r requirements.txt # 运行阶段:极简基础镜像,只复制必要文件 FROM python:3.11-slim # 移除所有非必要包,只保留运行时依赖 RUN apt-get update && apt-get remove -y \ gcc g++ make libc-dev libssl-dev \ && rm -rf /var/lib/apt/lists/* /usr/share/doc /usr/share/man WORKDIR /app # 复制构建阶段安装的包(--from=builder) COPY --from=builder /root/.local /root/.local # 复制技能代码 COPY skills/order-query/ . # 设置PATH,使技能可被全局调用 ENV PATH="/root/.local/bin:$PATH" # 声明技能所需环境变量(供调用方参考) ENV DB_HOST="localhost" ENV DB_PORT="5432" ENV DB_NAME="orders" ENV DB_USER="skill_user" # 健康检查:技能是否能响应基本请求 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8000/health || exit 1 # 启动命令:运行FastAPI适配器 CMD ["python", "adapter/fastapi.py"]这个Dockerfile的关键成果:
- 最终镜像大小:47.2MB(对比普通Python镜像200MB+);
- 启动时间:<800ms(实测数据);
- 内存占用:稳定在32MB以内(
ps aux --sort=-%mem | head -5验证); - 安全性:无root进程,无编译工具链,无文档包。
实操心得:我们曾为一个技能镜像加入
vim和curl调试工具,结果镜像暴涨到120MB,且因curl引入SSL证书依赖,导致在某些K8s集群中启动失败。现在原则是:调试用kubectl exec -it <pod> -- sh进入容器,生产镜像只保留绝对必需项。
4.2 跨框架迁移:一个技能如何同时服务LangChain、AutoGen和FastAPI
技能的核心价值在于复用。我们以order-query技能为例,展示它如何在三个完全不同的环境中运行:
场景一:LangChain Agent中作为Tool调用
在LangChain中,我们不直接注册core.py,而是通过适配器:
# langchain_usage.py from langchain.agents import AgentExecutor, create_tool_calling_agent from langchain_core.prompts import ChatPromptTemplate from skills.order_query.adapter.langchain import OrderSkillAdapter # 创建Tool实例 order_tool = OrderSkillAdapter() # 构建Agent prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个电商客服助手,能查询用户订单。"), ("placeholder", "{chat_history}"), ("human", "{input}"), ("placeholder", "{agent_scratchpad}"), ]) agent = create_tool_calling_agent( llm=ChatOpenAI(model="gpt-4-turbo"), tools=[order_tool], prompt=prompt ) agent_executor = AgentExecutor(agent=agent, tools=[order_tool], verbose=True)场景二:AutoGen Group Chat中作为Function Calling
AutoGen需要将skill注册为可调用函数:
# autogen_usage.py from autogen import AssistantAgent, UserProxyAgent, config_list_from_json from skills.order_query.adapter.autogen import register_order_skill # 注册技能函数 register_order_skill() # 创建Agent assistant = AssistantAgent( name="assistant", system_message="你是一个电商专家,能查询用户订单。", llm_config={"config_list": config_list_from_json("OAI_CONFIG_LIST")} ) user_proxy = UserProxyAgent( name="user_proxy", is_termination_msg=lambda x: x.get("content", "").rstrip().endswith("TERMINATE"), human_input_mode="NEVER", code_execution_config={"use_docker": False}, ) # 启动对话 user_proxy.initiate_chat( assistant, message="请查询用户13800138000最近2笔订单。" )场景三:独立HTTP服务,供任何系统调用
通过FastAPI适配器,技能变成标准REST API:
# adapter/fastapi.py from fastapi import FastAPI, HTTPException, Depends from pydantic import BaseModel from skills.order_query.core import execute from skills.order_query.db import PostgreSQLClient app = FastAPI(title="Order Query Skill", version="1.0.0") class OrderQueryRequest(BaseModel): user_id: str max_count: int = 3 @app.post("/query-orders") async def query_orders(request: OrderQueryRequest): try: # 初始化数据库客户端(从环境变量读取配置) db_client = PostgreSQLClient.from_env() # 执行核心技能 result = execute(request.dict(), db_client) return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.get("/health") def health_check(): return {"status": "ok", "skill": "order-query", "version": "1.0.0"}启动命令:uvicorn adapter.fastapi:app --host 0.0.0.0 --port 8000 --workers 4
调用示例:
curl -X POST http://localhost:8000/query-orders \ -H "Content-Type: application/json" \ -d '{"user_id": "13800138000", "max_count": 2}'迁移成本对比(实测数据):
| 框架 | 初始接入时间 | 后续维护成本 | 兼容性风险 |
|---|---|---|---|
| 直接写框架原生代码 | 4-6小时 | 高(每次框架升级需重写) | 极高(v0.2→v0.3接口变更) |
| 契约驱动适配器模式 | 1.5小时(首次) | 极低(后续适配器更新<30分钟) | 极低(核心逻辑零修改) |
4.3 监控与可观测性:如何让技能“自己说话”
一个生产级skill必须自带“体检报告”。我们在core.py中内置了轻量监控埋点:
# core.py(续) import time import logging from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor # 初始化追踪器(仅在需要时启用) provider = TracerProvider() processor = SimpleSpanProcessor(ConsoleSpanExporter()) provider.add_span_processor(processor) trace.set_tracer_provider(provider) def execute(input_data: Dict[str, Any], db_client: DatabaseClient) -> Dict[str, Any]: tracer = trace.get_tracer(__name__) with tracer.start_as_current_span("order_query.execute") as span: start_time = time.time() # 记录输入参数(脱敏后) span.set_attribute("input.user_id_masked", mask_user_id(input_data["user_id"])) span.set_attribute("input.max_count", input_data.get("max_count", 3)) try: # ... 执行业务逻辑 ... # 记录成功指标 span.set_attribute("output.status", "success") span.set_attribute("output.order_count", len(normalized_orders)) span.set_attribute("execution_time_ms", (time.time() - start_time) * 1000) return result except Exception as e: # 记录错误指标 span.set_attribute("output.status", "error") span.set_attribute("error.code", result["metadata"]["error_code"]) span.set_attribute("execution_time_ms", (time.time() - start_time) * 1000) raise这些埋点数据被导出到Prometheus,我们定义了三个核心SLO(服务等级目标):
| SLO指标 | 目标值 | 监控方式 | 告警阈值 |
|---|---|---|---|
skill_order_query_success_rate | ≥99.95% | 成功响应数 / 总响应数 | 连续5分钟<99.5% |
skill_order_query_p95_latency_ms | ≤300ms | P95响应时间 | 连续10分钟>500ms |
skill_order_query_error_rate_by_code | INVALID_INPUT≤0.1% | 按error_code分组的错误率 | DB_CONNECTION_FAILED>0.01% |
当DB_CONNECTION_FAILED错误率超标时,告警会自动触发数据库连接池健康检查脚本;当INVALID_INPUT错误率突增,说明上游Agent的参数生成逻辑可能出错,自动通知相关开发团队。
注意:监控不是越多越好。我们曾为一个技能添加27个指标,结果监控系统CPU飙升,反而影响技能性能。现在原则是:只监控直接影响用户体验的3个核心指标,其他数据按需查询。
5. 常见问题与避坑指南:来自三个真实项目的血泪总结
5.1 问题一:技能在本地测试100%通过,上线后失败率飙升至37%
现象描述:
某金融分析Agent使用的“股票行情查询”skill,在本地用Mock数据测试通过率100%,部署到K8s集群后,日均失败率37%,错误码集中为NETWORK_TIMEOUT。
排查过程:
- 首先检查网络:
kubectl exec -it <pod> -- ping quote-api.example.com→ 通; - 检查DNS:
kubectl exec -it <pod> -- nslookup quote-api.example.com→ 解析正常; - 检查连接:
kubectl exec -it <pod> -- telnet quote-api.example.com 443→ 超时; - 关键发现:
kubectl get nodes显示集群节点分布在两个可用区,而quote-api.example.com的DNS记录只返回了可用区A的IP,可用区B的Pod无法访问。
根本原因:
技能的HTTP客户端未设置合理的超时和重试策略。本地测试时网络极好,超时设为30秒;生产环境跨可用区延迟高,30秒内未收到响应即报错。更糟的是,客户端未启用重试,一次失败就终结。
解决方案:
- 在
core.py中重构HTTP调用,使用httpx.AsyncClient并配置:async with httpx.AsyncClient( timeout=httpx.Timeout(5.0, connect=3.0, read=5.0), limits=httpx.Limits(max_connections=10, max_keepalive_connections=5) ) as client: response = await client.get(url, follow_redirects=True) - 添加指数退避重试(最多3次):
from tenacity import retry, stop_after_attempt, wait_exponential @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10)) async def fetch_quote(symbol: str): # ... HTTP调用逻辑 ...
**经验