更新时间:2026-08-03 GMT+08:00
分享

Skill(技能)观测数据上报

当智能体引入Skill(技能)能力后,Skill的运行状态往往是影响整体体验的关键环节。常见的线上问题包括:某个Skill加载变慢拖累了整体响应、Skill频繁执行失败但没有告警、Skill内容过长导致Token成本异常攀升。由于Skill的调用过程嵌套在智能体链路内部,这些问题在没有专项观测的情况下很难被发现和定位。

通过将Skill的调用链路(Trace)与运行指标(Metric)上报至AgentArts观测平台,可以实现:

  • 链路定位:在调用链分析中还原 Skill 的加载与释放过程,定位具体异常发生在哪个步骤。
  • 稳定性监控:通过Skill调用次数与成功率趋势,及时发现Skill执行失败的规律。
  • 性能分析:通过Skill调用平均耗时与Top5排行,识别耗时异常的Skill。
  • 成本治理:统计Skill内容注入LLM时消耗的Token,量化Skill带来的额外成本。

实现原理

Skill 的一次完整生命周期包含两个阶段:加载(skill_tool)和释放(skill_complete)。这两个阶段本质上是工具调用,因此在 Trace 中不单独创建 Skill Span,而是在对应的工具执行 Span 上附加 Skill 属性与事件,避免链路结构冗余。

一次包含 Skill 的完整调用链结构如下:

Root Span(agent_dialogue_session)
├── Model Span(llm_decision_call)        ← LLM 决策:是否调用 Skill
├── Tool Span(skill_tool)                ← Skill 加载
│   ├── gen_ai.operation.name = load_skill
│   ├── gen_ai.skill.name = <skill名称>
│   ├── gen_ai.skill.id = <skill标识>
│   └── Event: skill.loaded               ← 加载成功事件,记录路径
├── Tool Span(skill_complete)            ← Skill 释放
│   ├── gen_ai.operation.name = release_skill
│   ├── gen_ai.skill.name = <skill名称>
│   └── Event: skill.released             ← 释放完成事件
└── Model Span(llm_final_answer)         ← 携带 Skill 内容的最终 LLM 调用

上报机制

表1 上报机制说明

数据类型

上报协议

接收端

说明

Trace(调用链)

OTel gRPC(OTLP)

华为云APM

记录单次请求的完整调用路径与节点详情

Metric(指标)

OTel gRPC(OTLP)

华为云AOM

记录调用次数、耗时、成功率等聚合统计

两路数据均通过OpenTelemetry Python SDK上报,Trace使用BatchSpanProcessor批量发送,Metric使用PeriodicExportingMetricReader定期批量发送。

本地调试阶段同时启用ConsoleSpanExporter和ConsoleMetricExporter,将数据同步写入本地otel_debug.log,用于在看板出数据前确认本地采集是否正常。

Trace数据写入APM,Metric数据写入AOM,两者均按实际使用量计费,并提供一定免费额度。生产环境部署前请参阅APM计费说明AOM计费说明

指标上报逻辑

表2 Skill专项指标

指标名

类型

触发时机

说明

gen_ai.skill.call.count

Counter

Skill加载阶段完成时(skill_tool Span结束后)。

Skill调用次数,成功与失败均计入,是计算Skill调用成功率的基础数据。

gen_ai.skill.duration

Histogram

Skill释放阶段完成时(skill_complete Span结束后)。

记录从Skill加载开始到释放完成的完整耗时,平台据此计算Skill调用平均耗时。

gen_ai.skill.error.count

Counter

Skill执行结果含错误时。

仅在Skill执行失败时累加,平台以call.count - error.count计算成功次数,进而得出成功率。

gen_ai.skill.token.usage

Counter

Skill内容加载完成后、LLM最终调用前。

Skill内容(SKILL.md 正文)注入LLM时消耗的Token估算值。

上报上述指标时,必须在 Attributes 中携带以下维度字段,否则看板无法正确聚合与展示对应数据:

表3 关键维度(Attributes)

维度

说明

适用指标

gen_ai.resource.id

智能体 ID

全部指标必填。平台通过此字段将Skill指标与对应的智能体关联,若缺失则该条指标数据无法归属到任何智能体,看板中该智能体下的所有Skill数据均无法展示。

gen_ai.skill.name

Skill 名称

全部指标必填。平台通过此字段对Skill进行去重统计(Skill数)和分组聚合(调用次数排行、耗时排行、成功率排行),若缺失则平台无法区分不同Skill,所有排行榜类看板均无数据。

