做LLM应用落地一年多,我最大的感受是:大模型应用的"故障"和传统软件根本不是一回事。服务没崩、接口200、延迟正常,但回答就是错的、偏的、带着幻觉的——这种问题日志里看不到,监控面板上也不会有告警。传统APM那套能告诉你系统"活着",但完全无法告诉你模型"答得对不对"。所以当我决定推进多轮对话产品的深度整改时,第一件事就是搭一套可观测与评测闭环。标题里的"24.8",是我们这次落地实践的内部版本代号,代表2024年8月这个周期内完成的一整套方案:Langfuse承担运行时链路追踪和数据集沉淀,LangSmith承担离线评测与回归,中间用OpenTelemetry(OTel)统一埋点协议,最后把Prompt A/B评测跑成常态化机制。这篇文章会把整个落地过程、选型逻辑、踩过的坑以及最终沉淀的流程完整写出来,给正在做同类事情的人一份可直接参考的实践记录。
1. 为什么大模型应用需要"可观测+评测闭环"
1.1 传统监控覆盖不了的盲区
先讲一个我们线上真实遇到的例子。某个客服问答模块,用户投诉"机器人乱说话"。点开服务端指标一看:CPU正常,内存正常,请求成功率99.9%,P95延迟800毫秒,传统监控里全是绿灯。但翻具体会话记录就发现,模型在某个多轮上下文里把"用户取消了订单"理解成了"用户要求取消订单",接着在这个错误前提下答了一整段。要不是用户投诉,这个问题在监控里根本不会暴露。
这就是大模型应用的本质差异:系统的正确性不在"请求有没有返回",而在"返回内容的质量"。而内容质量又受Prompt、模型版本、上下文长度、检索结果、工具调用结果等多重因素影响,任何一个环节偏差都会导致最终回答变化。要定位这类问题,必须有能力从完整链路中回放每次模型调用的输入输出——这是链路级别的可观测性,而不只是指标级别的监控。
具体来说,大模型应用的可观测性至少要能回答这几个问题:
- 一次用户请求,系统按什么顺序执行了哪些步骤?RAG检索、工具调用、多轮追问分别产生了什么中间结果?
- 每个环节消耗了多少Token、花了多长时间、用的是哪个模型和哪个Prompt版本?
- 模型输出到底长什么样?用户对结果有没有明确的点赞或点踩?
- 同一个Prompt在改版前后,线上真实流量的表现数据有没有变化?
没有这些数据,优化模型应用就像闭着眼睛开车,只能靠用户骂声来感知方向。这也是我坚持先补可观测能力、再谈优化的原因。
1.2 可观测与评测其实是同一件事的两个面
很多团队把"可观测"和"评测"当成两件事分开做,实际落地会发现它们必须绑在一起。可观测回答的是"实际上发生了什么",评测回答的是"发生的结果好不好"。只有可观测没有评测,你能看到某个会话里模型答错了,但不知道这是偶发个案还是系统性回归;只有评测没有可观测,你只知道离线得分下降了,但不知道线上真实环境下是哪一步引入的偏差。
闭环的含义就是让两边互相喂数据。在24.8实践里跑通的循环是:线上链路通过Langfuse追踪每次会话和模型调用的完整轨迹,把用户明确点踩或明显答非所问的场景,自动沉淀到评测集里;评测集在LangSmith上定期跑回归和A/B对比,新Prompt跑赢了就发布上线;上线后再通过可观测链路确认实际效果,发现新问题又回流到评测集。每次线上异常都能变成下一次迭代的输入,优化节奏从"拍脑袋改Prompt"变成"靠数据持续收敛"。
这里要强调一个认知:评测不是发布前的临时检查,而是持续运行的机制。Prompt会漂移、模型会升级、用户问题分布会变化,只有把评测固定在闭环里,才能及时发现这些变化。
1.3 24.8项目的目标与边界
动手之前,我先把这次落地的边界划清楚,避免做成一个大杂烩。核心目标就三条:
- 能看到:所有线上请求都能按会话维度回放完整链路,包括Prompt版本、模型参数、中间检索结果和工具返回值。
- 能评判:核心业务场景有可复用的评测集和评测标准,每次Prompt改动都能自动跑出对比数据。
- 能决策:A/B对比有明确的胜出门槛,杜绝"我觉得新版更好"这种主观判断。
边界也划得很明确:不做在线强化学习,不做多模型自动路由,不做全自动Prompt寻优。我们只要一个"人工决策但数据充分"的闭环。这个边界很重要,因为大模型评测领域还有很多未解问题,一上来就追求全自动,很容易陷入无底洞,最后连基础链路都没打通。
2. 三个工具怎么分工协作
2.1 Langfuse:运行时链路追踪的主阵地
Langfuse是目前大模型可观测领域少有的"开箱即用且数据模型契合"的开源方案。我们对它的定位很纯粹:运行时数据的存储和分析底座。它原生的Trace、Span、Generation三级数据结构,正好对应一次对话、一个处理环节、一次模型调用,不需要自己造轮子。它还内置了Token统计、成本估算和基于Trace的评分功能,可以直接把用户点赞/点踩作为分数写回链路。
部署上,我建议如果团队有基本运维能力,优先自托管。原因是实际业务里的会话数据涉及用户隐私和业务敏感信息,外发到第三方平台要过合规评估;自托管之后也方便把追踪数据和内部已有的日志系统打通。Langfuse自托管本身不复杂,一份docker-compose配置就能拉起Postgres、ClickHouse、Redis等依赖,单机内存16G左右的规格,支撑我们日均几万次模型调用的规模绰绰有余。
另外有一个实用建议:自托管之后要配置数据保留策略。Trace数据增长很快,尤其是把输入输出都存下来的情况下,一个月就能攒出好几个G。我们是线上全量链路保留30天,评测集相关的重要Trace保留180天,其余按天定期清理。
2.2 LangSmith:离线评测与回归的试验场
LangSmith对LangChain生态支持很深入,但不少非LangChain项目不知道它也能独立使用。我们的用法是把它当"评测实验室",主要不是做链路追踪:把评测集放到Dataset里,跑一批任务生成预测结果,再用自带的Evaluator(LLM-as-Judge、精确匹配、正则、自定义打分函数)对结果评分,最后生成对比报告。
它和Langfuse的区别,我用一句话概括:Langfuse是手术室里的监护仪,告诉你手术过程中病人各项体征怎么变化;LangSmith是检验科,告诉你样本化验结果到底合不合格。两者数据模型和服务目的不同,也不冲突。LangSmith对离线评测有一个很大的优势——跑完评测之后,官方SDK可以直接把预测结果和Evaluator分数关联到Dataset的example上,天然支持多次回归的历史对比,方便看版本演进趋势。
2.3 OTel:统一埋点的地基
为什么要引入OTel,而不是直接各用各家的SDK?原因很简单:我不想把可观测性和具体产品锁死。Langfuse和LangSmith都有自己的SDK,如果业务代码里到处直接调用它们的API,将来换平台或者引入新组件,埋点代码全得重写。OTel的价值在于它是行业标准:先用OTel规范把应用的所有遥测数据(分布式链路、指标、日志)采集出来,再通过Collector把数据路由给不同后端。Langfuse本身支持OTLP接入,可以接收标准链路数据;LangSmith侧则通过Dataset和Runs的API对接评测任务。
虽然OTel对GenAI领域的语义约定(LLM语义约定规范)还在演进,但基础的Trace、Span、Attribute模型已经够用。我们自己封装了一层轻量的采集函数,把Prompt版本、模型名称、Token用量、上下文信息挂在Span的Attribute上,这些字段不管后端是谁都能识别,只是各平台展示方式不同。
2.4 各司其职的总体数据流
用文字描述整体数据流:用户请求进入应用后,通过OTel SDK自动埋点,应用内的HTTP入口、OpenAI调用、检索调用都会生成Span,Span通过OTLP协议上报到本地OpenTelemetry Collector。Collector做一次数据清洗和采样后,把链路数据转发给Langfuse的OTLP接口。Langfuse为每条会话生成对应Trace,关联会话ID、用户ID和Prompt版本标签。
离线评测走另一条路径:从生产链路里筛出bad case和典型样本,导入LangSmith的Dataset;跑评测脚本时,同一批样本分别用Prompt A和Prompt B生成预测结果;评测结果写回Dataset并生成对比报告。报告结论达到阈值后,把新Prompt版本同步到Langfuse的Prompt管理功能,线上流量按比例切到新版本;新版本的线上表现继续通过Langfuse追踪,形成完整闭环。
三个工具的角色用一张表总结:
| 工具 | 主要承担职责 | 我们用它解决什么问题 |
|---|---|---|
| Langfuse | 运行时链路追踪、成本统计、线上评分 | 回答"线上实际发生了什么" |
| LangSmith | 数据集管理、离线评测、回归对比 | 回答"改动之后效果到底好不好" |
| OTel | 统一埋点标准、数据采集与路由 | 避免绑定单一厂商,让数据可迁移 |
这个组合乍看有点重,但跑起来会发现每层都有明确边界,也都能独立替换。后面每一节展开落地细节。
3. 落地步骤:从埋点到数据打通
3.1 基础环境准备
先交代环境。服务端统一是Python 3.11,用FastAPI对外提供服务;模型调用主要是OpenAI兼容接口和本地私有化部署的模型。落地前先把三组依赖装好:
pip install langfuse langsmith opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp opentelemetry-instrumentation-fastapi opentelemetry-instrumentation-openai这里有一个容易踩的坑:OTel的instrumentation包版本必须和SDK版本匹配。有一次我因为instrumentation版本比SDK新两个版本,结果HTTP请求的Span全部丢失,服务端日志还完全看不出来,debug了一下午才发现是版本兼容问题。经验是:所有opentelemetry相关包统一锁定版本号,升级时一起升,不要单独升级某一个。
自托管Langfuse我用官方提供的docker-compose,涉及Postgres、ClickHouse、Redis和几个服务端组件。机器不用太高配,8核16G内存足够支撑中等规模流量。启动后配置环境变量:
LANGFUSE_PUBLIC_KEY=xxx LANGFUSE_SECRET_KEY=xxx LANGFUSE_HOST=https://langfuse.internal.example.com这三个变量是Langfuse服务端对外的访问凭证,一定要走环境变量管理,别写死在代码里,防止泄露。
3.2 OTel埋点接入:自动加手动组合
OTel接入分两层。第一层是自动埋点:借助instrumentation库,FastAPI的HTTP入口、OpenAI客户端的调用都会自动生成Span,不需要手写逻辑。第二层是手动埋点:业务里的关键信息,比如"是否命中缓存""检索到了哪几篇文章""上下文被截断的长度",自动埋点拿不到,必须手动挂到当前Span上。
from opentelemetry import trace tracer = trace.get_tracer("rag.pipeline") def do_search(query, top_k): with tracer.start_as_current_span("retrieve") as span: # 实际检索逻辑 docs = vector_db.search(query, top_k=top_k) span.set_attribute("rag.top_k", top_k) span.set_attribute("rag.hit_count", len(docs)) span.set_attribute("rag.query_hash", hash(query)) return docs这里有个非常关键的点:手动埋点一定要把输入输出摘要挂上去,不能只挂耗时指标。很多团队只记录调用时长,出问题的时候根本没法回放具体内容。Prompt版本标签、模型名称、输入Token数、输出Token数、是否触发重试,这些字段在问题定位时比延迟值有用得多。我们内部有个不成文的规矩:任何业务Span至少挂5个Attribute,其中必须包含"目的"和"关键输入摘要"。
3.3 Langfuse对接与链路关联
Langfuse支持OTLP协议接入,最省事的做法是让Collector直接转发。在OpenTelemetry Collector配置里,把OTLP receiver收到的Span通过otlp exporter转发给Langfuse即可。但这里有个隐蔽的坑:Langfuse对Trace ID格式有要求,必须是合法的16字节hex字符串。如果上层框架生成了不符合规范的ID,数据会被静默丢弃。OpenTelemetry SDK默认生成的Trace ID没问题,但如果你用了某些中间件自造的ID,就会踩坑。
除了OTLP,Langfuse官方Python SDK也提供了一种更省心的方式,直接用装饰器给函数打点:
from langfuse.decorators import observe, langfuse_context @observe() def generate_answer(context, query): langfuse_context.update_current_trace( user_id="u_12345", session_id="s_67890", metadata={"prompt_version": "v24.8.1"} ) # 模型调用逻辑 return llm_client.chat(...)这种写法的好处是SDK内部自动把嵌套的模型调用串成父子Span,不用自己管理上下文传播。缺点是代码里多了对Langfuse SDK的直接依赖。我的建议是混合使用:用装饰器做粗粒度的会话/步骤切分,用OTel自动埋点做细粒度的组件调用追踪,两边通过统一的Trace ID关联。最终在Langfuse后台能看到一条完整Trace,从HTTP请求到检索再到模型输出,层级清晰。
3.4 LangSmith对接与评测数据准备
LangSmith接入相对独立,大部分流程是脚本化的:先从生产库导出评测样本,构造Dataset;再编写评测脚本,对Dataset里的每条样本分别用两套Prompt跑模型;最后把预测结果传给Evaluator打分。
from langsmith import Client client = Client() dataset = client.create_dataset( dataset_name="customer_service_v24.8", description="24.8周期客服问答评测集" ) client.create_examples( dataset_id=dataset.id, inputs=[{"question": q} for q in questions], outputs=[{"reference": r} for r in ref_answers] )这里强调一个原则:评测样本必须带参考回答,而且参考回答要经过人工确认。不能只靠脚本从日志里机械捞数据充当金标准,评测集的Label本身有错,后面测得再准都是白测。我们建评测集的时候,找业务方抽两个人专门做了两轮标注复核,宁可样本少一点,也要保证每条Label质量可靠。
3.5 数据关联与ID打通
打通的关键是统一ID体系。做法是:HTTP请求入口用中间件生成conversation_id,把它设置成链路在Langfuse中的session_id,同时挂到OTel Span的Attribute上;LangSmith评测脚本记录conversation_id和trace_id,再把评测结果和Langfuse里对应Trace关联起来。
为什么要费劲做这个关联?因为只有把离线评测分数和线上真实Trace对应上,才能回答"评测里得高分的样本,线上实际表现是不是也好"这类问题。我们后来建了一张简单的关联表,一行记录包含:conversation_id、trace_id、prompt_version_a、prompt_version_b、a_score、b_score、winner、evaluated_at。有了这张表,任何一次评测结论都能反查到线上原始链路,定位问题非常方便。
4. Prompt A/B评测的完整实践
4.1 评测维度与指标,以及成本底线
评估一个Prompt版本好不好,不能只看一个笼统的"得分"。"答得好不好"本身是个复杂概念,必须拆维度。我们根据客服问答场景拆成五个维度:正确性、完整性、格式合规、安全(正确拒答不当内容)、运行成本。前四个维度用LLM-as-Judge打分,每项0到100分;成本维度直接算每次请求的平均Token消耗和调用耗时。
这里有个实操细节:LLM-as-Judge的评分卡不是越复杂越好。刚开始我设计了一个很复杂的评分卡,要求裁判模型按七条标准逐项打分,结果裁判模型自己输出都不稳定,同一批样本跑两次分差能到十几分。简化成"先给裁判一段总体理由,再给0到100总分和分项拆解"之后,稳定性大幅提升。Judge模型的temperature必须设为0,评测用的Judge模型版本要固定,不能跟着线上模型一起升级,否则每次打分基准都在变,跨周期对比全部失真。
另外一个必须设置的约束是成本底线。Prompt改版经常出现"效果变好但成本翻倍"的情况,小流量实验期看不出来,全量上线之后账单很难看。我们的规矩是:同一场景下,新版单次调用Token消耗不得超过旧版的1.2倍,超了直接一票否决,哪怕效果分再高也不能上。这个底线让团队少做了很多"看似美好"的改版。
4.2 评测集构建:从哪来、怎么筛、多少够用
评测集是整个闭环的地基,它比选什么评测工具更重要。我们构建评测集用了三个来源:
- 生产bad case:从Langfuse后台筛选用户点踩、或答案被判为低分的会话。这是最重要的来源,代表真实用户痛点。
- 典型正向样本:从高频问题里抽一批正常问答,防止改版后为了修bad case把正常回答搞坏,保住回归能力。
- 对抗样本:自己构造的边界情况,比如超长上下文、诱导性发言、模糊指代("刚才那个订单呢")。这些在真实流量里占比不高,但对发现Prompt漏洞特别有效。
数量上,第一版评测集建了300条左右。经验判断:低于100条,结论波动太明显,一个bad case就能让总分浮动几个点;500条以上收益递减,但单次评测成本直线上升。300条是性价比不错的平衡点,覆盖约30个二级场景,每个场景10条样本。评测集维护也要制度化:线上每发现新类型bad case就沉淀进评测集,同时定期复查旧样本是否需要更新,防止评测集被单一问题带偏。
4.3 A/B实验流程:离线评测加线上小流量验证
Prompt A/B评测分两阶段。第一阶段是离线评测,在LangSmith里对同一批样本用Prompt A和Prompt B各跑一遍,得到两组成绩。第二阶段是线上验证,把新版Prompt以10%流量灰度上线,跑24到48小时,用Langfuse对比新旧版本在真实流量下的用户反馈、延迟和成本。
离线评测脚本的核心逻辑如下:
from langsmith import evaluate def predict_a(inputs): prompt = build_prompt(template_a, inputs["question"]) return {"answer": llm_call(prompt)} def predict_b(inputs): prompt = build_prompt(template_b, inputs["question"]) return {"answer": llm_call(prompt)} results_a = evaluate(predict_a, dataset_name="customer_service_v24.8", evaluators=[llm_judge]) results_b = evaluate(predict_b, dataset_name="customer_service_v24.8", evaluators=[llm_judge])两套Prompt跑同一个Dataset,天然就是配对实验,非常适合做逐条比较。要注意的是,模型有随机性,即便temperature=0也不能保证两次输出完全一致,尤其在推理模型上。所以对关键样本我会让同一版本跑2到3次,取多数结果再进入统计,降低抖动对结论的影响。
线上灰度阶段,重点盯三个指标:用户显式反馈(点赞/点踩率)、平均每会话Token消耗、P95响应延迟。用户反馈虽然稀疏但信号最真实;Token消耗和延迟是最直接的成本与体验指标。灰度期间只要新版差评率显著上升,无论离线得分多高都立刻回滚——因为离线评测集不可能覆盖所有线上分布。
4.4 结果分析与决策门槛
评测数据出来后,最难的环节是判断"谁赢了"。见过太多团队跑完A/B之后对着两个分数纠结半天,最后还是拍脑袋。为了避免这种情况,我在24.8实践里定了一套明确的决策流程。
第一步看配对胜率。对300条样本逐条比较得分,统计新版"赢、平、输"的条数。用配对检验的思路,新版在超过55%的样本上得分不低于旧版,且赢的条数明显多于输的条数,才算有初步优势。胜率不到50%的直接淘汰,不需要再看其他指标。
第二步看加权综合分。给五个维度设权重:正确性0.4、完整性0.2、格式合规0.1、安全0.2、成本0.1。加权后新版综合分必须高于旧版至少3分(百分制),这个门槛是为了避免"高1分就上线"这种被噪声驱动的决策。
第三步看成本底线和回归项。逐项检查旧版得分反而下降的维度,确认没有明显回退;Token消耗增幅超过20%直接否决。三项全部通过,才允许把新版流量从10%逐步提到50%再到100%。
第一次完整跑完这套流程,前后花了大约两周,大部分时间在清洗数据和调Judge提示词。流程跑顺之后,一次完整Prompt改版从提需求到决定是否上线,压缩到两天以内。这就是闭环带来的实际收益。
5. 常见问题与排查实录
5.1 埋点数据丢了一部分,怎么排查
上线一周后发现Langfuse里的Trace数量比网关统计的请求数少约8%。第一反应是采样配置,检查Collector配置发现采样率是100%,排除。然后查Collector日志,发现一部分数据导出时返回了405错误。进一步排查发现是Langfuse的OTLP端点对不同Content-Type处理有差异,我们用gzip压缩上报时,某些版本的服务端解压异常,日志只打了一行警告,数据就丢了。解决办法是把Collector的压缩方式改为none,或者升级Langfuse镜像到修复版本。
这类问题的教训是:可观测系统本身也会出故障,必须给可观测链路加健康检查。我们后来在Collector上挂了探活接口,每天定时拨测,一旦发现入库曲线有明显断崖就自动告警,避免"连监控都坏了还没发现"的尴尬。
5.2 Trace关联不上,会话链路断成好几截
断链问题最让人头疼。现象是Langfuse里同一会话的Trace被拆成好几条,无法按时间顺序串成完整上下文。根因大多是上下文传播断裂:异步任务、消息队列、多线程场景下,traceparent没有正确传递到下游Span。
我们有一个异步生成报告的功能,任务由Celery执行,子任务里的模型调用全部游离在主线Trace之外。解决思路是:任务入队时把当前traceparent存到任务参数里,任务执行时通过OpenTelemetry的上下文API恢复,包一层公共函数自动完成。这类问题属于"看起来不大、修起来费劲"的典型,建议在架构设计阶段就把异步链路和消息队列的上下文传播纳入考虑,别等上线后再补。
5.3 LLM-as-Judge结果不稳定,同批样本两次跑分不一样
这个问题的主要原因有三个:Judge模型本身有随机性、Judge提示词措辞有歧义、样本顺序会影响判断倾向(顺序偏差)。应对措施分别是:Judge模型固定版本且temperature=0;Judge提示词写成结构化评分卡,明确每个分数档位的行为描述;评测时对样本顺序做一次shuffle;关键结论跑两轮Judge取平均,对分差超过10分的样本抽出来人工复核。
这里我想再强调一遍:Judge模型和线上业务模型必须分开版本管理。有一次因为线上模型升级,顺手把Judge模型也升了,结果整周的评测分数体系全部需要重算,白白浪费了不少时间。
5.4 评测成本涨得太快,怎么控制
一次完整评测的成本可以算一下:300条样本、两个Prompt版本、每版跑2次去抖,再加上Judge调用,模型调用量大约是300乘以2乘以2再加300,算下来1500次左右。如果用中档模型、单次平均消耗2000 Token,一次评测约300万Token。按当时价格估算,单次离线评测成本在几十元人民币量级,一天跑两轮完全可控。
真正需要留意的是灰度阶段的成本。10%流量跑48小时,客服场景一天几千次调用,多出来的成本不大,但一定要注意别让所有日活用户都路由到新Prompt。万一新版有严重问题,影响范围会被瞬间放大。另外可以用Langfuse的Trace采样功能,灰度期间只采集20%新版请求做分析,样本来得没那么全,但趋势判断完全够用。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| Trace数量少于请求数 | 采样配置、OTLP压缩、版本兼容 | 查Collector日志,验证压缩方式,检查版本匹配 |
| 会话链路断裂 | 上下文传播丢失 | 检查异步/队列场景的traceparent传递 |
| 评测分数不稳定 | Judge随机性、顺序偏差 | 固定模型、temperature=0、跑多轮取平均 |
| 数据全部丢失 | SDK初始化失败、密钥错误 | 检查环境变量和初始化日志 |
| Prompt版本信息找不到 | Span Attribute未挂载 | 核对埋点代码,确认版本标签字段 |
6. 这套闭环后续还能怎么扩展
24.8这套闭环跑顺之后,我们已经在规划两个扩展方向。一个是把评测自动化嵌入CI流程,每次改动Prompt或模型配置时自动触发一轮小规模评测集回归,把人工介入降到最低。另一个是在线反馈信号的挖掘:当前用户点踩事件比较稀疏,计划接入更多隐式信号,比如用户追问次数、停留时长、是否复制答案,把这些行为信号变成可量化的评测维度。
需要提醒的是,这两个扩展都依赖一个前提:基础数据质量要过关。如果链路埋点、ID关联、评测集维护没有做到位,再高级的功能都是在沙子上盖楼。所以我的建议是,任何团队想做类似闭环,先把"埋点-关联-离线评测-灰度验证"这套骨架跑通,再谈扩展。
最后按惯例分享一点个人体会:可观测和评测闭环的价值,不是让你立刻把所有问题都解决,而是让你能越来越快地发现问题、定位问题、验证解法。这个过程没有太多玄学,就是把数据老老实实采全、把评测标准定清楚、把决策门槛写明白,然后持续转圈。踩过几次坑之后我才真正理解,模型应用优化的核心能力和写代码一样,都是"根据反馈持续收敛",而这套闭环就是那个反馈通道。