上一篇站在使用者视角把行车记录仪装上了:handler 翻译切面、队列攒批、四道工序。这篇下到桥接层最底下——libs/acl/langfuse 这个不含任何 Eino 概念的纯 HTTP 客户端库,回答三个出门前才需要想的问题:
- 一批事件出门时,线上到底跑的是什么协议?(batch ingestion)
- 用户发了张 base64 图片,怎么不把管道撑爆?(media 三步上传)
- 这套东西不发真请求怎么测?(两层 mock)
(一)batch ingestion:一个 POST 的三级响应
协议本身简单到一句话:POST {host}/api/public/ingestion,Basic 认证(pk:sk base64),body 是 {"batch": [事件数组], "metadata": {批次信息}}。值得看的是两边的设计。
事件信封是"类型字段 + 联合体"。 每个事件固定五件套 {id, type, timestamp, metadata, body},type 是上一篇讲过的八种(trace-create / span-create / …),body 是五选一的联合体:
type eventBodyUnion struct {
Trace *TraceEventBody `json:",inline,omitempty"`
Span *SpanEventBody `json:",inline,omitempty"`
Generation *GenerationEventBody `json:",inline,omitempty"`
Event *EventEventBody `json:",inline,omitempty"`
Log *SDKLogEventBody `json:",inline,omitempty"`
}
Go 没有原生 union,这里的做法是五个指针字段 + 手写 MarshalJSON 按优先级序列化非空的那个(event.go:85-96)。事件类型在信封上、字段形状在 body 里——这也是 Langfuse 服务端能按 ID upsert 的前提:span-create 和 span-update 带同一个 observation ID,谁先到都能落库。
响应分三级处理(client.go:134-150):
| 状态码 | 含义 | 客户端行为 |
|---|---|---|
| 200 / 201 | 全部成功 | 直接返回 nil |
| 207 Multi-Status | 部分成功 | 解析 errors 数组,聚合成 apiErrors 返回 |
| 其他 | 全部失败 | 包成 *apiError{Status, 原始body} 返回 |
207 是批量接口特有的语义:一批 15 条事件,14 条落库 1 条被拒(比如单字段超限),服务端不会整体回滚,而是告诉你哪条死了。合理,但和上一篇的重试分类碰在一起有个细节:重试判定用 errors.As(err, &apiError) 匹配 *apiError,而 207 聚合出来的是 apiErrors(切片类型)——类型不匹配,不会被判成永久失败,于是整批走重试。重试会把已成功的 14 条再发一遍。好在 ingestion 按 ID upsert,幂等兜住了,代价只是白传。这是个"两个各自合理的设计拼在一起产生冗余"的典型样本。
一个逐字段核对才会发现的坑:批次 metadata 的组装(consumer.go:284-290)里,SDK 标识三个字段的值交叉了:
"sdk_integration": i.sdkIntegration, // "eino" ✓
"sdk_name": i.sdkVersion, // "v0.0.1" ✗ 应为 "Golang"
"sdk_version": i.sdkIntegration, // "eino" ✗ 应为 "v0.0.1"
对照常量定义(langfuse.go:27-29:sdkName="Golang"、sdkIntegration="eino"、sdkVersion="v0.0.1"),sdk_name 填进了 version 的值、sdk_version 填进了 integration 的值。只影响 Langfuse 界面上 SDK 标识的展示,不影响数据,属于 LOW 级别缺陷——但它是真实存在的,demo 场景 A 把实际出门的 metadata 打了出来:
====== 场景 A:ingestion 响应三级分类 + 出门 metadata ======
批 1 → 200 OK :成功,无错误
批 2 → 207 Multi-Status :部分失败 API errors: api error 400: id=e2: field size exceeded
isPermanent(apiErrors)=false —— 类型不匹配 *apiError,会走重试
批 3 → 500 :api error 500:
isPermanent=false(5xx → 重试;若换成 400/401 则=true,吞掉不重试)
实际出门的批次 metadata:
{"batch_size":"3","public_key":"pk-lf-demo","sdk_integration":"eino","sdk_name":"v0.0.1","sdk_version":"eino"}
↑ sdk_name="v0.0.1" sdk_version="eino" —— consumer.go:287-288 两行值交叉(真实缺陷)
demo(/tmp/e89demo,纯标准库 + httptest 本地回环)把 client 的三级分类照抄了一遍,metadata 组装逐字照抄源码——所以最后一行不是 demo 的 bug,是源码 bug 的实锤复现。
(二)media 三步上传:base64 图片的旁门
先说为什么需要这个旁门。多模态消息里图片常以 data URI 内联(data:image/png;base64,....),base64 编码膨胀 4/3:一张 500KB 的图变 667KB 字符串。上一篇讲过单事件上限 MaxEventSizeBytes=1MB、超了从最大字段整字段清空——两三张图就会把 input 整个清成 (truncated),回放界面上图片和文字一起消失。
所以消费侧的处理顺序是媒体工序排第一(采样 → 媒体 → 脱敏 → 截断):先把图片从消息里摘出去传成 media,替换成一个几十字节的占位符,再轮到截断时,事件已经瘦身了。
摘取:tryNewMediaFromBase64(media.go:36-68)只认 data: 前缀的 URL——http(s) 链接本来就是服务端可拉取的,原样保留;解析 data URI 的头(contentType + base64 标记)和负载,解码出原始字节,顺手算 sha256。这个 hash 是后面的去重钥匙。
三步上传(consumer.go:392-446):
① POST /api/public/media 问服务端要上传地址
请求带 {traceId, observationId, contentType, contentLength, sha256Hash, field}
响应给 {mediaId, uploadUrl}
② PUT {uploadUrl} 直传对象存储(URL 是服务端签发的预签名地址)
头里同时带 x-amz-checksum-sha256(S3 风格)和 x-ms-blob-type: BlockBlob(Azure 风格)
③ PATCH /api/public/media/{id} 上报结果 {uploadedAt, uploadHttpStatus, uploadTimeMs}
三个设计点:
- ①是同步的,②③是异步的。 步骤①在 consumer goroutine 里做(要拿到 mediaId 才能构造占位符,慢不得);②③塞进独立 goroutine(
mediaWG.Add(1)+defer Done()+recover()兜底),不阻塞事件批次出门。而flush()的完整语义是q.join() + mediaWG.Wait()(task.go:71-74)——上一篇说"flusher 不调丢最后一批",其实它还等你没传完的图。 - 上传成功失败都要 PATCH。 步骤③不管 ② 的状态码是几都执行,把
uploadHttpStatus记到服务端——Langfuse 界面上 media 显示"上传失败"而不是无限转圈,靠的就是这个"失败也汇报"。 - sha256 是服务端去重钥匙。 步骤①把 hash 一起报上去,服务端发现同一内容已存在时返回空的 uploadUrl,客户端直接复用 mediaId、跳过②③。同一张图被三个节点引用(prompt 渲染、重试、缓存命中),只传一次。
出门的消息长这样:图片 URL 被替换成占位符 @@@langfuseMedia:type=image/png|id=media-1|source=base64_data_uri@@@,Langfuse 前端渲染时把它还原成 media 引用。而原消息一个字节都没动——convMedias 对每条消息做浅拷贝(consumer.go:328-332:复制 Message 结构体 + 复制 MultiContent 切片),在副本上改 URL。这不是洁癖:handler 拿到的 InMessages 可能还被业务侧持有,观测层改写共享数据等于埋并发雷。
demo 场景 B/C/D 依次验证这三点:
====== 场景 B:base64 图片 → 三步上传 + 占位符替换 ======
原消息: text + dataURI(解码 16 字节) + httpURL
服务端收到: POST /api/public/media
服务端收到: PUT /obj/media-1
服务端收到: PATCH /api/public/media/media-1
出门消息的图片 URL: "@@@langfuseMedia:type=image/png|id=media-1|source=base64_data_uri@@@"
http URL 原样保留: "https://cdn.example.com/http-only.png"(非 data: 前缀,不走媒体通道)
原消息的图片 URL 仍是 data: 前缀("data:image/png;base6"…)—— shallow copy 未动原消息
====== 场景 C:同内容 hash 去重(第二次零 PUT)======
同一张图传两次:mediaID m1=media-1 m2=media-1(服务端按 sha256 判重复用)
PUT 次数=1(第二次 getUploadURL 返回空 uploadUrl,直接复用)
====== 场景 D:媒体异步上传,flush 会等它 ======
PUT 完成、PATCH 已到服务端但被 gate 挡住: patchDone=false
flush() 阻塞中 —— 它在等 mediaWG(媒体 goroutine 没结束)
放行后 flush() 返回,PATCH 已完成: true
场景 D 用一个 gate channel 卡住假服务器的 PATCH handler:flush 被验证为确实在等媒体 goroutine——放行前阻塞、放行后返回,这行输出就是 flush = q.join() + mediaWG.Wait() 的行为证明。
还有一个容易漏看的序列化细节:GenerationEventBody 的 InMessages/OutMessage 标的是 json:"-"(event.go:260-261)——消息对象不直接进 JSON,消费侧统一 marshal 成字符串塞进 Input/Output 字段。原因是 schema.Message 自身的 JSON 形状不适合直接当观测数据(多模态时 Content 与 MultiContent 的表达冲突),mediaMessage 结构(media.go:78-87)把 MultiContent 提为 content 字段做了一次适配。观测层的数据形状由观测层定义,不迁就业务模型。
(三)mock 测试:两层桩,三个角度
这套集成怎么在不连 Langfuse 的情况下测?源码里有两层 mock 路径:
传输级(libs/acl/langfuse/langfuse_test.go:34-46):用字节的 mockey 库直接打桩 (*http.Client).Do,按请求 path 分流返回假响应——getUploadURLPath 返回签好的 URL、ingestionPath 返回空成功。HTTP 一个字节都没出门,但 client 的完整代码路径(构造请求、解析响应、三级分类)全跑到了。
接口级(callbacks/langfuse/langfuse_test.go):ACL 层导出了 Langfuse 接口(七方法)+ gomock 生成的 MockLangfuse。但 handler 内部自己调 NewLangfuse 构造真实现,外部注入不进去——测试的组合拳是 mockey.Mock(langfuse.NewLangfuse).Return(mockLangfuse),把构造函数整个换成返回 mock。接口 mock 提供可断言的桩,函数打桩解决"接线在内部"的注入问题。
接口级测试里有三个值得抄的姿势:
- 用调用顺序验证树结构。
CreateSpan的EXPECT().DoAndReturn里数调用次数:第 0 次必须没有ParentObservationID(图级 span),第 1、2 次的 parent 必须是图级 span 的 ID——上一篇"图也是 span、树靠 parent 连"的结论,在这个测试里是被逐调用断言钉死的。 - 流式用 Pipe 手工喂 chunk。 测试不真起模型,
schema.PipeSend 三个CallbackInput再Close(),模拟流式输入的分片到达,验证 handler 的流式拼接逻辑。 - 两个 Attack 测试守住边界。
TestAttack_NilMessageInOnEnd:OnEnd 的 Message 为 nil 时 Usage 必须为 nil(防 panic、防假数据);TestAttack_ExtractModelOutputErrorIgnored:输出拼接失败时EndGeneration只允许调一次——注释直说了这是流式双 goroutine 路径的坑。命名带Attack的测试是拿来防"恶意/畸形输入"的,这两条正是上篇"流式走另一条 goroutine"的回归防线。
demo 的 fakeServer(httptest + 状态码脚本 + 调用序列记录 + gate)本质上就是传输级 mock 的同构——不需要 mockey,标准库就够。
小结
| 问题 | 答案 | 关键源码 |
|---|---|---|
| 协议长什么样 | 一个 POST,信封{type, body联合体},Basic 认证 | client.go:100-151 |
| 部分失败怎么办 | 207 + errors 数组;但 apiErrors 不匹配重试分类 → 整批重试,upsert 幂等兜底 | client.go:136-149 |
| SDK 标识对吗 | 不对:sdk_name/sdk_version 值交叉(LOW,仅影响展示) | consumer.go:287-288 |
| 图片怎么不撑爆管道 | 先摘成 media 再截断;占位符替换;原消息不动 | media.go + consumer.go:324-446 |
| 图片传几次 | sha256 去重,同内容一次;失败也 PATCH 上报 | consumer.go:411-413 |
| flush 等不等图片 | 等:q.join() + mediaWG.Wait() | task.go:71-74 |
| 不联网怎么测 | 传输级 mockey 打桩 http.Do;接口级 gomock + 打桩构造函数 | 两个 _test.go |
几条设计判断:
- 批量接口要有 207 语义,但客户端的重试分类要认得它。 "部分成功"聚合成集合类型后逃过了
*apiError的判定,属于类型设计上的缝隙——幂等 upsert 救了它,但"靠下游幂等兜底"应该是有意为之的设计,不是侥幸碰上。 - 改写共享数据前先拷贝是观测层的铁律。哪怕只是换个 URL 字段,浅拷贝的成本可以忽略,并发雷的排查成本不能。
- 失败也要汇报(PATCH 上报失败状态)是可观测系统的自反性:观测工具自己出了问题,也应该被观测到。
- 去重键选内容 hash 而不是路径/ID:同一张图在 trace 里的不同位置、不同字段(input/output/metadata),天然共享一个 mediaId。
下一篇离开可观测,回到安全:日志和观测数据里不能出现身份证号——PII 脱敏的正则方案 + Hook 链,上一篇埋的 MaskFunc 管道挂点在那里接上完整方案。