domain.id

账号 ID

全部指标必填。用于租户之间的数据安全隔离,平台查询时会以此字段过滤出当前账号的数据,若填写错误或缺失,数据会被平台过滤掉,看板查询不到任何结果。

gen_ai.call.status

调用是否成功(True/False)

call.count、duration、error.count 必填。平台通过此维度区分成功与失败的调用,用于计算Skill调用成功率(成功率 = status 为 True 的 call.count / 总 call.count)及成功率排行;duration携带此字段可支持按成功/失败分别分析耗时分布;若缺失,平台无法计算成功率,成功率相关看板将无数据。

gen_ai.request.model

模型名称

token.usage必填,其余选填。上报gen_ai.skill.token.usage时携带,用于支撑按模型维度分析Skill带来的Token成本,例如对比不同模型下同一Skill的Token消耗差异;其余指标携带此字段可支持按模型过滤,但非强制要求。

前置准备:在AgentArts控制台创建接入智能体

  1. 登录AgentArts智能体平台
  2. 在左侧导航栏中选择“运营运维 > 观测”,在“智能体列表”页签,单击“智能体接入”。

    图1 智能体接入

  3. 填写智能体名称,智能体类型选择“单智能体”。
  4. 创建完成后,单击“确定”,在页面接入指南中获取以下信息。

    表4 接入信息说明

    参数

    说明

    agent_id

    trace_endpoint

    trace_token

    智能体 ID。

    Trace 数据接入地址。

    Trace 上报鉴权 Token。

    图2 获取接入地址、鉴权信息、智能体ID等信息

    metric_endpoint

    metric_token

    project_id

    promID

    Metric 数据接入地址。

    Metric 上报鉴权 Token。

    华为云项目 ID。

    AOM Prometheus 实例 ID。

    图3 获取接入地址、鉴权信息、项目ID等信息

    同时在控制台“我的凭证 > API凭证”页面获取:

    • domain_id(账号ID)
    • user_id(IAM用户ID)
    图4 获取domain id、user id

准备Skill文件

示例使用一个名为skill-creator的Skill作为演示对象,可以使用该Skill创建新的技能。脚本在运行时会检测该文件是否存在、读取其内容并将其作为上下文注入最终的LLM调用中。整个Skill的加载、执行、释放过程,正是脚本所要观测和上报的对象。因此在运行脚本之前,需要在本地创建好该Skill文件,否则脚本无法触发Skill调用路径,也就无法产生Skill相关的Trace和Metric数据。

准备Skill文件,目录结构如下,创建skill-creator文件夹,并在文件夹中创建SKILL.md文件。

skill-creator/
  SKILL.md

SKILL.md 示例内容如下:

---
name: skill-creator
description: 创建新 Skill 或改进已有 Skill。当用户想创建一个可复用的专项能力、
             固化某种工作流程、或希望智能体具备某项专项能力时使用。
---

# Skill Creator

根据用户需求,直接输出完整的 Skill 内容(SKILL.md 文本)供用户复制使用。

> 注意:直接在回复中输出 Skill 文本,不要尝试执行命令或写入文件。

## 工作方式

1. **理解需求**:明确 Skill 要解决什么问题、适用场景和输出格式。信息足够则直接开始写,不要过度追问。
2. **输出 Skill**:将完整 SKILL.md 放在代码块中输出,用户直接复制保存为 `<skill-name>/SKILL.md`。
3. **按需迭代**:根据用户反馈修改,每次输出完整文件,方便用户直接替换。

## Skill 结构模板

```markdown
---
name: skill 名称(英文小写,连字符分隔)
description: 说明做什么、何时触发,描述应涵盖用户可能使用的不同表述方式。
---

# Skill 标题

## 技能说明

## 工作流程

## 输出格式
```

## 写作原则

- 说明原因而非只下指令:模型理解背后逻辑后,在边界情况下也能正确处理
- 示例胜过规则:给出 1~2 个典型输入输出,比文字规则更有效
- 保持简洁:去掉不影响输出质量的内容

创建Skill数据上报脚本

安装运行依赖,Python建议3.9以上版本。

python -V
pip install opentelemetry-api==1.41.1 \
            opentelemetry-sdk==1.41.1 \
            opentelemetry-exporter-otlp==1.41.1 \
            openai==1.14.0 \
            python-dotenv==1.0.0

