07 | FastAPI + llama.cpp 接入 Langfuse监控Prompt请求

llm-serving 已经有 Prometheus 指标,能看到请求量、延迟、Token 总量这些聚合数据 。接入

Langfuse 是为了看到每一次请求的细节:输入的 messages、模型输出、Token 用量、耗时和错误。

文章仅做记录和参考,没有技术难度,因为可以直接让codex实现。但了解真实的实现过程一样重要。

本文实现应用层追踪:由 FastAPI 用 Langfuse Python SDK 手动记录 Trace / Span / Generation,不需要改动 llama.cpp。

目标架构

text 复制代码
Client / OpenAI SDK
        │
        ▼
llm-serving · FastAPI
  Langfuse SDK · Trace · Generation · Errors
        │                          │
        ▼                          ▼
llm-serving-llama · llama.cpp     Langfuse Cloud
        │                          HTTPS · https://jp.cloud.langfuse.com/
        ▼
   Qwen2 0.5B

注意:模型返回的 Token 统计是否准确,取决于 llama.cpp 是否返回了使用量信息;不能把字符数直接当作 Token 数。

过程概览

步骤 操作 关键内容
1 在 Langfuse Cloud 创建项目和密钥 日本区域实例,保存 Public Key、Secret Key
2 安装 SDK requirements.txt 增加 langfuse>=4.0.0,<5.0.0,重建镜像
3 配置环境变量 本地 export;Kubernetes 用 Secret + Deployment env;Compose 从 shell 传入
4 在 FastAPI 中创建 Trace 外层 span + 内层 generation,记录输入、输出、usage、错误
5 验证 Langfuse 连接 auth_check(),再发一次真实推理请求
6 构建并启动 API_HOST_PORT=8002 docker compose up --build -d api
7 压测 tests/load_metrics.py,180 秒,并发 2,每 2 个请求 1 个流式
8 对账 Prometheus 增量对客户端统计;Langfuse 里的 Trace 数与 Token 对客户端统计

验证结果

  • 构建与启动 :llm-serving-llama 变为 Healthy,llm-serving 随后 Started(日志)。
  • 压测 :180 秒内共发出 161 次操作,其中聊天请求 142 次(非流式 71 + 流式 71),全部返回 HTTP 200,没有错误样例(日志)。
  • Prometheus 对账 :5 个指标的增量与客户端统计完全一致------请求数 142、输入 Token 7856、输出 Token 9467、耗时样本 142、首字样本 71(日志)。
  • Langfuse 对账:本文给出了预期值(见第五节),实际控制台数据待补充。

环境信息

项目 值
宿主机 MacBook M4,Docker Desktop,docker compose
Langfuse Langfuse Cloud 日本区域 https://jp.cloud.langfuse.com
SDK Langfuse Python SDK 4.x(>=4.0.0,<5.0.0),基于 OpenTelemetry
FastAPI 镜像 llm-serving:local(基础镜像 python:3.12-slim),宿主机端口 8002
llama.cpp 镜像 llm-serving-llama:local,多阶段构建,模型 qwen2-0_5b-instruct-q4_k_m.gguf
被测模型 qwen2:0.5b

一、在 Langfuse Cloud 创建项目和密钥

  1. 打开日本区域实例:https://jp.cloud.langfuse.com。
  2. 登录后创建或选择一个项目,例如 llm-serving。
  3. 进入项目设置中的 API Keys,创建项目密钥。
  4. 保存下面三个值:
配置项 示例格式 用途
LANGFUSE_PUBLIC_KEY pk-lf-... 项目标识
LANGFUSE_SECRET_KEY sk-lf-... 服务端认证
LANGFUSE_BASE_URL https://jp.cloud.langfuse.com 日本区域 API 地址

二、修改 llm-serving

2.1 安装 SDK

在 llm-serving/requirements.txt 中增加:

text 复制代码
langfuse>=4.0.0,<5.0.0

然后重新构建 FastAPI 镜像。SDK 4.x 使用 OpenTelemetry 追踪接口,新项目不建议继续使用旧版的 langfuse.trace()、trace.generation() 等 API。

