本文端到端验证了 OpenLIT 三层可观测性栈(GPU eBPF / LLM eBPF / Agent SDK 注入)能否在真实 AI Agent 场景下产出可关联的全链路 trace。
概念与理论
可观测性的三层模型
传统 Web/Microservice 可观测性关心的是请求 → 服务 → 数据库这条数据流,而一个 AI Agent 的请求要穿越完全不同的层次:用户问题进入 Agent 框架(LangChain / Strands / CrewAI 等),框架把工具描述塞进 Prompt 调用 LLM,LLM 输出函数调用决策,框架解析后执行真实工具,工具结果再喂回 LLM,循环若干轮直到输出最终答案。每一轮的 LLM 调用又会落到某块 GPU 上跑前向推理,消耗显存、占用计算单元、产生功耗。
这条链路天然分三层:
- Agent 层:决策与编排,关心多步推理的链路、工具调用的因果、上下文的演化
- LLM API 层:模型调用本身,关心 model id、token 用量、cost、单次延迟
- GPU 硬件层:物理资源,关心 utilization、显存、kernel 启动、PCIe 传输
只看其中一层永远定位不了真正的根因。比如一次 Agent 响应突然变慢:可能是 LLM 选择了错误的工具引发额外往返(Agent 层),可能是 LLM 推理本身慢了(LLM 层),可能是 GPU 被同节点的另一个进程抢占了 KV cache(GPU 层)。三层关联起来才能从用户感知的"慢"一路追到具体的根因。
OpenLIT
OpenLIT 是一个开源的 AI 可观测性平台(Apache 2.0),它的特点是 OpenTelemetry-native + 自带后端。区别于纯 SDK 类方案(你得自己接 Datadog / Grafana),OpenLIT 自己包含了 dashboard、OTel collector、ClickHouse 后端,开箱即用。它由三套独立但互补的组件构成:
三种接入方式按侵入性递增对应不同场景:
- SDK 主动接入 —— 在应用代码里
import openlit; openlit.init()一行,依赖 OpenTelemetry auto-instrumentation 框架自动给 LLM SDK、Agent 框架、向量数据库打 patch。优点是粒度最细(能拿到 Agent 内部的 span),缺点是只支持 Python/TypeScript,且应用要重启 - Controller eBPF 拦截 —— 在主机内核态用 eBPF kprobe 挂
tcp_connect,发现进程到 LLM provider 的连接后解析 HTTP 流量。零代码、跨语言,但只能看到 LLM API 层的粗粒度数据(model、tokens、latency),看不到 Agent 内部的步骤 - GPU Collector —— 独立二进制,每节点一个,通过 NVML 拿常规 GPU 指标,可选启用 eBPF CUDA 追踪
cudaLaunchKernel/cudaMalloc/cudaMemcpy拿到 kernel 级别细节。和 Agent / LLM 层完全解耦
本次部署的策略是 SDK 主接入 + Controller 旁路 + GPU Collector 必接:Agent 层精度靠 SDK,GPU 层靠 collector 必备,Controller 作为旁路验证(同时观察从 strands-agent-app 进程出去的 LLM 流量是否能被 eBPF 看到)。
eBPF CUDA Tracing
GPU 监控传统上靠两条路:NVIDIA 官方的 NVML 库(拿利用率、显存、温度等聚合指标),或 Nsight 这类工具(启动 profiler 跑一次,事后看 timeline)。前者太粗,无法关联到具体进程或 kernel;后者太重,不能用于持续生产监控。eBPF CUDA tracing 是第三条路。
CUDA Runtime(libcudart.so)是用户态库,所有 CUDA API 调用都通过它进入 driver。eBPF 的 uprobe 机制可以挂载到任意用户态符号上,因此可以挂到:
cudaLaunchKernel—— 每次 GPU kernel 启动cudaMalloc/cudaFree—— 显存分配/释放cudaMemcpy/cudaMemcpyAsync—— Host↔Device 数据传输
每次这些函数被调用时,eBPF 程序会在内核态被触发,记录 PID、参数(kernel grid 大小、传输字节数)、时间戳。userspace agent 通过 perf buffer 拿到这些事件,聚合后输出成 OTel metric/trace。
注意事项:
- 必须 Linux 内核 ≥ 5.8 + BTF(CO-RE):CUDA 库的符号在不同 host 上偏移不同,CO-RE(Compile Once - Run Everywhere)让 eBPF 程序可移植
- 必须有 libcudart.so 在 host 上:DLAMI Ubuntu 22.04 已预装。
- container 模式需要把 host 的 libcudart 挂进去:因为 eBPF program 需要 host 态符号
- 容器必须 CAP_BPF + CAP_PERFMON:或者
--privileged,否则 uprobe 挂不上
SGLang 部署 Qwen3
SGLang 是 LMSYS 推出的 LLM serving 框架(vLLM 的主要竞争者之一)。主打三件事:
- RadixAttention —— KV cache 共享:相同前缀的请求复用 KV,对 system prompt 不变的多轮 Agent 场景特别有用
- Structured Output —— JSON schema 约束生成(grammar-constrained decoding),tool calling 场景下能保证模型输出严格符合 schema
- OpenAI-compatible API ——
/v1/chat/completions完全兼容,可以直接接现有的 OpenAI 客户端
Qwen3 系列(2025 年发布)原生支持 thinking / non-thinking mode 切换、工具调用,权重 Apache 2.0 公开。Qwen3-8B 在 L4 24GB 上 FP16 加载约 16GB,留 ~5-8GB 给 KV cache + CUDA graphs。
App框架这里使用AWS Strands。 AWS 在 2025 年发布 Python Agent SDK(可类比 LangChain,但更轻量)核心设计如下:
@tool装饰普通 Python 函数即变工具,docstring 自动转工具描述Agent(model=..., tools=[...])创建 agentagent("user query")触发推理,内部跑一个 event loop:调 LLM → 看是否有 tool_calls → 执行 tools → 把结果塞回 message history → 再调 LLM → 直到没有 tool_calls 或达到 max_iterations
Strands 一开始就 OpenTelemetry-native:内部用 OTel SDK 创建 span,识别 OTEL_EXPORTER_OTLP_ENDPOINT 环境变量自动导出。它发的 span 有四种:
invoke_agent <name>—— 单次agent(...)调用的根 spanexecute_event_loop_cycle—— 一轮 LLM + tool 执行chat/chat <model_id>—— LLM 调用(chat span 是 framework 层面的,chat是 SDK 层面的) execute_tool <tool_name>—— 单次工具执行POST—— 底层 HTTP 客户端的 span(httpx 自动 instrument)
OpenLIT 的 Python SDK 通过 openlit.init() 完成两件事:注册 OTLP exporter,再 monkey-patch 一些常见的 LLM/Agent 框架(包括 strands-agents),把它们的 trace 接到自己的 OTel pipeline,并补上 gen_ai.* 这套 OpenTelemetry 标准 GenAI 语义约定属性(input_tokens、output_tokens、model、temperature 等)。一个 LLM 调用最终会有两层 span:Strands 自己的 chat 和 OpenLIT 加的 chat <model_id>,后者带全 gen_ai.* 属性。
注意:
openlit.init()必须在from strands import Agent之前。OTel auto-instrumentation 是通过修改 import 时的模块对象生效的,如果框架已经 import 进来,patch 就 miss 了。
整体架构
物理与逻辑拓扑
测试栈跑在一台 g6.xlarge(NVIDIA L4 24GB / DLAMI Ubuntu 22.04 / Docker 29 + Compose v5)上。
- 4 个业务/存储 service 在 docker-compose 默认 bridge
compose_default(172.18.0.0/16) - 2 个观测 service(
otel-gpu-collector+openlit-controller)需要看到主机所有 PID + 内核态 eBPF 资源,跑在 host network namespace + host PID namespace,通过localhost反向访问 docker bridge 上 openlit 暴露的端口。
新实例运行的 6 个 docker service 与它们的端口/网络命名空间如下图所示
每个容器的作用、依赖、网络命名空间:
| 容器 | 角色 | 端口 / 协议 | 网络/PID 命名空间 |
|---|---|---|---|
| clickhouse | 列式数据库,保存所有 traces / metrics / logs;OpenLIT 的全部业务数据(dashboard 配置、prompt hub、API key)也在这里 | TCP :9000 (native) / HTTP :8123 |
docker bridge compose_default |
| openlit | OpenLIT Server:Web Dashboard + 内置 OpAMP supervisor + 受管的 OTel Collector | :3000 Dashboard / :4317 OTLP gRPC / :4318 OTLP HTTP |
docker bridge compose_default |
| openlit-controller | 旁路观察者:用 eBPF 在主机内核态拦截 LLM API 流量 + (Docker 模式下)通过 docker.sock 发现容器并可选注入 SDK | REST :4321 (内部健康检查) |
docker bridge + pid: host(看所有进程的 /proc/net/tcp) |
| otel-gpu-collector | GPU 数据源:通过 NVML 拿利用率/功耗/温度;可选启用 eBPF CUDA tracing 拿 kernel/显存调用 | 不暴露端口(纯 push) | network_mode: host + pid: host —— eBPF uprobe 要在 host 内核态附加,且需扫所有 PID 找 libcudart |
| sglang | LLM serving:加载 Qwen3-8B 提供 OpenAI 兼容 API;唯一真正吃 GPU 的容器 | :30000 (/v1/chat/completions 等) |
docker bridge compose_default |
| strands-agent-app | 演示应用:跑 Strands 多步多工具 Agent,主动 import openlit; openlit.init() 接入 SDK |
不暴露端口(CLI 入口) | docker bridge compose_default |
五点必须澄清的设计取舍:
- clickhouse 单独成一个 service —— OpenLIT 自带的 SQLite (
openlit-data:/app/client/data) 只存 dashboard 元数据(widget 布局、用户、API key),所有 OTel 数据都强制走 ClickHouse。两者职责互不重叠 - openlit 容器同时承担 Dashboard + OTel Collector 两个角色 —— 它内部跑 OpAMP supervisor,由 supervisor 启动一个 OTel collector 进程监听 4317/4318。这就是为什么忘了挂
assets/otel-collector-config.yaml时 4318 内部不监听(坑 2 的根源) - openlit-controller 是"旁路"不是"主路" —— trace 主要靠 strands-agent-app 主动 SDK 接入。Controller 同时在 host pid namespace 扫 LLM 流量,是用来对照"eBPF 能否独立看到这些请求",没启用它整个观测性也成立
- otel-gpu-collector 必须 host 模式而不是 docker bridge —— eBPF uprobe 在 host 内核态附加,需要
pid: host看所有 PID 的/proc/<PID>/maps找各容器加载的 libcudart inode;network_mode: host让 OTLP endpoint 改成localhost:4317直连 openlit 在 host 暴露的端口(compose DNS 在 host 网络里不可用)。这种"半数据平面、半 host"的混合命名空间正是它和其他 service 的根本区别。
以上的 fan-in 设计意味着 OpenLIT Server 是唯一接收 telemetry 的入口,只需要维护一个 collector 配置就能覆盖三层数据源。
下面这张图是单次用户问题"What is 15 * 7? Then tell me the weather in Tokyo."从入口到 ClickHouse 的全链路。
这条链路上一个问题大概产生 11 个 span(实际从 ClickHouse 查到的数):1 个 invoke_agent 根、2 个 execute_event_loop_cycle(两轮)、2 个 chat、2 个 chat Qwen/Qwen3-8B、2 个 POST、各一个 execute_tool calculator / execute_tool get_weather。
环境预检
部署前先在实例上确认所有依赖到位。每一项都对应后面某个具体环节能不能跑,缺一项后续就会卡住,而且故障表现往往不直观(容器降级运行而不是直接退出)。
$ uname -a
Linux ip-172-31-42-226 6.8.0-1052-aws #55~22.04.1-Ubuntu SMP Tue Apr 7 04:58:22 UTC 2026 x86_64$ docker --version && docker compose version
Docker version 29.4.1, build 055a478
Docker Compose version v5.1.3$ ls /sys/kernel/btf/vmlinux
/sys/kernel/btf/vmlinux
内核版本 ≥ 5.8 + BTF
Linux ip-172-31-42-226 6.8.0-1052-aws #55~22.04.1-Ubuntu SMP Tue Apr 7 04:58:22 UTC 2026 x86_64
/sys/kernel/btf/vmlinux ← 文件存在 = BTF 已暴露
eBPF 程序在跑之前要把 C 源码编译成内核字节码,但内核数据结构(struct task_struct、struct sock 等)的字段偏移在不同 kernel 版本上不一样。CO-RE(Compile Once - Run Everywhere) 通过读取目标内核的 BTF(BPF Type Format)信息在加载时重定位,让一份编译产物能跑在不同版本的 kernel 上。BTF 在 5.2 引入但 5.8 才稳定,且需要 kernel 编译时开启 CONFIG_DEBUG_INFO_BTF=y,运行时通过 /sys/kernel/btf/vmlinux 暴露。
如果内核 < 5.8 或 BTF 缺失:
- otel-gpu-collector 启动时报
failed to load BTF spec: not supported,eBPF CUDA tracing 完全用不了,只剩 NVML 数据 - openlit-controller 同样无法 attach kprobe 到
tcp_connect,整个 LLM 流量拦截功能失效
NVIDIA driver
$ nvidia-smi --query-gpu=name,driver_version,memory.total --format=csv
name, driver_version, memory.total [MiB]
NVIDIA L4, 580.126.09, 23034 MiB
- driver 版本 580.x:sglang container 里的 CUDA 12.4 runtime 要求 driver ≥ 550。driver 比 runtime 旧会报
CUDA driver version is insufficient for CUDA runtime version,完全跑不起来。driver 比 runtime 新没问题(向后兼容) - GPU 型号 L4:决定能跑多大的模型。L4 有 24GB 显存 + 7424 CUDA core + 没 NVLink,刚好够 Qwen3-8B FP16
- 显存 23034 MiB:标称 24GB 实际可用 23034 MiB(~22.5 GiB)。这是 Qwen3-8B 选型决策的依据:FP16 模型权重 16GB + CUDA runtime / cuBLAS / cudnn 等 ~1GB + KV cache + CUDA graphs 还能挤出 5GB。如果是 22GB 卡(比如 A10G 23028 MiB),还是够用。如果是 16GB 卡(比如 T4),Qwen3-8B 必须用 INT8 / AWQ 量化才行
注意事项:
- 跑这条 pre-flight 时 sglang 还没启动,所以
memory.used应该接近 0。如果显示 1GB+,说明有别的进程在抢卡,要排查
nvidia-container-toolkit
nvidia-smi 命令本身是 host 上的工具。真正决定 sglang / otel-gpu-collector 能不能运行的是 docker 启动时能否把 GPU 透传到容器内。DLAMI 已经在 /etc/docker/daemon.json 里配好了 nvidia runtime,用一条命令显式确认:
$ docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 580.126.09 Driver Version: 580.126.09 |
+-----------------------------------------------------------------------------+
| GPU Name Persistence-M Bus-Id Disp.A Volatile ... |
| 0 NVIDIA L4 On 00000000:31:00.0 Off |
+-----------------------------------------------------------------------------+
如果 nvidia-container-toolkit 缺失或没注册到 docker,会报:
docker: Error response from daemon: could not select device driver "" with capabilities: [[gpu]].
这种错对 sglang / otel-gpu-collector 是致命的,两者都依赖 deploy.resources.reservations.devices.driver: nvidia。提前一条 docker run 确认比后面 sglang 启不起来再排查节省 5-10 分钟。
注意事项:
- eBPF 工作的最低条件不止 kernel 版本:还要
/sys/kernel/debug和/sys/fs/bpf在 host 上 mount(这两个默认就 mount,但 hardened 镜像可能 disable,特别注意 read-only-root 的容器化内核)
服务栈部署
6 个 service 的依赖关系:
docker compose up 时按依赖顺序启动:clickhouse 先就绪 → openlit 启动并跑 OpAMP supervisor 起内部 OTel collector → controller / gpu-collector 接到 openlit 的 OTLP endpoint → sglang 启动并下载/加载 Qwen3-8B(最慢,5-10 分钟)→ strands-agent-app build 镜像后启动。
ClickHouse 与 OpenLIT Server
clickhouse + openlit 是 OpenLIT 官方 docker-compose 的最小可用配置。直接抄官方仓库的配置文件 + 三个 mount 文件:
services:clickhouse:image: clickhouse/clickhouse-server:24.4.1environment:CLICKHOUSE_PASSWORD: ${OPENLIT_DB_PASSWORD:-OPENLIT}CLICKHOUSE_USER: ${OPENLIT_DB_USER:-default}CLICKHOUSE_DATABASE: ${OPENLIT_DB_NAME:-openlit}CLICKHOUSE_ALWAYS_RUN_INITDB_SCRIPTS: "true"volumes:- clickhouse-data:/var/lib/clickhouse- ./assets/clickhouse-config.xml:/etc/clickhouse-server/config.d/custom-config.xml:ro- ./assets/clickhouse-init.sh:/docker-entrypoint-initdb.d/init.sh:roports: ["9000:9000", "8123:8123"]ulimits:nofile: { soft: 262144, hard: 262144 }healthcheck:test: ["CMD-SHELL", "clickhouse-client --user=$${CLICKHOUSE_USER} --password=$${CLICKHOUSE_PASSWORD} --query='SELECT 1' || exit 1"]interval: 5sretries: 30start_period: 30sopenlit:image: ghcr.io/openlit/openlit:latestenvironment:INIT_DB_HOST: clickhouseINIT_DB_PORT: "8123"INIT_DB_DATABASE: ${OPENLIT_DB_NAME:-openlit}INIT_DB_USERNAME: ${OPENLIT_DB_USER:-default}INIT_DB_PASSWORD: ${OPENLIT_DB_PASSWORD:-OPENLIT}SQLITE_DATABASE_URL: file:/app/client/data/data.dbPORT: "${PORT:-3000}"OPAMP_TLS_INSECURE_SKIP_VERIFY: "true"ports:- "${PORT:-3000}:${PORT:-3000}"- "4317:4317" # OTLP gRPC- "4318:4318" # OTLP HTTPdepends_on:clickhouse: { condition: service_healthy }volumes:- openlit-data:/app/client/data- ./assets/otel-collector-config.yaml:/etc/otel/otel-collector-config.yamlhealthcheck:test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))\""]interval: 5sretries: 30start_period: 30s
三个挂载文件是从 openlit GitHub 拉的:
mkdir -p compose/assets
curl -fsSL https://raw.githubusercontent.com/openlit/openlit/main/assets/otel-collector-config.yaml \-o compose/assets/otel-collector-config.yaml # 51 行,定义 receiver/processor/exporter
curl -fsSL https://raw.githubusercontent.com/openlit/openlit/main/assets/clickhouse-config.xml \-o compose/assets/clickhouse-config.xml # 74 行,自定义 ClickHouse 设置
curl -fsSL https://raw.githubusercontent.com/openlit/openlit/main/assets/clickhouse-init.sh \-o compose/assets/clickhouse-init.sh # 426 行,首次启动建表
chmod +x compose/assets/clickhouse-init.sh
第一次部署时我没意识到这三个 asset 文件的重要性,认为 OpenLIT 容器自带启动 OTel collector,于是 stack 起来后从 strands-agent-app 发查询,全程报:
Transient error HTTPConnectionPool(host='openlit', port=4318): Max retries exceeded with url: /v1/metrics
(Caused by NewConnectionError("HTTPConnection(host='openlit', port=4318): Failed to establish a new connection: [Errno 111] Connection refused"))
encountered while exporting metrics batch, retrying in 1.09s.
容器外 docker port openlit 显示 4318/tcp -> 0.0.0.0:4318,似乎正常。但容器内 curl http://openlit:4318 仍 connection refused。看 openlit 容器日志找到关键线索:
OpAMP Configuration:Certificates Directory: /app/opamp/certs
Generating OpAMP certificates...
✅ Supervisor configuration generated at /app/opamp/supervisor-runtime.yaml
Starting OpAMP Server...
2026/05/27 14:04:07.254 [OPAMP] Starting OpAMP server on 0.0.0.0:4320
2026/05/27 14:04:07.261 [MAIN] OpAMP Server running...
Starting OpAMP Supervisor...
2026/05/27 14:04:26 failed to start supervisor: could not get bootstrap info from the Collector: collector's OpAMP client never connected to the Supervisor
OpenLIT 的设计是用 OpAMP(Open Agent Management Protocol) 做 OTel collector 的 control plane:openlit 容器里跑一个 OpAMP server (:4320),再跑一个 supervisor 进程拉起一个被管理的 OTel collector binary。collector 启动时读 /etc/otel/otel-collector-config.yaml,没这文件就启动失败,于是 4317/4318 自然没人 listen。把这个 yaml 挂进去后,supervisor 成功 bootstrap collector,4318 就开始接受 OTLP 数据。
挂上之后验证:
$ docker exec openlit node -e 'fetch("http://localhost:4318/v1/traces", {method:"POST",headers:{"content-type":"application/json"},body:"{}"}).then(r=>console.log("status:",r.status))'
status: 200
注意:
- 更新 ClickHouse init 脚本时必须
docker volume rm:clickhouse-init.sh 只在数据库首次创建时跑一次。如果你已经用空配置启动过一次,volume 里已经有元数据,再挂上 init 脚本也不会执行,必须删 volume 重建 - OpAMP TLS 默认 production 模式:在测试环境用
OPAMP_TLS_INSECURE_SKIP_VERIFY: "true"关 cert 验证,否则 supervisor 报证书错。生产环境需要正确配置证书
OpenLIT Controller
OpenLIT Controller 是个独立的 Go 二进制,做两件事:用 eBPF 拦截 LLM API 流量,以及(在 Kubernetes / Docker 模式下)把 OpenLIT Python SDK 注入到运行中的 Python 进程。Docker 部署它需要 privileged + pid:host + 4 个 host volume:
openlit-controller:image: ghcr.io/openlit/openlit-controller:latest # ← 注意名字privileged: truepid: "host"volumes:- /proc:/host/proc:ro # 扫 /proc/net/tcp 发现 LLM 连接- /sys/kernel/debug:/sys/kernel/debug:ro # eBPF kprobe 挂载点- /sys/fs/bpf:/sys/fs/bpf:rw # eBPF map 持久化- /var/run/docker.sock:/var/run/docker.sock # 发现容器 + SDK 注入environment:OPENLIT_URL: "http://openlit:3000"OPENLIT_PROC_ROOT: "/host/proc"OTEL_EXPORTER_OTLP_ENDPOINT: "http://openlit:4318"OPENLIT_INSTANCE_ID: "openlit-test-controller-1"depends_on:openlit: { condition: service_healthy }
注意事项:
- 本次测试 Controller 只是被动观察者,trace 数据靠 strands-agent-app 自己接 SDK
otel-gpu-collector
GPU collector 是独立 Go 二进制,分两路采集:
- NVML 通过
libnvidia-ml.so—— 拿 utilization / memory / power / temperature / clock 等聚合指标(每 10s 一次) - eBPF 通过 uprobe 挂 libcudart —— 拿 kernel launch / cudaMalloc / cudaMemcpy 事件(开关
OTEL_GPU_EBPF_ENABLED=true)
otel-gpu-collector:image: ghcr.io/openlit/otel-gpu-collector:latestprivileged: truecap_add: [SYS_ADMIN]deploy:resources:reservations:devices:- driver: nvidiacount: 1capabilities: [gpu]volumes:- /sys/kernel/debug:/sys/kernel/debug:ro # eBPF kprobe- /sys/fs/bpf:/sys/fs/bpf:rw # eBPF map- /lib/modules:/lib/modules:ro # CO-RE 用的 BTFenvironment:OTEL_EXPORTER_OTLP_ENDPOINT: "http://openlit:4317"OTEL_EXPORTER_OTLP_PROTOCOL: "grpc"OTEL_SERVICE_NAME: "openlit-gpu-collector"OTEL_RESOURCE_ATTRIBUTES: "deployment.environment=test,host.name=openlit-test-g6"OTEL_METRIC_EXPORT_INTERVAL: "10000"OTEL_GPU_EBPF_ENABLED: "true"depends_on:openlit: { condition: service_healthy }
注意事项:
/lib/modules挂载是 CO-RE 必须的:eBPF 程序需要 host kernel 的vmlinux.h(BTF 信息),通常打包在 kernel modules 里
此外,eBPF CUDA tracing 默认关闭:OTEL_GPU_EBPF_ENABLED=true 才启用。关闭时仍能拿 NVML 指标,等于纯 GPU 监控。但是实际上启用 eBPF 后实际抓不到 sglang/vLLM 数据,因为collector base image 不带 CUDA toolkit,启动日志会 WARN libcudart.so not found; CUDA runtime is not installed 然后降级为纯 NVML 模式。即使加 pid: host + 挂 /usr/local/cuda 让 uprobe attach 成功,PyTorch wheel 自带的 vendored libcudart 与 host system libcudart 是不同 inode,sglang/vLLM 调用永远不触发。
OpenLIT GPU Collector 的 OTEL_GPU_EBPF_ENABLED=true 启用后会 attach 4 个 uprobe 到 libcudart 的 cudaLaunchKernel / cudaMalloc / cudaMemcpy / cudaMemcpyAsync,理论上输出 5 个 metric:gpu.kernel.launch.calls / gpu.kernel.{block,grid}.size / gpu.memory.allocations / gpu.memory.copies。但在 PyTorch 框架下默认完全不工作。
检查发现,sglang::scheduler 进程同时 mmap 三份 libcudart:
inode 4138607 /usr/local/cuda-13.0/.../libcudart.so.13.0.88
inode 6746082 .../site-packages/nvidia/cu13/lib/libcudart.so.13 PyTorch wheel hot path
inode 8272387 .../torchvision.libs/libcudart.faf08d9a.so.13 torchvision 副本
PyTorch 的 .so 编译时把 RPATH=$ORIGIN/../../nvidia/cu13/lib 写死,hot path 全部走 wheel 里那份 inode 6746082。collector 默认只 attach 第一个找到的 system libcudart(host 上 inode 1550270),跟 sglang 用的完全是不同 inode,uprobe 永远不触发。导致vLLM / TGI / TensorRT-LLM 同模式所有基于 PyTorch wheel 的 LLM serving 框架都漏抓。
这里通过**bind-mount **方法让 collector attach sglang 用的同一个 inode:
# 1. 找 sglang::scheduler 加载的 PyTorch wheel libcudart inode
SCHED_PID=$(pgrep -f 'sglang::scheduler' | head -1)
INODE=$(sudo cat "/proc/$SCHED_PID/maps" \| grep -E 'site-packages/nvidia/cu[0-9]+/lib/libcudart' \| awk '{print $5}' | head -1)
PYTORCH_PATH=$(sudo find /var/lib/containerd -inum "$INODE" 2>/dev/null | head -1)# 2. Bind-mount 到 collector 默认查找的 host CUDA 路径
sudo mount --bind "$PYTORCH_PATH" /usr/local/cuda/lib64/libcudart.so.12.8.90
sudo chmod +x /usr/local/cuda/lib64/libcudart.so.12.8.90 # PyTorch wheel 文件 644,collector 拒绝 non-executable# 3. 重启 collector
docker compose restart otel-gpu-collector
这个方法只能临时patch,因为:
- inode 不稳定 ——
docker pull拉新版 image 时 containerd 写新 snapshot,inode 变,bind-mount 失效 - 多 GPU node 部署 —— 每节点 inode 不同,要每节点单独 bind
sglang
参数解释:
--reasoning-parser qwen3—— 解析 Qwen3 的<think>块(thinking 模式)。即使关 thinking 也加上,无副作用--tool-call-parser hermes—— 见下面坑 3 复盘--mem-fraction-static 0.85—— 留 15% 显存给 OS overhead + 故障注入。0.95 太激进,OOM 风险大--max-running-requests 16—— L4 + Qwen3-8B 8K context 下并发上限--context-length 8192—— 不开 YaRN,原生 32K 截到 8K,省显存shm_size: "32gb" + ipc: "host"—— PyTorch DataLoader / NCCL 需要大共享内存
sglang:image: lmsysorg/sglang:latestdeploy:resources:reservations:devices:- driver: nvidiacount: 1capabilities: [gpu]shm_size: "32gb"ipc: "host"ports: ["30000:30000"]volumes:- hf-cache:/root/.cache/huggingfaceenvironment:HF_HUB_ENABLE_HF_TRANSFER: "1"command:- python3- -m- sglang.launch_server- --model-path- Qwen/Qwen3-8B- --host- 0.0.0.0- --port- "30000"- --reasoning-parser- qwen3- --mem-fraction-static- "0.85"- --max-running-requests- "16"- --context-length- "8192"- --tool-call-parser- hermes # ← 改了三次才对healthcheck:test: ["CMD-SHELL", "curl -fsS http://localhost:30000/v1/models | grep -q Qwen3-8B || exit 1"]interval: 15sretries: 60 # 60 × 15s = 15 min wait,够下载 + 加载start_period: 60s
镜像大小为47.3 GB远大于vllm,这是由于:
- Base 用
nvidia/cuda:12.4-devel(含 nvcc + headers)而非runtime(差 ~5GB) - 把 FlashInfer / FlashAttention / xFormers / Triton 全部 attention backend 预装并预编译
- 多个 GPU 架构(Ampere/Ada/Hopper)的 kernel 都打包进去
- AWQ / Marlin / GPTQ / FP8 量化 kernel 全装
第一次部署时 sglang 命令里没加 --tool-call-parser,跑 Strands Agent 的工具调用查询,得到这个奇怪的响应:
$ docker exec strands-agent-app python3 strands_agent.py 'What is 15 * 7?'
[INFO] HTTP Request: POST http://sglang:30000/v1/chat/completions "HTTP/1.1 200 OK"
<tool_call>
{"name": "calculator", "arguments": {"expression": "15 * 7"}}
</tool_call>
[INFO] Response in 1.63s: <tool_call>
{"name": "calculator", "arguments": {"expression": "15 * 7"}}
</tool_call>
模型确实输出了工具调用,但作为 plain text 在 content 字段,而 tool_calls 字段是空的。Strands 的 OpenAIModel provider 检查的是 tool_calls 字段,看是空的就以为没有工具调用,把整个文本当回答。直接 curl sglang 看响应结构确认:
$ curl -s -X POST http://localhost:30000/v1/chat/completions -d '{"model":"Qwen/Qwen3-8B",...,"tools":[{...calculator}]}'
{"choices": [{"message": {"role": "assistant","content": "<tool_call>\n{\"name\": \"calculator\", \"arguments\": {\"expression\": \"15 * 7\"}}\n</tool_call>","tool_calls": [] ← 空},"finish_reason": "tool_calls" ← 但 finish_reason 已识别}]
}
"finish_reason 是 tool_calls 但 tool_calls 数组为空"是 SGLang 没配 tool-call-parser 的典型 footprint:finish_reason 是模型自己说的(生成了 <tool_call> 触发了 stop sequence),而 tool_calls 字段需要一个 parser 把 content 里的文本按某种正则/语法解析进结构化字段。
加 --tool-call-parser qwen3:
sglang serve: error: argument --tool-call-parser: invalid choice: 'qwen3' (choose from 'auto', 'deepseekv3', 'deepseekv31', 'glm', 'glm45', 'gpt-oss', 'hermes', 'kimi_k2', 'llama3', 'mimo', 'mistral', 'pythonic', 'qwen', 'qwen25', 'qwen3_coder', 'step3', ...)
qwen3 不是合法选项。可选里有 qwen、qwen25、qwen3_coder,先试 qwen3_coder(猜测覆盖整个 qwen3 系列)。结果还是 plain text + 空 tool_calls。Qwen3_coder 是给 qwen3 的代码模型变体设计的,输出格式(应该是 markdown code blocks)和 base instruct 不一样。
第三次试 hermes。Hermes 是 Nous Research 早期推的 instruction format,不少模型抄了它的 <tool_call>...</tool_call> 标签风格。试出来的:
$ curl -s -X POST http://localhost:30000/v1/chat/completions -d '{"tools":[...]}'
{"choices": [{"message": {"role": "assistant","content": null, ← null 表示纯 tool 调用"tool_calls": [{"id": "call_71b331b5fda443bab73d3477","type": "function","function": {"name": "calculator","arguments": "{\"expression\": \"15 * 7\"}"}}]}}]
}
结构化的 tool_calls,OpenAI 标准格式,Strands 立刻能识别。再发一次 Strands 查询:
$ docker exec strands-agent-app python3 strands_agent.py 'What is 15 * 7? Then tell me the weather in Tokyo.'
[INFO] User query: What is 15 * 7? Then tell me the weather in Tokyo.
[INFO] HTTP Request: POST http://sglang:30000/v1/chat/completions "HTTP/1.1 200 OK"Tool #1: calculatorTool #2: get_weather
[INFO] HTTP Request: POST http://sglang:30000/v1/chat/completions "HTTP/1.1 200 OK"
15 * 7 equals 105. The weather in Tokyo is 22°C, Sunny, with 60% humidity.
[INFO] Response in 5.03s
5 秒完成多步多工具推理。
Strands Agent 集成
应用代码app/strands_agent.py ,包含 OpenLIT 接入、3 个 tool、Agent 配置、CLI 入口:
"""Multi-step + multi-tool Strands Agent demo for OpenLIT 3-layer observability test."""
import os
import time
import logging# 1. Init OpenLIT FIRST (before importing strands) to ensure auto-instrumentation
import openlit
openlit.init(otlp_endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://openlit:4318"),application_name=os.getenv("OTEL_SERVICE_NAME", "strands-agent-demo"),environment=os.getenv("OTEL_DEPLOYMENT_ENVIRONMENT", "test"),
)# 2. Import Strands AFTER openlit.init
from strands import Agent, tool
from strands.models.openai import OpenAIModellogging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("strands-demo")# 3. Define tools
@tool
def calculator(expression: str) -> str:"""Evaluate a mathematical expression. Supports +, -, *, /, %, parentheses.Args:expression: A math expression like "2 + 2 * 3" or "(15 - 5) / 2""""try:allowed_chars = set("0123456789+-*/().% ")if not all(c in allowed_chars for c in expression):return f"Error: invalid characters in expression: {expression!r}"result = eval(expression, {"__builtins__": {}}, {})return f"Result: {result}"except Exception as e:return f"Error: {e}"@tool
def get_weather(city: str) -> str:"""Get current weather for a city (mock implementation).Args:city: City name like "Tokyo" or "London""""mock_data = {"tokyo": "22°C, Sunny, 60% humidity","london": "12°C, Cloudy, 80% humidity","new york": "18°C, Partly cloudy, 50% humidity","san francisco": "16°C, Foggy, 75% humidity",}time.sleep(0.3)return mock_data.get(city.lower(), f"No weather data for {city}")@tool
def search_knowledge(topic: str) -> str:"""Search internal knowledge base for a topic (mock implementation).Args:topic: Topic to search like "AI observability" or "kubernetes""""mock_kb = {"ai observability": "AI observability is monitoring AI systems via traces, metrics, logs.","qwen3": "Qwen3 is Alibaba's latest LLM family released in 2025.","ebpf": "eBPF is Linux kernel tech for safe in-kernel programs without recompilation.",}time.sleep(0.2)return mock_kb.get(topic.lower(), f"No KB entry for '{topic}'")# 4. Configure SGLang as OpenAI-compatible model
model = OpenAIModel(client_args={"base_url": os.getenv("LLM_BASE_URL", "http://sglang:30000/v1"),"api_key": "EMPTY",},model_id="Qwen/Qwen3-8B",params={"temperature": 0.7,"max_tokens": 1024,"extra_body": {"chat_template_kwargs": {"enable_thinking": False}},},
)# 5. Create agent
agent = Agent(model=model,tools=[calculator, get_weather, search_knowledge],system_prompt=("You are a helpful assistant. Use the provided tools to answer questions. ""For multi-step problems, call tools in sequence. Always show your reasoning."),trace_attributes={"session.id": os.getenv("SESSION_ID", "demo-session-1"),"user.id": os.getenv("USER_ID", "test-user"),},
)def run_query(query: str):"""Run a single agent query and return response."""log.info("User query: %s", query)start = time.time()response = agent(query)elapsed = time.time() - startlog.info("Response in %.2fs: %s", elapsed, response)return response
关键点
OpenLIT init 顺序敏感。第一行就 import openlit + openlit.init(),第二行才 from strands import Agent。如果反过来:
- Strands 已 import 进 sys.modules
- openlit.init 调用
opentelemetry.instrumentation.strands的 instrument(),它通过wrapt.wrap_function_wrapper修改 strands 模块的对象 - 但 Strands 自己已经把内部引用绑定好了,patch 会被 miss
类似情况在 LangChain / OpenAI SDK / Anthropic SDK 都存在,OpenTelemetry auto-instrumentation 是在 import time 起作用的。记住这个顺序,所有 LLM observability SDK 都同样。
extra_body 把 OpenAI 不认识的字段透传给上游。Qwen3 通过 chat_template_kwargs 接受 enable_thinking 开关,但这是 sglang 扩展,OpenAI Python SDK 默认会拒绝陌生字段。extra_body 是 OpenAI SDK 提供的"逃生通道",把任意 dict 直接拼进请求 body。
api_key="EMPTY"。SGLang 默认不验证 token,但 OpenAI SDK 一定要传 api_key,传空字符串会报错。"EMPTY" 是社区约定的占位符。
trace_attributes 在创建 agent 时声明。这些会作为 resource attribute 加到 invoke_agent 的根 span 上,子 span 通过 OTel context 传播继承。后面 ClickHouse 查询里 mapValues(SpanAttributes) 能看到 'demo-session-1','test-user' 这两个值。
Dockerfile 与 build
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \curl ca-certificates iproute2 \&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY strands_agent.py .
ENV PYTHONUNBUFFERED=1
CMD ["python3", "strands_agent.py"]
requirements.txt:
strands-agents>=0.1.0
strands-agents-tools>=0.1.0
openlit>=1.30.0
openai>=1.50.0
httpx>=0.27.0
docker compose up -d --build strands-agent-app 启动后日志:
==> docker logs strands-agent-app
Strands Agent Demo (Ctrl+D to exit)
三层数据验证
看具体数据之前,先把"三层数据具体由哪个组件产出"这件事讲清楚,因为本次部署里 openlit-controller 实际上没在贡献任何 trace 数据,三层观测有效负载是 SDK + gpu-collector 联合扛起来的:
| 层 | 数据载体 | 来源(本次部署) | 备选来源(未启用) |
|---|---|---|---|
| L3 Agent | otel_traces 表 / invoke_agent / execute_event_loop_cycle / execute_tool span |
openlit.init() SDK auto-instrument Strands 框架 |
Controller SDK 注入(需 Dashboard click Enable) |
| L2 LLM API | otel_traces 表 / chat <model> span / gen_ai.* 属性 |
同上 SDK,OpenLIT auto-instrument 给 LLM 调用补 gen_ai.* 标准属性 |
Controller eBPF 在 tcp_connect kprobe 拦截 LLM API 流量 |
| L1 GPU NVML | otel_metrics_gauge / otel_metrics_sum / 7 个 hw.gpu.* metric |
otel-gpu-collector 通过 NVML / libnvidia-ml.so 每 10s 采样 |
DCGM exporter / nvidia-smi pmon |
| L1 GPU eBPF | otel_metrics_sum / otel_metrics_histogram / gpu.kernel.* / gpu.memory.* 5 个 metric |
默认不出数据 —— PyTorch wheel libcudart 与 host libcudart 是不同 inode,需 bind-mount workaround,详见 ### eBPF CUDA tracing 的真实定位 |
NVIDIA Nsight Systems profile-once / Pixie |
SDK 一个组件覆盖了 L2 + L3,gpu-collector NVML 部分覆盖了 L1 的 NVML 子集。
下面四个小节按顺序验证 L3 → L2 → L1(NVML) → 跨层关联,最后是 tool 调用统计。L1 eBPF 数据需要 bind-mount workaround,见后文 eBPF CUDA tracing 定位。
ClickHouse 查询可以如下运行:
docker exec -it clickhouse clickhouse-client \--user=default --password=OPENLIT --database=openlit
OpenLIT 用 OpenTelemetry ClickHouse exporter 标准 schema:
- trace 在
otel_traces(关键列Timestamp/TraceId/SpanId/ParentSpanId/SpanName/ServiceName/Duration/SpanAttributes Map(String,String)) - metric 按类型分 5 张表
otel_metrics_{gauge,sum,histogram,exponential_histogram,summary}。查询 Map 字段用m['key']语法,时间过滤一定要带(trace 表按 Timestamp 分区)。
Layer 3 单查询完整 trace
$ docker exec strands-agent-app python3 strands_agent.py 'What is 15 * 7? Then tell me the weather in Tokyo.'
2026-05-27 14:41:19,762 [INFO] User query: What is 15 * 7? Then tell me the weather in Tokyo.
2026-05-27 14:41:20,006 [INFO] HTTP Request: POST http://sglang:30000/v1/chat/completions "HTTP/1.1 200 OK"Tool #1: calculatorTool #2: get_weather
2026-05-27 14:41:22,987 [INFO] HTTP Request: POST http://sglang:30000/v1/chat/completions "HTTP/1.1 200 OK"
15 * 7 equals 105. The weather in Tokyo is 22°C, Sunny, with 60% humidity.
2026-05-27 14:41:24,790 [INFO] Response in 5.03s
5 秒后查 ClickHouse 对应的 trace:
WITH (SELECT TraceId FROM otel_tracesWHERE ServiceName='strands-agent-demo'AND Timestamp > now() - INTERVAL 10 MINUTEORDER BY Timestamp DESC LIMIT 1) AS tid
SELECT SpanName,ParentSpanId != '' AS has_parent,Duration/1e6 AS dur_ms,StatusCode
FROM otel_traces
WHERE TraceId=tid
ORDER BY Timestamp;
┃ SpanName ┃ has_parent ┃ dur_ms ┃ StatusCode ┃
─────────────────────────────────────────────────────────────────────────
│ invoke_agent Strands Agents │ 0 │ 7154.420129 │ Ok │
│ execute_event_loop_cycle │ 1 │ 3265.370468 │ Ok │
│ chat │ 1 │ 3063.230355 │ Ok │
│ chat Qwen/Qwen3-8B │ 1 │ 3036.540706 │ Ok │
│ POST │ 1 │ 85.152029 │ Unset │
│ execute_tool search_knowledge │ 1 │ 201.235209 │ Ok │
│ execute_tool calculator │ 1 │ 0.997121 │ Ok │
│ execute_event_loop_cycle │ 1 │ 3887.768088 │ Ok │
│ chat │ 1 │ 3887.349984 │ Ok │
│ chat Qwen/Qwen3-8B │ 1 │ 3874.718552 │ Ok │
│ POST │ 1 │ 86.013879 │ Unset │
11 个 span 同一 TraceId,按时间线展开正好对应:
这个 trace 完整证明了 Layer 3 (Agent) 数据正确:
- 根 span 没有 parent (has_parent=0),对应一次完整的 user query
- 第一轮 event_loop_cycle 包含一次 LLM 调用 + 两个 tool 调用
- 第二轮 event_loop_cycle 只有一次 LLM 调用(合成最终答案,不需要再调 tool)
- chat 和 chat Qwen/Qwen3-8B 是同一个 LLM 调用的两层 wrapping,前者是 Strands 框架抽象,后者是 OpenLIT 加的带
gen_ai.*属性的 span
在openlit上查看trace