与skill-creator文件夹同目录文件下,创建环境变量文件以及skill脚本。

skill-creator/
  SKILL.md
skill.py
.env

创建.env文件,环境变量值请按照实际进行替换。

# ===== 华为云 MaaS 模型凭证 =====
MODEL_NAME=deepseek-v4-flash
MODEL_URL=https://api.modelarts-maas.com/openai/v1
MODEL_API_KEY=替换为你在MaaS申请的API Key

# ===== AgentArts 接入凭证 =====
AGENT_ID=替换为agent_id
AGENT_NAME=替换为智能体名称

# ===== Trace 上报凭证 =====
TRACE_ENDPOINT=替换为trace_endpoint
TRACE_TOKEN=替换为trace_token

# ===== Metric 上报凭证 =====
METRIC_ENDPOINT=替换为metric_endpoint
METRIC_TOKEN=替换为metric_token
PROJECT_ID=替换为project_id
PROM_ID=替换为promID

# ===== 身份字段(必须填写真实值)=====
DOMAIN_ID=替换为华为云账号ID
USER_ID=替换为IAM用户ID

华为云MaaS服务的模型API Key,请登录MaaS服务获取。

图5 模型API Key

创建skill.py文件,下面是本案例的完整实现脚本。启动时从.env文件加载接入凭证,同步初始化Trace和 Metric两路上报通道,并开启本地日志输出(otel_debug.log)用于调试;对话过程中,每次调用依次经历LLM决策、Skill加载、Skill释放、LLM最终回答四个环节,各节点完成后即时上报对应Metric,Root Span在关闭前汇总所有Model Span的Token消耗并统一写入。

import os
import time
import uuid
import json
import subprocess
from openai import OpenAI
from dotenv import load_dotenv

from opentelemetry import trace, metrics
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import (
    BatchSpanProcessor, SimpleSpanProcessor, ConsoleSpanExporter,
)
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import (
    PeriodicExportingMetricReader, ConsoleMetricExporter,
)
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter

load_dotenv()

# ============================================================
# 1. 凭证读取
# ============================================================

AGENT_ID   = os.getenv("AGENT_ID", "")
AGENT_NAME = os.getenv("AGENT_NAME", "")
DOMAIN_ID  = os.getenv("DOMAIN_ID", "")
USER_ID    = os.getenv("USER_ID", "")
PROJECT_ID = os.getenv("PROJECT_ID", "")
PROM_ID    = os.getenv("PROM_ID", "")
MODEL_NAME = os.getenv("MODEL_NAME", "")

# ============================================================
# 2. OTel 初始化
# ============================================================

log_file = open("otel_debug.log", "a", encoding="utf-8")

# Trace 环境变量
os.environ["OTEL_SERVICE_NAME"] = f"AgentArts.{AGENT_ID}.default"
os.environ["OTEL_EXPORTER_OTLP_TRACES_HEADERS"] = (
    f"Authentication={os.getenv('TRACE_TOKEN')}"
)
os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = os.getenv("TRACE_ENDPOINT", "")
os.environ["OTEL_EXPORTER_OTLP_TRACES_INSECURE"] = "true"

# Metric 环境变量
# Bearer 前缀处理:去重后统一补上,避免 "Bearer Bearer xxx" 问题
metric_token = os.getenv("METRIC_TOKEN", "").strip()
metric_token = metric_token.removeprefix("Bearer ").strip()
metric_token = f"Bearer {metric_token}"

os.environ["OTEL_EXPORTER_OTLP_METRICS_HEADERS"] = (
    f"Authorization={metric_token},"
    f"projectID={PROJECT_ID},"
    f"promID={PROM_ID},"
    f"Content-Type=application/x-protobuf"
)
os.environ["OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"] = os.getenv("METRIC_ENDPOINT", "")
os.environ["OTEL_EXPORTER_OTLP_METRICS_INSECURE"] = "true"

# Trace Provider(双路:OTLP 上报 + 本地日志)
tracer_provider = TracerProvider()
tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
tracer_provider.add_span_processor(
    SimpleSpanProcessor(ConsoleSpanExporter(out=log_file))
)
trace.set_tracer_provider(tracer_provider)
tracer = trace.get_tracer(__name__)

