当前位置: 首页 > news >正文

基于OpenLIT实现三层 LLM Agent 可观测性的实践

本文端到端验证了 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 后端,开箱即用。它由三套独立但互补的组件构成:

graph LRsubgraph App["应用进程"]SDK[OpenLIT SDK<br/>主动接入]endsubgraph Host["主机层"]CTL[OpenLIT Controller<br/>eBPF 网络拦截]GPU[opentelemetry-gpu-collector<br/>NVML + eBPF CUDA]endsubgraph Server["OpenLIT Server"]OTL[内置 OTel Collector<br/>4317 gRPC / 4318 HTTP]DASH[Dashboard :3000]endsubgraph StorageCH[(ClickHouse)]endSDK -- OTLP --> OTLCTL -- OTLP --> OTLGPU -- OTLP --> OTLOTL --> CHDASH --> CH

三种接入方式按侵入性递增对应不同场景:

  • 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。

graph TBAPP[应用进程<br/>PyTorch / vLLM / SGLang]LIBC[libcudart.so]DRV[NVIDIA GPU Driver]HW[L4 GPU]APP -->|cudaLaunchKernel| LIBCLIBC -->|ioctl| DRVDRV -->|MMIO| HWUPROBE[eBPF uprobe<br/>挂在 libcudart 符号上]LIBC -.-> UPROBEUPROBE -->|perf event| AGENT[gpu-collector]AGENT -->|OTLP| BACKEND[OpenLIT Server]

