← 上一节:04 · 子代理跑在别的文件里 · 章目录 · 下一节:06 · 这一章花了多少钱
05 · 不绑厂商的出口
到这里,harness 自己已经能画瀑布图了。那还要外部后端干什么?
因为自建的图只能看这一次 run。跨 run 的对比、"这个月哪个工具最常失败"、"换了模型之后 p95 有没有变",这些要的是一个真正的观测后端。而这类东西自己写就是重造轮子——成熟方案早就有了。
问题只剩一个:接哪个,以及接的时候绑住的是什么。
先排除一个
LangSmith 常被拿来当默认选项。但它的 SDK 开源、后端 / UI / 存储都不开源,自托管是 Enterprise-only,要 license key。
对个人开发者来说这一条就够了:它不是"重"的问题,是根本不能自托管。
开源可自托管的选项里,两个有代表性的:
- Langfuse — 功能最全(trace + 评测 + prompt 管理 + 成本),但自托管是 6 个容器:web、worker、Postgres、ClickHouse、Redis、MinIO。官方给生产的建议是 4 核 16G。
- Arize Phoenix — Apache-2.0,一个容器,本地跑起来大约 400 MiB 内存、十几秒就绪。
关键的一层:绑语义约定,不绑后端
这两个后端有个共同点:都原生吃 OTLP。
这件事决定了整个接入方式。你在 harness 里要写死的东西,不是"Langfuse 的 SDK",也不是"Phoenix 的 SDK",而是:
- OpenTelemetry 的 id 格式 — 第 02 节已经做了,那时候还没提为什么。
- GenAI 语义约定 — 属性叫什么名字。
绑住这两层,后端就是可插拔的:
export type OtlpExportTarget = {
endpoint: string; // 换后端 = 换这一行
headers?: Record<string, string>; // Langfuse Cloud 的 Basic auth 放这儿
serviceName?: string;
encoding?: OtlpEncoding;
};如果反过来——在主循环里 import { Langfuse } from 'langfuse'——那换后端就是改主循环,而且 README 上"no agent framework, no LangChain, no agent SDK"那句话也不用写了。
属性名同时发两套:
function llmAttributes(model: string): OtlpAttributeInput[] {
return [
stringAttribute('gen_ai.system', 'openai'),
stringAttribute('gen_ai.operation.name', 'chat'),
stringAttribute('gen_ai.request.model', model),
stringAttribute('openinference.span.kind', 'LLM'),
stringAttribute('llm.model_name', model),
];
}gen_ai.* 是 OTel 的 GenAI 语义约定,Langfuse 读它;openinference.* / llm.* 是 OpenInference,Phoenix 原生渲染它。两套不冲突,多发几个属性的成本可以忽略,换来的是同一次 run 打到哪个后端都好看。
导出器是读者,不是第二个写者
这是整套设计里最该守住的一条:
export function buildSpansForSession(sessionPath: string): OtlpSpan[] {
const spans: OtlpSpan[] = [];
buildSpansFromRecords(readAgentSessionRecords(sessionPath), spans);
for (const child of listSubagentSessionSummaries(sessionPath)) {
buildSpansFromRecords(readAgentSessionRecords(child.path), spans);
}
return spans;
}导出器读会话文件,然后 POST。它不参与 run,不在热路径上,不持有状态。
这带来三个性质:
- 导出可以重跑。后端挂了?改天再导一次。
- 导出可以换目标。今天 Phoenix,明天 Langfuse Cloud,同一批历史数据。
- 关掉导出不丢数据。因为数据从来没有"只在飞往厂商的路上"过。
如果反过来做成"运行时同步往后端打点",上面三条全没了,而且后端超时会拖慢 agent。
实测踩到的坑:JSON 不够用
写完 JSON 版本,打到 Phoenix:
{"ok":false,"spanCount":8,"status":415,"error":"Unsupported content type: application/json"}415。验一下:
JSON 到 /v1/traces → 415
protobuf content-type → 200Phoenix 的 OTLP HTTP 端点只收 protobuf。 而 Langfuse 两种都收。
这种事只有真跑一次才知道——文档上"支持 OTLP/HTTP"这句话,两边写的一样。
于是要么放弃 Phoenix,要么会写 protobuf。选了后者,因为 OTLP 的 trace schema 我们只用到很小一个子集,手写编码器大约 190 行:
function encodeTag(fieldNumber: number, wireType: number): Buffer {
return encodeVarint((fieldNumber << 3) | wireType);
}protobuf 的线格式就这么点东西:字段 = varint 标签 (字段号 << 3) | 线类型,后面跟值,一共用到四种线类型。
为什么不装个 protobuf 库:@opentelemetry/exporter-trace-otlp-proto 会带进来一棵依赖树和一个全局 tracer-provider 单例,而这个仓库的全部卖点是"你能读完它"。190 行可读的编码器,比一棵不可读的依赖树更符合这个定位。
编码器里有两个地方特别容易错,都写了注释:
// trace_id 和 span_id 在线上是裸字节,不是 JSON 编码里用的 hex 文本——
// 这是 span 被静默丢弃的常见原因。
encodeBytesField(1, Buffer.from(span.traceId, 'hex')),/** 时间戳在 OTLP schema 里是 fixed64,不是 varint。 */
function encodeFixed64Field(fieldNumber: number, value: bigint): Buffer {搞错任何一个,后端要么报错要么静默吞掉 span,都很难查。
验收长什么样
导一次带子代理的 run 进 Phoenix,然后问它要 span 列表:
agent run CHAIN parent=None
├─ chat gpt-4o-mini LLM parent=agent run
├─ read TOOL parent=agent run
├─ chat gpt-4o-mini LLM parent=agent run
└─ task TOOL parent=agent run
└─ subagent AGENT parent=task ← 来自另一个文件
├─ chat LLM parent=subagent
└─ grep TOOL parent=subagent子代理躺在 subagents/agent-<id>.jsonl 里,从来没和主会话在同一个文件出现过,但它落在了 task 底下。
接起来的东西只有一个:createChildSpanContext 继承下来的那个 traceId。
← 上一节:04 · 子代理跑在别的文件里 · 章目录 · 下一节:06 · 这一章花了多少钱