# Metric Provider(双路:OTLP 上报 + 本地日志)
meter_provider = MeterProvider(metric_readers=[
    PeriodicExportingMetricReader(OTLPMetricExporter(),
                                  export_interval_millis=60000),
    PeriodicExportingMetricReader(ConsoleMetricExporter(out=log_file),
                                  export_interval_millis=15000),
])
metrics.set_meter_provider(meter_provider)
meter = metrics.get_meter(__name__)

# ============================================================
# 3. 指标注册(全局单例,不可在循环内重复注册)
# ============================================================

# 应用级指标:支撑用户数、会话数、应用调用次数、QPS/QPM、响应成功率、链路平均耗时
usage_status_counter   = meter.create_up_down_counter(
    "gen_ai.usage.status",       unit="1", description="用户数/会话数统计")
app_request_counter    = meter.create_counter(
    "requests_total",            unit="1", description="应用调用总次数/响应成功率/QPS")
app_duration_histogram = meter.create_histogram(
    "client.operation.duration", unit="s", description="应用端到端调用耗时/链路平均耗时")

# 标准大模型指标
request_counter       = meter.create_counter(
    "gen_ai.total.requests",      unit="1", description="大模型调用次数")
input_tokens_counter  = meter.create_counter(
    "gen_ai.usage.input_tokens",  unit="1", description="输入Token消耗")
output_tokens_counter = meter.create_counter(
    "gen_ai.usage.output_tokens", unit="1", description="输出Token消耗")

# Skill 专项指标
skill_call_counter       = meter.create_counter(
    "gen_ai.skill.call.count",  unit="1", description="Skill调用次数")
skill_duration_histogram = meter.create_histogram(
    "gen_ai.skill.duration",    unit="s",  description="Skill调用耗时")
skill_error_counter      = meter.create_counter(
    "gen_ai.skill.error.count", unit="1", description="Skill错误次数")
skill_token_counter      = meter.create_counter(
    "gen_ai.skill.token.usage", unit="1", description="Skill内容Token消耗")

# ============================================================
# 4. 智能体核心逻辑
# ============================================================