2.2 配置环境变量

先在本地测试环境设置:

bash 复制代码
export LANGFUSE_PUBLIC_KEY="pk-lf-替换为你的密钥"
export LANGFUSE_SECRET_KEY="sk-lf-替换为你的密钥"
export LANGFUSE_BASE_URL="https://jp.cloud.langfuse.com"
export LANGFUSE_TRACING_ENVIRONMENT="development"

LANGFUSE_TRACING_ENVIRONMENT 用来给 Trace 打上环境标签(这里是 development),方便在控制台里区分开发和生产数据。不要把实际密钥写进 Dockerfile,也不要提交到 .env 文件。

如果 FastAPI 运行在 Kubernetes,先创建 Secret:

bash 复制代码
kubectl create secret generic langfuse-credentials \
  -n llm-serving \
  --from-literal=LANGFUSE_PUBLIC_KEY='替换为实际公钥' \
  --from-literal=LANGFUSE_SECRET_KEY='替换为实际私钥' \
  --dry-run=client -o yaml | kubectl apply -f -

再把环境变量配置到 llm-serving Deployment:

yaml 复制代码
env:
  - name: LANGFUSE_PUBLIC_KEY
    valueFrom:
      secretKeyRef:
        name: langfuse-credentials
        key: LANGFUSE_PUBLIC_KEY

  - name: LANGFUSE_SECRET_KEY
    valueFrom:
      secretKeyRef:
        name: langfuse-credentials
        key: LANGFUSE_SECRET_KEY

  - name: LANGFUSE_BASE_URL
    value: "https://jp.cloud.langfuse.com"

  - name: LANGFUSE_TRACING_ENVIRONMENT
    value: "development"

2.3 在 FastAPI 中创建 Trace

llm-serving 是通过 HTTP 调用 llm-serving-llama 的,所以最直接的方案是用 Langfuse Python SDK 手动建立 Span 和 Generation:不需要改 llama.cpp,也不依赖它是否使用 OpenAI Python SDK。

先在 FastAPI 代码中初始化客户端:

python 复制代码
from langfuse import get_client

langfuse = get_client()

然后在 /v1/chat/completions 的业务逻辑里,对模型调用增加追踪:

python 复制代码
with langfuse.start_as_current_observation(
    as_type="span",
    name="llm-serving.chat-completions",
    input={"model": model, "messages": messages},
) as span:
    with langfuse.start_as_current_observation(
        as_type="generation",
        name="llama.cpp.inference",
        model=model,
        input={"messages": messages},
    ) as generation:
        # 保留你当前调用 llm-serving-llama 的 HTTP 请求代码
        response = await call_llama_backend(...)
        generation.update(
            output=response,
            usage={
                "input": response.get("usage", {}).get("prompt_tokens", 0),
                "output": response.get("usage", {}).get("completion_tokens", 0),
                "total": response.get("usage", {}).get("total_tokens", 0),
            },
        )
    span.update(output=response)

三、构建并启动服务