注意事项:

  • 必须 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=[...]) 创建 agent
  • agent("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(...) 调用的根 span
  • execute_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_default172.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 与它们的端口/网络命名空间如下图所示

graph TBsubgraph H["新实例 openlit-test-g6 / host namespace"]subgraph N["compose_default network 172.18.0.0/16 (docker bridge)"]CH[clickhouse<br/>:8123 :9000<br/>volume clickhouse-data]OL[openlit<br/>:3000 :4317 :4318<br/>volume openlit-data<br/>含 OpAMP supervisor]SG[sglang<br/>:30000<br/>shm 32G + ipc:host<br/>volume hf-cache]APP[strands-agent-app<br/>OpenLIT SDK 注入]endCTL[openlit-controller<br/>privileged + pid:host<br/>4 个 host volume mount]GC[otel-gpu-collector<br/>privileged + pid:host<br/>+ network_mode:host<br/>+ 挂载 host CUDA + /]endAPP --> SGAPP -- OTLP HTTP openlit:4318 --> OLCTL -- OTLP HTTP openlit:4318<br/>(via docker socket / host net) --> OLGC -- OTLP gRPC localhost:4317<br/>(host loopback → docker port forward) --> OLOL --> CHstyle GC fill:#fefstyle CTL fill:#fef

每个容器的作用、依赖、网络命名空间:

容器 角色 端口 / 协议 网络/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 的全链路。

sequenceDiagramautonumberactor U as 用户participant SA as strands-agent-appparticipant SDK as OpenLIT SDK<br/>(in-process)participant SL as sglang<br/>Qwen3-8Bparticipant CALC as @tool calculatorparticipant WTH as @tool get_weatherparticipant OTL as openlit OTel Collectorparticipant CH as ClickHouseU->>SA: querySA->>SDK: 创建 invoke_agent spanSDK->>SL: chat / chat Qwen/Qwen3-8B span<br/>POST /v1/chat/completionsSL-->>SDK: tool_calls=[calculator,get_weather]par 并行执行 toolsSDK->>CALC: execute_tool calculator spanCALC-->>SDK: 105andSDK->>WTH: execute_tool get_weather spanWTH-->>SDK: 22°C, SunnyendSDK->>SL: 第二轮 chat span (含 tool 结果)SL-->>SDK: 最终回答文本SDK-->>SA: responseSA-->>U: responseNote over SDK,OTL: 异步刷盘<br/>(BatchSpanProcessor)SDK->>OTL: OTLP HTTP /v1/tracesOTL->>CH: INSERT into otel_tracesNote over SL: 与此同时SL-->>OTL: GPU collector 持续 push<br/>otel_metrics_gauge<br/>(hw.gpu.power.draw 等)

这条链路上一个问题大概产生 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_structstruct 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 的依赖关系:

graph LRCH[clickhouse]OL[openlit]CTL[openlit-controller]GC[otel-gpu-collector]SG[sglang]APP[strands-agent-app]CH -- service_healthy --> OLOL -- service_healthy --> CTLOL -- service_healthy --> GCOL -- service_healthy --> APPSG -- service_healthy --> APP

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 不是合法选项。可选里有 qwenqwen25qwen3_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
graph LRsubgraph App["strands-agent-app 进程内"]SDK[OpenLIT SDK<br/>openlit.init]STR[Strands Agent + tools]endsubgraph Host["host namespace"]CTL[openlit-controller<br/>本次未启用 Enable]GC[otel-gpu-collector]endsubgraph DB["OpenLIT Server + ClickHouse"]T[otel_traces]MG[otel_metrics_gauge]MS[otel_metrics_sum]MH[otel_metrics_histogram]endSTR -->|invoke_agent / chat / execute_tool| SDKSDK ==>|OTLP HTTP<br/>含 gen_ai.* 属性| TGC ==>|hw.gpu.* NVML| MGGC -.->|"gpu.kernel.launch.calls<br/>gpu.memory.allocations<br/>(需要 bind-mount workaround)"| MSGC -.->|"gpu.kernel.block.size / grid.size<br/>gpu.memory.copies"| MHCTL -.->|未启用| Tstyle CTL fill:#eee,stroke-dasharray: 5 5style MS stroke-dasharray: 5 5style MH stroke-dasharray: 5 5

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,按时间线展开正好对应:

gantttitle 单次 Agent 调用的 span 时间线 (7.15s 总耗时)dateFormat XaxisFormat %S ssection invoke_agentinvoke_agent root : 0, 7154section cycle 1 (3.26s)execute_event_loop_cycle 1 : 0, 3265chat (Strands) : 50, 3113chat Qwen/Qwen3-8B (LLM): 80, 3116POST : 90, 175execute_tool search : 3060, 3261execute_tool calculator : 3262, 3263section cycle 2 (3.89s)execute_event_loop_cycle 2 : 3266, 7154chat : 3266, 7153chat Qwen/Qwen3-8B : 3279, 7154POST : 3280, 3366

这个 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

image

单条查询只能证明"链路通",但不足以让数据有覆盖度。为了让 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,几乎完美对应注入值。

sequenceDiagramparticipant SA as strands-agent-appparticipant TP as toxiproxy:30001participant SG as sglang:30000Note over SA,SG: Baseline (no toxic)SA->>TP: POST /v1/chat/completionsTP->>SG: 直接转发SG-->>TP: response (~900ms inference)TP-->>SA: response 直接转发Note over SA: chat span: 904msNote over TP: toxic add latency=500ms downstreamSA->>TP: POST /v1/chat/completionsTP->>SG: 直接转发 (upstream 不延迟)SG-->>TP: response (~900ms inference)TP-->>TP: ★ 等 500ms (downstream latency)TP-->>SA: responseNote over SA: chat span: 1409ms (+500ms ✓)

注意事项:

  • 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/

http://www.zskr.cn/news/1417070.html

相关文章:

  • 基于Arduino与红外传感器的DIY音乐盒:从传感器原理到嵌入式音乐合成
  • AI Agent 开发大比拼!2026年选型指南,Python仍是王者,TypeScript崛起,混合架构成主流!
  • 嵌入式Linux内存稳定性测试:手把手教你用memtester排查硬件‘暗病’(附RK3399实测)
  • Ka波段SIW接收机设计:实现立方星高速星间通信
  • 别再踩坑了!用mqtt.js连接MQTT时,WebSocket端口(8083/8084)和TCP端口(1883)到底怎么选?
  • Python3 注释
  • 大厂面试高频考点!手把手拆解AI Agent工具调用与Function Calling原理及工程实践
  • GRBL Plotter:从创意到现实的数控加工终极指南 [特殊字符]
  • 将Taotoken作为统一AI网关融入微服务架构
  • 用STM32F103C8T6和LD3320语音模块做个声控小台灯:GPIO电平读取的保姆级教程
  • H3C S10500/S7500E交换机密码恢复:保留原配置 vs. 彻底重置,两种方案怎么选?
  • 告别Visio和PPT!用Python的Plotly+Dash为数学建模打造动态交互式流程图
  • OpenVoiceV2核心技术完全解析:从架构原理到实战部署
  • 基于EVM预测的Massive MIMO自适应用户分组算法解析
  • PCB阻焊覆盖的唯一依据:Gerber文件
  • qmcdump:免费解锁QQ音乐加密文件,一键转换通用音频格式终极指南
  • sentence-transformers模型加载报错?试试这个本地路径加载的万能公式(附常见模型文件清单)
  • 从波形图看懂数字电路:用Quartus和ModelSim仿真一个二分频器(Verilog HDL)
  • 应对生活无聊感的实用建议
  • 别再用ACR了!用DCRAW命令行无损提取RAW数据,手把手教你做传感器分析
  • 基于74283与CD4511的硬件加法器:从二进制运算到数码管显示
  • 26年二季度国际搬家公司格局解析:主流厂商资质与服务评价 - 速递信息
  • Claude与Kafka/RabbitMQ/Pulsar深度对比(2024Q2最新基准测试:吞吐/延迟/资源开销/可观测性四维雷达图)
  • 光子计算中的矩阵运算与状态空间分析
  • 测试报告别再只靠截图了!手把手教你配置Katalon Studio的Basic Report插件与TestOps看板
  • 基于Arduino与TB6612的四驱蓝牙遥控小车:从PWM原理到系统集成实战
  • 从一根跳线到整条链路:手把手教你搭配SFP光模块与LC/SC光纤接头(含兼容性清单)
  • 电线电缆厂家选购指南:工程批量采购攻略 - 速递信息
  • 用InsightFace和ONNX Runtime-GPU,5分钟搞定一个Python人脸识别系统(附完整代码)
  • 2026国产便携式污泥浓度计品牌排行榜:十大品牌深度解析与选型指南 - 仪表品牌排行榜