单条查询只能证明"链路通",但不足以让数据有覆盖度。为了让 token 用量、工具调用频率、GPU 活跃模式都有可统计的样本,再跑 5 个查询,每个有意覆盖一种"工具使用形态":
| # | Query | 期望工具组合 |
|---|---|---|
| 1 | What is 25 * 4 + 100? |
calculator 单次 |
| 2 | Compare weather in Tokyo and London. |
get_weather 调 2 次(同一工具不同参数) |
| 3 | Calculate 50 * 3, then tell me about ai observability. |
calculator + search_knowledge 串行 |
| 4 | If Tokyo is 22 degrees and London is 12 degrees, what is the difference? |
推理为主 + calculator 验算 |
| 5 | Search ebpf, then calculate the area of a circle with radius 7 using 3.14 for pi. |
search_knowledge + calculator |
执行方式很直接,串行跑是为了让 trace 时间线清晰对应:
QUERIES=("What is 25 * 4 + 100?""Compare weather in Tokyo and London.""Calculate 50 * 3, then tell me about ai observability.""If Tokyo is 22 degrees and London is 12 degrees, what is the difference?""Search ebpf, then calculate the area of a circle with radius 7 using 3.14 for pi."
)
for Q in "${QUERIES[@]}"; dodocker exec strands-agent-app python3 strands_agent.py "$Q"sleep 2
done
sleep 15 # BatchSpanProcessor 异步刷盘
每个 query 的实际响应:
Scenario 1: The result of 25 * 4 + 100 is 200.
Scenario 2: Tokyo: 22°C, Sunny, 60% humidity. London: 12°C, Cloudy, 80% humidity.
Scenario 3: 50 * 3 = 150. AI observability is monitoring AI systems via traces, metrics, logs.
Scenario 4: The difference between the temperatures in Tokyo and London is 10 degrees.
Scenario 5: The area of a circle with radius 7 (using 3.14 for π) is 153.86. eBPF is ...
跑完 5 个功能场景的工具使用分布:
SELECT replaceOne(SpanName, 'execute_tool ', '') AS tool, count() AS calls
FROM otel_traces
WHERE ServiceName='strands-agent-demo' AND SpanName LIKE 'execute_tool%'
GROUP BY tool ORDER BY calls DESC;
┃ tool ┃ calls ┃
────────────────────────────
│ calculator │ 18 │
│ get_weather │ 3 │
│ search_knowledge │ 3 │
calculator 被调用最多(18 次)符合预期:5 个 scenario 里有 4 个含算术。get_weather 和 search_knowledge 各 3 次。从这个分布出发,可以做成本归因(哪个 tool 触发的下游调用最贵)、性能优化(哪个 tool 是慢路径)。
Layer 2 gen_ai.* 语义约定
OpenTelemetry 的 GenAI 语义约定(Semantic Conventions for GenAI)规定了一组标准属性名,让所有 LLM observability 工具能用同一套字段名。OpenLIT SDK 在 chat <model> span 上贴的就是这些:
SELECT SpanName,SpanAttributes['gen_ai.request.model'] AS model,SpanAttributes['gen_ai.usage.input_tokens'] AS in_tok,SpanAttributes['gen_ai.usage.output_tokens'] AS out_tok,Duration/1e6 AS dur_ms
FROM otel_traces
WHERE has(mapKeys(SpanAttributes), 'gen_ai.request.model')AND Timestamp > now() - INTERVAL 10 MINUTE
ORDER BY Timestamp DESC
LIMIT 5;
┃ SpanName ┃ model ┃ in_tok ┃ out_tok ┃ dur_ms ┃
──────────────────────────────────────────────────────────────────────
│ chat Qwen/Qwen3-8B │ Qwen/Qwen3-8B │ 511 │ 31 │ 1891.081479 │
│ chat │ Qwen/Qwen3-8B │ 511 │ 31 │ 1900.053166 │
│ chat Qwen/Qwen3-8B │ Qwen/Qwen3-8B │ 432 │ 44 │ 2763.196803 │
│ chat │ Qwen/Qwen3-8B │ 432 │ 44 │ 2821.968817 │
│ invoke_agent │ Qwen/Qwen3-8B │ 943 │ 75 │ 5025.431915 │
注意 invoke_agent 这一行 in_tok=943, out_tok=75 是累加的(因为 OpenLIT 把根 span 上也加了 token 总计,方便不展开 trace 也能拿成本数据)。
跑完 5 个 functional scenarios 后的累计:
SELECT count() AS llm_calls,sum(toUInt32OrZero(SpanAttributes['gen_ai.usage.input_tokens'])) AS total_in,sum(toUInt32OrZero(SpanAttributes['gen_ai.usage.output_tokens'])) AS total_out,round(avg(Duration)/1e6, 2) AS avg_dur_ms
FROM otel_traces
WHERE has(mapKeys(SpanAttributes), 'gen_ai.request.model');
┃ llm_calls ┃ total_in ┃ total_out ┃ avg_dur_ms ┃
─────────────────────────────────────────────────
│ 104 │ 55662 │ 5997 │ 3548.88 │
104 次 LLM 调用、55,662 input tokens、5,997 output tokens、平均 3.55s/call。这种聚合查询是后续做 cost dashboard / 性能 SLO 的基础。
Layer 1 GPU metrics
这里的 7 个
hw.gpu.*metric 全部来自 NVML(NVIDIA Management Library)派生路径,跟OTEL_GPU_EBPF_ENABLED开关无关。即使关掉 eBPF,这些指标照样产出。
GPU metrics 落到 ClickHouse 的 5 个不同表(按 metric type 分),其中 gauge 类型最常用:
SHOW TABLES;
-- otel_metrics_exponential_histogram
-- otel_metrics_gauge ← 大部分 GPU 指标在这里
-- otel_metrics_histogram
-- otel_metrics_sum
-- otel_metrics_summary
发现 GPU 在哪一类:
SELECT MetricName, count() AS samples
FROM otel_metrics_gauge
WHERE TimeUnix > now() - INTERVAL 15 MINUTE AND MetricName LIKE 'hw.gpu%'
GROUP BY MetricName ORDER BY samples DESC;
┃ MetricName ┃ samples ┃
───────────────────────────────────────
│ hw.gpu.utilization │ 270 │
│ hw.gpu.clock.graphics │ 90 │
│ hw.gpu.power.draw │ 90 │
│ hw.gpu.power.limit │ 90 │
│ hw.gpu.clock.memory │ 90 │
│ hw.gpu.temperature │ 90 │
│ hw.gpu.memory.utilization │ 90 │
7 种 hw.gpu.* 指标,都是 OpenTelemetry semantic conventions 标准命名(不是 NVIDIA DCGM 自创的名字)。hw.gpu.utilization 90 vs 270 的差异是因为 utilization 每次采样有 3 个数据点(compute/memory/encoder 三种 utilization 子类型),其他每次 1 个。
样本值:
SELECT MetricName, ServiceName, Value,ResourceAttributes['host.name'] AS host
FROM otel_metrics_gauge
WHERE TimeUnix > now() - INTERVAL 5 MINUTEAND MetricName LIKE 'hw.gpu%'
ORDER BY TimeUnix DESC LIMIT 10;
┃ MetricName ┃ ServiceName ┃ Value ┃ host ┃
─────────────────────────────────────────────────────────────────────────────────
│ hw.gpu.clock.memory │ openlit-gpu-collector │ 6251 │ openlit-test-g6 │ ← MHz
│ hw.gpu.clock.graphics │ openlit-gpu-collector │ 2040 │ openlit-test-g6 │ ← MHz
│ hw.gpu.power.limit │ openlit-gpu-collector │ 72 │ openlit-test-g6 │ ← W
│ hw.gpu.power.draw │ openlit-gpu-collector │ 29.204 │ openlit-test-g6 │ ← W
│ hw.gpu.temperature │ openlit-gpu-collector │ 55 │ openlit-test-g6 │ ← °C
│ hw.gpu.memory.utilization │ openlit-gpu-collector │ 0 │ openlit-test-g6 │ ← 0-1 比率
│ hw.gpu.utilization │ openlit-gpu-collector │ 0 │ openlit-test-g6 │ ← 0-1 比率
L4 power.limit=72W 是 g6.xlarge 这一档 instance 的 NVIDIA 默认 TDP cap。idle 状态下 power.draw 大约 29W,开始推理后跳到 72W。
跨 Layer 关联
把 GPU 指标按时间聚合,可以直接看到 Agent 活动节奏:
SELECT toStartOfInterval(TimeUnix, INTERVAL 30 SECOND) AS ts,MetricName,max(Value) AS val
FROM otel_metrics_gauge
WHERE MetricName IN ('hw.gpu.power.draw', 'hw.gpu.temperature')AND TimeUnix > now() - INTERVAL 10 MINUTE
GROUP BY ts, MetricName
ORDER BY ts DESC, MetricName;
┃ ts ┃ MetricName ┃ val ┃
────────────────────────────────────────────────────
│ 2026-05-27 14:41:00 │ hw.gpu.power.draw │ 71.837 │ ← 推理中
│ 2026-05-27 14:41:00 │ hw.gpu.temperature │ 58 │
│ 2026-05-27 14:42:00 │ hw.gpu.power.draw │ 29.354 │ ← idle
│ 2026-05-27 14:42:00 │ hw.gpu.temperature │ 56 │
│ 2026-05-27 14:43:00 │ hw.gpu.power.draw │ 72.008 │ ← 推理中,温度上升
│ 2026-05-27 14:43:00 │ hw.gpu.temperature │ 63 │
│ 2026-05-27 14:44:00 │ hw.gpu.power.draw │ 30.352 │ ← idle
│ 2026-05-27 14:44:00 │ hw.gpu.temperature │ 62 │
│ 2026-05-27 14:49:00 │ hw.gpu.power.draw │ 72.164 │ ← 持续推理
│ 2026-05-27 14:49:00 │ hw.gpu.temperature │ 65 │
│ 2026-05-27 14:50:00 │ hw.gpu.power.draw │ 72.307 │
│ 2026-05-27 14:50:00 │ hw.gpu.temperature │ 72 │ ← 温度峰值
│ 2026-05-27 14:51:00 │ hw.gpu.power.draw │ 31.358 │ ← 测试完成,降温
│ 2026-05-27 14:51:00 │ hw.gpu.temperature │ 67 │
30W ↔ 72W 的 power 跳变清晰对应 Agent 活动(每次 invoke_agent 都触发推理)。温度 55 → 72°C 的爬升揭示了 sustained load 下的散热表现。
故障注入
Fault 1: cudaMalloc churn
sglang --mem-fraction-static 0.85 已占 20.6GB / 23GB,外部 PyTorch 容器想分 8GB tensor 会触发 silent OOM NVML 显存指标完全没动。既然外部分配大块 tensor 注入不进去,通过反复 alloc + 立刻 free 的 churn 模式测试。这种瞬时的分配 NVML 因为采样间隔 10s 几乎抓不到,但 eBPF 的 gpu.memory.allocations counter 累加每一次 cudaMalloc 字节数,churn 完全可见。
host 上执行如下代码,因为 bind-mount 让 collector 抓的就是 host CUDA path 上挂载的 PyTorch wheel libcudart:
import ctypes
lib = ctypes.CDLL('/usr/local/cuda/lib64/libcudart.so')
SIZE = 1024 * 1024 * 1024 # 1 GB
for _ in range(200): # 200 次 churnptr = ctypes.c_void_p()lib.cudaMalloc(ctypes.byref(ptr), SIZE)lib.cudaFree(ptr)
5 秒跑完。30 秒后查数据:
-- NVML hw.gpu.memory.usage 时序 (期望平稳)
SELECT toStartOfMinute(TimeUnix) AS minute, max(Value)/1024/1024 AS used_MiB
FROM otel_metrics_sum
WHERE MetricName='hw.gpu.memory.usage' AND TimeUnix > now() - INTERVAL 5 MINUTE
GROUP BY minute ORDER BY minute;
┃ minute ┃ used_MiB ┃
─────────────────────────────────────
│ 2026-05-28 10:31:00 │ 21083.375 │ ← Qwen3-8B baseline
│ 2026-05-28 10:32:00 │ 21083.375 │
│ 2026-05-28 10:33:00 │ 21083.375 │
│ 2026-05-28 10:34:00 │ 21083.375 │
│ 2026-05-28 10:35:00 │ 22298.5625 │ ← churn 期间一个采样点抓到部分 alloc
│ 2026-05-28 10:36:00 │ 21083.4375│ ← 又回到 baseline
-- eBPF gpu.memory.allocations 累计
SELECT toStartOfMinute(TimeUnix) AS minute, max(Value)/1024/1024/1024 AS cum_GiB
FROM otel_metrics_sum
WHERE MetricName='gpu.memory.allocations' AND TimeUnix > now() - INTERVAL 5 MINUTE
GROUP BY minute ORDER BY minute;
┃ minute ┃ cum_GiB ┃
──────────────────────────────────
│ 2026-05-28 10:35:00 │ 123 │ ← churn 进行中,半数 alloc 已记录
│ 2026-05-28 10:36:00 │ 200 │ ← 完整 200 GiB 累计可见
对比结论:
- NVML:10s 采样窗口下,churn 在 10:35 那一分钟最高刚刚抓到 1215 MiB 增量,10:36 已经回到 baseline。如果 NVML 采样间隔放到 30s,可能完全错过这次故障
- eBPF:每次 cudaMalloc 都被 uprobe 捕获并累加,5 秒内显示出 200 GiB 累计分配,故障的"频次"特征完整呈现
这正是 OpenLIT eBPF 在覆盖好的场景下能补足 NVML 盲区的真实价值。
Fault 2: toxiproxy 注入 LLM API 延迟
用 toxiproxy(Shopify 出品,专为故障注入设计)作为透明代理。strands-agent-app 通过 env 变量切到 proxy,sglang 完全不动。
# 加到 compose
toxiproxy:image: ghcr.io/shopify/toxiproxy:2.9.0command: ["-host=0.0.0.0"]ports:- "8474:8474" # toxiproxy admin API- "30001:30001" # proxy listening port (转发到 sglang:30000)networks: [default]
# 1. 创建 proxy
docker exec toxiproxy /toxiproxy-cli create --listen 0.0.0.0:30001 --upstream sglang:30000 sglang_proxy# 2. 注入 500ms downstream latency (sglang 响应回程)
docker exec toxiproxy /toxiproxy-cli toxic add -t latency -n lat_down -a latency=500 sglang_proxy
# Added downstream latency toxic 'lat_down' on proxy 'sglang_proxy'# 3. 把 strands-agent-app 一次性指向 proxy 跑查询 (用 env 临时覆盖)
docker exec strands-agent-app sh -c \'LLM_BASE_URL=http://toxiproxy:30001/v1 python3 strands_agent.py "What is 1 + 1?"'# 4. 移除 toxic
docker exec toxiproxy /toxiproxy-cli toxic delete -n lat_down sglang_proxy
数据对比 (ClickHouse 30 秒滚动桶):
SELECT toStartOfInterval(Timestamp, INTERVAL 30 SECOND) AS bucket,count() AS calls,round(avg(Duration)/1e6, 1) AS avg_ms,round(min(Duration)/1e6, 1) AS min_ms,round(max(Duration)/1e6, 1) AS max_ms
FROM otel_traces
WHERE has(mapKeys(SpanAttributes), 'gen_ai.request.model')AND SpanName='chat Qwen/Qwen3-8B'AND Timestamp > now() - INTERVAL 5 MINUTE
GROUP BY bucket ORDER BY bucket;
┃ bucket ┃ calls ┃ avg_ms ┃ min_ms ┃ max_ms ┃
─────────────────────────────────────────────────────────────
│ 2026-05-28 10:34:00 │ 2 │ 1788.6 │ 1592.2 │ 1985 │ ← 之前测试,正常
│ 2026-05-28 10:37:00 │ 8 │ 1118.9 │ 904.2 │ 1347.7 │ ← Baseline (经 proxy,无 toxic)
│ 2026-05-28 10:38:00 │ 2 │ 1156.2 │ 961.6 │ 1350.8 │ ← 仍 baseline
│ 2026-05-28 10:38:30 │ 6 │ 1608.7 │ 1358.9 │ 1865.8 │ ← latency 注入后 +490ms ★
注入 500ms downstream latency 后 LLM call avg duration 从 1118.9ms 涨到 1608.7ms,差 +489.8ms,几乎完美对应注入值。
注意事项:
- toxic 的
-d(downstream) vs-u(upstream):downstream 是 sglang → strands-agent-app 方向(响应回程),upstream 是 strands-agent-app → sglang 方向(请求过去)。注入大模型的真实场景通常 downstream 更敏感(响应数据量大,长链接 streaming 时延堆积) - toxiproxy 不需要任何容器特权:故障注入靠用户态代理,比 tc / netem 干净得多。
network_mode: bridge+ports暴露足够 - 可以同时注入多种 toxic:除了
latency,还有bandwidth(限带宽)、slow_close(响应中断)、timeout(超时)、reset_peer(RST 包)、limit_data(数据截断) 等。覆盖几乎所有 LLM API 在生产可能遇到的网络故障
Fault 3: ZeroDivisionError
构造一个合法的数学表达式让 LLM 接受并调用 tool,但表达式本身在数学上未定义触发 Python ZeroDivisionError。calculator 的现有实现已经能 catch 异常并返回 Error 字串,无需改代码:
@tool
def calculator(expression: str) -> str:try:...result = eval(expression, {"__builtins__": {}}, {})return f"Result: {result}"except Exception as e: # ← ZeroDivisionError 进这里return f"Error: {e}"
执行:
docker exec strands-agent-app python3 strands_agent.py \'Use the calculator tool to compute 5 / (3 - 3). Tell me what happens.'
输出:
[INFO] User query: Use the calculator tool to compute 5 / (3 - 3). Tell me what happens.
[INFO] HTTP Request: POST http://sglang:30000/v1/chat/completions "200 OK"Tool #1: calculator ← Qwen3 决定调 tool
[INFO] HTTP Request: POST http://sglang:30000/v1/chat/completions "200 OK"
The expression `5 / (3 - 3)` results in a division by zero error
because the denominator becomes zero. This is mathematically undefined.← LLM 读到 tool 错误结果后的最终回答
trace 完整链:
┃ SpanName ┃ has_parent ┃ dur_ms ┃ StatusCode ┃
─────────────────────────────────────────────────────────────────────
│ invoke_agent Strands Agents │ 0 │ 3609.093198 │ Ok │ ← root
│ execute_event_loop_cycle │ 1 │ 1615.489162 │ Ok │ ← cycle 1: 决定调 tool
│ chat │ 1 │ 1613.792045 │ Ok │
│ chat Qwen/Qwen3-8B │ 1 │ 1592.230152 │ Ok │
│ POST │ 1 │ 85.138526 │ Unset │
│ execute_tool calculator │ 1 │ 0.979389 │ Ok │ 0.98ms 抛 ZeroDivisionError
│ execute_event_loop_cycle │ 1 │ 1992.756107 │ Ok │ ← cycle 2: 读 tool 结果生成最终回答
│ chat │ 1 │ 1992.386867 │ Ok │
│ chat Qwen/Qwen3-8B │ 1 │ 1985.00169 │ Ok │
│ POST │ 1 │ 120.973835 │ Unset │
StatusCode 全是 Ok 是 Strands 的语义选择:tool 函数没 throw(返回了字符串),从 Strands 角度看是"成功执行",仅 tool 返回值内容包含错误信息。trace 上找错误:
SELECT SpanAttributes FROM otel_traces
WHERE Timestamp > now() - INTERVAL 3 MINUTEAND SpanName='execute_tool calculator'
ORDER BY Timestamp DESC LIMIT 1 FORMAT Vertical;
SpanAttributes: {'gen_ai.event.start_time':'2026-05-28T10:34:21.441728+00:00','gen_ai.event.end_time':'2026-05-28T10:34:21.442692+00:00','gen_ai.operation.name':'execute_tool','gen_ai.system':'strands-agents','gen_ai.tool.call.id':'call_37f674d81af740c58ea63052','gen_ai.tool.description':'Evaluate a mathematical expression. ...','gen_ai.tool.json_schema':'{"properties":{"expression":...}}','gen_ai.tool.name':'calculator','gen_ai.tool.status':'success', ← 这是 "success"'session.id':'demo-session-1','user.id':'test-user'
}
tool 错误信息默认不在 span attribute 里。OpenLIT Strands instrumentation 出于隐私 + 数据膨胀考虑,默认不抓 tool 输入/输出文本。要看 ZeroDivisionError 字串只能:
- 看 LLM 的 final response("division by zero error")—— 间接证据,但能确认错误传到了 LLM
注意事项:
gen_ai.tool.status: success是 Strands 设计:tool 函数没 throw 异常 = success。要让"返回值是 Error 字串"也算 status=error,需在@tool函数里抛异常而不是返回字串,但这又让 LLM 看不到错误内容。设计权衡- 如果想测破坏性故障(tool 真的崩溃 + Strands 框架 retry / fallback 路径),改 calculator 抛 exception 即可,trace 会自然出现 StatusCode=Error
- 生产 LLM Agent 调试 tool 错误:trace 上看
LLM final response文本是最快路径。要做精确归因哪次调用哪个参数失败OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT
总结
整个测试达成了最初设定的目标,即在真实 AI Agent 场景下,OpenLIT 三层可观测性栈(GPU + LLM API + Agent)能产出可关联的全链路 trace,并通过 ClickHouse 查询验证三层数据均到位。
如果要把这套方案推到生产,下一步应该考虑:
- 用 OpenLIT 的 destinations 把数据 fan-out 到 Datadog / Grafana Cloud / 自己的 Tempo
- 接 Prometheus metrics scrape,用现有 alerting 体系做 SLO(token 消耗 > X / 延迟 P99 > Y)
- 多节点部署:每个 GPU node 跑一份 gpu-collector,OpenLIT Server + ClickHouse 集中
- ClickHouse 数据保留策略:测试 stack 用本地 volume,生产应配 TTL + S3 冷存储
参考文档
-
GitHub 仓库官方 docker-compose 模板:https://github.com/openlit/openlit
-
Strands Agents 集成:https://docs.openlit.io/latest/sdk/integrations/strands —— 强调
import openlit; openlit.init()的顺序 -
GPU Collector 安装与配置:https://docs.openlit.io/latest/gpu-collector/installation / /configuration —— 含
OTEL_GPU_EBPF_ENABLED等环境变量列表 -
GenAI 语义约定(
gen_ai.request.model/gen_ai.usage.input_tokens等所有 attribute 名字):https://opentelemetry.io/docs/specs/semconv/gen-ai/ -
BPF CO-RE reference guide:https://nakryiko.com/posts/bpf-core-reference-guide/
-
nvidia-container-toolkit 安装与 docker daemon 配置:https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html
-
ClickHouse 24.4 文档(Map 类型查询、分区策略、ulimits 调优):https://clickhouse.com/docs/en/