用 docker compose 构建镜像并启动 api 服务,同时用 API_HOST_PORT 指定宿主机端口为 8002:

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % API_HOST_PORT=8002 docker compose up --build -d api
[+] Building 1.5s (24/24) FINISHED
 => [internal] load local bake definitions                                                               0.0s
 => => reading from stdin 1.06kB                                                                         0.0s
 => [llm-serving-llama internal] load build definition from Dockerfile                                   0.0s
 => => transferring dockerfile: 955B                                                                     0.0s
 => [api internal] load build definition from Dockerfile                                                 0.0s
 => => transferring dockerfile: 354B                                                                     0.0s
 => [api internal] load metadata for docker.io/library/python:3.12-slim                                  0.1s
 => [llm-serving-llama internal] load metadata for docker.io/library/alpine:3.20                         0.1s
 => [llm-serving-llama internal] load metadata for ghcr.io/ggml-org/llama.cpp:server                     1.2s
 => [auth] ggml-org/llama.cpp:pull token for ghcr.io                                                     0.0s
 => [api internal] load .dockerignore                                                                    0.0s
 => => transferring context: 2B                                                                          0.0s
 => [api 1/5] FROM docker.io/library/python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1   0.0s
 => => resolve docker.io/library/python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1       0.0s
 => [api internal] load build context                                                                    0.0s
 => => transferring context: 1.34kB                                                                      0.0s
 => CACHED [api 2/5] WORKDIR /app                                                                        0.0s
 => CACHED [api 3/5] COPY requirements.txt .                                                             0.0s
 => CACHED [api 4/5] RUN pip install --no-cache-dir -r requirements.txt                                  0.0s
 => CACHED [api 5/5] COPY app ./app                                                                      0.0s
 => [api] exporting to image                                                                             0.0s
 => => exporting layers                                                                                  0.0s
 => => writing image sha256:f7cae246f66126d95986648549d4db184525026fb5cb8c8e05791d031ac017cf             0.0s
 => => naming to docker.io/library/llm-serving:local                                                     0.0s
 => [api] resolving provenance for metadata file                                                         0.0s
 => [llm-serving-llama internal] load .dockerignore                                                      0.0s
 => => transferring context: 2B                                                                          0.0s
 => [llm-serving-llama model-downloader 1/3] FROM docker.io/library/alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc   0.0s
 => [llm-serving-llama stage-1 1/2] FROM ghcr.io/ggml-org/llama.cpp:server@sha256:052888c603894ecfa08deb6714fdc2d7084e491e174d5ab41bfb36b49ee7151c      0.0s
 => CACHED [llm-serving-llama model-downloader 2/3] RUN apk add --no-cache curl                          0.0s
 => CACHED [llm-serving-llama model-downloader 3/3] RUN mkdir -p /models && curl -fL --retry 3 "https://huggingface.co/Qwen/Qwen2-0.5B-Instruct  ...   0.0s
 => CACHED [llm-serving-llama stage-1 2/2] COPY --from=model-downloader /models/qwen2-0_5b-instruct-q4_k_m.gguf /models/qwen2-0_5b-instruct-q4_...    0.0s
 => [llm-serving-llama] exporting to image                                                               0.0s
 => => exporting layers                                                                                  0.0s
 => => writing image sha256:a6dcac02ea7f576e6c14ef765e2165b05ab45be5a5565a02e3655eef4af47c4d             0.0s
 => => naming to docker.io/library/llm-serving-llama:local                                               0.0s
 => [llm-serving-llama] resolving provenance for metadata file                                           0.0s
[+] Running 4/4
 ✔ llm-serving-llama:local      Built                                                                    0.0s
 ✔ llm-serving:local            Built                                                                    0.0s
 ✔ Container llm-serving-llama  Healthy                                                                  6.6s
 ✔ Container llm-serving        Started                                                                  6.3s

四、测试过程

4.1 运行压测

对 8002 端口的 FastAPI 压测 180 秒,并发 2,每 2 个请求里有 1 个流式:

bash 复制代码
(.venv) (base) igwanglyang@MacbookProdahouzicn llm-serving % BASE_URL=http://127.0.0.1:8002 \
DURATION_SECONDS=180 \
CONCURRENCY=2 \
STREAM_EVERY=2 \
python tests/load_metrics.py
Target: http://127.0.0.1:8002
Duration: 180s; concurrency: 2; model: qwen2:0.5b; stream every 2 request(s)
Initial /health: HTTP 200 {"status":"ok","service":"llm-serving","backend":"llama.cpp"}

