做LLM应用工程化的人,最近一年应该都有同感:模型跑通容易,但“上线之后到底行不行、为什么不行、怎么证明它行”这三个问题,才是真正磨人的地方。可观测性和评测,就是冲着这三个问题去的。这篇内容围绕Langfuse与LangSmith两个工具,结合OTel(OpenTelemetry)标准,把大模型应用的遥测数据、调用链路和Prompt版本效果打通,落地了一套从埋点到A/B评测再到版本固化的闭环方案,我把这个迭代周期命名为v24.8。适合正在做LLM应用工程化、想搞清楚线上效果却不知道怎么下手的团队和开发者参考。
这套方案解决的核心问题,说直白点就是:你的系统里跑了好几个Prompt版本,怎么知道哪个版本更好?好多少?这个结论敢不敢拿给业务方看?如果只凭一两个case的感觉,大概率会被挑战得很难看。有了统一的可观测数据底座和评测闭环,结论不再是“我感觉”,而是“数据显示,在24.8这个迭代周期里,B版本相对A版本在核心通过率上提升了11.2%,统计显著性p<0.05,可以发布”。
下面我会把设计思路、工具分工、埋点细节、实操步骤,以及我实际踩过的坑完整过一遍。内容偏工程实践,但我会尽量讲清楚每一步的“为什么”。
1. 为什么要搭这套闭环:LLM应用上线后的“三无”困境
1.1 传统可观测手段为什么失效
很多团队在LLM应用上的监控,最开始是从传统后端监控直接搬过来的一整套:记录HTTP状态码、接口耗时、错误率,顶多再打几个业务埋点。这套东西在普通Web服务上够用,但面对LLM应用,你会发现它几乎没有解释力。
举个真实场景。某客服助手在线上运行,业务方反馈“用户说答案变差了”,但传统监控里所有指标都正常:接口200,响应时间没变化,服务器CPU也没异常。问题出在哪个Prompt版本、哪一次模型调用、哪一段上下文拼接上?传统监控完全给不出答案。因为LLM应用的不确定性来自多个叠加层:模型本身是概率性的,Prompt措辞稍有变化输出就完全不同;上下文长度和内容会影响模型行为;外部模型服务可能在静默切换版本;甚至同一个Prompt在不同用户输入下,表现方差都很大。
这时候最需要的是什么?是能够精确还原每一次用户请求内部的调用链条:用户写了什么、系统拼了哪些上下文、调用的是哪个模型、Prompt最终长什么样、模型返回了什么、用了多少token、花了多少钱。这就是大模型可观测性的核心诉求。传统的“接口级别”监控只能看到黑盒外部的烟雾,看不到盒子内部的工作过程。
1.2 可观测与评测必须打通的原因
可观测数据只是“录像”,录像拍下来之后还得有人给“打分”。评测就是那个打分环节。很多团队把这两个问题分成两拨人做:做监控的管指标,做效果的管评测,两边数据不通。结果就是,评测报告说“B版本效果更好”,监控那边却查不到B版本的真实调用样本;反过来,监控发现某个时段错误率升高,评测那边却不知道这个时段线上跑的是哪个Prompt版本。
把可观测和评测打通之后,至少能得到三个有价值的能力。第一,每个评测结论都能追溯到具体请求样本,谁打的低分、为什么打低分,都能翻出原始trace复盘;第二,线上流量就是天然的评测样本池,不用额外造数据;第三,Prompt版本从开发、上线、评测、回滚的整个生命周期,都有数据记录,出了问题能快速定位是哪个环节造成的。这套闭环跑通之后,你会发现Prompt迭代从“玄学”变成了“工程”。
2. 工具定位与整体架构:Langfuse、LangSmith和OTel各自该干什么
2.1 三个工具在闭环里的分工
很多人看到Langfuse和LangSmith会问:这俩功能不是重叠吗?都做trace,都做评测,为什么不二选一?我实际用下来,两者的侧重点差别挺明显,组合起来反而顺手。
Langfuse更接近一个完整的“数据基础设施”:它负责trace和span的存储与展示、token和成本统计、数据集管理、人工标注、评分管理,还支持自托管。它的核心价值是让你有一个长期、稳定的数据中心,所有线上请求都有据可查。我把它定位成整个闭环的“数据库和裁判席”。
LangSmith则在“LLM工作流开发”这个环节上更强:它有Prompt版本管理、LangChain生态的深度集成、在线对比视图,以及针对模型输出的离线评测工具。它的核心价值在开发期和评测期,方便你快速调整Prompt、跑小批量对比,然后选出一个候选版本。我把它定位成“工作台和实验记录本”。
OTel在这套体系里扮演的是“普通话”的角色。Langfuse虽然有自己的SDK和API,但它同时提供了兼容OTel的数据接收端点。这意味着你不需要在业务代码里强依赖某个厂商的SDK,而是用统一的OTel协议上报span数据,再由后端转换成Langfuse能识别的格式。这样一来,你的埋点代码是可迁移的,将来换任何支持OTel的后端,业务代码都不用重写。
2.2 三个工具的组合边界
说直白点,Langfuse负责“线上运行时的真相”,LangSmith负责“开发调试时的便利”,OTel负责“埋点标准的统一”。举个例子:一个请求进来,应用通过OTel SDK上报了完整的trace、span、token用量和业务属性;Langfuse把数据存下来,在UI上可以检索、评分、做数据集;同时,如果这是A/B评测流量,系统会打上对应的prompt_version标签。评测阶段,我在LangSmith里定义两个Prompt版本,跑一组离线用例,快速看哪个版本更有潜力;挑中的版本再放到线上做小流量A/B,最终用Langfuse里的人工评分和规则评分来确定胜负。
这个组合的好处是,你没有把鸡蛋放在一个篮子里。Langfuse挂了,trace照常通过OTel collector缓存和转发;LangSmith连不上,离线评测可以延后,线上链路不受影响。对工程团队来说,这种解耦很重要。
2.3 整体数据链路
整体链路用文字描述比较直观。业务应用内部通过OTel SDK创建tracer,每次用户请求生成一个root span,向内把每步LLM调用拆成子span,每个span上附加模型名、Prompt版本、输入输出摘要、token用量等属性。应用同时接入Langfuse的集成层,把内部事件转成Langfuse期望的格式;数据可以走Langfuse的OTel ingestion endpoint直接上报,也可以经过OTel Collector统一汇聚后再转发。LangSmith那边则通过它自己的SDK上报实验数据,用于开发期的Prompt调试和小型对比评测。
这里有个关键点:两套数据流的关联字段是trace_id和自定义的request_id。也就是说,无论数据落在Langfuse还是LangSmith,只要带上同一个request_id,就能把两条链路对齐。我后面会详细讲这个字段怎么设计,它是整个闭环能够“闭环”的关节。
3. 落地细节:埋点设计、Prompt版本管理和A/B分流方案
3.1 埋点设计的关键字段
埋点不是简单地把调用信息打出来就完事,字段设计直接决定后面能不能做分析和评测。我在v24.8这套实现里,每个root span都挂了七个核心属性。
业务标识:包括request_id(全局唯一请求ID)、session_id(会话ID)、user_id(用户ID,可脱敏)、业务线名称。没有request_id,后面所有数据关联都无从谈起。会话和用户维度用于分析同一个用户多次请求之间的变体,判断是偶发问题还是持续性劣化。
Prompt版本标识:prompt_version字段,格式统一,比如“customer_service_v2_sys_v3”。线上流量分流时,这个字段必须准确覆盖每条请求,否则评测分组直接作废。我在初期就吃过这个亏,有一部分流量没打上版本号,导致A/B样本量白白损失了20%左右。
模型与调用参数:model_name、model_provider、temperature、max_tokens、实际input_tokens和output_tokens。token用量不仅要看总量,还要区分输入输出,因为计费标准不同。这些数据Langfuse会用来算成本,也会用于发现异常消耗。
业务结果标签:是否命中拒答、是否触发安全策略、是否超时、用户是否反馈问题。这类标签是连接“可观测数据”和“业务效果”的桥梁,最好在应用层写入。
自定义评测标记:例如llm_score、rule_check_result,这些可以由后续的异步评测任务回填,而不是在请求链路里同步计算。
3.2 Prompt版本管理的落地方式
Prompt版本管理一直是LLM应用的痛点。很多人直接在代码里写死字符串,改一次发一次版,线上和线下的Prompt版本根本对不上。我建议至少做到“运行时可指定版本”,也就是把Prompt模板和服务参数做成配置,应用在每次请求时按prompt_version加载对应模板。
LangSmith在Prompt管理方面有一套比较顺手的界面,可以创建“Prompt对象”,每次修改保存一个新版本,并设置某个版本为“生产版本”。开发期我就是在LangSmith里反复改模板、跑测试用例,记录哪个版本在哪些输入上表现如何。但LangSmith里的实验结果不能直接代表线上分布,所以真正的判决还得回到Langfuse里看线上数据。
Langfuse这边,可以在每次trace里把prompt_version作为span attribute写入,也可以把它创建成Langfuse的Prompt对象,调用时带上版本号。我实际的做法是双轨并行:开发期用LangSmith管理模板和快速实验;线上运行时用Langfuse记录版本信息和线上效果。两边的版本号保持一致,这样实验和线上能对应起来。
3.3 A/B分流策略怎么选
A/B评测不一定要引入复杂的实验平台。对于Prompt层面的评测,分流策略只要满足稳定、均匀、可解释三个条件就够了。
常用的分流方式有三种。按用户ID哈希分流,实现简单,同一个人始终看到同一个版本的Prompt,用户体验一致,但可能造成用户群体样本不均衡,比如活跃用户都进了A组。按请求ID取模分流,样本分配最均匀,实现也简单,但同一个用户的多次请求可能命中不同版本,在做“用户满意度”这类指标分析时要小心。按时间段分流(比如单日交替)最简单,但如果线上行为有时间规律(上午和晚上的用户意图差异很大),会有明显的混杂偏差。
我在这次落地中选择的是按业务用户ID哈希分流,原因是客服助手场景更关注用户级体验一致性。如果某个用户第一次用新Prompt得到了好效果,第二次又被切回旧版,用户会觉得产品不稳定。按用户哈希分流能避开这个问题。样本均匀性的问题,可以通过分层抽样来缓解,比如按用户活跃度分层后再做哈希。如果实测发现A/B样本量差异超过15%,说明分流Key选得不够好,需要调整算法。
4. 实操流程:从环境准备到评测固化的完整步骤
4.1 环境与依赖准备
整个链路的前提是有一套可用的数据基础设施。Langfuse支持自托管方案,我自己是在一台4C8G的机器上通过容器编排启动的,依赖PostgreSQL数据库和Redis缓存。如果只做小规模评测,单机部署完全够用。容器编排文件如果自己写,核心就是把数据库连接、Redis配置、以及对外开放的端口调好。启动后确认两个端点可用:一是Web管理界面,用于数据查看和评分;二是OTel接收端点,端口通常是4318,路径是/v1/traces。这个端点是否通,直接决定后面的trace能不能进来。
LangSmith用的是托管平台,注册后在项目设置里拿API Key和endpoint。Python环境里需要安装对应的SDK包。需要说明的是,LangSmith API Key属于敏感凭证,我建议从环境变量读取,不要写进代码仓库。另外,如果OTel方面还引入了Collector做中间转发,也需要在Collector的配置里加一个导出器,把数据同时发往Langfuse的OTel端点。
4.2 应用接入与数据上报
应用接入的核心是两件事:初始化OTel SDK,以及让每次LLM调用的span带上足够的信息。基础代码看起来像下面这样。这段更接近示意代码,逻辑是通用的,核心思路是把OTel的资源信息和span属性配好,让Langfuse能够从OTel协议里识别出“这是一个LLM调用”。
# 基于常见SDK的示意实现,逻辑通用 from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.resources import Resource # 资源信息会作为span的全局属性出现,Langfuse按这个识别来源 resource = Resource.create({ "service.name": "customer-service-assistant", "deployment.environment": "production", }) provider = TracerProvider(resource=resource) # LANGUFFUSE_OTEL_ENDPOINT 是Langfuse的OTel接收地址, # 例如 http://your-langfuse-host:4318/v1/traces exporter = OTLPSpanExporter( endpoint="http://your-langfuse-host:4318/v1/traces", headers=("Authorization", "Bearer YOUR_LANGUFFUSE_PUBLIC_KEY"), ) provider.add_span_processor(BatchSpanProcessor(exporter)) trace.set_tracer_provider(provider) tracer = trace.get_tracer("customer-service-app") def call_llm(request_id, session_id, user_id, prompt_version, model_name, prompt_text): with tracer.start_as_current_span("llm_call") as span: span.set_attribute("request_id", request_id) span.set_attribute("session_id", session_id) span.set_attribute("user_id", user_id) span.set_attribute("prompt_version", prompt_version) span.set_attribute("model_name", model_name) # 继续调用模型,拿到response后继续补属性 # span.set_attribute("gen_ai.usage.input_tokens", usage_input) # span.set_attribute("gen_ai.usage.output_tokens", usage_output) # span.set_attribute("gen_ai.usage.total_tokens", usage_total) return response这里有个关键细节:Langfuse能够识别OTel语义规则里的gen_ai.*属性,比如gen_ai.usage.input_tokens。如果你只是把token数字写进一个自定义字段,Langfuse不会把它算进成本统计里。我当时因为两个模型一个用官方SDK直接集成了OTel,一个走了原生HTTP调用,导致后者在Langfuse里没有token用量记录。排查了很久才发现是属性命名不符合规范。后来做了一层封装,所有模型调用统一返回usage结构,由封装函数写入gen_ai.usage.*系列字段,数据才对齐。
4.3 评测任务定义与执行
评测任务分离线与在线两条线。
离线部分,我先从Langfuse里导出一批实际业务请求构造评测数据集,这批数据需要覆盖高频请求类型、边界case和已知bad case。然后定义好评分规则。评分规则分三层:第一层是硬性规则,比如敏感词检查、拒绝率、是否返回了空内容、输出格式是否合法;第二层是LLM-as-judge,用一个强模型对输出按给定维度打分,但评测时会让裁判模型盲评,不给它看Prompt版本信息;第三层是人工抽检,按10%的比例抽检评分结果,确保裁判模型没有系统性偏差。这套规则我先在LangSmith里跑,选择候选Prompt版本。
在线部分,在应用中接入了分流逻辑后,让A和B两个版本的Prompt同时运行。在这个周期内,我通过Langfuse的评分API,把离线评测任务和在线部分样本的评分结果回填到对应的trace上。需要说明的是,在线样本的“真实评分”依赖真实用户行为,比如用户是否点“有帮助”、是否继续追问、是否发起投诉,这些信号在业务系统里记录后,异步写入Langfuse。
4.4 分析结果并固化版本
分析周期结束之后,数据导出为表格,按prompt_version分组计算指标。关键的指标包括:核心回答通过率、平均响应时长、单次请求平均成本、拒绝率、以及用户反馈负面率。只看均值不够,还要看分布,B版本的平均分高一点但方差极大,说明它在某些case上翻车严重,并不适合直接发布。
这里要特别说统计显著性。24.8这个迭代周期里,A/B两个版本样本量都在3000以上,B版本的核心通过率从82.1%提升到86.3%,表面看提升了4.2个百分点。但是算完置信区间后发现,A版本的真实得分可能落在80.5%到83.7%,B版本落在84.1%到88.5%,两个区间虽然整体偏向B,但分布有重叠,并不能直接宣布B必胜。我当时的做法是继续跑了三天加量,直到样本量翻倍,B版本的置信区间下限稳定高于A版本的上限,才形成“B版本胜出”的结论。这个步骤被很多人跳过,但我建议不要省,否则你很难向业务方解释为什么发布B版本一周后数据又波动回去了。
胜出版本确认后,需要完成版本固化的闭环动作:在Langfuse和LangSmith两边的Prompt管理里,把胜出的B版本标记为生产版本;更新应用配置中心,让默认prompt_version指向新版本;保留A版本的模板和历史数据,以便后续复盘。至此,从“埋点采集”到“评测打分”再到“版本上线”的闭环才算真正走完一轮。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
我在多次迭代中遇到过不少问题,有些在社区里也经常看到有人问,整理成速查表更实用。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| Langfuse里没有trace | OTel exporter配置错误,或端点不通 | 检查4318端口连通性,查看exporter日志 |
| 有trace但token用量和成本为0 | 未按gen_ai.usage.*规范写入token属性 | 修正埋点字段,重新构造usage结构 |
| Langfuse和LangSmith同一请求数据对不上 | 两边的sample rate不同,或request_id不一致 | 统一request_id字段,关闭或对齐采样率 |
| A/B分组样本量偏差过大 | 分流Key选择不当,用户活跃度差异 | 改按分层哈希,定期检查分组均衡度 |
| LLM-as-judge评分偏高 | 裁判模型识别出了Prompt版本产生偏好 | 盲评,隐藏版本信息,必要时用多模型仲裁 |
| 同一用户会话中命中不同Prompt版本 | 分流用了request_id取模,而请求属于同一会话 | 改按session_id哈希分流 |
| 线上数据量大导致trace上报延迟 | OTel批量导出参数不适合高吞吐 | 调大BatchSpanProcessor的批量上限和导出间隔 |
| trace里中文乱码或特殊字符截断 | SDK配置了错误编码 | 统一UTF-8编码,属性值做长度截断 |
5.2 我踩过的三个坑
第一个坑是评测任务和线上请求不同步。刚开始我在Langfuse里人工给一批历史trace打分,然后直接拿这个评分结果去对比两个Prompt版本。但后来发现,这批历史trace里两个版本的流量比例根本不是预期的1比1,而是3比1,因为上线初期B版本只放量了一部分。最终花了很大力气做样本重加权。教训是:评测打分之前,先确认两个版本的样本量占比符合预期,而且要检查时间分布,避免一个版本大部分样本都集中在某个时段。
第二个坑是LLM-as-judge的“位置偏差”。我在裁判模型的评分模板里,把A版本的回答放在前面,B版本放在后面,结果A版本分数整体偏高。发现之后改成随机顺序盲评,并且做双向互换验证,B版本的评分才开始稳定。现在所有llm-judge任务里,我都默认采用双向对比取平均的方式。
第三个坑是“24.8”这个版本迭代里我犯过的经典失误:把Prompt版本号直接写死在代码里,忘记在分流时动态注入。结果线上有整整半天时间,所有请求都走默认版本,A/B实验实际上断流了。由于打点系统一直在上报数据,监控面板看起来一切正常,直到导出数据时才发现样本没有版本标签。那天之后,我专门加了一个服务启动时的配置校验,如果prompt_version字段缺失,直接拒绝启动并告警。
写在最后
把Langfuse、LangSmith和OTel组合起来跑通闭环之后,我个人的感受是:工具只是支撑,真正让这套东西有价值的,是大家对“结论怎么来”这件事形成了共识。现在我再提出“修改Prompt”,不再是说“我感觉这样写用户会更容易理解”,而是“我先把改动挂上去跑三天,拿数据说话”。如果你也正在做LLM应用,建议不用等数据量大再开始,哪怕先接上OTel把trace完整记录下来,把Prompt版本标签打上,就已经比大多数团队往前走了一步。后续要加评测,只是在这套数据底座上再加一层打分逻辑而已,整个体系会慢慢长出来。