def chat_with_agent(
    user_query: str,
    session_id: str,
    conversation_history: list,
) -> str:
    client = OpenAI(
        api_key=os.getenv("MODEL_API_KEY"),
        base_url=os.getenv("MODEL_URL"),
    )

    # 公共 Metric 维度(同时携带多种形式确保各看板均能命中)
    base_metric_attrs = {
        "gen_ai.resource.id":     AGENT_ID,
        "gen_ai.resource.type":   "agent",
        "gen_ai.model.id":        MODEL_NAME,
        "gen_ai.session.id":      session_id,
        "gen_ai.conversation.id": session_id,
        "gen_ai.domain.id":       DOMAIN_ID,
        "domain.id":              DOMAIN_ID,
        "gen_ai.project.id":      PROJECT_ID,
        "gen_ai.user.id":         USER_ID,
        "user.id":                USER_ID,
        "gen_ai.space.id":        "default",
    }

    # LLM 调用 Metric 维度(提前定义,供两次 LLM 调用共用)
    llm_attrs = {**base_metric_attrs, "gen_ai.call.status": True}

    # Token 累加器:汇总本次请求中所有 Model Span 的 Token 消耗
    # Root Span 上的 Token 字段需为所有 Model Span 消耗的总和
    total_input_tokens  = 0
    total_output_tokens = 0
    total_tokens        = 0

    # 记录应用级整体调用开始时间,用于上报 client.operation.duration
    app_start_time = time.time()

    # 上报 gen_ai.usage.status:标记本次调用的用户与会话活跃状态
    # 用于支撑看板"用户数"、"会话数"、"在线应用数"统计
    usage_attrs = {
        "gen_ai.resource.id":     AGENT_ID,
        "gen_ai.resource.type":   "agent",
        "domain.id":              DOMAIN_ID,
        "user.id":                USER_ID,
        "gen_ai.conversation.id": session_id,
    }
    usage_status_counter.add(1, attributes=usage_attrs)

    with tracer.start_as_current_span("agent_dialogue_session") as root_span:
        root_span.set_attribute("gen_ai.span.type",        "root")
        root_span.set_attribute("gen_ai.resource.id",      AGENT_ID)
        root_span.set_attribute("gen_ai.resource.type",    "agent")
        root_span.set_attribute("gen_ai.agent.name",       AGENT_NAME)
        root_span.set_attribute("gen_ai.call.type",        "API")
        root_span.set_attribute("gen_ai.application_name", "default")
        root_span.set_attribute("gen_ai.environment",      "default")
        root_span.set_attribute("resource_version",        "default")
        root_span.set_attribute("domain.id",               DOMAIN_ID)
        root_span.set_attribute("user.id",                 USER_ID)
        root_span.set_attribute("gen_ai.conversation.id",  session_id)
        root_span.set_attribute("input.value",             user_query)

        tools = [{
            "type": "function",
            "function": {
                "name": "skill-creator",
                "description": "用于自动生成、辅助编写新 Skill 技能代码的工具。",
                "parameters": {
                    "type": "object",
                    "properties": {"prompt": {"type": "string"}},
                    "required": ["prompt"],
                },
            },
        }]

        # 本轮消息 = 历史消息 + 当前用户输入
        current_messages = conversation_history + [
            {"role": "user", "content": user_query}
        ]

        # ---------- 节点1:LLM 决策调用 ----------
        with tracer.start_as_current_span("llm_decision_call") as model_span:
            model_span.set_attribute("gen_ai.span.type",        "model")
            model_span.set_attribute("gen_ai.resource.id",      AGENT_ID)
            model_span.set_attribute("gen_ai.resource.type",    "agent")
            model_span.set_attribute("gen_ai.agent.name",       AGENT_NAME)
            model_span.set_attribute("domain.id",               DOMAIN_ID)
            model_span.set_attribute("user.id",                 USER_ID)
            model_span.set_attribute("gen_ai.conversation.id",  session_id)
            model_span.set_attribute("traceloop.workflow.type", "LLM")
            model_span.set_attribute("gen_ai.request.model",    MODEL_NAME)
            model_span.set_attribute("input.value",             user_query)

            start_time = time.time()
            response   = client.chat.completions.create(
                model=MODEL_NAME,
                messages=current_messages,
                tools=tools,
            )
            duration = time.time() - start_time
            message  = response.choices[0].message
            usage    = response.usage

            model_span.set_attribute("gen_ai.usage.input_tokens",        usage.prompt_tokens)
            model_span.set_attribute("gen_ai.usage.output_tokens",       usage.completion_tokens)
            model_span.set_attribute("gen_ai.usage.total_tokens",        usage.total_tokens)
            model_span.set_attribute("gen_ai.client.operation.duration", str(duration))
            model_span.set_attribute(
                "output.value",
                "准备调用工具" if message.tool_calls else (message.content or "")
            )

            request_counter.add(1,                             attributes=llm_attrs)
            input_tokens_counter.add(usage.prompt_tokens,      attributes=llm_attrs)
            output_tokens_counter.add(usage.completion_tokens, attributes=llm_attrs)

            # 将本次 Model Span 的 Token 消耗累加至总计
            total_input_tokens  += usage.prompt_tokens
            total_output_tokens += usage.completion_tokens
            total_tokens        += usage.total_tokens

        if message.tool_calls:
            tool_call   = message.tool_calls[0]
            skill_name  = tool_call.function.name
            skill_args  = json.loads(tool_call.function.arguments)
            prompt_text = skill_args.get("prompt", "")

            skill_path    = f"{skill_name}/SKILL.md"
            skill_scripts = [
                f"{skill_name}/scripts/main.py",
                f"{skill_name}/main.py",
            ]
            script_path = next(
                (p for p in skill_scripts if os.path.exists(p)), None
            )

            # ---------- 节点2:skill_tool(加载 Skill)----------
            # skill_activate_time 在 skill_tool 之前开始计时,
            # 确保 skill_duration 覆盖从加载到释放的完整生命周期
            skill_activate_time = time.time()
            call_status         = True
            skill_result_text   = ""
            skill_body          = ""

            with tracer.start_as_current_span("skill_tool") as skill_span:
                skill_span.set_attribute("gen_ai.span.type",       "model")
                skill_span.set_attribute("gen_ai.resource.id",     AGENT_ID)
                skill_span.set_attribute("gen_ai.resource.type",   "agent")
                skill_span.set_attribute("gen_ai.agent.name",      AGENT_NAME)
                skill_span.set_attribute("domain.id",              DOMAIN_ID)
                skill_span.set_attribute("user.id",                USER_ID)
                skill_span.set_attribute("gen_ai.conversation.id", session_id)
                skill_span.set_attribute("gen_ai.operation.name",  "load_skill")
                skill_span.set_attribute("gen_ai.skill.name",      skill_name)
                skill_span.set_attribute(
                    "gen_ai.skill.id",
                    f"skill_{abs(hash(skill_name)) % (10 ** 8)}"
                )
                skill_span.set_attribute("input.value", f"加载技能参数: {prompt_text}")

                if os.path.exists(skill_path):
                    skill_span.add_event("skill.loaded", {
                        "skill.name": skill_name,
                        "skill.path": skill_path,
                    })

                try:
                    if script_path:
                        print(f"\n[Skill执行中] 检测到代码型技能,正在运行 {script_path}...")
                        result = subprocess.run(
                            ["python", script_path, prompt_text],
                            capture_output=True, text=True,
                            check=True, encoding="utf-8",
                        )
                        skill_result_text = result.stdout
                    elif os.path.exists(skill_path):
                        print(f"\n[Skill执行中] 检测到提示词型技能,正在加载 {skill_path}...")
                        with open(skill_path, "r", encoding="utf-8") as f:
                            skill_body = f.read()
                        skill_result_text = (
                            "【已成功加载该技能规范,请严格遵循以下内容处理用户请求】:\n\n"
                            + skill_body
                        )
                    else:
                        skill_result_text = f"Skill 执行失败:未找到 {skill_path} 或执行脚本。"
                        call_status = False
                except Exception as e:
                    skill_result_text = f"Skill 执行时发生故障: {e}"
                    call_status = False

                skill_span.set_attribute("output.value", skill_result_text[:500])

            # ---- Skill call.count 在 skill_tool 结束后上报 ----
            # Skill数、Skill调用次数、Skill调用成功率及各排行
            skill_attrs = {
                **base_metric_attrs,
                "gen_ai.skill.name":    skill_name,
                "gen_ai.skill.version": "1.0.0",
                "gen_ai.system":        "python-sdk",
                "gen_ai.call.status":   call_status,
            }
            skill_call_counter.add(1, attributes=skill_attrs)
            if not call_status:
                skill_error_counter.add(1, attributes=skill_attrs)

            # Skill 内容 Token 估算
            skill_content        = skill_body if skill_body else skill_result_text
            skill_content_tokens = max(1, len(skill_content.encode("utf-8")) // 4)
            skill_token_counter.add(skill_content_tokens, attributes={
                **skill_attrs,
                "gen_ai.request.model": MODEL_NAME,
            })

            # ---------- 节点3:skill_complete(释放 Skill)----------
            with tracer.start_as_current_span("skill_complete") as release_span:
                release_span.set_attribute("gen_ai.span.type",       "model")
                release_span.set_attribute("gen_ai.resource.id",     AGENT_ID)
                release_span.set_attribute("gen_ai.resource.type",   "agent")
                release_span.set_attribute("gen_ai.agent.name",      AGENT_NAME)
                release_span.set_attribute("domain.id",              DOMAIN_ID)
                release_span.set_attribute("user.id",                USER_ID)
                release_span.set_attribute("gen_ai.conversation.id", session_id)
                release_span.set_attribute("gen_ai.operation.name",  "release_skill")
                release_span.set_attribute("gen_ai.skill.name",      skill_name)
                release_span.set_attribute(
                    "gen_ai.skill.id",
                    f"skill_{abs(hash(skill_name)) % (10 ** 8)}"
                )
                release_span.set_attribute("input.value",  "释放技能")
                release_span.set_attribute("output.value", "释放成功")
                release_span.add_event("skill.released", {"skill.name": skill_name})

            # ---- skill_duration 在 skill_complete 结束后计算并上报 ----
            # 覆盖从 skill_tool 开始到 skill_complete 结束的完整生命周期
            # 支撑:Skill调用平均耗时、Skill调用平均耗时排行Top5
            skill_duration = time.time() - skill_activate_time
            skill_duration_histogram.record(skill_duration, attributes=skill_attrs)

            print(
                f"[{time.strftime('%H:%M:%S')}] "
                f"Skill={skill_name} | ok={call_status} | "
                f"duration={skill_duration:.2f}s | "
                f"skill_tokens≈{skill_content_tokens}"
            )

            # ---------- 节点4:LLM 最终回答 ----------
            with tracer.start_as_current_span("llm_final_answer") as final_span:
                final_span.set_attribute("gen_ai.span.type",        "model")
                final_span.set_attribute("gen_ai.resource.id",      AGENT_ID)
                final_span.set_attribute("gen_ai.resource.type",    "agent")
                final_span.set_attribute("gen_ai.agent.name",       AGENT_NAME)
                final_span.set_attribute("domain.id",               DOMAIN_ID)
                final_span.set_attribute("user.id",                 USER_ID)
                final_span.set_attribute("gen_ai.conversation.id",  session_id)
                final_span.set_attribute("traceloop.workflow.type", "LLM")
                final_span.set_attribute("gen_ai.request.model",    MODEL_NAME)
                final_span.set_attribute(
                    "input.value",
                    f"工具反馈结果长度: {len(skill_result_text)} 字符"
                )

                assistant_message = message.model_dump(exclude_none=True)
                if not assistant_message.get("content"):
                    assistant_message["content"] = ""

                # 最终回答消息 = 历史 + 本轮用户问题 + 助手决策 + 工具结果
                final_messages = conversation_history + [
                    {"role": "user", "content": user_query},
                    assistant_message,
                    {
                        "role":         "tool",
                        "tool_call_id": tool_call.id,
                        "content":      skill_result_text,
                    },
                ]

                start_time     = time.time()
                final_response = client.chat.completions.create(
                    model=MODEL_NAME,
                    messages=final_messages,
                )
                duration     = time.time() - start_time
                usage        = final_response.usage
                final_answer = final_response.choices[0].message.content

                final_span.set_attribute("gen_ai.usage.input_tokens",        usage.prompt_tokens)
                final_span.set_attribute("gen_ai.usage.output_tokens",       usage.completion_tokens)
                final_span.set_attribute("gen_ai.usage.total_tokens",        usage.total_tokens)
                final_span.set_attribute("gen_ai.client.operation.duration", str(duration))
                final_span.set_attribute("output.value",                     final_answer)

                request_counter.add(1,                             attributes=llm_attrs)
                input_tokens_counter.add(usage.prompt_tokens,      attributes=llm_attrs)
                output_tokens_counter.add(usage.completion_tokens, attributes=llm_attrs)

                # 将本次 Model Span 的 Token 消耗累加至总计
                total_input_tokens  += usage.prompt_tokens
                total_output_tokens += usage.completion_tokens
                total_tokens        += usage.total_tokens

            # Root Span 关闭前统一写入:所有 Model Span Token 消耗的累加总和
            # 本次请求包含两次 LLM 调用(决策 + 最终回答),Token 为两次之和
            root_span.set_attribute("output.value",               final_answer)
            root_span.set_attribute("gen_ai.usage.input_tokens",  total_input_tokens)
            root_span.set_attribute("gen_ai.usage.output_tokens", total_output_tokens)
            root_span.set_attribute("gen_ai.usage.total_tokens",  total_tokens)

            # 上报应用级指标:计算本次调用整体耗时
            # 应用调用次数、QPS/QPM、响应成功率、链路整体平均耗时
            app_total_duration = time.time() - app_start_time
            app_attrs = {
                "gen_ai.resource.id":     AGENT_ID,
                "gen_ai.resource.type":   "agent",
                "domain.id":              DOMAIN_ID,
                "gen_ai.conversation.id": session_id,
                "gen_ai.call.status":     True,
            }
            app_request_counter.add(1,                       attributes=app_attrs)
            app_duration_histogram.record(app_total_duration, attributes=app_attrs)

            # 将本轮完整对话追加至历史,供下一轮携带
            conversation_history.append({"role": "user", "content": user_query})
            conversation_history.append(assistant_message)
            conversation_history.append({
                "role":         "tool",
                "tool_call_id": tool_call.id,
                "content":      skill_result_text,
            })
            conversation_history.append({
                "role":    "assistant",
                "content": final_answer,
            })

            return final_answer

        else:
            # 非工具调用分支:模型直接回答,无 Skill 介入
            final_answer = message.content or ""

            # Root Span 关闭前写入 Token 字段
            # 此分支只有一次 LLM 调用,直接取该次的 Token 值
            root_span.set_attribute("output.value",               final_answer)
            root_span.set_attribute("gen_ai.usage.input_tokens",  total_input_tokens)
            root_span.set_attribute("gen_ai.usage.output_tokens", total_output_tokens)
            root_span.set_attribute("gen_ai.usage.total_tokens",  total_tokens)

            # 非工具调用分支同样上报应用级指标
            app_total_duration = time.time() - app_start_time
            app_attrs = {
                "gen_ai.resource.id":     AGENT_ID,
                "gen_ai.resource.type":   "agent",
                "domain.id":              DOMAIN_ID,
                "gen_ai.conversation.id": session_id,
                "gen_ai.call.status":     True,
            }
            app_request_counter.add(1,                       attributes=app_attrs)
            app_duration_histogram.record(app_total_duration, attributes=app_attrs)

            conversation_history.append({"role": "user",      "content": user_query})
            conversation_history.append({"role": "assistant", "content": final_answer})
            return final_answer


# ============================================================
# 5. 启动入口
# ============================================================

if __name__ == "__main__":
    session_id           = f"sess-{uuid.uuid4().hex[:12]}"
    conversation_history = []

    print("==================================================")
    print(" AgentArts Skill Telemetry Verify 已启动")
    print(f"  agent_id   = {AGENT_ID}")
    print(f"  agent_name = {AGENT_NAME}")
    print(f"  session_id = {session_id}")
    print(" 本地调试:OTel 数据同步写入 otel_debug.log")
    print(" 指标每 60s 批量上报,请对话 3 分钟以上再查看看板")
    print("==================================================")

    try:
        while True:
            user_input = input("\nUser: ").strip()
            if not user_input:
                continue
            if user_input.lower() in ("exit", "quit", "q"):
                break
            reply = chat_with_agent(user_input, session_id, conversation_history)
            print(f"\nAgent: {reply}")
    except KeyboardInterrupt:
        print("\n检测到 Ctrl+C,正在准备退出...")
    finally:
        print("\n正在强制刷新残留数据至 AgentArts 平台...")
        tracer_provider.shutdown()
        meter_provider.shutdown()
        log_file.close()
        print("数据上报完成!")

测试Skill观测数据上报

  1. 在脚本同目录下,执行python skill.py命令,并与智能体进行对话。

    示例中创建的skill-creator是一个skill生成器,因此对话过程汇总可以让智能体生成各式各样的skill。

    图6 运行示例

  2. 与智能体进行多次会话后,登录AgentArts智能体平台,在“运营运维 > 观测 > 智能体列表”页面,单击对应的智能体名称查看上报的数据。

    图7 Skill数据
    图8 调用链数据

常见问题

  • UNAUTHENTICATED:鉴权失败报错

    检查METRIC_TOKEN是否正确;脚本会自动处理Bearer前缀,不需要手动添加;检查projectID/promID大小写是否与接入指南完全一致。

  • 看板有数据但没有Skill指标

    确认Skill相关维度(gen_ai.resource.id、gen_ai.skill.name、domain.id)均已正确携带;指标每60秒批量上报一次,建议持续对话3分钟以上再查看。

  • LLM没有触发Skill,控制台只有LLM指标

    模型并非每次都会调用工具,建议在提问中明确说明需要创建或使用Skill,引导模型触发工具调用。

    Invalid type NoneType for attribute报错

    .env文件中缺少对应的环境变量配置,导致读取结果为None。常见缺失项为AGENT_NAME,请检查.env文件中所有必填参数是否均已填写真实值。

  • UNAVAILABLE:连不上服务器报错

    检查METRIC_ENDPOINT/TRACE_ENDPOINT格式,确保与接入指南给出的格式保持一致(纯域名,不带https://)。

  • 会话分析或调用链分析中Tokens列显示为0

    Token相关字段(gen_ai.usage.input_tokens、gen_ai.usage.output_tokens、gen_ai.usage.total_tokens)仅在Model Span上设置,未在Root Span上同步设置。平台统计会话/Trace维度的Token消耗时读取的是Root Span上的字段,若缺失则显示为0。正确做法是将本次请求中所有Model Span的Token消耗累加后,在Root Span关闭前统一写入。

  • 看板中Skill调用次数显示为小数

    AOM底层使用Prometheus的increase()函数计算Counter类指标在时间窗口内的增长量,当查询时间窗口的边界与指标上报时间点不完全对齐时,会对首尾两端进行线性插值外推,从而产生小数。实际累计调用次数仍为整数,小数是速率估算的结果。若需查看精确整数,可将查询时间窗口对齐为60秒的整数倍(与脚本上报周期一致)。

  • domain.id或user.id填写了假数据,看板查询不到任何数据

    平台存在租户隔离机制,查询时会以当前登录账号的真实domain.id进行过滤。若上报的指标中domain.id与实际账号ID不一致,数据会被直接过滤,看板无法展示任何结果。请登录“我的凭证 > API凭证”页面获取真实的domain_id和user_id并填入.env文件。

相关文档