========== CLIENT-SIDE LOAD SUMMARY ==========
Finished at: 2026-10-13T11:15:33+08:00
Elapsed: 183.0s; issued operations: 161; throughput: 0.88 ops/s
chat_nonstream_ok: 71
chat_nonstream_requests: 71
chat_nonstream_status_200: 71
chat_response_bytes: 1365479
chat_stream_ok: 71
chat_stream_requests: 71
chat_stream_status_200: 71
chat_total_latency_ms: 357040
client_input_tokens_reported: 7856
client_output_tokens_reported: 9467
health_ok: 9
health_requests: 9
health_status_200: 9
models_ok: 10
models_requests: 10
models_status_200: 10
stream_content_chunks: 4648
stream_done: 71
http latency samples: 19
http latency P50: 0.011s
http latency P90: 0.018s
http latency P95: 0.048s
http latency P99: 0.068s
chat latency samples: 142
chat latency P50: 2.105s
chat latency P90: 4.202s
chat latency P95: 5.593s
chat latency P99: 8.643s
Streaming first-content samples: 71
Client observed first-content P50: 0.087s
Client observed first-content P90: 0.162s
Client observed first-content P95: 0.172s

========== PROMETHEUS COUNTER DELTAS ==========
llm_requests_total: +142
llm_input_tokens_total: +7856
llm_output_tokens_total: +9467
llm_request_duration_seconds_count: +142
llm_ttft_seconds_count: +71

PromQL: sum(rate(llm_requests_total[1m]))
PromQL: histogram_quantile(0.95, sum by (le) (rate(llm_ttft_seconds_bucket[5m])))

4.2 客户端结果解读

项目 结果 说明
压测时长 180s 设定,实际 183.0s 截止后不再发新请求,等待在途请求完成
总操作数 161 聊天 142 + health 9 + models 10
吞吐 0.88 ops/s 161 ÷ 183.0
聊天请求 142(非流式 71 + 流式 71) 全部 HTTP 200,没有错误样例
health / models 9 / 10,全部 HTTP 200 与分配规则推算的次数一致
流式完成 stream_done: 71 71 个流式请求都收到了 [DONE]
流式内容块 4648 个 平均每个流式请求约 65 个 chunk(4648 ÷ 71)
响应体总量 1,365,479 字节 非流式的响应体大小 + 流式读取到的文本行长度

延迟(客户端观测):

类别 样本数 P50 P90 P95 P99
/health + /v1/models 19 0.011s 0.018s 0.048s 0.068s
聊天(完整响应) 142 2.105s 4.202s 5.593s 8.643s
流式首字(first-content) 71 0.087s 0.162s 0.172s ---

几点分析:

  • 首字延迟只有约 0.09 秒,而完整响应要 2 秒多:流式的价值就在这里,用户几乎立刻就能看到第一个字,后面的内容边生成边显示。
  • 聊天延迟的长尾:P99(8.6 秒)约为 P50(2.1 秒)的 4 倍,符合"输出越长耗时越长"的特点------输出长度由模型决定,每次不同。
  • 并发被模型速度吃满 :聊天总耗时 chat_total_latency_ms: 357040(357 秒),而 2 个 worker 在 183 秒里一共有约 366 个 worker 秒,也就是说 worker 约 97% 的时间都在等模型。要提高吞吐,得加快单次推理或增加并发,而不是调脚本。
  • 平均每个聊天请求:输入约 55 个 token(7856 ÷ 142)、输出约 67 个 token(9467 ÷ 142)、耗时约 2.5 秒(357.04 ÷ 142)。

4.3 Prometheus 对账结果

Prometheus 指标 增量 客户端统计 是否一致
llm_requests_total +142 聊天请求 71 + 71 = 142 ✅
llm_input_tokens_total +7856 client_input_tokens_reported: 7856 ✅
llm_output_tokens_total +9467 client_output_tokens_reported: 9467 ✅
llm_request_duration_seconds_count +142 聊天请求 142 ✅
llm_ttft_seconds_count +71 流式请求 71 ✅

结论:应用的 Prometheus 指标和客户端观测到的请求、Token、首字样本数完全吻合,包括流式请求(流式请求的 token 也被正确累计了)。再次强调,这是"同源"对账,验证的是应用的统计与转发,而不是后端 token 数本身是否准确。

输出末尾脚本打印了两条 PromQL,可以在 Prometheus 里直接使用:

