Files
moeka-project/apps/server/docs/ai-context/langfuse-tracing.md
T
2026-05-30 19:35:37 +08:00

112 lines
9.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Langfuse LLM-native 观测接入
逐条 prompt 级 trace + 评测 + 成本归因到用户/会话。阶段 1(chat completion + TTS speech)已实现并验证,见下「已实现」与 `verifications/langfuse-tracing.md`
## 目标
1. 逐条 prompt trace:每次 `/api/v1/openai/chat/completions` 的 input messages、output、model 完整可读。
2. 评测 evaldataset、人工标注、LLM-as-judge。
3. 成本归因:按 user 和按 conversation/session 切分 token 与成本。
## 为什么直接 Langfuse,不先用 Grafana 顶
Grafana 栈(Prometheus/Tempo/Loki)已经管好 ops 聚合层,继续不动:rate、latency、聚合 token 吞吐/消耗、按模型 flux、错误、fallback、上游健康。这层完整。
上面三个目标里 Grafana 栈的实际能力:
- 逐条 prompt traceTempo 能塞 span,但 trace 是 head-sampled,采样比 < 1 直接丢 promptspan 属性有体积上限,长 prompt 被截;没有把 prompt 当一等对象读/对比/标注的界面。
- eval:零能力,dataset / 标注队列 / LLM-as-judge 全得自己造。
- 按 user/session 成本:Prometheus 按 userId/sessionId 做 label 会高基数爆炸,做不了。按 user 的成本原料其实在 `llm_request_log` 表里,用 SQL 能查,跟 Grafana 无关。
这三件事里正文采集是最重的活,两边都得从零写,先做 Grafana 一点不省。真正能省的只有 eval 和 per-user/session 成本,而这俩在 Grafana 上等于手搓一个更差的 Langfuse,迁移时全扔。所以不走「先 Grafana 再 Langfuse」,直接 Langfuse 补 LLM-native 这一层。
边界划分:
- Grafana 栈:ops 指标,不动。
- Langfuse:逐条 prompt trace + 正文 + eval + user/session 成本归因。
## 选型:Langfuse v5 = OpenTelemetry SpanProcessor
Langfuse v5 JS SDK 基于 OpenTelemetry`@langfuse/otel``LangfuseSpanProcessor` 就是一个 OTel SpanProcessor。AIRI 这里没有把它挂到现有 NodeSDK 上,而是起一个独立 `NodeTracerProvider` 并通过 `setLangfuseTracerProvider()` 只给 `@langfuse/tracing` 使用。
- Grafana 出口:`OTLPTraceExporter` → Grafana Cloud Tempo。
- Langfuse 出口:独立 provider 上的 `LangfuseSpanProcessor` → Langfuse Cloud。
- 两者共享 OTel context/trace id,但不共享 SpanProcessor,避免 prompt/completion 正文进 Grafana Tempo。
不走「复用 OTLP exporter 指向 Langfuse」的原因:① 目标里有 evaleval 只能走 Langfuse SDK/APIOTLP 解决不了;② 现有 span 用 `airi.gen_ai.*` 自定义 attribute keyLangfuse 不认,得改成它认的 generation 字段。一套 SDK 把 trace + 正文 + 成本 + eval 全包,不维护两条上报路。
需要的包:`@langfuse/tracing``@langfuse/otel`
## 部署形态与脱敏决策
- 形态:Langfuse Cloud。
- 脱敏:不脱敏。prompt/completion 正文全量出境到 Langfuse 托管,已确认可接受。
- 凭据:`LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY` / `LANGFUSE_BASE_URL`(SDK 默认变量名,带下划线),走 Railway env secret,本地走 `.env.local`。instrumentation.ts 和网关 route 都直读 `process.env`,**不进 `env.ts` 的 valibot schema**。原因:instrumentation 是 preload(在 env 解析之前跑),且这是部署开关(类比 NODE_ENV)不是服务依赖,不需要进 DI。
## Session 来源决策
网关 `openai/v1/index.ts` 是无状态 OpenAI 兼容代理,本身没有 conversation/session 概念,每请求只有 `c.get('user')` 的 userId 和一个临时 `nanoid()` requestId。`chats` 表存在但跟这条代理路径零关联(那是另一套 `/chats` API)。
所以 session id 必须客户端传。决策:客户端传对话 id,网关读出来当 Langfuse `sessionId`
落地点几乎免费:stage-ui 的 `packages/stage-ui/src/libs/providers/providers/official/shared.ts` 已有 `withCredentials()` fetch 包装器在每请求注入 `Authorization` header。在同一处加一个 `x-airi-session-id` header 即可,不用碰 `@xsai` 的 body 透传。stage-web 和 stage-tamagotchi 都复用这个 provider,改一处两端生效。
改端范围仅此一处。telegram-bot 自带 provider,不经 server 网关,不在范围内。
## 成本归因方案
- USD 真金成本:交给 Langfuse 按 model 定价表自动算(传 `usageDetails` 的 input/output tokens 即可)。风险见下。
- flux 业务成本:作为自定义字段放 generation 的 `metadata`flux 不是货币,不塞 `costDetails`)。
- 这样业务成本(flux)和真金成本(USD)都在,按 user / session 都能切。
## 已实现(阶段 1:chat completion)
### `apps/server/instrumentation.ts`
- `langfuseEnabled = !!LANGFUSE_PUBLIC_KEY && !!LANGFUSE_SECRET_KEY`,与 `otlpEndpoint` **独立门控**。两者任一开启就启动 NodeSDK;只配一个不会让另一个静默 no-op。
- OTLP 的 trace exporter / metric reader / log processor 仅在 `otlpEndpoint` 存在时挂(条件 spread),NodeSDK 的 `spanProcessors` 数组只装 OTLP 的 `BatchSpanProcessor`
- Langfuse 起独立 `NodeTracerProvider`(`langfuseProvider`),其上挂 `LangfuseSpanProcessor`(`exportMode: 'batched'`),再 `setLangfuseTracerProvider(langfuseProvider)``startObservation` 路由到它。
- `shouldExportSpan`:只放行带 `langfuse.*` 属性的 span(SDK 创建的 generation 都带 `langfuse.observation.type` 等)。这是防御性兜底,独立 provider 本来就只见自己的 span。
- 独立 provider 显式 `AlwaysOnSampler`:调低 `OTEL_TRACES_SAMPLING_RATIO` 给 Grafana 降量,不会丢 Langfuse generation,逐条 prompt 捕获保持完整。
- SIGTERM:`Promise.all([sdk.shutdown(), langfuseProvider?.shutdown()])``sdk.shutdown()` 不会 drain 独立 provider,必须显式 shutdown,否则最后一批 generation 在部署重启时丢失。
### `apps/server/src/routes/openai/v1/index.ts`(`handleCompletion`)
`langfuseEnabled` 门控(只读一次,非每请求):读 `process.env.LANGFUSE_TRACING_ACTIVE`,这个 sentinel 由 instrumentation.ts 在 `setLangfuseTracerProvider()` 成功**之后**才置 `'1'`。**禁用时不创建 generation** —— 这很关键:Langfuse 关闭时没调 `setLangfuseTracerProvider`,`startObservation` 会 fallback 到全局 provider,正文就会漏进 OTLP/Grafana。gate 绑定真实 provider 状态(单一真相在 instrumentation.ts),而不是在 route 里独立再判一次 key —— 避免将来改 instrumentation 的开关条件时两处 desync 导致正文漏到错误后端。
output(给 eval 用,要可读):非流式 = `responseBody`;流式 = 从 SSE delta 解析出的 assistant 正文(`extractSseDeltaText` 逐行解析 `choices[0].delta.content`,**不是**原始 `data: {...}` 框架),硬上界 1M 字符防止长输出 × 高并发占内存。
generation 形态:
- `startObservation('chat.completion', { input: body.messages, model: requestModel, metadata: { requestId, stream } }, { asType: 'generation' })`
- 身份:`generation.otelSpan.setAttribute('langfuse.user.id', user.id)`;有 `x-airi-session-id` header 时再 set `langfuse.session.id`。这两个 compat 属性被平台提升为 trace 级,支撑按 user/session 归因。
- output:非流式 = `responseBody`;流式 = 后台累积的 assistant 正文。
- usageDetails:`{ input: promptTokens, output: completionTokens }`,复用计费已提取的 usage。
- metadata:保留 `{ requestId, stream }`,完成时补 `{ fluxConsumed }`
- 生命周期:5 个退出分支各 `generation?.update(...)` + `generation?.end()` 恰好一次 —— router throw catch、`!response.ok`、流式 interrupted(finally)、流式 completed(finally)、非流式。错误分支标 `level: 'ERROR'` + `statusMessage`。流式 generation 在后台 async IIFE 的 finally 里结束,跟 `span.end()` 对齐,不在 response 返回时提前结束。
### `apps/server/src/routes/openai/v1/index.ts`(`handleTTS`)
- `startTtsGeneration({ input: { text, voice, speed, responseFormat }, model, requestId, userId, sessionId })` 创建 `tts.speech` generation。
- 不缓冲二进制 audio 到 Langfuse;成功 output 只记录 `{ contentType }`
- usageDetails 使用 `{ input: inputChars }`flux 作为 metadata 记录。
- router throw、上游非 2xx、billing/Redis failure 都会 `fail(...)` 并 end;成功在 `ttsMeter.accumulate()``succeed(...)`
### `packages/stage-ui/src/libs/providers/providers/official/shared.ts`
- `withCredentials()``Authorization` 外,会在 Pinia 已初始化且有 active chat session 时注入 `x-airi-session-id`
- stage-web / stage-tamagotchi / stage-pocket 复用 official provider 的请求都会带同一个会话 id;匿名或非 chat 上下文没有 active session 时自动退化为 user-only trace。
### `apps/server/src/libs/env.ts`
未改 —— LANGFUSE_* 故意不进 valibot schema(见「部署形态与脱敏决策」)。
## 验证
见 [`verifications/langfuse-tracing.md`](./verifications/langfuse-tracing.md)。Langfuse Cloud(project airi)回读到 `chat.completion` GENERATION,完整带 input(messages)/ output / model / usageDetails / userId / sessionId / metadata,trace 与 generation 共享 traceId。当前代码路径的 typecheck、targeted Vitest、eslint 通过。
## 待办(后续阶段)
- **staging 真实端到端**:`.env.local` 的 DB/Redis/OTLP 全指生产,本地起真实 server 会连生产。在 staging(指向非生产)起 server 发一次真实 chat 请求补全 HTTP 路径端到端。
- **model 定价匹配**:网关 model 是解析后的路由名,能否命中 Langfuse 定价表算 USD 待实测;命不中就配自定义定价或传 `costDetails`。flux 已放 metadata。