promql 复制代码
sum(rate(llm_requests_total[1m]))
histogram_quantile(0.95, sum by (le) (rate(llm_ttft_seconds_bucket[5m])))

五、在 Langfuse 里对账

5.1 时间窗口

压测结束于 2026-10-13T11:15:33+08:00,耗时 183.0 秒,所以开始时间约为 11:12:30(UTC+8)。在 Langfuse 的 Tracing 页面里,把时间范围设为这段时间(留一点余量),并按环境 development 过滤。

5.2 对账项与判断标准

对账项 预期 实际(待补充) 不一致时的可能原因
Trace 数量(llm-serving.chat-completions) 142 只追踪了非流式则为 71,说明流式分支没有埋点;或 SDK 还没来得及上传
Generation 数量(llama.cpp.inference) 142 同上
其中非流式 / 流式 71 / 71 流式需要单独处理,示例代码只覆盖非流式
输入 Token 合计 7856 usage 字段名或取值方式不对;缺失时被记成 0
输出 Token 合计 9467 同上;流式响应里没有读到 usage
错误数量 0 压测中所有请求都是 HTTP 200
/health、/v1/models 不应出现在 Trace 里 如果出现,说明追踪埋点放得太宽

这次压测里每两个聊天请求就有一个是流式的(STREAM_EVERY=2),正好可以用来检验流式是否被追踪。2.3 里的示例只覆盖非流式,如果 Trace 数量只有 71,就说明流式分支还需要补埋点。

5.3 Token 用量的核对

Langfuse 里的 Token 用量与 Prometheus、客户端三者应该是同一组数字(7856 / 9467)。如果 Langfuse 里的数值与它们不同,优先检查:

  1. 应用是否把后端返回的 usage 原样记录,而没有按字符数估算;
  2. 缺失 usage 时有没有被记成 0 而不是"未知";
  3. 流式响应是否读到了带 usage 的最后一个 chunk。

测试脚本概览

项目 说明
作用 对 FastAPI + llama.cpp 执行有限时长的并发压测 ,并用压测前后 /metrics 的差值,核对 Prometheus 指标是否与客户端统计一致
运行方式 python tests/load_metrics.py --duration 120 --concurrency 3 --stream-every 2
默认负载 运行 60 秒、并发 2,适合本地小模型
依赖 Python 标准库 + httpx(异步 HTTP 客户端)
并发模型 asyncio:concurrency 个 worker 协程各自循环发请求,直到截止时间
被测接口 GET /health、GET /v1/models、POST /v1/chat/completions(非流式 + 流式)、GET /metrics
启动前检查 先请求一次 /health,不是 2xx 就立即退出,避免把无效请求当成压测结果
输出 客户端汇总、延迟分位数、流式首字延迟、Prometheus 计数器增量、错误样例、两条 PromQL 提示
相关推荐
cui_ruicheng3 小时前
FastAPI 应用开发(五)——SQLAlchemy ORM、APIRouter 与 Alembic 数据库迁移
python·fastapi·web
forest_094 小时前
Agent 系列07|记忆与状态设计
人工智能·机器学习·架构·prompt·agi
for_ever_love__4 小时前
LangChain RunnableConfig 详解:运行时传参、并发控制与回调注入
语言模型·langchain·llm·prompt·config·invoke·运行时参数
空空dream5 小时前
06 | Prometheus打通FastAPI云原生数据采集
云原生·prometheus·fastapi
goodlook01235 小时前
opencode调用本地模型三
人工智能·llama
梦想不只是梦与想6 小时前
SQLAlchemy:异步、连接池 (四)
数据库·fastapi·sqlalchemy
const_qiu9 小时前
第一章 1.3:System Prompt 定义模型角色 —— 告诉 AI“你是谁“
人工智能·ubuntu·prompt
Joy T16 小时前
Agent 确定性上下文设计:从 Prompt 参数注入到能力分层
java·开发语言·微服务·架构·prompt·agent context
·云扬·20 小时前
Prompt 工程与安全:从 System 设计到注入防御
java·人工智能·安全·ai·prompt